Skip to main content

katgpt_types/
lora.rs

1//! CPU-side LoRA adapter.
2
3use super::domain::{read_f32_le, read_u16_le, read_u32_le};
4use super::*;
5
6// ---------------------------------------------------------------------------
7// LoRA Adapter — CPU inference path (Plan 025)
8// ---------------------------------------------------------------------------
9
10/// CPU-side LoRA adapter for inference.
11/// Loads from the same binary format as `GpuLoraAdapter` (Plan 008):
12/// `[LORA(4) | version(4) | blake3(32) | payload...]`
13/// where payload = `[n_adapters(4) | rank(4) | alpha(4) | adapter_data...]`
14/// and adapter_data = `[in_dim(4) | out_dim(4) | a_f32s | b_f32s]`
15///
16/// Use [`LoraAdapter::load`] to read ALL adapters from a multi-adapter file
17/// (correct for L2+ models), or [`LoraAdapter::load_first`] when only the
18/// first adapter is needed (e.g., single-forward-pass heuristic players).
19///
20/// Zero-copy: loaded once per domain, reference-passed during inference.
21///
22/// Fields ordered by descending alignment to minimize padding:
23/// usize/Vec (8-byte) → f32 (4-byte).
24#[derive(Clone)]
25pub struct LoraAdapter {
26    /// LoRA rank.
27    pub rank: usize,
28    /// Input dimension.
29    pub in_dim: usize,
30    /// Output dimension.
31    pub out_dim: usize,
32    /// Down-projection: [rank × in_dim]
33    pub a: Vec<f32>,
34    /// Up-projection: [out_dim × rank]
35    pub b: Vec<f32>,
36    /// Scaling factor (alpha / rank).
37    pub alpha: f32,
38}
39
40impl LoraAdapter {
41    /// Load ALL adapters from a Plan 008 binary LoRA file.
42    ///
43    /// Multi-adapter files (e.g., L2+ with 6 adapters/layer × n_layer) return every
44    /// adapter in declaration order. Single-adapter files return a 1-element Vec.
45    ///
46    /// Issue 299: previously this returned only the first adapter, silently
47    /// discarding layers 1+ and invalidating L2+ arena benchmarks.
48    pub fn load(path: &std::path::Path) -> Result<Vec<Self>, String> {
49        const LORA_MAGIC: &[u8; 4] = b"LORA";
50        const LORA_VERSION: u32 = 1;
51
52        let file_data =
53            std::fs::read(path).map_err(|e| format!("Failed to read lora file: {e}"))?;
54
55        if file_data.len() < 44 {
56            return Err("File too small for lora header".into());
57        }
58
59        if &file_data[0..4] != LORA_MAGIC {
60            return Err("Invalid lora magic bytes".into());
61        }
62
63        let version = u32::from_le_bytes(
64            file_data[4..8]
65                .try_into()
66                .map_err(|e: std::array::TryFromSliceError| format!("Version parse: {e}"))?,
67        );
68        if version != LORA_VERSION {
69            return Err(format!("Unsupported lora version: {version}"));
70        }
71
72        let stored_checksum = &file_data[8..40];
73        let payload = &file_data[40..];
74
75        let computed = blake3::hash(payload);
76        if computed.as_bytes() != stored_checksum {
77            return Err("LoRA file checksum mismatch".into());
78        }
79
80        let mut offset = 0usize;
81        let n_adapters = read_u32_le(payload, &mut offset)? as usize;
82        let rank = read_u32_le(payload, &mut offset)? as usize;
83        let alpha = read_f32_le(payload, &mut offset)?;
84
85        if n_adapters == 0 {
86            return Err("No adapters in lora file".into());
87        }
88
89        let mut adapters = Vec::with_capacity(n_adapters);
90        for i in 0..n_adapters {
91            let in_dim = read_u32_le(payload, &mut offset)? as usize;
92            let out_dim = read_u32_le(payload, &mut offset)? as usize;
93
94            let a_count = rank * in_dim;
95            let b_count = out_dim * rank;
96            let a_bytes = a_count * std::mem::size_of::<f32>();
97            let b_bytes = b_count * std::mem::size_of::<f32>();
98
99            if offset + a_bytes + b_bytes > payload.len() {
100                return Err(format!("Truncated adapter {i} data"));
101            }
102
103            let a: Vec<f32> = {
104                #[cfg(target_endian = "little")]
105                {
106                    let mut v = Vec::with_capacity(a_count);
107                    unsafe {
108                        std::ptr::copy_nonoverlapping(
109                            payload[offset..].as_ptr(),
110                            v.as_mut_ptr() as *mut u8,
111                            a_bytes,
112                        );
113                        v.set_len(a_count);
114                    }
115                    v
116                }
117                #[cfg(not(target_endian = "little"))]
118                {
119                    payload[offset..offset + a_bytes]
120                        .chunks_exact(4)
121                        .map(|c| f32::from_le_bytes(c.try_into().expect("chunk is 4 bytes")))
122                        .collect()
123                }
124            };
125            offset += a_bytes;
126
127            let b: Vec<f32> = {
128                #[cfg(target_endian = "little")]
129                {
130                    let mut v = Vec::with_capacity(b_count);
131                    unsafe {
132                        std::ptr::copy_nonoverlapping(
133                            payload[offset..].as_ptr(),
134                            v.as_mut_ptr() as *mut u8,
135                            b_bytes,
136                        );
137                        v.set_len(b_count);
138                    }
139                    v
140                }
141                #[cfg(not(target_endian = "little"))]
142                {
143                    payload[offset..offset + b_bytes]
144                        .chunks_exact(4)
145                        .map(|c| f32::from_le_bytes(c.try_into().expect("chunk is 4 bytes")))
146                        .collect()
147                }
148            };
149            offset += b_bytes;
150
151            adapters.push(Self {
152                rank,
153                in_dim,
154                out_dim,
155                alpha,
156                a,
157                b,
158            });
159        }
160
161        if offset != payload.len() {
162            return Err(format!(
163                "LoRA payload trailing data: consumed {offset}, payload {}",
164                payload.len()
165            ));
166        }
167
168        Ok(adapters)
169    }
170
171    /// Load only the first adapter from a Plan 008 binary LoRA file.
172    ///
173    /// Convenience for consumers that store a single `LoraAdapter` and only run
174    /// one forward pass (e.g., `LoraPlayer`, `FullHLPlayer`). Multi-adapter
175    /// files (L2+) have layers 1+ silently dropped — this is explicit about
176    /// that limitation so callers cannot accidentally regress on Issue 299.
177    ///
178    /// For correct multi-adapter evaluation, use [`load`](Self::load) and apply
179    /// each adapter to its target projection during the forward pass.
180    pub fn load_first(path: &std::path::Path) -> Result<Self, String> {
181        let adapters = Self::load(path)?;
182        adapters
183            .into_iter()
184            .next()
185            .ok_or_else(|| "LoRA file declared zero-length adapter list".into())
186    }
187
188    /// Save adapters to a Plan 008 binary LoRA file (the inverse of [`load`](Self::load)).
189    ///
190    /// All adapters MUST share the same `rank` and `alpha` — the file format stores
191    /// them once in the header. Per-adapter `in_dim`/`out_dim` are stored individually
192    /// (they can differ across targets, e.g. Q vs K in GQA).
193    ///
194    /// Format: `["LORA" | version=1(u32) | blake3(payload)(32) | payload]` where
195    /// payload = `[n_adapters(u32) | rank(u32) | alpha(f32) | per-adapter:
196    /// in_dim(u32) | out_dim(u32) | a_f32s | b_f32s]`.
197    ///
198    /// This is the CPU-side counterpart to the private GPU LoRA exporter,
199    /// producing byte-identical files that load via either path. Used by
200    /// `CpuLoraTrainer` (Issue 018 CPU fallback) to produce arena-loadable
201    /// adapters without a GPU.
202    pub fn save(
203        adapters: &[&Self],
204        rank: usize,
205        alpha: f32,
206        path: &std::path::Path,
207    ) -> Result<(), String> {
208        const LORA_MAGIC: &[u8; 4] = b"LORA";
209        const LORA_VERSION: u32 = 1;
210
211        if adapters.is_empty() {
212            return Err("Cannot save zero adapters".into());
213        }
214
215        let mut payload = Vec::with_capacity(12 + adapters.len() * (8 + 16));
216
217        // Header
218        payload.extend_from_slice(&(adapters.len() as u32).to_le_bytes());
219        payload.extend_from_slice(&(rank as u32).to_le_bytes());
220        payload.extend_from_slice(&alpha.to_le_bytes());
221
222        // Adapter data
223        for adapter in adapters {
224            if adapter.rank != rank {
225                return Err(format!(
226                    "Adapter rank mismatch: header={rank}, adapter={}",
227                    adapter.rank
228                ));
229            }
230            payload.extend_from_slice(&(adapter.in_dim as u32).to_le_bytes());
231            payload.extend_from_slice(&(adapter.out_dim as u32).to_le_bytes());
232
233            let a_count = rank * adapter.in_dim;
234            let b_count = adapter.out_dim * rank;
235            if adapter.a.len() != a_count {
236                return Err(format!(
237                    "A matrix size mismatch: expected {a_count}, got {}",
238                    adapter.a.len()
239                ));
240            }
241            if adapter.b.len() != b_count {
242                return Err(format!(
243                    "B matrix size mismatch: expected {b_count}, got {}",
244                    adapter.b.len()
245                ));
246            }
247            // Bulk write matrix data on LE targets (avoids per-element
248            // extend_from_slice overhead). f32 is plain-old-data with no
249            // padding; on LE targets to_ne_bytes == to_le_bytes.
250            // SAFETY: f32 has no padding and is plain-old-data; we reinterpret
251            // the contiguous &[f32] as bytes for a single bulk copy.
252            #[cfg(target_endian = "little")]
253            {
254                let a_bytes = unsafe {
255                    std::slice::from_raw_parts(
256                        adapter.a.as_ptr() as *const u8,
257                        adapter.a.len() * std::mem::size_of::<f32>(),
258                    )
259                };
260                payload.extend_from_slice(a_bytes);
261                let b_bytes = unsafe {
262                    std::slice::from_raw_parts(
263                        adapter.b.as_ptr() as *const u8,
264                        adapter.b.len() * std::mem::size_of::<f32>(),
265                    )
266                };
267                payload.extend_from_slice(b_bytes);
268            }
269            #[cfg(not(target_endian = "little"))]
270            {
271                for val in &adapter.a {
272                    payload.extend_from_slice(&val.to_le_bytes());
273                }
274                for val in &adapter.b {
275                    payload.extend_from_slice(&val.to_le_bytes());
276                }
277            }
278        }
279
280        // Compute blake3 checksum of payload
281        let checksum = blake3::hash(&payload);
282
283        // Assemble file: magic + version + checksum + payload
284        let mut file_data = Vec::with_capacity(4 + 4 + 32 + payload.len());
285        file_data.extend_from_slice(LORA_MAGIC);
286        file_data.extend_from_slice(&LORA_VERSION.to_le_bytes());
287        file_data.extend_from_slice(checksum.as_bytes());
288        file_data.extend_from_slice(&payload);
289
290        std::fs::write(path, &file_data).map_err(|e| format!("Failed to write lora file: {e}"))
291    }
292
293    /// Load LoRA adapters from a compact binary format.
294    ///
295    /// Format:
296    /// ```text
297    /// [MAGIC: "LORA" 4B]
298    /// [VERSION: 1B]
299    /// [RANK: 2B LE]
300    /// [N_LAYERS: 2B LE]
301    /// [N_TARGETS: 2B LE]
302    /// [TARGET_IDS: N_TARGETS × 2B LE]  (0=q_proj, 1=k_proj, 2=v_proj, 3=o_proj,
303    ///                                    4=gate_proj, 5=up_proj, 6=down_proj)
304    /// [LAYER_DATA: for each (layer, target):
305    ///   [A_ROWS: 2B][A_COLS: 2B][A_DATA: A_ROWS×A_COLS × 4B f32]
306    ///   [B_ROWS: 2B][B_COLS: 2B][B_DATA: B_ROWS×B_COLS × 4B f32]
307    /// ]
308    /// [BLAKE3_HASH: 32B]  — covers everything before it
309    /// ```
310    ///
311    /// Alpha defaults to `rank * 2`.
312    pub fn load_from_bin(path: &std::path::Path) -> Result<Vec<Self>, String> {
313        const LORA_MAGIC: &[u8; 4] = b"LORA";
314        const LORA_VERSION: u8 = 1;
315
316        let file_data =
317            std::fs::read(path).map_err(|e| format!("Failed to read lora bin file: {e}"))?;
318
319        // Minimum: magic(4) + version(1) + rank(2) + n_layers(2) + n_targets(2) + hash(32) = 43
320        if file_data.len() < 43 {
321            return Err("File too small for lora bin header".into());
322        }
323
324        // Validate blake3 checksum — last 32 bytes cover everything before them
325        let data_len = file_data.len() - 32;
326        let stored_checksum = &file_data[data_len..];
327        let computed = blake3::hash(&file_data[..data_len]);
328        if computed.as_bytes() != stored_checksum {
329            return Err("LoRA bin file checksum mismatch".into());
330        }
331
332        let mut offset = 0usize;
333
334        // Magic
335        if &file_data[offset..offset + 4] != LORA_MAGIC {
336            return Err("Invalid lora bin magic bytes".into());
337        }
338        offset += 4;
339
340        // Version
341        let version = file_data[offset];
342        if version != LORA_VERSION {
343            return Err(format!("Unsupported lora bin version: {version}"));
344        }
345        offset += 1;
346
347        // Rank
348        let rank = read_u16_le(&file_data, &mut offset)? as usize;
349
350        // N_LAYERS
351        let n_layers = read_u16_le(&file_data, &mut offset)? as usize;
352
353        // N_TARGETS
354        let n_targets = read_u16_le(&file_data, &mut offset)? as usize;
355
356        if n_layers == 0 || n_targets == 0 {
357            return Err("No layers or targets in lora bin file".into());
358        }
359
360        // TARGET_IDS
361        let mut target_ids = Vec::with_capacity(n_targets);
362        for _ in 0..n_targets {
363            let tid = read_u16_le(&file_data, &mut offset)?;
364            match tid {
365                0..=6 => target_ids.push(tid),
366                _ => return Err(format!("Invalid target ID: {tid}")),
367            }
368        }
369
370        // LAYER_DATA
371        let alpha = (rank * 2) as f32;
372        let mut adapters = Vec::with_capacity(n_layers * n_targets);
373
374        for _layer in 0..n_layers {
375            for &_target_id in &target_ids {
376                // A matrix: [rank × in_dim]
377                let a_rows = read_u16_le(&file_data, &mut offset)? as usize;
378                let a_cols = read_u16_le(&file_data, &mut offset)? as usize;
379                let a_count = a_rows * a_cols;
380                let a_bytes = a_count * std::mem::size_of::<f32>();
381
382                if offset + a_bytes > data_len {
383                    return Err("Truncated A matrix data".into());
384                }
385
386                let a: Vec<f32> = {
387                    #[cfg(target_endian = "little")]
388                    {
389                        let mut v = Vec::with_capacity(a_count);
390                        unsafe {
391                            std::ptr::copy_nonoverlapping(
392                                file_data[offset..].as_ptr(),
393                                v.as_mut_ptr() as *mut u8,
394                                a_bytes,
395                            );
396                            v.set_len(a_count);
397                        }
398                        v
399                    }
400                    #[cfg(not(target_endian = "little"))]
401                    {
402                        file_data[offset..offset + a_bytes]
403                            .chunks_exact(4)
404                            .map(|c| f32::from_le_bytes(c.try_into().expect("chunk is 4 bytes")))
405                            .collect()
406                    }
407                };
408                offset += a_bytes;
409
410                // B matrix: [out_dim × rank]
411                let b_rows = read_u16_le(&file_data, &mut offset)? as usize;
412                let b_cols = read_u16_le(&file_data, &mut offset)? as usize;
413                let b_count = b_rows * b_cols;
414                let b_bytes = b_count * std::mem::size_of::<f32>();
415
416                if offset + b_bytes > data_len {
417                    return Err("Truncated B matrix data".into());
418                }
419
420                let b: Vec<f32> = {
421                    #[cfg(target_endian = "little")]
422                    {
423                        let mut v = Vec::with_capacity(b_count);
424                        unsafe {
425                            std::ptr::copy_nonoverlapping(
426                                file_data[offset..].as_ptr(),
427                                v.as_mut_ptr() as *mut u8,
428                                b_bytes,
429                            );
430                            v.set_len(b_count);
431                        }
432                        v
433                    }
434                    #[cfg(not(target_endian = "little"))]
435                    {
436                        file_data[offset..offset + b_bytes]
437                            .chunks_exact(4)
438                            .map(|c| f32::from_le_bytes(c.try_into().expect("chunk is 4 bytes")))
439                            .collect()
440                    }
441                };
442                offset += b_bytes;
443
444                let in_dim = a_cols;
445                let out_dim = b_rows;
446
447                adapters.push(Self {
448                    rank,
449                    in_dim,
450                    out_dim,
451                    alpha,
452                    a,
453                    b,
454                });
455            }
456        }
457
458        if offset != data_len {
459            return Err(format!(
460                "Unexpected trailing data: read {offset}, expected {data_len}"
461            ));
462        }
463
464        if adapters.is_empty() {
465            return Err("No adapters loaded from lora bin file".into());
466        }
467
468        Ok(adapters)
469    }
470}
471
472/// Apply LoRA delta in-place: `output += (alpha/rank) × B @ (A @ input)`
473///
474/// `lora_buf` is a pre-allocated `[rank]` intermediate buffer — zero alloc in hot path.
475/// The B×hidden multiplication and scaling are fused directly into the output accumulation,
476/// avoiding a separate delta buffer.
477#[inline(always)]
478pub fn lora_apply(output: &mut [f32], lora: &LoraAdapter, input: &[f32], lora_buf: &mut [f32]) {
479    let scale = lora.alpha / lora.rank as f32;
480
481    // 1. hidden = A @ input  (rank × in_dim) @ [in_dim] → [rank]
482    matmul(lora_buf, &lora.a, input, lora.rank, lora.in_dim);
483
484    // 2. output += scale × (B @ hidden) — SIMD-accelerated per-row dot product
485    for r in 0..lora.out_dim {
486        let row_off = r * lora.rank;
487        let sum =
488            crate::simd::simd_dot_f32(&lora.b[row_off..row_off + lora.rank], lora_buf, lora.rank);
489        unsafe {
490            *output.get_unchecked_mut(r) += scale * sum;
491        }
492    }
493}
494
495/// A loaded LoRA pair for modality-specific inference (Plan 025).
496/// Reader is active during bidirectional prefill, writer during causal decode.
497/// Switching is a reference swap — zero data movement.
498pub struct LoraPair {
499    /// LoRA active during bidirectional prefill (e.g., Python Reader).
500    pub reader: Option<LoraAdapter>,
501    /// LoRA active during causal decode (e.g., Rust Writer).
502    pub writer: Option<LoraAdapter>,
503}
504
505impl LoraPair {
506    /// Empty pair — no LoRA applied.
507    pub fn none() -> Self {
508        Self {
509            reader: None,
510            writer: None,
511        }
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518
519    fn make_test_adapters() -> Vec<LoraAdapter> {
520        // 6 adapters mirroring the Bomber/Generic layout: Q, K, V, O, Mlp1, Mlp2.
521        // rank=4, in_dim=32 (n_embd), out_dim varies (GQA: K/V are n_kv_head*head_dim).
522        vec![
523            LoraAdapter {
524                rank: 4,
525                in_dim: 32,
526                out_dim: 32,
527                a: (0..4 * 32).map(|i| i as f32 * 0.01).collect(),
528                b: (0..32 * 4).map(|i| i as f32 * -0.01).collect(),
529                alpha: 8.0,
530            },
531            LoraAdapter {
532                rank: 4,
533                in_dim: 32,
534                out_dim: 8, // kv_dim (n_kv_head=1, head_dim=8)
535                a: (0..4 * 32).map(|i| i as f32).collect(),
536                b: (0..8 * 4).map(|i| i as f32 * 2.0).collect(),
537                alpha: 8.0,
538            },
539            LoraAdapter {
540                rank: 4,
541                in_dim: 32,
542                out_dim: 8,
543                a: (0..4 * 32).map(|i| i as f32 * 0.5).collect(),
544                b: (0..8 * 4).map(|i| i as f32 * -2.0).collect(),
545                alpha: 8.0,
546            },
547            LoraAdapter {
548                rank: 4,
549                in_dim: 32,
550                out_dim: 32,
551                a: (0..4 * 32).map(|i| i as f32 * 0.1).collect(),
552                b: (0..32 * 4).map(|i| i as f32 * 0.3).collect(),
553                alpha: 8.0,
554            },
555            LoraAdapter {
556                rank: 4,
557                in_dim: 32,
558                out_dim: 32, // FFN up (mlp1)
559                a: (0..4 * 32).map(|i| i as f32 * 1.5).collect(),
560                b: (0..32 * 4).map(|i| i as f32 * -1.5).collect(),
561                alpha: 8.0,
562            },
563            LoraAdapter {
564                rank: 4,
565                in_dim: 32,
566                out_dim: 32, // FFN down (mlp2)
567                a: (0..4 * 32).map(|i| i as f32 * 2.5).collect(),
568                b: (0..32 * 4).map(|i| i as f32 * -2.5).collect(),
569                alpha: 8.0,
570            },
571        ]
572    }
573
574    #[test]
575    fn save_load_roundtrip_preserves_all_adapters() {
576        let tmp = std::env::temp_dir().join("katgpt_lora_roundtrip_test.bin");
577        let original = make_test_adapters();
578        let refs: Vec<&LoraAdapter> = original.iter().collect();
579
580        LoraAdapter::save(&refs, 4, 8.0, &tmp).expect("save should succeed");
581        let loaded = LoraAdapter::load(&tmp).expect("load should succeed");
582
583        assert_eq!(loaded.len(), original.len(), "adapter count must match");
584        for (i, (orig, load)) in original.iter().zip(loaded.iter()).enumerate() {
585            assert_eq!(load.rank, orig.rank, "adapter {i} rank");
586            assert_eq!(load.in_dim, orig.in_dim, "adapter {i} in_dim");
587            assert_eq!(load.out_dim, orig.out_dim, "adapter {i} out_dim");
588            assert_eq!(load.alpha, orig.alpha, "adapter {i} alpha");
589            assert_eq!(load.a.len(), orig.a.len(), "adapter {i} A length");
590            assert_eq!(load.b.len(), orig.b.len(), "adapter {i} B length");
591            for (j, (a, b)) in orig.a.iter().zip(load.a.iter()).enumerate() {
592                assert_eq!(a.to_bits(), b.to_bits(), "adapter {i} A[{j}] bit-identical");
593            }
594            for (j, (a, b)) in orig.b.iter().zip(load.b.iter()).enumerate() {
595                assert_eq!(a.to_bits(), b.to_bits(), "adapter {i} B[{j}] bit-identical");
596            }
597        }
598
599        let _ = std::fs::remove_file(&tmp);
600    }
601
602    #[test]
603    fn save_rejects_empty_adapter_list() {
604        let tmp = std::env::temp_dir().join("katgpt_lora_empty_test.bin");
605        let empty: Vec<&LoraAdapter> = vec![];
606        let result = LoraAdapter::save(&empty, 4, 8.0, &tmp);
607        assert!(result.is_err());
608        let _ = std::fs::remove_file(&tmp);
609    }
610
611    #[test]
612    fn save_rejects_rank_mismatch() {
613        let tmp = std::env::temp_dir().join("katgpt_lora_rankmismatch_test.bin");
614        let adapters = make_test_adapters();
615        let refs: Vec<&LoraAdapter> = adapters.iter().collect();
616        // Pass rank=8 but adapters have rank=4
617        let result = LoraAdapter::save(&refs, 8, 8.0, &tmp);
618        assert!(result.is_err());
619        let _ = std::fs::remove_file(&tmp);
620    }
621}