Skip to main content

cortiq_engine/
ltxte.rs

1//! The LTX-2.5 prompt encoder: Gemma-4 12B, the two aggregate projections,
2//! and the embeddings connectors — everything between a prompt string and
3//! the context the DiT cross-attends to.
4//!
5//! Three stages, all read from one `ltx-2.5-av` container:
6//!
7//! 1. **Gemma-4 12B** (`te.model.*`), 48 layers over a 1024-token window.
8//!    Two layer kinds alternate: forty *sliding* layers (head 256, 16 query
9//!    heads over 8 key heads, θ = 10⁴) and eight *full* layers every sixth
10//!    (head 512, 16 query heads over one key head, θ = 10⁶ with only the
11//!    first quarter of each head rotated, and the value projection **is**
12//!    the key projection). Attention is unscaled — the q/k RMS-norms carry
13//!    the scale — and every layer ends multiplied by its `layer_scalar`.
14//! 2. **The aggregate projections** (`te.text_embedding_projection.*`).
15//!    The features are not the last hidden state: all forty-nine layer
16//!    outputs are RMS-normalized per token *per layer*, concatenated into
17//!    188160 numbers, rescaled by `sqrt(out_dim / hidden)`, and projected —
18//!    once to 4096 for video, once to 2048 for audio.
19//! 3. **The connectors** (`dit.{video,audio}_embeddings_connector.*`), eight
20//!    gated-attention blocks with 1-D RoPE. Padded positions are replaced by
21//!    128 learnable registers tiled across the window first, which is why
22//!    the DiT needs no prompt mask: after the substitution every position
23//!    carries signal.
24
25use crate::ltxdit::{Attn, Lin, Rope, gelu_tanh, rms_plain, rows};
26use crate::pool::Pool;
27use crate::qtensor::QTensor;
28use cortiq_core::CmfModel;
29use std::sync::Arc;
30
31const EPS: f64 = 1e-6;
32
33/// RMS normalization with a plain (not `1 + w`) learned weight — Gemma-4's,
34/// unlike Gemma-2's and Gemma-3's.
35fn rms(x: &[f32], w: &[f32], dst: &mut [f32]) {
36    let ss = x.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>() / x.len() as f64;
37    let inv = 1.0 / (ss + EPS).sqrt();
38    for ((d, &v), &g) in dst.iter_mut().zip(x).zip(w) {
39        *d = (v as f64 * inv) as f32 * g;
40    }
41}
42
43/// RMS normalization with no learned weight (the value norm).
44fn rms_nw(x: &mut [f32]) {
45    let ss = x.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>() / x.len() as f64;
46    let inv = 1.0 / (ss + EPS).sqrt();
47    for v in x.iter_mut() {
48        *v = (*v as f64 * inv) as f32;
49    }
50}
51
52/// Half-rotation RoPE tables: `cos`/`sin` of `[T, head_dim/2]`, applied as
53/// `x·cos + rotate_half(x)·sin`.
54struct Rot {
55    cos: Vec<f32>,
56    sin: Vec<f32>,
57    half: usize,
58}
59
60impl Rot {
61    /// `inv_freq[j] = base^(-2j/head_dim)` for the rotated prefix, zero for
62    /// the rest — a zero frequency is an identity rotation, which is how the
63    /// "proportional" variant leaves three quarters of a full-attention head
64    /// unrotated.
65    fn build(seq: usize, head_dim: usize, base: f64, rotary: f64) -> Rot {
66        let half = head_dim / 2;
67        let rope_angles = (rotary * head_dim as f64 / 2.0) as usize;
68        let inv: Vec<f64> = (0..half)
69            .map(|j| {
70                if j < rope_angles {
71                    1.0 / base.powf((2 * j) as f64 / head_dim as f64)
72                } else {
73                    0.0
74                }
75            })
76            .collect();
77        let mut cos = vec![0f32; seq * half];
78        let mut sin = vec![0f32; seq * half];
79        for p in 0..seq {
80            for (j, &f) in inv.iter().enumerate() {
81                let a = p as f64 * f;
82                cos[p * half + j] = a.cos() as f32;
83                sin[p * half + j] = a.sin() as f32;
84            }
85        }
86        Rot { cos, sin, half }
87    }
88
89    fn apply(&self, t: usize, row: &mut [f32]) {
90        let h = self.half;
91        let (c, s) = (&self.cos[t * h..(t + 1) * h], &self.sin[t * h..(t + 1) * h]);
92        for i in 0..h {
93            let (a, b) = (row[i], row[i + h]);
94            row[i] = a * c[i] - b * s[i];
95            row[i + h] = b * c[i] + a * s[i];
96        }
97    }
98}
99
100struct GemmaLayer {
101    q: Lin,
102    k: Lin,
103    v: Option<Lin>,
104    o: Lin,
105    q_norm: Vec<f32>,
106    k_norm: Vec<f32>,
107    in_norm: Vec<f32>,
108    post_attn_norm: Vec<f32>,
109    pre_ff_norm: Vec<f32>,
110    post_ff_norm: Vec<f32>,
111    gate: Lin,
112    up: Lin,
113    down: Lin,
114    scalar: f32,
115    head_dim: usize,
116    q_heads: usize,
117    kv_heads: usize,
118    sliding: bool,
119}
120
121/// The full prompt encoder.
122pub struct LtxTextEncoder {
123    embed: QTensor,
124    layers: Vec<GemmaLayer>,
125    norm: Vec<f32>,
126    video_agg: Lin,
127    audio_agg: Lin,
128    v_conn: Connector,
129    a_conn: Connector,
130    hidden: usize,
131    embed_scale: f32,
132    pub max_len: usize,
133    pub bos: u32,
134    pub pad: u32,
135}
136
137struct Connector {
138    blocks: Vec<(Attn, Lin, Lin)>,
139    registers: Vec<f32>,
140    dim: usize,
141    heads: usize,
142    dh: usize,
143    max_pos: f64,
144}
145
146fn vecf(model: &Arc<CmfModel>, name: &str) -> Result<Vec<f32>, String> {
147    crate::dit::cmf_f32(model, name)
148}
149
150impl LtxTextEncoder {
151    pub fn from_cmf(model: &Arc<CmfModel>) -> Result<LtxTextEncoder, String> {
152        let cfg_bytes = ["te.gemma_config_json", "ltx.gemma_config_json"]
153            .iter()
154            .find_map(|n| model.tensor(n).map(|e| model.entry_bytes(e)));
155        let cfg: serde_json::Value = match cfg_bytes {
156            Some(b) => serde_json::from_slice(b).map_err(|e| format!("gemma config: {e}"))?,
157            None => serde_json::Value::Null,
158        };
159        let tc = cfg.get("text_config").cloned().unwrap_or(serde_json::Value::Null);
160        let g = |k: &str, d: f64| tc.get(k).and_then(|v| v.as_f64()).unwrap_or(d);
161        let hidden = g("hidden_size", 3840.0) as usize;
162        let n_layers = g("num_hidden_layers", 48.0) as usize;
163        let head_dim = g("head_dim", 256.0) as usize;
164        let global_head_dim = g("global_head_dim", 512.0) as usize;
165        let types: Vec<String> = tc
166            .get("layer_types")
167            .and_then(|v| v.as_array())
168            .map(|a| a.iter().map(|s| s.as_str().unwrap_or("sliding_attention").to_string()).collect())
169            .unwrap_or_else(|| {
170                // the released layout: every sixth layer is a full-attention one
171                (0..n_layers)
172                    .map(|i| {
173                        if i % 6 == 5 { "full_attention".into() } else { "sliding_attention".into() }
174                    })
175                    .collect()
176            });
177
178        let mut layers = Vec::with_capacity(n_layers);
179        for i in 0..n_layers {
180            let p = format!("te.model.layers.{i}");
181            let sliding = types[i] != "full_attention";
182            let hd = if sliding { head_dim } else { global_head_dim };
183            let q = Lin::load(model, &format!("{p}.self_attn.q_proj"), false)?;
184            let k = Lin::load(model, &format!("{p}.self_attn.k_proj"), false)?;
185            let v = match model.tensor(&format!("{p}.self_attn.v_proj.weight")) {
186                Some(_) => Some(Lin::load(model, &format!("{p}.self_attn.v_proj"), false)?),
187                None => None,
188            };
189            let q_rows = model
190                .tensor(&format!("{p}.self_attn.q_proj.weight"))
191                .ok_or_else(|| format!("missing {p}.self_attn.q_proj.weight"))?
192                .shape[0];
193            let k_rows = model
194                .tensor(&format!("{p}.self_attn.k_proj.weight"))
195                .ok_or("missing k_proj")?
196                .shape[0];
197            layers.push(GemmaLayer {
198                q,
199                k,
200                v,
201                o: Lin::load(model, &format!("{p}.self_attn.o_proj"), false)?,
202                q_norm: vecf(model, &format!("{p}.self_attn.q_norm.weight"))?,
203                k_norm: vecf(model, &format!("{p}.self_attn.k_norm.weight"))?,
204                in_norm: vecf(model, &format!("{p}.input_layernorm.weight"))?,
205                post_attn_norm: vecf(model, &format!("{p}.post_attention_layernorm.weight"))?,
206                pre_ff_norm: vecf(model, &format!("{p}.pre_feedforward_layernorm.weight"))?,
207                post_ff_norm: vecf(model, &format!("{p}.post_feedforward_layernorm.weight"))?,
208                gate: Lin::load(model, &format!("{p}.mlp.gate_proj"), false)?,
209                up: Lin::load(model, &format!("{p}.mlp.up_proj"), false)?,
210                down: Lin::load(model, &format!("{p}.mlp.down_proj"), false)?,
211                scalar: vecf(model, &format!("{p}.layer_scalar"))?[0],
212                head_dim: hd,
213                q_heads: q_rows / hd,
214                kv_heads: k_rows / hd,
215                sliding,
216            });
217        }
218
219        let conn = |prefix: &str, dim: usize, heads: usize, dh: usize| -> Result<Connector, String> {
220            let mut blocks = Vec::new();
221            let mut i = 0usize;
222            while model
223                .tensor(&format!("{prefix}.transformer_1d_blocks.{i}.attn1.to_q.weight"))
224                .is_some()
225            {
226                let p = format!("{prefix}.transformer_1d_blocks.{i}");
227                blocks.push((
228                    Attn::load(model, &format!("{p}.attn1"), heads, dh)?,
229                    Lin::load(model, &format!("{p}.ff.net.0.proj"), true)?,
230                    Lin::load(model, &format!("{p}.ff.net.2"), true)?,
231                ));
232                i += 1;
233            }
234            Ok(Connector {
235                blocks,
236                registers: vecf(model, &format!("{prefix}.learnable_registers"))?,
237                dim,
238                heads,
239                dh,
240                max_pos: 4096.0,
241            })
242        };
243
244        Ok(LtxTextEncoder {
245            embed: QTensor::from_model(model, "te.model.embed_tokens.weight")?,
246            layers,
247            norm: vecf(model, "te.model.norm.weight")?,
248            video_agg: Lin::load(model, "te.text_embedding_projection.video_aggregate_embed", true)?,
249            audio_agg: Lin::load(model, "te.text_embedding_projection.audio_aggregate_embed", true)?,
250            v_conn: conn("dit.video_embeddings_connector", 4096, 32, 128)?,
251            a_conn: conn("dit.audio_embeddings_connector", 2048, 32, 64)?,
252            hidden,
253            embed_scale: (hidden as f64).sqrt() as f32,
254            max_len: 1024,
255            bos: tc.get("bos_token_id").and_then(|v| v.as_u64()).unwrap_or(2) as u32,
256            pad: tc.get("pad_token_id").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
257        })
258    }
259
260    /// Left-pad the prompt to the encoder's window, prepending BOS — the
261    /// layout the reference tokenizer produces.
262    pub fn pad_ids(&self, ids: &[u32]) -> (Vec<u32>, Vec<f32>) {
263        let mut body = Vec::with_capacity(self.max_len);
264        if ids.first() != Some(&self.bos) {
265            body.push(self.bos);
266        }
267        body.extend_from_slice(ids);
268        body.truncate(self.max_len);
269        let padlen = self.max_len - body.len();
270        let mut out = vec![self.pad; padlen];
271        out.extend_from_slice(&body);
272        let mut mask = vec![0f32; padlen];
273        mask.extend(std::iter::repeat_n(1f32, body.len()));
274        (out, mask)
275    }
276
277    /// Every one of the 49 hidden states, in HF's order: the scaled
278    /// embedding first, then each layer's output.
279    pub fn hidden_states(&self, ids: &[u32], mask: &[f32], pool: Option<&Pool>) -> Vec<Vec<f32>> {
280        let t = ids.len();
281        let d = self.hidden;
282        let mut x = vec![0f32; t * d];
283        for (i, &id) in ids.iter().enumerate() {
284            self.embed.row_f32(id as usize, &mut x[i * d..(i + 1) * d]);
285            for v in x[i * d..(i + 1) * d].iter_mut() {
286                *v *= self.embed_scale;
287            }
288        }
289        let mut out = vec![x.clone()];
290        // one rotation table per (head_dim, theta) pair in use
291        let rot_slide = Rot::build(t, self.layers[0].head_dim, 10000.0, 1.0);
292        let full = self.layers.iter().find(|l| !l.sliding);
293        let rot_full = full.map(|l| Rot::build(t, l.head_dim, 1_000_000.0, 0.25));
294
295        for layer in &self.layers {
296            let rot = if layer.sliding { &rot_slide } else { rot_full.as_ref().unwrap() };
297            let mut h = vec![0f32; t * d];
298            for i in 0..t {
299                rms(&x[i * d..(i + 1) * d], &layer.in_norm, &mut h[i * d..(i + 1) * d]);
300            }
301            let attn = self.attention(layer, &h, t, mask, rot, pool);
302            for i in 0..t {
303                let mut n = vec![0f32; d];
304                rms(&attn[i * d..(i + 1) * d], &layer.post_attn_norm, &mut n);
305                for (v, &a) in x[i * d..(i + 1) * d].iter_mut().zip(&n) {
306                    *v += a;
307                }
308            }
309            let mut h2 = vec![0f32; t * d];
310            for i in 0..t {
311                rms(&x[i * d..(i + 1) * d], &layer.pre_ff_norm, &mut h2[i * d..(i + 1) * d]);
312            }
313            let mut g = layer.gate.apply(&h2, t, pool);
314            let u = layer.up.apply(&h2, t, pool);
315            for (a, &b) in g.iter_mut().zip(&u) {
316                *a = gelu_tanh(*a) * b;
317            }
318            let ff = layer.down.apply(&g, t, pool);
319            for i in 0..t {
320                let mut n = vec![0f32; d];
321                rms(&ff[i * d..(i + 1) * d], &layer.post_ff_norm, &mut n);
322                for (v, &a) in x[i * d..(i + 1) * d].iter_mut().zip(&n) {
323                    *v += a;
324                }
325            }
326            for v in x.iter_mut() {
327                *v *= layer.scalar;
328            }
329            out.push(x.clone());
330        }
331        // HF's hidden-state tuple is (embedding, layer 0 … layer 46,
332        // norm(layer 47)): the last entry is the *normalized* final state,
333        // not the raw one. The feature extractor reads all forty-nine, so
334        // getting this wrong poisons a forty-ninth of every token's
335        // features — and it is the entry with the largest magnitude.
336        if let Some(last) = out.last_mut() {
337            let mut n = vec![0f32; t * d];
338            for i in 0..t {
339                rms(&last[i * d..(i + 1) * d], &self.norm, &mut n[i * d..(i + 1) * d]);
340            }
341            *last = n;
342        }
343        out
344    }
345
346    fn attention(
347        &self,
348        l: &GemmaLayer,
349        h: &[f32],
350        t: usize,
351        mask: &[f32],
352        rot: &Rot,
353        pool: Option<&Pool>,
354    ) -> Vec<f32> {
355        let hd = l.head_dim;
356        let qi = l.q_heads * hd;
357        let ki = l.kv_heads * hd;
358        let mut q = l.q.apply(h, t, pool);
359        let mut k = l.k.apply(h, t, pool);
360        let mut v = match &l.v {
361            Some(p) => p.apply(h, t, pool),
362            None => k.clone(),
363        };
364        for i in 0..t {
365            for hh in 0..l.q_heads {
366                let r = &mut q[i * qi + hh * hd..i * qi + (hh + 1) * hd];
367                let mut n = vec![0f32; hd];
368                rms(r, &l.q_norm, &mut n);
369                r.copy_from_slice(&n);
370                rot.apply(i, r);
371            }
372            for hh in 0..l.kv_heads {
373                let r = &mut k[i * ki + hh * hd..i * ki + (hh + 1) * hd];
374                let mut n = vec![0f32; hd];
375                rms(r, &l.k_norm, &mut n);
376                r.copy_from_slice(&n);
377                rot.apply(i, r);
378                rms_nw(&mut v[i * ki + hh * hd..i * ki + (hh + 1) * hd]);
379            }
380        }
381        // The mask is the same for every head: causal, bounded by the
382        // sliding window, and closed over the prompt's left padding. Build
383        // it once, then run both halves of attention as GEMMs per head.
384        let window = if l.sliding { 1024usize } else { usize::MAX };
385        let mut bias = vec![0f32; t * t];
386        for i in 0..t {
387            for j in 0..t {
388                let blocked =
389                    j > i || (window != usize::MAX && i - j >= window) || mask[j] == 0.0;
390                bias[i * t + j] = if blocked { f32::NEG_INFINITY } else { 0.0 };
391            }
392        }
393        // A left-pad row attends to nothing at all. The reference's masked
394        // softmax leaves it a uniform average; we leave it zero. Either is
395        // dead weight — the feature extractor masks these rows and the
396        // connector overwrites them with registers — but a row of all
397        // -inf would come back NaN, so mark it and zero it after.
398        let dead: Vec<bool> = (0..t).map(|i| mask[i] == 0.0).collect();
399        let mut out = vec![0f32; t * qi];
400        let mut qh = vec![0f32; t * hd];
401        let mut kh = vec![0f32; t * hd];
402        let mut vh = vec![0f32; t * hd];
403        let mut sc = vec![0f32; t * t];
404        let mut oh = vec![0f32; t * hd];
405        for hh in 0..l.q_heads {
406            let kv = hh * l.kv_heads / l.q_heads;
407            for i in 0..t {
408                qh[i * hd..(i + 1) * hd].copy_from_slice(&q[i * qi + hh * hd..][..hd]);
409                kh[i * hd..(i + 1) * hd].copy_from_slice(&k[i * ki + kv * hd..][..hd]);
410                vh[i * hd..(i + 1) * hd].copy_from_slice(&v[i * ki + kv * hd..][..hd]);
411            }
412            crate::fcd_ops::gemm_nt(&qh, &kh, &mut sc, t, hd, t, pool);
413            let sp = crate::ltxdit::Shared(sc.as_mut_ptr());
414            rows(pool, t, &|s, e| {
415                let r = unsafe { sp.at(s * t, (e - s) * t) };
416                for (row, i) in r.chunks_exact_mut(t).zip(s..e) {
417                    if dead[i] {
418                        row.iter_mut().for_each(|x| *x = 0.0);
419                        continue;
420                    }
421                    for (x, b) in row.iter_mut().zip(&bias[i * t..(i + 1) * t]) {
422                        *x += *b;
423                    }
424                    crate::ltxdit::softmax(row);
425                }
426            });
427            oh.iter_mut().for_each(|x| *x = 0.0);
428            crate::fcd_ops::gemm_dx(&sc, &vh, &mut oh, t, hd, t, pool);
429            for i in 0..t {
430                out[i * qi + hh * hd..i * qi + (hh + 1) * hd]
431                    .copy_from_slice(&oh[i * hd..(i + 1) * hd]);
432            }
433        }
434        l.o.apply(&out, t, pool)
435    }
436
437    /// Hidden states → the two context tensors the DiT reads.
438    pub fn encode_ids(
439        &self,
440        ids: &[u32],
441        mask: &[f32],
442        pool: Option<&Pool>,
443    ) -> (Vec<f32>, Vec<f32>, usize) {
444        let hs = self.hidden_states(ids, mask, pool);
445        let (t, d, l) = (ids.len(), self.hidden, hs.len());
446        // per-token, per-layer RMS over the hidden dimension, concatenated
447        // layer-last: [T, d·L]
448        let mut feats = vec![0f32; t * d * l];
449        for (li, layer) in hs.iter().enumerate() {
450            for i in 0..t {
451                let row = &layer[i * d..(i + 1) * d];
452                let var = row.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>() / d as f64;
453                let inv = 1.0 / (var + 1e-6).sqrt();
454                let keep = mask[i] != 0.0;
455                for j in 0..d {
456                    feats[i * d * l + j * l + li] = if keep { (row[j] as f64 * inv) as f32 } else { 0.0 };
457                }
458            }
459        }
460        // The aggregate projection is 4096x188160 — one weight buffer past
461        // what a GPU binding may address, and it runs twice per prompt, not
462        // per step. Keep it on the CPU rather than teach the device to page
463        // a 2 GiB binding for a millisecond of work.
464        let project = |agg: &Lin, out_dim: usize| -> Vec<f32> {
465            let scale = ((out_dim as f64) / (d as f64)).sqrt() as f32;
466            let scaled: Vec<f32> = feats.iter().map(|&v| v * scale).collect();
467            crate::gpu::cpu_scope(|| agg.apply(&scaled, t, pool))
468        };
469        let vfeat = project(&self.video_agg, 4096);
470        let afeat = project(&self.audio_agg, 2048);
471        // the connectors want valid tokens first; the prompt arrives left-padded
472        let order: Vec<usize> = (0..t)
473            .filter(|&i| mask[i] != 0.0)
474            .chain((0..t).filter(|&i| mask[i] == 0.0))
475            .collect();
476        let valid = mask.iter().filter(|&&m| m != 0.0).count();
477        let reorder = |x: &[f32], dim: usize| -> Vec<f32> {
478            let mut o = vec![0f32; t * dim];
479            for (new, &old) in order.iter().enumerate() {
480                o[new * dim..(new + 1) * dim].copy_from_slice(&x[old * dim..(old + 1) * dim]);
481            }
482            o
483        };
484        let v = self.v_conn.run(&reorder(&vfeat, 4096), t, valid, pool);
485        let a = self.a_conn.run(&reorder(&afeat, 2048), t, valid, pool);
486        (v, a, t)
487    }
488}
489
490impl Connector {
491    /// Eight gated-attention blocks over the whole window. Padded positions
492    /// are first replaced by the learnable registers, tiled across the
493    /// window, which is what makes the mask vanish: after the substitution
494    /// every position is signal and attention is unmasked.
495    fn run(&self, x: &[f32], t: usize, valid: usize, pool: Option<&Pool>) -> Vec<f32> {
496        let d = self.dim;
497        let regs = self.registers.len() / d;
498        let mut h = x.to_vec();
499        for i in valid..t {
500            let r = i % regs;
501            h[i * d..(i + 1) * d].copy_from_slice(&self.registers[r * d..(r + 1) * d]);
502        }
503        let pos: Vec<Vec<f64>> = (0..t).map(|i| vec![i as f64]).collect();
504        let pe = Rope::build(&pos, &[self.max_pos], d, self.heads, 10000.0);
505        for (attn, ff_in, ff_out) in &self.blocks {
506            let mut n = vec![0f32; t * d];
507            for i in 0..t {
508                rms_plain(&h[i * d..(i + 1) * d], &mut n[i * d..(i + 1) * d]);
509            }
510            let a = attn.forward(&n, t, &n, t, Some(&pe), Some(&pe), None, pool);
511            for (v, &y) in h.iter_mut().zip(&a) {
512                *v += y;
513            }
514            let mut n2 = vec![0f32; t * d];
515            for i in 0..t {
516                rms_plain(&h[i * d..(i + 1) * d], &mut n2[i * d..(i + 1) * d]);
517            }
518            let mut g = ff_in.apply(&n2, t, pool);
519            for v in g.iter_mut() {
520                *v = gelu_tanh(*v);
521            }
522            let f = ff_out.apply(&g, t, pool);
523            for (v, &y) in h.iter_mut().zip(&f) {
524                *v += y;
525            }
526        }
527        let mut out = vec![0f32; t * d];
528        for i in 0..t {
529            rms_plain(&h[i * d..(i + 1) * d], &mut out[i * d..(i + 1) * d]);
530        }
531        let _ = self.dh;
532        out
533    }
534}