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