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