Skip to main content

cortiq_engine/
ltxlora.rs

1//! Runtime LoRA for the LTX-2.5 DiT, and the reference-slot embedding the
2//! multi-subject adapters ship alongside it.
3//!
4//! The container's weights are q4tp — four bits on a per-row scale ladder —
5//! so a low-rank update cannot be folded into them without dequantizing the
6//! whole DiT and requantizing it, which would cost the file's size in RAM and
7//! throw away the codec's error budget on an update the size of a rounding
8//! step. The branch is evaluated instead:
9//!
10//! ```text
11//! y = x·Wᵀ + s · (x·Aᵀ)·Bᵀ
12//! ```
13//!
14//! `A` is `[rank, in]` and `B` is `[out, rank]`, which is the layout
15//! `lora_A.weight` / `lora_B.weight` already have. At rank 128 against a
16//! 4096×4096 projection that is 2·4096·128 multiply-adds against the base's
17//! 16.8 M — about 6% — so the branch costs a few per cent of a step and no
18//! memory beyond the adapter itself.
19//!
20//! `SlotEmbed` is the second half of the multi-subject adapters: a Fourier
21//! feature of the slot index through a two-layer MLP, added to the *latent*
22//! channels of a reference image before it is prepended to the sequence. See
23//! [`crate::ltxpipe::Conditioning::with_references`] for where the tokens go.
24
25use crate::ltxdit::{Shared, rows};
26use crate::pool::Pool;
27use std::collections::HashMap;
28use std::path::Path;
29
30/// One projection's low-rank branch, already scaled.
31pub struct LoraBranch {
32    a: Vec<f32>, // [rank, in]
33    b: Vec<f32>, // [out, rank]
34    rank: usize,
35    inn: usize,
36    out: usize,
37    scale: f32,
38}
39
40impl LoraBranch {
41    pub fn rank(&self) -> usize {
42        self.rank
43    }
44
45    /// `dst += scale · (x·Aᵀ)·Bᵀ`, over `n` rows of `x`.
46    ///
47    /// Both halves are N·Kᵀ products, which is the shape `gemm_nt` is built
48    /// for — Accelerate's AMX path on Apple silicon, register-blocked SIMD
49    /// elsewhere, and the wgpu arm when the shape earns it. Written as two
50    /// scalar loops instead, rank 128 over 480 branches cost 40 s a step on
51    /// an M4 against the base GEMMs' 9; the arithmetic is small only if it
52    /// runs on the same machinery as everything else.
53    pub fn add(&self, x: &[f32], n: usize, dst: &mut [f32], pool: Option<&Pool>) {
54        debug_assert_eq!(x.len(), n * self.inn);
55        debug_assert_eq!(dst.len(), n * self.out);
56        if n == 0 {
57            return;
58        }
59        // Held on the host on purpose. These are small f32 GEMMs standing
60        // beside a q4tp GEMM that already owns the device, and the generic
61        // `GemmNt` probe will happily send them there — where they queue
62        // behind the base projection and pay a submit and a readback each.
63        // Measured on an M4, 384 tokens: 39.7 s a step through the probe
64        // against 12.4 pinned to the host, for GEMMs whose own arithmetic is
65        // 1.1 s of that. Accelerate runs these shapes at 250-1300 GFLOP/s.
66        let mut h = vec![0f32; n * self.rank];
67        let mut d = vec![0f32; n * self.out];
68        crate::gpu::cpu_scope(|| {
69            crate::fcd_ops::gemm_nt(x, &self.a, &mut h, n, self.inn, self.rank, pool);
70            crate::fcd_ops::gemm_nt(&h, &self.b, &mut d, n, self.rank, self.out, pool);
71        });
72        let scale = self.scale;
73        let sink = Shared(dst.as_mut_ptr());
74        rows(pool, n, &|s, e| {
75            let row = unsafe { sink.at(s * self.out, (e - s) * self.out) };
76            for (o, v) in row.iter_mut().enumerate() {
77                *v += scale * d[s * self.out + o];
78            }
79        });
80    }
81}
82
83/// The learned per-reference slot embedding: `slot_id` through Fourier
84/// features and a two-layer SiLU MLP, landing on the latent's channel count.
85///
86/// Reproduces the adapter's own definition exactly — the index is divided by
87/// 16 before the phases are taken, and the raw scaled value is the first
88/// feature, so the input width is `1 + 2·num_frequencies`.
89pub struct SlotEmbed {
90    freqs: Vec<f32>,
91    w0: Vec<f32>,
92    b0: Vec<f32>,
93    w2: Vec<f32>,
94    b2: Vec<f32>,
95    hidden: usize,
96    dim: usize,
97}
98
99impl SlotEmbed {
100    pub fn dim(&self) -> usize {
101        self.dim
102    }
103
104    /// The embedding for slot `slot_id` (1-based, as the adapter numbers them).
105    pub fn embed(&self, slot_id: usize) -> Vec<f32> {
106        let scaled = slot_id as f32 / 16.0;
107        let mut feat = Vec::with_capacity(1 + 2 * self.freqs.len());
108        feat.push(scaled);
109        for f in &self.freqs {
110            feat.push((scaled * f).sin());
111        }
112        for f in &self.freqs {
113            feat.push((scaled * f).cos());
114        }
115        let win = feat.len();
116        let mut hid = vec![0f32; self.hidden];
117        for (o, hv) in hid.iter_mut().enumerate() {
118            let row = &self.w0[o * win..(o + 1) * win];
119            let mut acc = self.b0[o];
120            for (fv, wv) in feat.iter().zip(row) {
121                acc += fv * wv;
122            }
123            // SiLU
124            *hv = acc / (1.0 + (-acc).exp());
125        }
126        let mut outv = vec![0f32; self.dim];
127        for (o, ov) in outv.iter_mut().enumerate() {
128            let row = &self.w2[o * self.hidden..(o + 1) * self.hidden];
129            let mut acc = self.b2[o];
130            for (hv, wv) in hid.iter().zip(row) {
131                acc += hv * wv;
132            }
133            *ov = acc;
134        }
135        outv
136    }
137}
138
139/// Every branch an adapter file carries, keyed by the projection it belongs
140/// to — the container's name with the `dit.` prefix dropped, which is what
141/// the adapters use after their own `diffusion_model.`.
142pub struct LoraBank {
143    pairs: HashMap<String, (Vec<f32>, Vec<f32>, usize, usize, usize)>,
144    pub slot: Option<SlotEmbed>,
145    pub meta: HashMap<String, String>,
146    scale: f32,
147}
148
149fn st_read(path: &Path) -> Result<(HashMap<String, (Vec<usize>, Vec<f32>)>, HashMap<String, String>), String> {
150    let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
151    if bytes.len() < 8 {
152        return Err("lora: truncated safetensors header".into());
153    }
154    let hlen = u64::from_le_bytes(bytes[..8].try_into().unwrap()) as usize;
155    let header: serde_json::Value = serde_json::from_slice(
156        bytes.get(8..8 + hlen).ok_or("lora: header past end of file")?,
157    )
158    .map_err(|e| format!("lora header: {e}"))?;
159    let base = 8 + hlen;
160    let obj = header.as_object().ok_or("lora: header not an object")?;
161    let mut meta = HashMap::new();
162    let mut out = HashMap::new();
163    for (name, m) in obj {
164        if name == "__metadata__" {
165            if let Some(o) = m.as_object() {
166                for (k, v) in o {
167                    if let Some(s) = v.as_str() {
168                        meta.insert(k.clone(), s.to_string());
169                    }
170                }
171            }
172            continue;
173        }
174        let dtype = m["dtype"].as_str().ok_or("lora: dtype")?;
175        let shape: Vec<usize> = m["shape"]
176            .as_array()
177            .ok_or("lora: shape")?
178            .iter()
179            .map(|v| v.as_u64().unwrap_or(0) as usize)
180            .collect();
181        let offs = m["data_offsets"].as_array().ok_or("lora: offsets")?;
182        let s = offs[0].as_u64().unwrap_or(0) as usize + base;
183        let e = offs[1].as_u64().unwrap_or(0) as usize + base;
184        let raw = bytes.get(s..e).ok_or("lora: tensor span past end of file")?;
185        let mut data = Vec::with_capacity(shape.iter().product::<usize>().max(1));
186        match dtype {
187            "F32" => {
188                for c in raw.chunks_exact(4) {
189                    data.push(f32::from_le_bytes(c.try_into().unwrap()));
190                }
191            }
192            "F16" => {
193                for c in raw.chunks_exact(2) {
194                    data.push(cortiq_core::quant::f16_to_f32(u16::from_le_bytes(
195                        c.try_into().unwrap(),
196                    )));
197                }
198            }
199            "BF16" => {
200                for c in raw.chunks_exact(2) {
201                    let b = u16::from_le_bytes(c.try_into().unwrap());
202                    data.push(f32::from_bits((b as u32) << 16));
203                }
204            }
205            other => return Err(format!("lora: unsupported dtype {other} on {name}")),
206        }
207        out.insert(name.clone(), (shape, data));
208    }
209    Ok((out, meta))
210}
211
212impl LoraBank {
213    /// Read an adapter. `scale` multiplies every branch — the strength dial.
214    ///
215    /// `alpha` is honoured when the file records it: the trained convention is
216    /// `scale = strength · alpha / rank`, and an adapter that ships neither
217    /// `alpha` nor `lora_alpha` is taken at `strength` as-is, which is what
218    /// the diffusers loaders do for a file whose A/B are already scaled.
219    pub fn load(path: &Path, strength: f32) -> Result<LoraBank, String> {
220        let (tensors, meta) = st_read(path)?;
221        let mut a_side: HashMap<String, (Vec<usize>, Vec<f32>)> = HashMap::new();
222        let mut b_side: HashMap<String, (Vec<usize>, Vec<f32>)> = HashMap::new();
223        let mut slot_parts: HashMap<String, (Vec<usize>, Vec<f32>)> = HashMap::new();
224        for (name, val) in tensors {
225            let short = name
226                .strip_prefix("diffusion_model.")
227                .unwrap_or(&name)
228                .to_string();
229            if let Some(rest) = short.strip_prefix("reference_slot_embedding.") {
230                slot_parts.insert(rest.to_string(), val);
231            } else if let Some(base) = short.strip_suffix(".lora_A.weight") {
232                a_side.insert(base.to_string(), val);
233            } else if let Some(base) = short.strip_suffix(".lora_B.weight") {
234                b_side.insert(base.to_string(), val);
235            } else if let Some(base) = short.strip_suffix(".lora_down.weight") {
236                a_side.insert(base.to_string(), val);
237            } else if let Some(base) = short.strip_suffix(".lora_up.weight") {
238                b_side.insert(base.to_string(), val);
239            }
240        }
241        let alpha = meta
242            .get("alpha")
243            .or_else(|| meta.get("lora_alpha"))
244            .and_then(|v| v.parse::<f32>().ok());
245
246        let mut pairs = HashMap::new();
247        for (base, (ashape, adata)) in a_side {
248            let Some((bshape, bdata)) = b_side.remove(&base) else {
249                return Err(format!("lora: {base} has an A side and no B side"));
250            };
251            if ashape.len() != 2 || bshape.len() != 2 {
252                return Err(format!("lora: {base} is not a matrix pair"));
253            }
254            let (rank, inn) = (ashape[0], ashape[1]);
255            let (out, rank_b) = (bshape[0], bshape[1]);
256            if rank != rank_b {
257                return Err(format!(
258                    "lora: {base} rank mismatch — A is {rank}, B is {rank_b}"
259                ));
260            }
261            let scale = match alpha {
262                Some(al) if rank > 0 => strength * al / rank as f32,
263                _ => strength,
264            };
265            pairs.insert(base, (adata, bdata, rank, inn, out));
266            let _ = scale; // per-branch scale is uniform; kept on the bank
267        }
268        if !b_side.is_empty() {
269            let orphan = b_side.keys().next().cloned().unwrap_or_default();
270            return Err(format!("lora: {orphan} has a B side and no A side"));
271        }
272
273        // The slot embedding is present only on the multi-reference adapters.
274        // Half of it is not a thing we can guess at: refuse a partial one
275        // rather than silently render without the reference conditioning.
276        let slot = if slot_parts.is_empty() {
277            None
278        } else {
279            let need = |k: &str| -> Result<&(Vec<usize>, Vec<f32>), String> {
280                slot_parts
281                    .get(k)
282                    .ok_or_else(|| format!("lora: reference_slot_embedding.{k} is missing"))
283            };
284            let freqs = need("frequencies")?.1.clone();
285            let (s0, w0) = need("net.0.weight").map(|t| (t.0.clone(), t.1.clone()))?;
286            let b0 = need("net.0.bias")?.1.clone();
287            let (s2, w2) = need("net.2.weight").map(|t| (t.0.clone(), t.1.clone()))?;
288            let b2 = need("net.2.bias")?.1.clone();
289            if s0.len() != 2 || s2.len() != 2 {
290                return Err("lora: slot embedding layers are not matrices".into());
291            }
292            if s0[1] != 1 + 2 * freqs.len() {
293                return Err(format!(
294                    "lora: slot embedding takes {} features, {} frequencies imply {}",
295                    s0[1],
296                    freqs.len(),
297                    1 + 2 * freqs.len()
298                ));
299            }
300            Some(SlotEmbed {
301                freqs,
302                w0,
303                b0,
304                w2,
305                b2,
306                hidden: s0[0],
307                dim: s2[0],
308            })
309        };
310
311        let scale = match alpha {
312            Some(al) => {
313                let r = pairs.values().next().map(|p| p.2).unwrap_or(1).max(1);
314                strength * al / r as f32
315            }
316            None => strength,
317        };
318        Ok(LoraBank { pairs, slot, meta, scale })
319    }
320
321    pub fn len(&self) -> usize {
322        self.pairs.len()
323    }
324
325    pub fn is_empty(&self) -> bool {
326        self.pairs.is_empty()
327    }
328
329    /// The rank the file was trained at, for the log line.
330    pub fn rank(&self) -> usize {
331        self.pairs.values().next().map(|p| p.2).unwrap_or(0)
332    }
333
334    /// The branch for a container tensor name, if this adapter carries one.
335    /// `name` is the projection without `.weight` — `dit.transformer_blocks.0.attn1.to_q`.
336    pub fn branch(&self, name: &str) -> Option<LoraBranch> {
337        let key = name.strip_prefix("dit.").unwrap_or(name);
338        let (a, b, rank, inn, out) = self.pairs.get(key)?;
339        Some(LoraBranch {
340            a: a.clone(),
341            b: b.clone(),
342            rank: *rank,
343            inn: *inn,
344            out: *out,
345            scale: self.scale,
346        })
347    }
348
349    /// How the adapter wants reference tokens placed. Both are refused rather
350    /// than approximated when they are not what this implementation does.
351    pub fn check_reference_convention(&self) -> Result<(), String> {
352        if let Some(order) = self.meta.get("reference_token_order") {
353            if order != "prepend" {
354                return Err(format!(
355                    "lora: reference_token_order={order}, this build only prepends"
356                ));
357            }
358        }
359        if let Some(off) = self.meta.get("reference_slot_time_offsets") {
360            if off != "pic1_based_negative_time" {
361                return Err(format!(
362                    "lora: reference_slot_time_offsets={off}, this build only places \
363                     references at negative latent frames"
364                ));
365            }
366        }
367        Ok(())
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    /// The branch must equal a plain dense evaluation of `s·(xAᵀ)Bᵀ`.
376    #[test]
377    fn branch_matches_dense() {
378        let (n, inn, rank, out) = (3usize, 5usize, 2usize, 4usize);
379        let a: Vec<f32> = (0..rank * inn).map(|i| (i as f32 * 0.37).sin()).collect();
380        let b: Vec<f32> = (0..out * rank).map(|i| (i as f32 * 0.11).cos()).collect();
381        let x: Vec<f32> = (0..n * inn).map(|i| (i as f32 * 0.7).sin()).collect();
382        let br = LoraBranch { a: a.clone(), b: b.clone(), rank, inn, out, scale: 0.5 };
383        let mut got = vec![1.5f32; n * out];
384        br.add(&x, n, &mut got, None);
385        for t in 0..n {
386            for o in 0..out {
387                let mut acc = 0f32;
388                for r in 0..rank {
389                    let h: f32 = (0..inn).map(|i| x[t * inn + i] * a[r * inn + i]).sum();
390                    acc += h * b[o * rank + r];
391                }
392                let want = 1.5 + 0.5 * acc;
393                assert!(
394                    (got[t * out + o] - want).abs() < 1e-4,
395                    "row {t} col {o}: {} vs {want}",
396                    got[t * out + o]
397                );
398            }
399        }
400    }
401
402    /// A zero-rank-B adapter must be the identity on the output.
403    #[test]
404    fn zero_b_changes_nothing() {
405        let br = LoraBranch {
406            a: vec![1.0; 4],
407            b: vec![0.0; 6],
408            rank: 2,
409            inn: 2,
410            out: 3,
411            scale: 1.0,
412        };
413        let mut out = vec![7.0f32; 3];
414        br.add(&[1.0, 2.0], 1, &mut out, None);
415        assert_eq!(out, vec![7.0, 7.0, 7.0]);
416    }
417
418    /// A file's names must land on the container's projections: the adapters
419    /// write `diffusion_model.transformer_blocks.N.attn1.to_q.lora_A.weight`
420    /// and the container calls that tensor
421    /// `dit.transformer_blocks.N.attn1.to_q.weight`, so the bank has to strip
422    /// one prefix and the lookup the other.
423    #[test]
424    fn names_bind_to_container_projections() {
425        use std::io::Write;
426        let dir = std::env::temp_dir().join("cmf_lora_name_test");
427        std::fs::create_dir_all(&dir).unwrap();
428        let path = dir.join("tiny.safetensors");
429        // rank 2, in 3, out 4 — one pair, plus a slot embedding
430        let names = [
431            ("diffusion_model.transformer_blocks.0.attn1.to_q.lora_A.weight", vec![2usize, 3]),
432            ("diffusion_model.transformer_blocks.0.attn1.to_q.lora_B.weight", vec![4, 2]),
433        ];
434        let mut header = serde_json::Map::new();
435        let mut blob: Vec<u8> = Vec::new();
436        for (n, shape) in &names {
437            let count: usize = shape.iter().product();
438            let start = blob.len();
439            for i in 0..count {
440                blob.extend_from_slice(&(i as f32 * 0.25).to_le_bytes());
441            }
442            header.insert(
443                (*n).to_string(),
444                serde_json::json!({
445                    "dtype": "F32",
446                    "shape": shape,
447                    "data_offsets": [start, blob.len()],
448                }),
449            );
450        }
451        let hdr = serde_json::to_vec(&serde_json::Value::Object(header)).unwrap();
452        let mut f = std::fs::File::create(&path).unwrap();
453        f.write_all(&(hdr.len() as u64).to_le_bytes()).unwrap();
454        f.write_all(&hdr).unwrap();
455        f.write_all(&blob).unwrap();
456        drop(f);
457
458        let bank = LoraBank::load(&path, 1.0).expect("load");
459        assert_eq!(bank.len(), 1);
460        assert_eq!(bank.rank(), 2);
461        assert!(bank.slot.is_none());
462        let br = bank
463            .branch("dit.transformer_blocks.0.attn1.to_q")
464            .expect("the container's name must find the adapter's branch");
465        assert_eq!(br.rank(), 2);
466        assert!(bank.branch("dit.transformer_blocks.0.attn1.to_k").is_none());
467        // and the branch computes: x = [1,0,0] picks A's first column
468        let mut out = vec![0f32; 4];
469        br.add(&[1.0, 0.0, 0.0], 1, &mut out, None);
470        // A = [[0,.25,.5],[.75,1,1.25]] so h = [0, .75]
471        // B = [[0,.25],[.5,.75],[1,1.25],[1.5,1.75]] so out = .75 * B[:,1]
472        let want = [0.25, 0.75, 1.25, 1.75].map(|v: f32| v * 0.75);
473        for (g, w) in out.iter().zip(&want) {
474            assert!((g - w).abs() < 1e-5, "{g} vs {w}");
475        }
476        let _ = std::fs::remove_dir_all(&dir);
477    }
478
479    /// A file with only one side of a pair is a broken adapter, and must say
480    /// so rather than render as if the branch were zero.
481    #[test]
482    fn orphan_side_is_refused() {
483        use std::io::Write;
484        let dir = std::env::temp_dir().join("cmf_lora_orphan_test");
485        std::fs::create_dir_all(&dir).unwrap();
486        let path = dir.join("orphan.safetensors");
487        let mut header = serde_json::Map::new();
488        let mut blob: Vec<u8> = Vec::new();
489        for i in 0..6 {
490            blob.extend_from_slice(&(i as f32).to_le_bytes());
491        }
492        header.insert(
493            "diffusion_model.transformer_blocks.0.attn1.to_q.lora_A.weight".to_string(),
494            serde_json::json!({"dtype":"F32","shape":[2,3],"data_offsets":[0,24]}),
495        );
496        let hdr = serde_json::to_vec(&serde_json::Value::Object(header)).unwrap();
497        let mut f = std::fs::File::create(&path).unwrap();
498        f.write_all(&(hdr.len() as u64).to_le_bytes()).unwrap();
499        f.write_all(&hdr).unwrap();
500        f.write_all(&blob).unwrap();
501        drop(f);
502        let err = match LoraBank::load(&path, 1.0) {
503            Err(e) => e,
504            Ok(_) => panic!("an adapter with a lone A side must be refused"),
505        };
506        assert!(err.contains("no B side"), "{err}");
507        let _ = std::fs::remove_dir_all(&dir);
508    }
509
510    /// The slot embedding's feature vector is `[v, sin(v·f), cos(v·f)]` with
511    /// `v = slot/16` — check it against a hand-evaluated one-frequency MLP.
512    #[test]
513    fn slot_embedding_matches_definition() {
514        let s = SlotEmbed {
515            freqs: vec![2.0],
516            // hidden = 1, input width 3
517            w0: vec![1.0, 0.5, -0.25],
518            b0: vec![0.1],
519            w2: vec![2.0],
520            b2: vec![-0.3],
521            hidden: 1,
522            dim: 1,
523        };
524        let v = 3.0f32 / 16.0;
525        let feat = [v, (v * 2.0).sin(), (v * 2.0).cos()];
526        let pre = 0.1 + feat[0] * 1.0 + feat[1] * 0.5 + feat[2] * -0.25;
527        let hid = pre / (1.0 + (-pre).exp());
528        let want = -0.3 + hid * 2.0;
529        let got = s.embed(3);
530        assert_eq!(got.len(), 1);
531        assert!((got[0] - want).abs() < 1e-6, "{} vs {want}", got[0]);
532    }
533}