1use crate::ltxdit::{Shared, rows};
26use crate::pool::Pool;
27use std::collections::HashMap;
28use std::path::Path;
29
30pub struct LoraBranch {
32 a: Vec<f32>, b: Vec<f32>, rank: usize,
35 inn: usize,
36 out: usize,
37 scale: f32,
38 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 #[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 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 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
100pub 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 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 *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
156pub 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 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; }
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 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 pub fn rank(&self) -> usize {
348 self.pairs.values().next().map(|p| p.2).unwrap_or(0)
349 }
350
351 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 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 #[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 #[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 #[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 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 let mut out = vec![0f32; 4];
488 br.add(&[1.0, 0.0, 0.0], 1, &mut out, None);
489 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 #[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 #[test]
532 fn slot_embedding_matches_definition() {
533 let s = SlotEmbed {
534 freqs: vec![2.0],
535 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}