Skip to main content

cortiq_engine/
ltxdit.rs

1//! LTX-2.5 `AVTransformer3DModel` — the audio-video diffusion transformer,
2//! read straight from an `ltx-2.5-av` CMF container.
3//!
4//! One forward is one denoising step for both streams at once: 48 blocks
5//! that each run video self-attention, video↔prompt cross-attention,
6//! audio self-attention, audio↔prompt cross-attention and the two
7//! directions of audio↔video cross-attention, every one of them modulated
8//! by adaLN values that come from the timestep — per token, not per sample.
9//!
10//! What the reference does and this reproduces:
11//!
12//! * **adaLN-single**: a sinusoidal timestep embedding (256) → SiLU MLP →
13//!   one `[9·dim]` vector per distinct timestep, added to the block's own
14//!   `scale_shift_table`. Rows 0..3 modulate self-attention, 3..6 the
15//!   feed-forward, 6..9 the prompt cross-attention.
16//! * **ada-zero**: `rms_norm(x) · (1 + scale) + shift`, no learned weight.
17//! * **post-SA**: `x + y·gate`, then a second `rms_norm` whose output is
18//!   what cross-attention reads — the block never normalizes twice.
19//! * **Split RoPE** over three video axes (frame, row, column) and one
20//!   audio axis, evaluated at the *middle* of each patch's `[start, end)`
21//!   bounds, with the frequency ladder built in f64 (the checkpoint's
22//!   `frequencies_precision: float64`).
23//! * **Gated attention**: `2·sigmoid(to_gate_logits(x))`, per head.
24//! * **q/k RMS-norm across the whole inner dimension**, not per head.
25//! * The A↔V pair reads the *pre-fusion* state of both streams, so the
26//!   order the two directions run in cannot bias the result.
27//!
28//! Gated against reference forward-hook dumps by `cortiq ltx-dit`.
29
30use crate::dit::{Proj, cmf_f32};
31use crate::pool::Pool;
32use cortiq_core::CmfModel;
33use std::sync::Arc;
34
35const EPS: f64 = 1e-6;
36
37// ---------------------------------------------------------------- helpers
38
39/// Rows of `n` items split across pool workers (serial without a pool).
40pub(crate) fn rows(pool: Option<&Pool>, n: usize, f: &(dyn Fn(usize, usize) + Sync)) {
41    match pool {
42        Some(p) => p.run_rows(n, f),
43        None => f(0, n),
44    }
45}
46
47/// Row handout for pool workers over one flat buffer.
48pub(crate) struct Shared(pub(crate) *mut f32);
49unsafe impl Send for Shared {}
50unsafe impl Sync for Shared {}
51impl Shared {
52    /// SAFETY: callers take disjoint `[off, off+len)` ranges.
53    #[allow(clippy::mut_from_ref)]
54    pub(crate) unsafe fn at(&self, off: usize, len: usize) -> &mut [f32] {
55        unsafe { std::slice::from_raw_parts_mut(self.0.add(off), len) }
56    }
57}
58
59/// RMS normalization with no learned weight (`ada_zero`, `post_sa`).
60pub(crate) fn rms_plain(x: &[f32], dst: &mut [f32]) {
61    let ss = x.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>() / x.len() as f64;
62    let inv = 1.0 / (ss + EPS).sqrt();
63    for (d, &v) in dst.iter_mut().zip(x) {
64        *d = (v as f64 * inv) as f32;
65    }
66}
67
68/// RMS normalization with a learned weight (q/k-norm).
69fn rms_w(x: &mut [f32], w: &[f32]) {
70    let ss = x.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>() / x.len() as f64;
71    let inv = 1.0 / (ss + EPS).sqrt();
72    for (v, &g) in x.iter_mut().zip(w) {
73        *v = (*v as f64 * inv) as f32 * g;
74    }
75}
76
77fn silu(v: f32) -> f32 {
78    v / (1.0 + (-v).exp())
79}
80
81/// `gelu(x, approximate="tanh")`, the feed-forward's projection activation.
82///
83/// In f32 and, where it matters, across the pool. A step puts half a billion
84/// values through this — 672 tokens × 16384 wide × 48 blocks — so computing
85/// it in f64 on one thread was worth several seconds of every step on its
86/// own. The f32 form agrees with the f64 one to ~1e-7 relative, which is far
87/// inside what the 4-bit weights around it carry.
88#[inline]
89pub(crate) fn gelu_tanh(v: f32) -> f32 {
90    const K: f32 = 0.797_884_56; // sqrt(2/pi)
91    0.5 * v * (1.0 + (K * (v + 0.044715 * v * v * v)).tanh())
92}
93
94/// The same activation over a whole buffer, split across the pool.
95pub(crate) fn gelu_tanh_rows(x: &mut [f32], pool: Option<&Pool>) {
96    let dst = Shared(x.as_mut_ptr());
97    let n = x.len();
98    let grain = 4096usize;
99    let chunks = n.div_ceil(grain);
100    rows(pool, chunks, &|s, e| {
101        let (lo, hi) = (s * grain, (e * grain).min(n));
102        let r = unsafe { dst.at(lo, hi - lo) };
103        for v in r.iter_mut() {
104            *v = gelu_tanh(*v);
105        }
106    });
107}
108
109/// One attention row, normalized. A step softmaxes on the order of a
110/// billion scores — forty-eight blocks × thirty-two heads × every query
111/// against every key — so the scalar `exp()` here was not a detail: it was
112/// the arithmetic. The NEON path evaluates four at a time.
113pub(crate) fn softmax(row: &mut [f32]) {
114    #[cfg(target_arch = "aarch64")]
115    {
116        crate::attention::softmax_row(row);
117    }
118    #[cfg(not(target_arch = "aarch64"))]
119    {
120        let mx = row.iter().cloned().fold(f32::MIN, f32::max);
121        let mut den = 0f32;
122        for r in row.iter_mut() {
123            *r = (*r - mx).exp();
124            den += *r;
125        }
126        if den > 0.0 {
127            let inv = 1.0 / den;
128            for r in row.iter_mut() {
129                *r *= inv;
130            }
131        }
132    }
133}
134
135/// LayerNorm with no affine — the output head's only normalization.
136fn layer_norm(x: &[f32], dst: &mut [f32]) {
137    let n = x.len() as f64;
138    let mean = x.iter().map(|&v| v as f64).sum::<f64>() / n;
139    let var = x
140        .iter()
141        .map(|&v| (v as f64 - mean) * (v as f64 - mean))
142        .sum::<f64>()
143        / n;
144    let inv = 1.0 / (var + EPS).sqrt();
145    for (d, &v) in dst.iter_mut().zip(x) {
146        *d = ((v as f64 - mean) * inv) as f32;
147    }
148}
149
150// ---------------------------------------------------------------- linear
151
152/// `y = x·Wᵀ + b`, the weight read in place when the container quantized it.
153pub(crate) struct Lin {
154    w: Proj,
155    b: Option<Vec<f32>>,
156    /// A runtime low-rank branch, when an adapter is loaded. The container's
157    /// q4tp weights cannot absorb one, so it is evaluated beside them.
158    lora: Option<crate::ltxlora::LoraBranch>,
159}
160
161impl Lin {
162    pub(crate) fn load(model: &Arc<CmfModel>, name: &str, bias: bool) -> Result<Lin, String> {
163        Lin::load_lora(model, name, bias, None)
164    }
165
166    /// The same, consulting an adapter for a low-rank branch on this
167    /// projection. A bank that carries nothing for `name` leaves the
168    /// projection exactly as `load` would.
169    pub(crate) fn load_lora(
170        model: &Arc<CmfModel>,
171        name: &str,
172        bias: bool,
173        bank: Option<&crate::ltxlora::LoraBank>,
174    ) -> Result<Lin, String> {
175        let w = Proj::from_model(model, &format!("{name}.weight"))?;
176        let b = if bias {
177            Some(cmf_f32(model, &format!("{name}.bias"))?)
178        } else {
179            None
180        };
181        // Shape-checked: a branch that does not match this projection is an
182        // adapter for another model, and the branch writes through a raw
183        // pointer sized by ITS out dimension.
184        let lora = match bank {
185            Some(k) => k.branch_for(name, w.rows(), w.cols())?,
186            None => None,
187        };
188        Ok(Lin { w, b, lora })
189    }
190
191    /// The adapter half of `apply`, for the fused paths that got the base
192    /// product elsewhere. Runs before the bias, which is where the reference
193    /// implementations put it — the branch sees the same activations the
194    /// base projection did.
195    pub(crate) fn has_lora(&self) -> bool {
196        self.lora.as_ref().is_some_and(|l| l.live())
197    }
198
199    pub(crate) fn add_lora(&self, out: &mut [f32], x: &[f32], n: usize, pool: Option<&Pool>) {
200        if let Some(l) = &self.lora {
201            l.add(x, n, out, pool);
202        }
203    }
204
205    /// The container-mapped q4tp weight behind this projection, when there
206    /// is one: (model, tensor index, rows, cols).
207    #[cfg(target_os = "macos")]
208    fn mapped(&self) -> Option<(&Arc<CmfModel>, usize, usize, usize)> {
209        let (model, idx) = self.w.q4tp_mapped()?;
210        Some((model, idx, self.w.rows(), self.w.cols()))
211    }
212
213    /// The bias half of `apply`, for callers that got the product elsewhere.
214    pub(crate) fn add_bias(&self, out: &mut [f32], n: usize, pool: Option<&Pool>) {
215        let Some(b) = &self.b else { return };
216        let m = self.w.rows();
217        let dst = Shared(out.as_mut_ptr());
218        rows(pool, n, &|s, e| {
219            let r = unsafe { dst.at(s * m, (e - s) * m) };
220            for row in r.chunks_exact_mut(m) {
221                for (v, &bb) in row.iter_mut().zip(b) {
222                    *v += bb;
223                }
224            }
225        });
226    }
227
228    pub(crate) fn apply(&self, x: &[f32], n: usize, pool: Option<&Pool>) -> Vec<f32> {
229        let m = self.w.rows();
230        let cols = self.w.cols();
231        let t_alloc = std::time::Instant::now();
232        let mut out = vec![0f32; n * m];
233        attn_prof::ALLOC.fetch_add(
234            t_alloc.elapsed().as_micros() as u64,
235            std::sync::atomic::Ordering::Relaxed,
236        );
237        let t_mm = std::time::Instant::now();
238        // With an adapter on this projection, the base GEMM and the branch go
239        // to the device in ONE submission: the branch reads the activation the
240        // base GEMM already uploaded and accumulates into the output it just
241        // wrote, so the adapter costs no transfer. Falls through to the split
242        // path when the device declines the shape.
243        #[cfg(target_os = "macos")]
244        if let (Some(l), Some((model, idx, r, cl))) = (&self.lora, self.mapped()) {
245            // The router measures a branch by comparing it against the base
246            // panel, and this path never separates the two. With routing on,
247            // take the split path so the measurement can happen at all.
248            if l.live()
249                && crate::ltxlora::route_threshold().is_none()
250                && n >= 32
251                && n * r * cl >= 128_000_000
252                && cl % 32 == 0
253                && !crate::gpu::mm_killed()
254                && crate::gpu::enabled_here()
255                && crate::gpu_metal::q4tp_matmat_lora(model, idx, x, n, r, cl, &mut out, &l.side())
256            {
257                attn_prof::MATMAT.fetch_add(
258                    t_mm.elapsed().as_micros() as u64,
259                    std::sync::atomic::Ordering::Relaxed,
260                );
261                self.add_bias(&mut out, n, pool);
262                return out;
263            }
264        }
265        // A GPU binding is capped near 2 GiB, and the prompt encoder's
266        // aggregate projection reads 188160 numbers per token — 1024 of them
267        // at once is past the cap. Chunk the batch so no single dispatch
268        // binds more than a quarter of a gigabyte of activations.
269        let per_row = cols * 4;
270        let chunk = (0x1000_0000usize / per_row.max(1)).max(1);
271        let mut done = 0usize;
272        while done < n {
273            let take = chunk.min(n - done);
274            self.w.matmat(
275                &x[done * cols..(done + take) * cols],
276                take,
277                &mut out[done * m..(done + take) * m],
278                pool,
279            );
280            done += take;
281        }
282        attn_prof::MATMAT.fetch_add(
283            t_mm.elapsed().as_micros() as u64,
284            std::sync::atomic::Ordering::Relaxed,
285        );
286        if let Some(l) = &self.lora {
287            l.add(x, n, &mut out, pool);
288        }
289        if let Some(b) = &self.b {
290            let dst = Shared(out.as_mut_ptr());
291            rows(pool, n, &|s, e| {
292                let r = unsafe { dst.at(s * m, (e - s) * m) };
293                for row in r.chunks_exact_mut(m) {
294                    for (v, &bb) in row.iter_mut().zip(b) {
295                        *v += bb;
296                    }
297                }
298            });
299        }
300        out
301    }
302}
303
304// ------------------------------------------------------------------ rope
305
306/// Split-RoPE tables: `cos`/`sin` laid out as `[tokens, heads·dh/2]`.
307pub struct Rope {
308    cos: Vec<f32>,
309    sin: Vec<f32>,
310    heads: usize,
311    half: usize,
312}
313
314impl Rope {
315    /// `positions[t][d]` are patch midpoints and `max_pos[d]` the axis
316    /// extent they are divided by. `dim` is the *inner* dimension
317    /// (heads·dh) the frequency ladder is sized from.
318    pub fn build(
319        positions: &[Vec<f64>],
320        max_pos: &[f64],
321        dim: usize,
322        heads: usize,
323        theta: f64,
324    ) -> Rope {
325        let ndim = max_pos.len();
326        let count = dim / (2 * ndim);
327        // indices = theta^linspace(0, 1, count) · π/2, in f64
328        let idx: Vec<f64> = (0..count)
329            .map(|j| {
330                let e = if count > 1 {
331                    j as f64 / (count - 1) as f64
332                } else {
333                    0.0
334                };
335                theta.powf(e) * std::f64::consts::PI / 2.0
336            })
337            .collect();
338        let n = positions.len();
339        let half = dim / 2;
340        let pad = half - count * ndim;
341        let mut cos = vec![0f32; n * half];
342        let mut sin = vec![0f32; n * half];
343        for (t, p) in positions.iter().enumerate() {
344            let base = t * half;
345            for i in 0..pad {
346                cos[base + i] = 1.0;
347            }
348            for (j, &ind) in idx.iter().enumerate() {
349                for (d, &mp) in max_pos.iter().enumerate() {
350                    let f = ind * (p[d] / mp * 2.0 - 1.0);
351                    let o = base + pad + j * ndim + d;
352                    cos[o] = f.cos() as f32;
353                    sin[o] = f.sin() as f32;
354                }
355            }
356        }
357        Rope {
358            cos,
359            sin,
360            heads,
361            half: half / heads,
362        }
363    }
364
365    /// In-place split rotation of one token's `[heads·dh]` projection.
366    fn apply_row(&self, t: usize, row: &mut [f32]) {
367        let dh = self.half * 2;
368        let stride = self.heads * self.half;
369        for h in 0..self.heads {
370            let off = t * stride + h * self.half;
371            let (c, s) = (
372                &self.cos[off..off + self.half],
373                &self.sin[off..off + self.half],
374            );
375            let v = &mut row[h * dh..(h + 1) * dh];
376            for i in 0..self.half {
377                let (a, b) = (v[i], v[i + self.half]);
378                v[i] = a * c[i] - b * s[i];
379                v[i + self.half] = b * c[i] + a * s[i];
380            }
381        }
382    }
383}
384
385/// Sub-phase microseconds inside attention, summed across every call in a
386/// step. `CMF_LTX_PROF=1` prints them: the phase timers above say *which*
387/// attention is slow, these say *what part of it* is.
388pub(crate) mod attn_prof {
389    use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
390    pub static PROJ: AtomicU64 = AtomicU64::new(0);
391    pub static NORM: AtomicU64 = AtomicU64::new(0);
392    pub static GATHER: AtomicU64 = AtomicU64::new(0);
393    pub static SCORE: AtomicU64 = AtomicU64::new(0);
394    pub static SOFT: AtomicU64 = AtomicU64::new(0);
395    pub static VALUE: AtomicU64 = AtomicU64::new(0);
396    pub static OUT: AtomicU64 = AtomicU64::new(0);
397    pub static ALLOC: AtomicU64 = AtomicU64::new(0);
398    pub static MATMAT: AtomicU64 = AtomicU64::new(0);
399
400    pub fn add(c: &AtomicU64, t: std::time::Instant) -> std::time::Instant {
401        c.fetch_add(t.elapsed().as_micros() as u64, Relaxed);
402        std::time::Instant::now()
403    }
404
405    pub fn report() -> String {
406        let s = |c: &AtomicU64| c.swap(0, Relaxed) as f64 / 1e6;
407        format!(
408            "proj {:.2}s  qk-norm+rope {:.2}s  gather {:.2}s  scores {:.2}s  softmax {:.2}s  values {:.2}s  out {:.2}s  [linear: alloc {:.2}s  matmat {:.2}s]",
409            s(&PROJ),
410            s(&NORM),
411            s(&GATHER),
412            s(&SCORE),
413            s(&SOFT),
414            s(&VALUE),
415            s(&OUT),
416            s(&ALLOC),
417            s(&MATMAT)
418        )
419    }
420}
421
422// ------------------------------------------------------------- attention
423
424pub(crate) struct Attn {
425    q: Lin,
426    k: Lin,
427    v: Lin,
428    o: Lin,
429    q_norm: Vec<f32>,
430    k_norm: Vec<f32>,
431    gate: Option<Lin>,
432    heads: usize,
433    dh: usize,
434}
435
436impl Attn {
437    pub(crate) fn load(
438        model: &Arc<CmfModel>,
439        p: &str,
440        heads: usize,
441        dh: usize,
442    ) -> Result<Attn, String> {
443        Attn::load_lora(model, p, heads, dh, None)
444    }
445
446    pub(crate) fn load_lora(
447        model: &Arc<CmfModel>,
448        p: &str,
449        heads: usize,
450        dh: usize,
451        bank: Option<&crate::ltxlora::LoraBank>,
452    ) -> Result<Attn, String> {
453        Ok(Attn {
454            q: Lin::load_lora(model, &format!("{p}.to_q"), true, bank)?,
455            k: Lin::load_lora(model, &format!("{p}.to_k"), true, bank)?,
456            v: Lin::load_lora(model, &format!("{p}.to_v"), true, bank)?,
457            o: Lin::load_lora(model, &format!("{p}.to_out.0"), true, bank)?,
458            q_norm: cmf_f32(model, &format!("{p}.q_norm.weight"))?,
459            k_norm: cmf_f32(model, &format!("{p}.k_norm.weight"))?,
460            gate: match model.tensor(&format!("{p}.to_gate_logits.weight")) {
461                Some(_) => Some(Lin::load(model, &format!("{p}.to_gate_logits"), true)?),
462                None => None,
463            },
464            heads,
465            dh,
466        })
467    }
468
469    /// The three projections in one submission, when the platform has a
470    /// batched entry and all three read the same activation. Returns `None`
471    /// when they do not — cross-attention's query comes from the tokens and
472    /// its keys from the prompt — and the caller falls back to three calls.
473    #[cfg(target_os = "macos")]
474    fn fused_qkv(
475        &self,
476        x: &[f32],
477        n: usize,
478        ctx: &[f32],
479        m: usize,
480        pool: Option<&Pool>,
481    ) -> Option<(Vec<f32>, Vec<f32>, Vec<f32>)> {
482        if !std::ptr::eq(x.as_ptr(), ctx.as_ptr()) || n != m {
483            return None;
484        }
485        if !crate::gpu::enabled_here() || crate::gpu::mm_killed() {
486            return None;
487        }
488        // The batched entry carries one weight per job and no branch. When an
489        // adapter is loaded, decline: `apply` then takes the per-projection
490        // path that fuses the base GEMM and the branch into one submission,
491        // which is the cheaper of the two.
492        if self.q.has_lora() || self.k.has_lora() || self.v.has_lora() {
493            return None;
494        }
495        let (qw, kw, vw) = (self.q.mapped()?, self.k.mapped()?, self.v.mapped()?);
496        if !Arc::ptr_eq(qw.0, kw.0) || !Arc::ptr_eq(qw.0, vw.0) {
497            return None;
498        }
499        let jobs = [
500            crate::gpu_metal::MmJob {
501                idx: qw.1,
502                rows: qw.2,
503                cols: qw.3,
504            },
505            crate::gpu_metal::MmJob {
506                idx: kw.1,
507                rows: kw.2,
508                cols: kw.3,
509            },
510            crate::gpu_metal::MmJob {
511                idx: vw.1,
512                rows: vw.2,
513                cols: vw.3,
514            },
515        ];
516        if n * jobs[0].rows * jobs[0].cols < 128_000_000 || n < 32 {
517            return None;
518        }
519        let mut oq = vec![0f32; n * jobs[0].rows];
520        let mut ok = vec![0f32; n * jobs[1].rows];
521        let mut ov = vec![0f32; n * jobs[2].rows];
522        let done = {
523            let mut outs: [&mut [f32]; 3] = [&mut oq, &mut ok, &mut ov];
524            crate::gpu_metal::q4tp_matmat_many(qw.0, &jobs, x, n, &mut outs)
525        };
526        if !done {
527            return None;
528        }
529        self.q.add_lora(&mut oq, x, n, pool);
530        self.k.add_lora(&mut ok, x, n, pool);
531        self.v.add_lora(&mut ov, x, n, pool);
532        self.q.add_bias(&mut oq, n, pool);
533        self.k.add_bias(&mut ok, n, pool);
534        self.v.add_bias(&mut ov, n, pool);
535        Some((oq, ok, ov))
536    }
537
538    /// The key and value projections in one submission — they always read
539    /// the same buffer, whatever the query does.
540    #[cfg(target_os = "macos")]
541    fn fused_kv(&self, ctx: &[f32], m: usize) -> Option<(Vec<f32>, Vec<f32>)> {
542        if !crate::gpu::enabled_here() || crate::gpu::mm_killed() {
543            return None;
544        }
545        if self.k.has_lora() || self.v.has_lora() {
546            return None;
547        }
548        let (kw, vw) = (self.k.mapped()?, self.v.mapped()?);
549        if !Arc::ptr_eq(kw.0, vw.0) {
550            return None;
551        }
552        let jobs = [
553            crate::gpu_metal::MmJob {
554                idx: kw.1,
555                rows: kw.2,
556                cols: kw.3,
557            },
558            crate::gpu_metal::MmJob {
559                idx: vw.1,
560                rows: vw.2,
561                cols: vw.3,
562            },
563        ];
564        if m < 32 || m * jobs[0].rows * jobs[0].cols < 128_000_000 {
565            return None;
566        }
567        let mut ok = vec![0f32; m * jobs[0].rows];
568        let mut ov = vec![0f32; m * jobs[1].rows];
569        let done = {
570            let mut outs: [&mut [f32]; 2] = [&mut ok, &mut ov];
571            crate::gpu_metal::q4tp_matmat_many(kw.0, &jobs, ctx, m, &mut outs)
572        };
573        if !done {
574            return None;
575        }
576        self.k.add_lora(&mut ok, ctx, m, None);
577        self.v.add_lora(&mut ov, ctx, m, None);
578        self.k.add_bias(&mut ok, m, None);
579        self.v.add_bias(&mut ov, m, None);
580        Some((ok, ov))
581    }
582
583    #[cfg(not(target_os = "macos"))]
584    fn fused_kv(&self, _ctx: &[f32], _m: usize) -> Option<(Vec<f32>, Vec<f32>)> {
585        None
586    }
587
588    #[cfg(not(target_os = "macos"))]
589    fn fused_qkv(
590        &self,
591        _x: &[f32],
592        _n: usize,
593        _ctx: &[f32],
594        _m: usize,
595        _pool: Option<&Pool>,
596    ) -> Option<(Vec<f32>, Vec<f32>, Vec<f32>)> {
597        None
598    }
599
600    /// `x` is `[n, query_dim]`, `ctx` is `[m, context_dim]` (self-attention
601    /// passes the same buffer twice). `mask` is an additive per-key bias.
602    #[allow(clippy::too_many_arguments)]
603    pub(crate) fn forward(
604        &self,
605        x: &[f32],
606        n: usize,
607        ctx: &[f32],
608        m: usize,
609        pe_q: Option<&Rope>,
610        pe_k: Option<&Rope>,
611        mask: Option<&[f32]>,
612        pool: Option<&Pool>,
613    ) -> Vec<f32> {
614        let inner = self.heads * self.dh;
615        let prof = std::env::var("CMF_LTX_PROF").is_ok();
616        let mut t = std::time::Instant::now();
617        // q, k and v do not depend on each other. When they also read the
618        // same buffer — self-attention, and the A↔V pair on the context
619        // side — they go to the device as one command buffer instead of
620        // three, which is three times less of the ~1.3 ms a completion
621        // costs whatever it contains.
622        let (mut q, mut k, v) = match self.fused_qkv(x, n, ctx, m, pool) {
623            Some(t) => t,
624            // Cross-attention's query reads the tokens and its keys read the
625            // prompt, so those two cannot share a submission — but the keys
626            // and the values still can.
627            None => match self.fused_kv(ctx, m) {
628                Some((k, v)) => (self.q.apply(x, n, pool), k, v),
629                None => (
630                    self.q.apply(x, n, pool),
631                    self.k.apply(ctx, m, pool),
632                    self.v.apply(ctx, m, pool),
633                ),
634            },
635        };
636        if prof {
637            t = attn_prof::add(&attn_prof::PROJ, t);
638        }
639
640        let qn = Shared(q.as_mut_ptr());
641        rows(pool, n, &|s, e| {
642            let r = unsafe { qn.at(s * inner, (e - s) * inner) };
643            for (i, row) in r.chunks_exact_mut(inner).enumerate() {
644                rms_w(row, &self.q_norm);
645                if let Some(pe) = pe_q {
646                    pe.apply_row(s + i, row);
647                }
648            }
649        });
650        let kn = Shared(k.as_mut_ptr());
651        rows(pool, m, &|s, e| {
652            let r = unsafe { kn.at(s * inner, (e - s) * inner) };
653            for (i, row) in r.chunks_exact_mut(inner).enumerate() {
654                rms_w(row, &self.k_norm);
655                if let Some(pe) = pe_k {
656                    pe.apply_row(s + i, row);
657                }
658            }
659        });
660
661        // Per head, both halves of attention are GEMMs: scores are
662        // q·kᵀ and the value product is p·v. Gathering each head into a
663        // contiguous `[tokens, dh]` block costs one copy and buys the
664        // engine's blocked/BLAS/GPU kernels instead of a scalar loop —
665        // this is most of a step's arithmetic.
666        if prof {
667            t = attn_prof::add(&attn_prof::NORM, t);
668        }
669        let mut out = vec![0f32; n * inner];
670        let scale = 1.0 / (self.dh as f32).sqrt();
671        let dh = self.dh;
672        let mut qh = vec![0f32; n * dh];
673        let mut kh = vec![0f32; m * dh];
674        let mut vh = vec![0f32; m * dh];
675        let mut sc = vec![0f32; n * m];
676        let mut oh = vec![0f32; n * dh];
677        // PV and QK cost the same flops, and PV takes twice the clock:
678        // measured on this stand at 512x512x49, per denoise step, scores
679        // 6.03 s against values 12.13 s (three steps, 6.03-6.54 and
680        // 12.13-12.71). The reason is which cooperative kernel each lands
681        // on -- `gemm_nt` picks `gemm_nt_coop`, whose right operand is read
682        // along rows, while `gemm_dx` picks `gemm_nn_coop` and reads v down
683        // columns of its [tokens, dh] block. Both are on the matrix units;
684        // the difference is the access pattern, not the acceleration.
685        //
686        // The image DiT already solved exactly this (`gpu_wgpu.rs:23664`:
687        // "transpose it once per block and PV becomes the NT product QK
688        // already is", 4.76 -> 2.91 s a step, frames within 0.1%). Here the
689        // transpose is free: v is gathered per head anyway, so gathering it
690        // as [dh, tokens] is the same count of writes to different offsets.
691        //
692        // OFF by default until measured on THIS path. The image DiT's
693        // number is its own; quoting it as the expected win here would be
694        // the same mistake as taking a benchmark out of a docstring.
695        // `CMF_LTX_PV_NT=1` turns it on.
696        let pv_nt = std::env::var("CMF_LTX_PV_NT").as_deref() == Ok("1");
697        for h in 0..self.heads {
698            for i in 0..n {
699                qh[i * dh..(i + 1) * dh].copy_from_slice(&q[i * inner + h * dh..][..dh]);
700            }
701            for j in 0..m {
702                kh[j * dh..(j + 1) * dh].copy_from_slice(&k[j * inner + h * dh..][..dh]);
703            }
704            if pv_nt {
705                // vh holds vᵀ: [dh, tokens], so PV is an NT product.
706                for j in 0..m {
707                    let src = &v[j * inner + h * dh..][..dh];
708                    for (d, s) in src.iter().enumerate() {
709                        vh[d * m + j] = *s;
710                    }
711                }
712            } else {
713                for j in 0..m {
714                    vh[j * dh..(j + 1) * dh].copy_from_slice(&v[j * inner + h * dh..][..dh]);
715                }
716            }
717            if prof {
718                t = attn_prof::add(&attn_prof::GATHER, t);
719            }
720            crate::fcd_ops::gemm_nt(&qh, &kh, &mut sc, n, dh, m, pool);
721            if prof {
722                t = attn_prof::add(&attn_prof::SCORE, t);
723            }
724            let sp = Shared(sc.as_mut_ptr());
725            rows(pool, n, &|s, e| {
726                let r = unsafe { sp.at(s * m, (e - s) * m) };
727                for row in r.chunks_exact_mut(m) {
728                    for (x, j) in row.iter_mut().zip(0..m) {
729                        *x = *x * scale + mask.map_or(0.0, |mk| mk[j]);
730                    }
731                    softmax(row);
732                }
733            });
734            if prof {
735                t = attn_prof::add(&attn_prof::SOFT, t);
736            }
737            oh.iter_mut().for_each(|x| *x = 0.0);
738            if pv_nt {
739                // oh[n, dh] = sc[n, m] · (vᵀ[dh, m])ᵀ  -- gemm_nt's (n, k, m)
740                // is (tokens_out, reduction, width), so k = m and m = dh here.
741                crate::fcd_ops::gemm_nt(&sc, &vh, &mut oh, n, m, dh, pool);
742            } else {
743                crate::fcd_ops::gemm_dx(&sc, &vh, &mut oh, n, dh, m, pool);
744            }
745            if prof {
746                t = attn_prof::add(&attn_prof::VALUE, t);
747            }
748            for i in 0..n {
749                out[i * inner + h * dh..i * inner + (h + 1) * dh]
750                    .copy_from_slice(&oh[i * dh..(i + 1) * dh]);
751            }
752            if prof {
753                t = attn_prof::add(&attn_prof::GATHER, t);
754            }
755        }
756
757        if let Some(g) = &self.gate {
758            let logits = g.apply(x, n, pool);
759            let h = self.heads;
760            let dst = Shared(out.as_mut_ptr());
761            rows(pool, n, &|s, e| {
762                let r = unsafe { dst.at(s * inner, (e - s) * inner) };
763                for (i, row) in r.chunks_exact_mut(inner).enumerate() {
764                    for hh in 0..h {
765                        let gate = 2.0 / (1.0 + (-logits[(s + i) * h + hh]).exp());
766                        for d in row[hh * self.dh..(hh + 1) * self.dh].iter_mut() {
767                            *d *= gate;
768                        }
769                    }
770                }
771            });
772        }
773        let r = self.o.apply(&out, n, pool);
774        if prof {
775            attn_prof::add(&attn_prof::OUT, t);
776        }
777        r
778    }
779}
780
781// ---------------------------------------------------------- adaLN single
782
783/// `AdaLayerNormSingle`: sinusoidal timestep → SiLU MLP → `coeff·dim`
784/// modulation values, plus the embedding the output head reuses.
785struct AdaLn {
786    l1: Lin,
787    l2: Lin,
788    lin: Lin,
789    dim: usize,
790}
791
792impl AdaLn {
793    fn load(model: &Arc<CmfModel>, p: &str, dim: usize) -> Result<AdaLn, String> {
794        Ok(AdaLn {
795            l1: Lin::load(model, &format!("{p}.emb.timestep_embedder.linear_1"), true)?,
796            l2: Lin::load(model, &format!("{p}.emb.timestep_embedder.linear_2"), true)?,
797            lin: Lin::load(model, &format!("{p}.linear"), true)?,
798            dim,
799        })
800    }
801
802    /// `(values [n, coeff·dim], embedded [n, dim])` for `n` timesteps.
803    fn forward(&self, t: &[f32], pool: Option<&Pool>) -> (Vec<f32>, Vec<f32>) {
804        let n = t.len();
805        // get_timestep_embedding(256, flip_sin_to_cos=True, shift=0): the
806        // flip puts cosine first, so the halves are [cos, sin].
807        let half = 128usize;
808        let mut proj = vec![0f32; n * 256];
809        let ws: Vec<f64> = (0..half)
810            .map(|j| (-(10000f64).ln() * j as f64 / half as f64).exp())
811            .collect();
812        for (i, &tv) in t.iter().enumerate() {
813            for (j, &w) in ws.iter().enumerate() {
814                let a = tv as f64 * w;
815                proj[i * 256 + j] = a.cos() as f32;
816                proj[i * 256 + half + j] = a.sin() as f32;
817            }
818        }
819        let mut h = self.l1.apply(&proj, n, pool);
820        for v in h.iter_mut() {
821            *v = silu(*v);
822        }
823        let embedded = self.l2.apply(&h, n, pool);
824        let mut act = embedded.clone();
825        for v in act.iter_mut() {
826            *v = silu(*v);
827        }
828        (self.lin.apply(&act, n, pool), embedded)
829    }
830}
831
832/// adaLN values for the *distinct* timesteps of a stream, plus each token's
833/// index into them. Per-token timesteps take only a handful of values here
834/// (a conditioning token sits at 0 while the rest sit at the current
835/// sigma), so the `[36864, 4096]` projection runs a few times, not `T`.
836struct TsTable {
837    vals: Vec<f32>,
838    emb: Vec<f32>,
839    idx: Vec<usize>,
840    width: usize,
841    edim: usize,
842}
843
844impl TsTable {
845    fn build(a: &AdaLn, ts: &[f32], scale: f64, pool: Option<&Pool>) -> TsTable {
846        let mut vals: Vec<f32> = Vec::new();
847        let mut idx = Vec::with_capacity(ts.len());
848        for &t in ts {
849            match vals.iter().position(|&v| v.to_bits() == t.to_bits()) {
850                Some(i) => idx.push(i),
851                None => {
852                    vals.push(t);
853                    idx.push(vals.len() - 1);
854                }
855            }
856        }
857        let scaled: Vec<f32> = vals.iter().map(|&v| (v as f64 * scale) as f32).collect();
858        let (v, e) = a.forward(&scaled, pool);
859        let width = v.len() / scaled.len().max(1);
860        TsTable {
861            vals: v,
862            emb: e,
863            idx,
864            width,
865            edim: a.dim,
866        }
867    }
868
869    fn distinct(&self) -> usize {
870        self.vals.len() / self.width.max(1)
871    }
872
873    fn row(&self, r: usize) -> &[f32] {
874        &self.vals[r * self.width..(r + 1) * self.width]
875    }
876
877    fn emb_row(&self, r: usize) -> &[f32] {
878        &self.emb[r * self.edim..(r + 1) * self.edim]
879    }
880
881    /// `(shift, scale, gate)` per distinct timestep for the adaLN triple at
882    /// table row `off`: the block's static table plus the timestep's own
883    /// contribution, summed once instead of once per token.
884    fn triples(&self, table: &[f32], dim: usize, off: usize) -> Vec<[Vec<f32>; 3]> {
885        (0..self.distinct())
886            .map(|r| {
887                let v = self.row(r);
888                std::array::from_fn(|j| {
889                    let o = (off + j) * dim;
890                    (0..dim).map(|d| table[o + d] + v[o + d]).collect()
891                })
892            })
893            .collect()
894    }
895
896    /// `(scale, shift)` per distinct timestep for an A↔V table, which — unlike
897    /// the self-attention rows — comes out scale-first.
898    fn pairs(&self, table: &[f32], dim: usize, off: usize) -> Vec<[Vec<f32>; 2]> {
899        (0..self.distinct())
900            .map(|r| {
901                let v = self.row(r);
902                std::array::from_fn(|j| {
903                    let o = (off + j) * dim;
904                    (0..dim).map(|d| table[o + d] + v[o + d]).collect()
905                })
906            })
907            .collect()
908    }
909}
910
911/// `rms_norm(x) · (1 + scale) + shift` over every token, in parallel.
912/// `mods[r]` is the `(shift, scale, gate)` triple of the r-th distinct
913/// timestep and `idx[t]` says which one token `t` uses.
914fn ada_zero_rows(
915    x: &[f32],
916    out: &mut [f32],
917    n: usize,
918    dim: usize,
919    mods: &[[Vec<f32>; 3]],
920    idx: &[usize],
921    pool: Option<&Pool>,
922) {
923    let dst = Shared(out.as_mut_ptr());
924    rows(pool, n, &|s, e| {
925        let r = unsafe { dst.at(s * dim, (e - s) * dim) };
926        for (row, i) in r.chunks_exact_mut(dim).zip(s..e) {
927            let md = &mods[idx[i]];
928            rms_plain(&x[i * dim..(i + 1) * dim], row);
929            for d in 0..dim {
930                row[d] = row[d] * (1.0 + md[1][d]) + md[0][d];
931            }
932        }
933    });
934}
935
936/// `x += y · gate`, the residual every sub-layer writes back through.
937fn add_gated(
938    x: &mut [f32],
939    y: &[f32],
940    n: usize,
941    dim: usize,
942    mods: &[[Vec<f32>; 3]],
943    idx: &[usize],
944    pool: Option<&Pool>,
945) {
946    let dst = Shared(x.as_mut_ptr());
947    rows(pool, n, &|s, e| {
948        let r = unsafe { dst.at(s * dim, (e - s) * dim) };
949        for (row, i) in r.chunks_exact_mut(dim).zip(s..e) {
950            let g = &mods[idx[i]][2];
951            for d in 0..dim {
952                row[d] += y[i * dim + d] * g[d];
953            }
954        }
955    });
956}
957
958/// The post-SA pair: fold the gated update into the residual and hand the
959/// re-normalized result to cross-attention, in one pass over the tokens.
960fn post_sa_rows(
961    x: &mut [f32],
962    y: &[f32],
963    normed: &mut [f32],
964    n: usize,
965    dim: usize,
966    mods: &[[Vec<f32>; 3]],
967    idx: &[usize],
968    pool: Option<&Pool>,
969) {
970    let a = Shared(x.as_mut_ptr());
971    let b = Shared(normed.as_mut_ptr());
972    rows(pool, n, &|s, e| {
973        let xr = unsafe { a.at(s * dim, (e - s) * dim) };
974        let nr = unsafe { b.at(s * dim, (e - s) * dim) };
975        for ((row, nrow), i) in xr
976            .chunks_exact_mut(dim)
977            .zip(nr.chunks_exact_mut(dim))
978            .zip(s..e)
979        {
980            let g = &mods[idx[i]][2];
981            for d in 0..dim {
982                row[d] += y[i * dim + d] * g[d];
983            }
984            rms_plain(row, nrow);
985        }
986    });
987}
988
989/// `x += y · g` with one shared per-channel gate (the A↔V fusion).
990fn add_scaled(x: &mut [f32], y: &[f32], n: usize, dim: usize, g: &[f32], pool: Option<&Pool>) {
991    let dst = Shared(x.as_mut_ptr());
992    rows(pool, n, &|s, e| {
993        let r = unsafe { dst.at(s * dim, (e - s) * dim) };
994        for (row, i) in r.chunks_exact_mut(dim).zip(s..e) {
995            for d in 0..dim {
996                row[d] += y[i * dim + d] * g[d];
997            }
998        }
999    });
1000}
1001
1002/// The cross-attention query: an affine on an already-normalized row.
1003fn affine_rows(
1004    x: &[f32],
1005    out: &mut [f32],
1006    n: usize,
1007    dim: usize,
1008    mods: &[[Vec<f32>; 3]],
1009    idx: &[usize],
1010    pool: Option<&Pool>,
1011) {
1012    let dst = Shared(out.as_mut_ptr());
1013    rows(pool, n, &|s, e| {
1014        let r = unsafe { dst.at(s * dim, (e - s) * dim) };
1015        for (row, i) in r.chunks_exact_mut(dim).zip(s..e) {
1016            let md = &mods[idx[i]];
1017            for d in 0..dim {
1018                row[d] = x[i * dim + d] * (1.0 + md[1][d]) + md[0][d];
1019            }
1020        }
1021    });
1022}
1023
1024/// Where a denoising step actually goes. `CMF_LTX_PROF=1` prints the split
1025/// once per forward: guessing at this is how ports stay slow.
1026#[derive(Default)]
1027struct Prof {
1028    on: bool,
1029    t: [f64; 6],
1030}
1031
1032const P_ADALN: usize = 0;
1033const P_SELF: usize = 1;
1034const P_CROSS: usize = 2;
1035const P_FUSE: usize = 3;
1036const P_FF: usize = 4;
1037const P_MOD: usize = 5;
1038
1039impl Prof {
1040    fn new() -> Prof {
1041        Prof {
1042            on: std::env::var("CMF_LTX_PROF").is_ok(),
1043            t: [0.0; 6],
1044        }
1045    }
1046    #[inline]
1047    fn tick(&mut self, slot: usize, at: std::time::Instant) -> std::time::Instant {
1048        if self.on {
1049            self.t[slot] += at.elapsed().as_secs_f64();
1050            return std::time::Instant::now();
1051        }
1052        at
1053    }
1054    fn report(&self) {
1055        if !self.on {
1056            return;
1057        }
1058        // The q4tp GEMM's own split, when Metal is keeping it: a copy-bound
1059        // step wants fused blocks, a kernel-bound one wants a better kernel.
1060        #[cfg(target_os = "macos")]
1061        {
1062            use std::sync::atomic::Ordering::Relaxed;
1063            let n = crate::gpu_metal::MM_N.swap(0, Relaxed);
1064            if n > 0 {
1065                let us = |a: &std::sync::atomic::AtomicU64| a.swap(0, Relaxed) as f64 / 1e6;
1066                println!(
1067                    "  q4tp on device: {n} calls, upload {:.2}s  kernel {:.2}s  download {:.2}s",
1068                    us(&crate::gpu_metal::MM_UP),
1069                    us(&crate::gpu_metal::MM_GPU),
1070                    us(&crate::gpu_metal::MM_DN),
1071                );
1072            }
1073        }
1074        println!("  attention: {}", attn_prof::report());
1075        let names = [
1076            "adaln",
1077            "self-attn",
1078            "cross-attn",
1079            "a<->v",
1080            "ffn",
1081            "modulate",
1082        ];
1083        let total: f64 = self.t.iter().sum();
1084        let parts: Vec<String> = names
1085            .iter()
1086            .zip(&self.t)
1087            .map(|(n, v)| format!("{n} {v:.1}s ({:.0}%)", 100.0 * v / total.max(1e-9)))
1088            .collect();
1089        println!("  profile: {}", parts.join("  "));
1090    }
1091}
1092
1093// ----------------------------------------------------------------- block
1094
1095struct Stream {
1096    attn1: Attn,
1097    attn2: Attn,
1098    ff_in: Lin,
1099    ff_out: Lin,
1100    sst: Vec<f32>,        // [9, dim]
1101    prompt_sst: Vec<f32>, // [2, dim]
1102}
1103
1104impl Stream {
1105    fn load(
1106        model: &Arc<CmfModel>,
1107        p: &str,
1108        prefix: &str,
1109        heads: usize,
1110        dh: usize,
1111        ff_bias: bool,
1112        bank: Option<&crate::ltxlora::LoraBank>,
1113    ) -> Result<Stream, String> {
1114        let a = |n: &str| format!("{p}.{prefix}{n}");
1115        Ok(Stream {
1116            attn1: Attn::load_lora(model, &a("attn1"), heads, dh, bank)?,
1117            attn2: Attn::load_lora(model, &a("attn2"), heads, dh, bank)?,
1118            ff_in: Lin::load_lora(model, &a("ff.net.0.proj"), ff_bias, bank)?,
1119            ff_out: Lin::load_lora(model, &a("ff.net.2"), ff_bias, bank)?,
1120            sst: cmf_f32(model, &a("scale_shift_table"))?,
1121            prompt_sst: cmf_f32(model, &a("prompt_scale_shift_table"))?,
1122        })
1123    }
1124
1125    fn ff(&self, x: &[f32], n: usize, pool: Option<&Pool>) -> Vec<f32> {
1126        let mut h = self.ff_in.apply(x, n, pool);
1127        gelu_tanh_rows(&mut h, pool);
1128        self.ff_out.apply(&h, n, pool)
1129    }
1130}
1131
1132struct Block {
1133    video: Stream,
1134    audio: Stream,
1135    a2v: Attn,
1136    v2a: Attn,
1137    sst_a2v_video: Vec<f32>, // [5, 4096]
1138    sst_a2v_audio: Vec<f32>, // [5, 2048]
1139}
1140
1141// ----------------------------------------------------------------- model
1142
1143/// One modality's per-step conditioning: everything the blocks read that is
1144/// not a weight.
1145pub struct StreamInput {
1146    /// Patchified latent, `[tokens, in_channels]`.
1147    pub latent: Vec<f32>,
1148    pub tokens: usize,
1149    /// Per-token timestep (the sigma·denoising-mask product).
1150    pub timesteps: Vec<f32>,
1151    /// Per-token patch midpoints, one entry per RoPE axis.
1152    pub positions: Vec<Vec<f64>>,
1153    /// Prompt embeddings out of the connector, `[ctx_len, cross_dim]`.
1154    pub context: Vec<f32>,
1155    pub ctx_len: usize,
1156    /// Additive per-key prompt mask, or empty for "attend to all".
1157    pub context_mask: Vec<f32>,
1158    /// Non-zero for tokens holding a standalone pixel frame (video only).
1159    pub keyframes: Vec<f32>,
1160    /// This stream's sigma — the *other* stream's fusion gate reads it.
1161    pub sigma: f32,
1162}
1163
1164pub struct LtxDit {
1165    model: Arc<CmfModel>,
1166    blocks: Vec<Block>,
1167    patchify: Lin,
1168    a_patchify: Lin,
1169    keyframes_emb: Option<Vec<f32>>,
1170    adaln: AdaLn,
1171    a_adaln: AdaLn,
1172    prompt_adaln: AdaLn,
1173    a_prompt_adaln: AdaLn,
1174    av_v_ss: AdaLn,
1175    av_a_ss: AdaLn,
1176    av_a2v_gate: AdaLn,
1177    av_v2a_gate: AdaLn,
1178    proj_out: Lin,
1179    a_proj_out: Lin,
1180    sst_out: Vec<f32>,
1181    a_sst_out: Vec<f32>,
1182    pub heads: usize,
1183    pub dh: usize,
1184    pub a_heads: usize,
1185    pub a_dh: usize,
1186    pub max_pos: Vec<f64>,
1187    pub a_max_pos: Vec<f64>,
1188    pub cross_max_pos: f64,
1189    pub theta: f64,
1190    pub t_scale: f64,
1191    pub av_t_scale: f64,
1192    pub audio_cross_dim: usize,
1193}
1194
1195impl LtxDit {
1196    pub fn from_cmf(model: &Arc<CmfModel>) -> Result<LtxDit, String> {
1197        LtxDit::from_cmf_lora(model, None)
1198    }
1199
1200    /// The same, with a runtime adapter bound to whichever projections it
1201    /// names. Reported once at load so a file that matched nothing says so
1202    /// instead of rendering as if it had.
1203    pub fn from_cmf_lora(
1204        model: &Arc<CmfModel>,
1205        bank: Option<&crate::ltxlora::LoraBank>,
1206    ) -> Result<LtxDit, String> {
1207        let cfg_bytes = ["ltx.config_json", "dit.config_json"]
1208            .iter()
1209            .find_map(|n| model.tensor(n).map(|e| model.entry_bytes(e)))
1210            .ok_or("container carries no ltx.config_json")?;
1211        let cfg: serde_json::Value =
1212            serde_json::from_slice(cfg_bytes).map_err(|e| format!("ltx.config_json: {e}"))?;
1213        let t = cfg.get("transformer").unwrap_or(&cfg).clone();
1214        let g = |k: &str, d: f64| t.get(k).and_then(|v| v.as_f64()).unwrap_or(d);
1215        let heads = g("num_attention_heads", 32.0) as usize;
1216        let dh = g("attention_head_dim", 128.0) as usize;
1217        let a_heads = g("audio_num_attention_heads", 32.0) as usize;
1218        let a_dh = g("audio_attention_head_dim", 64.0) as usize;
1219        let n_layers = g("num_layers", 48.0) as usize;
1220        let ff_bias = t.get("ff_bias").and_then(|v| v.as_bool()).unwrap_or(true);
1221        let a_ff_bias = t
1222            .get("audio_ff_bias")
1223            .and_then(|v| v.as_bool())
1224            .unwrap_or(true);
1225        let arr = |k: &str, d: Vec<f64>| -> Vec<f64> {
1226            t.get(k)
1227                .and_then(|v| v.as_array())
1228                .map(|a| a.iter().filter_map(|x| x.as_f64()).collect())
1229                .unwrap_or(d)
1230        };
1231        let max_pos = arr("positional_embedding_max_pos", vec![20.0, 2048.0, 2048.0]);
1232        let a_max_pos = arr("audio_positional_embedding_max_pos", vec![20.0]);
1233        let cross_max_pos = max_pos[0].max(a_max_pos[0]);
1234        let dim = heads * dh;
1235        let a_dim = a_heads * a_dh;
1236
1237        let mut blocks = Vec::with_capacity(n_layers);
1238        for i in 0..n_layers {
1239            let p = format!("dit.transformer_blocks.{i}");
1240            blocks.push(Block {
1241                video: Stream::load(model, &p, "", heads, dh, ff_bias, bank)?,
1242                audio: Stream::load(model, &p, "audio_", a_heads, a_dh, a_ff_bias, bank)?,
1243                a2v: Attn::load(model, &format!("{p}.audio_to_video_attn"), a_heads, a_dh)?,
1244                v2a: Attn::load(model, &format!("{p}.video_to_audio_attn"), a_heads, a_dh)?,
1245                sst_a2v_video: cmf_f32(model, &format!("{p}.scale_shift_table_a2v_ca_video"))?,
1246                sst_a2v_audio: cmf_f32(model, &format!("{p}.scale_shift_table_a2v_ca_audio"))?,
1247            });
1248        }
1249        let _ = (dim, a_dim);
1250        Ok(LtxDit {
1251            blocks,
1252            patchify: Lin::load(model, "dit.patchify_proj", true)?,
1253            a_patchify: Lin::load(model, "dit.audio_patchify_proj", true)?,
1254            keyframes_emb: match model.tensor("dit.keyframes_abs_pos_embedding") {
1255                Some(_) => Some(cmf_f32(model, "dit.keyframes_abs_pos_embedding")?),
1256                None => None,
1257            },
1258            adaln: AdaLn::load(model, "dit.adaln_single", dim)?,
1259            a_adaln: AdaLn::load(model, "dit.audio_adaln_single", a_dim)?,
1260            prompt_adaln: AdaLn::load(model, "dit.prompt_adaln_single", dim)?,
1261            a_prompt_adaln: AdaLn::load(model, "dit.audio_prompt_adaln_single", a_dim)?,
1262            av_v_ss: AdaLn::load(model, "dit.av_ca_video_scale_shift_adaln_single", dim)?,
1263            av_a_ss: AdaLn::load(model, "dit.av_ca_audio_scale_shift_adaln_single", a_dim)?,
1264            av_a2v_gate: AdaLn::load(model, "dit.av_ca_a2v_gate_adaln_single", dim)?,
1265            av_v2a_gate: AdaLn::load(model, "dit.av_ca_v2a_gate_adaln_single", a_dim)?,
1266            proj_out: Lin::load(model, "dit.proj_out", true)?,
1267            a_proj_out: Lin::load(model, "dit.audio_proj_out", true)?,
1268            sst_out: cmf_f32(model, "dit.scale_shift_table")?,
1269            a_sst_out: cmf_f32(model, "dit.audio_scale_shift_table")?,
1270            heads,
1271            dh,
1272            a_heads,
1273            a_dh,
1274            max_pos,
1275            a_max_pos,
1276            cross_max_pos,
1277            theta: g("positional_embedding_theta", 10000.0),
1278            t_scale: g("timestep_scale_multiplier", 1000.0),
1279            av_t_scale: g("av_ca_timestep_scale_multiplier", 1.0),
1280            audio_cross_dim: g("audio_cross_attention_dim", 2048.0) as usize,
1281            model: model.clone(),
1282        })
1283    }
1284
1285    pub fn blocks(&self) -> usize {
1286        self.blocks.len()
1287    }
1288
1289    pub fn container(&self) -> &Arc<CmfModel> {
1290        &self.model
1291    }
1292
1293    /// One denoising step: `(video velocity [T, C], audio velocity [T, C])`.
1294    pub fn forward(
1295        &self,
1296        video: &StreamInput,
1297        audio: &StreamInput,
1298        pool: Option<&Pool>,
1299    ) -> (Vec<f32>, Vec<f32>) {
1300        self.forward_traced(video, audio, pool, &mut |_, _| {})
1301    }
1302
1303    pub fn forward_traced(
1304        &self,
1305        video: &StreamInput,
1306        audio: &StreamInput,
1307        pool: Option<&Pool>,
1308        trace: &mut dyn FnMut(&str, &[f32]),
1309    ) -> (Vec<f32>, Vec<f32>) {
1310        // A denoising step is the opposite of what the per-op probe is built
1311        // for: forty-eight identical blocks, the same shapes every time, the
1312        // device warm throughout. Take the probe out of it.
1313        let _trust = crate::gpu::trust_gpu();
1314        let dim = self.heads * self.dh;
1315        let a_dim = self.a_heads * self.a_dh;
1316        let (n, m) = (video.tokens, audio.tokens);
1317
1318        // --- patchify -----------------------------------------------------
1319        let mut vx = self.patchify.apply(&video.latent, n, pool);
1320        if let Some(emb) = &self.keyframes_emb {
1321            for i in 0..n {
1322                if video.keyframes.get(i).copied().unwrap_or(0.0) > 0.0 {
1323                    for (d, &e) in vx[i * dim..(i + 1) * dim].iter_mut().zip(emb) {
1324                        *d += e;
1325                    }
1326                }
1327            }
1328        }
1329        let mut ax = self.a_patchify.apply(&audio.latent, m, pool);
1330        trace("v.args.x", &vx);
1331        trace("a.args.x", &ax);
1332
1333        // --- adaLN tables, one row per distinct timestep -------------------
1334        let vt = TsTable::build(&self.adaln, &video.timesteps, self.t_scale, pool);
1335        let at = TsTable::build(&self.a_adaln, &audio.timesteps, self.t_scale, pool);
1336        let vpt = TsTable::build(&self.prompt_adaln, &[video.sigma], self.t_scale, pool);
1337        let apt = TsTable::build(&self.a_prompt_adaln, &[audio.sigma], self.t_scale, pool);
1338        if std::env::var("CMF_LTX_PROMPTADALN").is_ok() {
1339            let r = vpt.row(0);
1340            let mx = r.iter().fold(0f32, |m, &v| m.max(v.abs()));
1341            let sum: f32 = r.iter().sum();
1342            eprintln!(
1343                "prompt-adaln sigma={:.6} max|row|={mx:.6e} sum={sum:.6e}",
1344                video.sigma
1345            );
1346        }
1347        let vxs = TsTable::build(&self.av_v_ss, &video.timesteps, self.t_scale, pool);
1348        let axs = TsTable::build(&self.av_a_ss, &audio.timesteps, self.t_scale, pool);
1349        // The fusion gate reads the *other* stream's sigma — the noise level
1350        // it is being asked to trust — at the A-V multiplier.
1351        let vgt = TsTable::build(&self.av_a2v_gate, &[audio.sigma], self.av_t_scale, pool);
1352        let agt = TsTable::build(&self.av_v2a_gate, &[video.sigma], self.av_t_scale, pool);
1353
1354        // --- RoPE ---------------------------------------------------------
1355        let v_pe = Rope::build(&video.positions, &self.max_pos, dim, self.heads, self.theta);
1356        let a_pe = Rope::build(
1357            &audio.positions,
1358            &self.a_max_pos,
1359            a_dim,
1360            self.a_heads,
1361            self.theta,
1362        );
1363        let time_only = |p: &[Vec<f64>]| p.iter().map(|r| vec![r[0]]).collect::<Vec<_>>();
1364        let v_xpe = Rope::build(
1365            &time_only(&video.positions),
1366            &[self.cross_max_pos],
1367            self.audio_cross_dim,
1368            self.heads,
1369            self.theta,
1370        );
1371        let a_xpe = Rope::build(
1372            &time_only(&audio.positions),
1373            &[self.cross_max_pos],
1374            self.audio_cross_dim,
1375            self.a_heads,
1376            self.theta,
1377        );
1378
1379        let vmask = (!video.context_mask.is_empty()).then_some(&video.context_mask[..]);
1380        let amask = (!audio.context_mask.is_empty()).then_some(&audio.context_mask[..]);
1381
1382        let mut prof = Prof::new();
1383        for (bi, blk) in self.blocks.iter().enumerate() {
1384            let mut pt = std::time::Instant::now();
1385            let v_msa = vt.triples(&blk.video.sst, dim, 0);
1386            let v_ca = vt.triples(&blk.video.sst, dim, 6);
1387            let v_mlp = vt.triples(&blk.video.sst, dim, 3);
1388            let a_msa = at.triples(&blk.audio.sst, a_dim, 0);
1389            let a_ca = at.triples(&blk.audio.sst, a_dim, 6);
1390            let a_mlp = at.triples(&blk.audio.sst, a_dim, 3);
1391
1392            // ---- video: self-attention, then prompt cross-attention ----
1393            pt = prof.tick(P_ADALN, pt);
1394            let mut vnorm = vec![0f32; n * dim];
1395            ada_zero_rows(&vx, &mut vnorm, n, dim, &v_msa, &vt.idx, pool);
1396            pt = prof.tick(P_MOD, pt);
1397            if bi == 0 {
1398                trace("v.b0.sa.in", &vnorm);
1399            }
1400            let vsa =
1401                blk.video
1402                    .attn1
1403                    .forward(&vnorm, n, &vnorm, n, Some(&v_pe), Some(&v_pe), None, pool);
1404            if bi == 0 {
1405                trace("v.b0.sa.out", &vsa);
1406            }
1407            pt = prof.tick(P_SELF, pt);
1408            let mut vnormed = vec![0f32; n * dim];
1409            post_sa_rows(&mut vx, &vsa, &mut vnormed, n, dim, &v_msa, &vt.idx, pool);
1410            let mut vq = vec![0f32; n * dim];
1411            affine_rows(&vnormed, &mut vq, n, dim, &v_ca, &vt.idx, pool);
1412            let vctx = modulate_kv(
1413                &video.context,
1414                video.ctx_len,
1415                dim,
1416                &blk.video.prompt_sst,
1417                vpt.row(0),
1418                pool,
1419            );
1420            let vca =
1421                blk.video
1422                    .attn2
1423                    .forward(&vq, n, &vctx, video.ctx_len, None, None, vmask, pool);
1424            if bi == 0 {
1425                trace("v.b0.ca.in", &vq);
1426                trace("v.b0.ca.ctx", &vctx);
1427                trace("v.b0.ca.out", &vca);
1428            }
1429            add_gated(&mut vx, &vca, n, dim, &v_ca, &vt.idx, pool);
1430            pt = prof.tick(P_CROSS, pt);
1431
1432            // ---- audio: the same two steps ----
1433            let mut anorm = vec![0f32; m * a_dim];
1434            ada_zero_rows(&ax, &mut anorm, m, a_dim, &a_msa, &at.idx, pool);
1435            if bi == 0 {
1436                trace("a.b0.sa.in", &anorm);
1437            }
1438            let asa =
1439                blk.audio
1440                    .attn1
1441                    .forward(&anorm, m, &anorm, m, Some(&a_pe), Some(&a_pe), None, pool);
1442            if bi == 0 {
1443                trace("a.b0.sa.out", &asa);
1444            }
1445            let mut anormed = vec![0f32; m * a_dim];
1446            post_sa_rows(&mut ax, &asa, &mut anormed, m, a_dim, &a_msa, &at.idx, pool);
1447            let mut aq = vec![0f32; m * a_dim];
1448            affine_rows(&anormed, &mut aq, m, a_dim, &a_ca, &at.idx, pool);
1449            let actx = modulate_kv(
1450                &audio.context,
1451                audio.ctx_len,
1452                a_dim,
1453                &blk.audio.prompt_sst,
1454                apt.row(0),
1455                pool,
1456            );
1457            let aca =
1458                blk.audio
1459                    .attn2
1460                    .forward(&aq, m, &actx, audio.ctx_len, None, None, amask, pool);
1461            if bi == 0 {
1462                trace("a.b0.ca.in", &aq);
1463                trace("a.b0.ca.ctx", &actx);
1464                trace("a.b0.ca.out", &aca);
1465            }
1466            add_gated(&mut ax, &aca, m, a_dim, &a_ca, &at.idx, pool);
1467            pt = prof.tick(P_CROSS, pt);
1468
1469            // ---- audio ↔ video, both directions off the pre-fusion state ----
1470            let vx_pre = vx.clone();
1471            let ax_pre = ax.clone();
1472            let a2v_vp = vxs.pairs(&blk.sst_a2v_video, dim, 0);
1473            let a2v_ap = axs.pairs(&blk.sst_a2v_audio, a_dim, 0);
1474            let a2v_v = ada_pair(&vx_pre, n, dim, &a2v_vp, &vxs.idx, pool);
1475            let a2v_a = ada_pair(&ax_pre, m, a_dim, &a2v_ap, &axs.idx, pool);
1476            let a2v = blk
1477                .a2v
1478                .forward(&a2v_v, n, &a2v_a, m, Some(&v_xpe), Some(&a_xpe), None, pool);
1479            if bi == 0 {
1480                trace("v.b0.a2v.in", &a2v_v);
1481                trace("v.b0.a2v.ctx", &a2v_a);
1482                trace("v.b0.a2v.out", &a2v);
1483            }
1484            let gate_a2v = gate_row(&blk.sst_a2v_video, dim, vgt.row(0));
1485            add_scaled(&mut vx, &a2v, n, dim, &gate_a2v, pool);
1486            let v2a_ap = axs.pairs(&blk.sst_a2v_audio, a_dim, 2);
1487            let v2a_vp = vxs.pairs(&blk.sst_a2v_video, dim, 2);
1488            let v2a_a = ada_pair(&ax_pre, m, a_dim, &v2a_ap, &axs.idx, pool);
1489            let v2a_v = ada_pair(&vx_pre, n, dim, &v2a_vp, &vxs.idx, pool);
1490            let v2a = blk
1491                .v2a
1492                .forward(&v2a_a, m, &v2a_v, n, Some(&a_xpe), Some(&v_xpe), None, pool);
1493            if bi == 0 {
1494                trace("a.b0.v2a.in", &v2a_a);
1495                trace("a.b0.v2a.ctx", &v2a_v);
1496                trace("a.b0.v2a.out", &v2a);
1497            }
1498            let gate_v2a = gate_row(&blk.sst_a2v_audio, a_dim, agt.row(0));
1499            add_scaled(&mut ax, &v2a, m, a_dim, &gate_v2a, pool);
1500            pt = prof.tick(P_FUSE, pt);
1501
1502            // ---- feed-forward ----
1503            let mut vsc = vec![0f32; n * dim];
1504            ada_zero_rows(&vx, &mut vsc, n, dim, &v_mlp, &vt.idx, pool);
1505            let vff = blk.video.ff(&vsc, n, pool);
1506            if bi == 0 {
1507                trace("v.b0.ff.in", &vsc);
1508                trace("v.b0.ff.out", &vff);
1509            }
1510            add_gated(&mut vx, &vff, n, dim, &v_mlp, &vt.idx, pool);
1511            let mut asc = vec![0f32; m * a_dim];
1512            ada_zero_rows(&ax, &mut asc, m, a_dim, &a_mlp, &at.idx, pool);
1513            let aff = blk.audio.ff(&asc, m, pool);
1514            if bi == 0 {
1515                trace("a.b0.ff.in", &asc);
1516                trace("a.b0.ff.out", &aff);
1517            }
1518            add_gated(&mut ax, &aff, m, a_dim, &a_mlp, &at.idx, pool);
1519            pt = prof.tick(P_FF, pt);
1520            trace(&format!("v.block{bi}"), &vx);
1521            trace(&format!("a.block{bi}"), &ax);
1522        }
1523
1524        prof.report();
1525
1526        // --- output head: LayerNorm (no affine), adaLN, projection --------
1527        let vout = head(&vx, n, dim, &self.sst_out, &vt, &self.proj_out, pool);
1528        let aout = head(&ax, m, a_dim, &self.a_sst_out, &at, &self.a_proj_out, pool);
1529        trace("v.out", &vout);
1530        trace("a.out", &aout);
1531        (vout, aout)
1532    }
1533}
1534
1535/// `prompt_scale_shift_table` plus the prompt adaLN row, modulating the
1536/// cross-attention K/V — the same modulation for every context token.
1537fn modulate_kv(
1538    ctx: &[f32],
1539    len: usize,
1540    dim: usize,
1541    table: &[f32],
1542    extra: &[f32],
1543    pool: Option<&Pool>,
1544) -> Vec<f32> {
1545    let mut out = vec![0f32; len * dim];
1546    let shift: Vec<f32> = (0..dim).map(|d| table[d] + extra[d]).collect();
1547    let scale: Vec<f32> = (0..dim).map(|d| table[dim + d] + extra[dim + d]).collect();
1548    // A thousand prompt tokens by four thousand channels, rebuilt in every
1549    // one of forty-eight blocks: three hundred million writes a step, which
1550    // is not something to do on one thread.
1551    let dst = Shared(out.as_mut_ptr());
1552    rows(pool, len, &|s, e| {
1553        let r = unsafe { dst.at(s * dim, (e - s) * dim) };
1554        for (row, i) in r.chunks_exact_mut(dim).zip(s..e) {
1555            for d in 0..dim {
1556                row[d] = ctx[i * dim + d] * (1.0 + scale[d]) + shift[d];
1557            }
1558        }
1559    });
1560    out
1561}
1562
1563/// `ada_zero` with an A↔V `(scale, shift)` pair per distinct timestep.
1564fn ada_pair(
1565    x: &[f32],
1566    n: usize,
1567    dim: usize,
1568    pairs: &[[Vec<f32>; 2]],
1569    idx: &[usize],
1570    pool: Option<&Pool>,
1571) -> Vec<f32> {
1572    let mut out = vec![0f32; n * dim];
1573    let dst = Shared(out.as_mut_ptr());
1574    rows(pool, n, &|s, e| {
1575        let r = unsafe { dst.at(s * dim, (e - s) * dim) };
1576        for (row, i) in r.chunks_exact_mut(dim).zip(s..e) {
1577            let p = &pairs[idx[i]];
1578            rms_plain(&x[i * dim..(i + 1) * dim], row);
1579            for d in 0..dim {
1580                row[d] = row[d] * (1.0 + p[0][d]) + p[1][d];
1581            }
1582        }
1583    });
1584    out
1585}
1586
1587/// The single gate row of an A↔V table — row 4 of `[5, dim]`, plus the
1588/// gate adaLN's own output.
1589fn gate_row(table: &[f32], dim: usize, extra: &[f32]) -> Vec<f32> {
1590    (0..dim).map(|d| table[4 * dim + d] + extra[d]).collect()
1591}
1592
1593/// The output head: LayerNorm without affine, the final scale/shift pair
1594/// (both offset by the same embedded timestep), then the projection.
1595fn head(
1596    x: &[f32],
1597    n: usize,
1598    dim: usize,
1599    sst: &[f32],
1600    ts: &TsTable,
1601    proj: &Lin,
1602    pool: Option<&Pool>,
1603) -> Vec<f32> {
1604    let mut y = vec![0f32; n * dim];
1605    let dst = Shared(y.as_mut_ptr());
1606    rows(pool, n, &|s, e| {
1607        let r = unsafe { dst.at(s * dim, (e - s) * dim) };
1608        let mut ln = vec![0f32; dim];
1609        for (row, i) in r.chunks_exact_mut(dim).zip(s..e) {
1610            let emb = ts.emb_row(ts.idx[i.min(ts.idx.len() - 1)]);
1611            layer_norm(&x[i * dim..(i + 1) * dim], &mut ln);
1612            for d in 0..dim {
1613                row[d] = ln[d] * (1.0 + sst[dim + d] + emb[d]) + sst[d] + emb[d];
1614            }
1615        }
1616    });
1617    proj.apply(&y, n, pool)
1618}