Skip to main content

ferrox_core/weight_matrix/
lora.rs

1//! LoRA deltas on a [`WeightMatrix`](super::WeightMatrix): the low-rank
2//! term of `W x + Σ_i s_i · B_i (A_i x)`.
3//!
4//! llama.cpp applies an adapter inside ONE function, `build_lora_mm`
5//! (`src/llama-graph.cpp:1486-1514`): every projection in every graph
6//! goes through it, and the adapter is a lookup on the weight tensor
7//! plus two more `mul_mat`s and an `add`. ferrox has no graph, so the
8//! equivalent seam is the type every projection already is: a
9//! [`WeightMatrix`](super::WeightMatrix) that carries a [`LoraStack`]
10//! computes the delta inside its own `apply` / `apply_batch` /
11//! `apply_gpu` / `dequant_row`, and the CPU row body, the batched host
12//! bodies and the per-matrix GPU launches cannot disagree about whether
13//! the adapter was applied, because none of them can see the base
14//! weights without going through the same methods.
15//!
16//! **Layouts.** A projection's base is `[rows][cols]` (`n_out x n_in`,
17//! row-major, a contiguous input vector per row). Its `lora_a` is
18//! `[rank][cols]` and its `lora_b` is `[rows][rank]` in the same sense,
19//! which is exactly ggml's `ne = [n_in, rank]` and `ne = [rank, n_out]`
20//! (`llama-adapter.cpp:362-367` checks precisely those three
21//! equalities), so `B (A x)` is two matvecs of the same row-major kind
22//! as `W x`. A token-embedding adapter is stored FLIPPED upstream --
23//! `token_embd.weight.lora_a` is `ne = [rank, n_vocab]` because the
24//! graph gathers a ROW of it per token and multiplies by `lora_b`
25//! (`llama-graph.cpp:2296-2304`, `llama-adapter.cpp:355-359`); the
26//! loader in `ferrox-models` transposes that pair into this module's
27//! one layout, so `dequant_row(token)` here is the same formula as
28//! every other row.
29//!
30//! **Scale.** `scale = adapter_scale * alpha / rank` when the file
31//! carries a nonzero `adapter.lora.alpha`, else `adapter_scale` alone
32//! (`llama-adapter.h:53-57`, `rank = b->ne[0]`). The adapter half of
33//! that product is a [`LoraScale`] SHARED by every delta one adapter
34//! attached, so `POST /lora-adapters` and a per-request `lora` list
35//! change one atomic per adapter and nothing is recomputed.
36//!
37//! **Cost when absent.** A matrix with no stack is a different enum
38//! variant, so the seam costs nothing: the base arms are untouched and
39//! the logits are byte-identical (pinned by the fixture suite). A
40//! stack whose every scale is zero adds nothing either -- the delta is
41//! skipped before any arithmetic, which is what makes `scale 0` equal
42//! to "no adapter" bit for bit, as it is in libllama (measured: the
43//! `--lora x:0` golden is byte-identical to the base golden).
44//!
45//! **No per-token allocation.** The `A x` intermediate is `rank`
46//! floats; it lives in a thread-local scratch that is sized once and
47//! reused, so the decode path allocates nothing here.
48
49use std::cell::RefCell;
50use std::sync::atomic::{AtomicU32, Ordering};
51use std::sync::Arc;
52
53/// One adapter's runtime scale (`adapter_scale` in llama.cpp's terms),
54/// shared by every [`LoraDelta`] that adapter attached.
55///
56/// An `f32` in an atomic so a server can change it between requests
57/// without a lock on the model: every projection reads it once per
58/// apply with a relaxed load.
59#[derive(Debug)]
60pub struct LoraScale(AtomicU32);
61
62impl LoraScale {
63    pub fn new(scale: f32) -> Arc<Self> {
64        Arc::new(Self(AtomicU32::new(scale.to_bits())))
65    }
66
67    pub fn get(&self) -> f32 {
68        f32::from_bits(self.0.load(Ordering::Relaxed))
69    }
70
71    pub fn set(&self, scale: f32) {
72        self.0.store(scale.to_bits(), Ordering::Relaxed);
73    }
74}
75
76/// Why a pair of `lora_a` / `lora_b` matrices cannot decorate a base of
77/// a given shape. The message names every dimension involved, because
78/// the usual cause is an adapter converted for a different base model.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct LoraShapeError {
81    pub rows: usize,
82    pub cols: usize,
83    pub rank: usize,
84    pub a_len: usize,
85    pub b_len: usize,
86}
87
88impl std::fmt::Display for LoraShapeError {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        write!(
91            f,
92            "LoRA pair does not fit a [{} x {}] base at rank {}: lora_a has {} values \
93             (want rank x cols = {}), lora_b has {} values (want rows x rank = {})",
94            self.rows,
95            self.cols,
96            self.rank,
97            self.a_len,
98            self.rank * self.cols,
99            self.b_len,
100            self.rows * self.rank
101        )
102    }
103}
104
105impl std::error::Error for LoraShapeError {}
106
107/// One adapter's `(A, B)` pair for one base matrix.
108#[derive(Debug)]
109pub struct LoraDelta {
110    /// `[rank][cols]`, row-major.
111    a: Vec<f32>,
112    /// `[rows][rank]`, row-major.
113    b: Vec<f32>,
114    rank: usize,
115    rows: usize,
116    cols: usize,
117    /// `alpha / rank` when the adapter declares a nonzero alpha, else
118    /// `1.0`: the half of llama.cpp's `get_scale` that is fixed at load.
119    alpha_over_rank: f32,
120    scale: Arc<LoraScale>,
121}
122
123impl LoraDelta {
124    /// `a` is `[rank][cols]`, `b` is `[rows][rank]`, both row-major;
125    /// `alpha` is the file's `adapter.lora.alpha` (0 when absent, which
126    /// upstream reads as "no alpha scaling").
127    pub fn new(
128        a: Vec<f32>,
129        b: Vec<f32>,
130        rank: usize,
131        rows: usize,
132        cols: usize,
133        alpha: f32,
134        scale: Arc<LoraScale>,
135    ) -> Result<Self, LoraShapeError> {
136        if rank == 0 || a.len() != rank * cols || b.len() != rows * rank {
137            return Err(LoraShapeError {
138                rows,
139                cols,
140                rank,
141                a_len: a.len(),
142                b_len: b.len(),
143            });
144        }
145        let alpha_over_rank = if alpha != 0.0 {
146            alpha / rank as f32
147        } else {
148            1.0
149        };
150        Ok(Self {
151            a,
152            b,
153            rank,
154            rows,
155            cols,
156            alpha_over_rank,
157            scale,
158        })
159    }
160
161    pub fn rank(&self) -> usize {
162        self.rank
163    }
164
165    pub fn rows(&self) -> usize {
166        self.rows
167    }
168
169    pub fn cols(&self) -> usize {
170        self.cols
171    }
172
173    /// The adapter's shared scale, for a caller that wants to change it.
174    pub fn scale_handle(&self) -> &Arc<LoraScale> {
175        &self.scale
176    }
177
178    /// Bytes this delta keeps resident.
179    pub fn resident_bytes(&self) -> usize {
180        (self.a.len() + self.b.len()) * 4
181    }
182
183    /// The whole of llama.cpp's `get_scale`: `adapter_scale * alpha /
184    /// rank`, or `adapter_scale` when alpha is zero.
185    #[inline]
186    fn effective_scale(&self) -> f32 {
187        self.scale.get() * self.alpha_over_rank
188    }
189
190    /// `y = A x`, written into `y` (`rank` long).
191    #[inline]
192    fn project(&self, x: &[f32], y: &mut [f32]) {
193        for (k, yk) in y.iter_mut().enumerate() {
194            let row = &self.a[k * self.cols..(k + 1) * self.cols];
195            *yk = dot(row, x);
196        }
197    }
198
199    /// `out[r] += s * b[r] . y` for every row.
200    #[inline]
201    fn accumulate(&self, s: f32, y: &[f32], out: &mut [f32]) {
202        for (r, o) in out.iter_mut().enumerate() {
203            let brow = &self.b[r * self.rank..(r + 1) * self.rank];
204            *o += s * dot(brow, y);
205        }
206    }
207
208    /// `out += s · B (A x)` for one activation. `out` is `rows` long.
209    pub fn add_to(&self, x: &[f32], out: &mut [f32], scratch: &mut Vec<f32>) {
210        debug_assert_eq!(x.len(), self.cols);
211        debug_assert_eq!(out.len(), self.rows);
212        let s = self.effective_scale();
213        if s == 0.0 {
214            return;
215        }
216        scratch.clear();
217        scratch.resize(self.rank, 0.0);
218        self.project(x, scratch);
219        self.accumulate(s, scratch, out);
220    }
221
222    /// `out_row += s · (b[r] A)`: the delta of ONE row of the adapted
223    /// matrix, which is what a row gather (the token embedding) needs.
224    pub fn add_row_to(&self, r: usize, out_row: &mut [f32]) {
225        debug_assert!(r < self.rows);
226        debug_assert_eq!(out_row.len(), self.cols);
227        let s = self.effective_scale();
228        if s == 0.0 {
229            return;
230        }
231        let brow = &self.b[r * self.rank..(r + 1) * self.rank];
232        for (k, &bk) in brow.iter().enumerate() {
233            let arow = &self.a[k * self.cols..(k + 1) * self.cols];
234            let sb = s * bk;
235            for (o, &a) in out_row.iter_mut().zip(arow) {
236                *o += sb * a;
237            }
238        }
239    }
240
241    /// `out[b] += s · B (A x_b)` for every position of a batch. `x_batch`
242    /// is `[batch][cols]`, `out` is `[batch][rows]`, both row-major --
243    /// the layouts `apply_batch` takes and returns.
244    ///
245    /// Parallel over positions: a prefill's `batch x rows x rank` is the
246    /// only place the low-rank term is not negligible next to the base
247    /// GEMM, and one thread per position keeps every write disjoint.
248    pub fn add_batch_to(&self, x_batch: &[f32], batch: usize, out: &mut [f32]) {
249        debug_assert_eq!(x_batch.len(), batch * self.cols);
250        debug_assert_eq!(out.len(), batch * self.rows);
251        let s = self.effective_scale();
252        if s == 0.0 || batch == 0 {
253            return;
254        }
255        let rows = self.rows;
256        let cols = self.cols;
257        crate::par::chunks_mut(out, rows, 1, |b, out_b| {
258            SCRATCH.with(|cell| {
259                let mut y = cell.borrow_mut();
260                y.clear();
261                y.resize(self.rank, 0.0);
262                self.project(&x_batch[b * cols..(b + 1) * cols], &mut y);
263                self.accumulate(s, &y, out_b);
264            });
265        });
266    }
267}
268
269thread_local! {
270    /// The `A x` intermediate, `rank` floats, reused across calls so
271    /// the decode path never allocates here.
272    static SCRATCH: RefCell<Vec<f32>> = const { RefCell::new(Vec::new()) };
273}
274
275#[inline]
276fn dot(a: &[f32], b: &[f32]) -> f32 {
277    a.iter().zip(b).map(|(x, y)| x * y).sum()
278}
279
280/// Every adapter attached to one base matrix, applied in order. Two
281/// adapters on one weight are two terms of the sum, as they are in
282/// llama.cpp's `for (const auto & lora : *loras)`.
283#[derive(Debug, Default)]
284pub struct LoraStack {
285    deltas: Vec<LoraDelta>,
286}
287
288impl LoraStack {
289    pub fn new(delta: LoraDelta) -> Self {
290        Self {
291            deltas: vec![delta],
292        }
293    }
294
295    pub fn push(&mut self, delta: LoraDelta) {
296        self.deltas.push(delta);
297    }
298
299    pub fn len(&self) -> usize {
300        self.deltas.len()
301    }
302
303    pub fn is_empty(&self) -> bool {
304        self.deltas.is_empty()
305    }
306
307    pub fn deltas(&self) -> &[LoraDelta] {
308        &self.deltas
309    }
310
311    pub fn resident_bytes(&self) -> usize {
312        self.deltas.iter().map(LoraDelta::resident_bytes).sum()
313    }
314
315    /// `out += Σ_i s_i · B_i (A_i x)`.
316    pub fn add_to(&self, x: &[f32], out: &mut [f32]) {
317        SCRATCH.with(|cell| {
318            let mut scratch = cell.borrow_mut();
319            for d in &self.deltas {
320                d.add_to(x, out, &mut scratch);
321            }
322        });
323    }
324
325    /// The delta of one row, for a row gather.
326    pub fn add_row_to(&self, r: usize, out_row: &mut [f32]) {
327        for d in &self.deltas {
328            d.add_row_to(r, out_row);
329        }
330    }
331
332    /// `out[b] += Σ_i s_i · B_i (A_i x_b)` over a `[batch][cols]` input.
333    pub fn add_batch_to(&self, x_batch: &[f32], batch: usize, out: &mut [f32]) {
334        for d in &self.deltas {
335            d.add_batch_to(x_batch, batch, out);
336        }
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    fn delta(rows: usize, cols: usize, rank: usize, alpha: f32, scale: f32) -> LoraDelta {
345        let a: Vec<f32> = (0..rank * cols).map(|i| (i as f32 * 0.37).sin()).collect();
346        let b: Vec<f32> = (0..rows * rank).map(|i| (i as f32 * 0.53).cos()).collect();
347        LoraDelta::new(a, b, rank, rows, cols, alpha, LoraScale::new(scale)).unwrap()
348    }
349
350    /// The reference: materialise `s * B A` and multiply.
351    fn dense_delta(d: &LoraDelta, x: &[f32]) -> Vec<f32> {
352        let s = d.effective_scale();
353        (0..d.rows)
354            .map(|r| {
355                let mut acc = 0.0;
356                for k in 0..d.rank {
357                    let bk = d.b[r * d.rank + k];
358                    for (c, &xc) in x.iter().enumerate() {
359                        acc += s * bk * d.a[k * d.cols + c] * xc;
360                    }
361                }
362                acc
363            })
364            .collect()
365    }
366
367    fn close(a: &[f32], b: &[f32]) {
368        assert_eq!(a.len(), b.len());
369        for (x, y) in a.iter().zip(b) {
370            assert!((x - y).abs() < 1e-4, "{x} vs {y}");
371        }
372    }
373
374    #[test]
375    fn matvec_delta_matches_the_materialised_product() {
376        let d = delta(7, 5, 3, 6.0, 0.8);
377        let x: Vec<f32> = (0..5).map(|i| i as f32 - 2.0).collect();
378        let mut out = vec![0.0; 7];
379        d.add_to(&x, &mut out, &mut Vec::new());
380        close(&out, &dense_delta(&d, &x));
381    }
382
383    #[test]
384    fn alpha_over_rank_is_llama_cpp_s_get_scale() {
385        // alpha 6 at rank 3 is a factor of 2; alpha 0 is a factor of 1.
386        let with = delta(4, 4, 3, 6.0, 0.5);
387        let without = delta(4, 4, 3, 0.0, 0.5);
388        assert!((with.effective_scale() - 1.0).abs() < 1e-7);
389        assert!((without.effective_scale() - 0.5).abs() < 1e-7);
390    }
391
392    #[test]
393    fn row_delta_agrees_with_the_matvec_on_a_unit_vector() {
394        let d = delta(6, 8, 2, 4.0, 1.3);
395        for r in 0..6 {
396            let mut row = vec![0.0; 8];
397            d.add_row_to(r, &mut row);
398            // Row r of (s B A) dotted with e_c is entry (r, c).
399            for c in 0..8 {
400                let mut e = vec![0.0; 8];
401                e[c] = 1.0;
402                let mut out = vec![0.0; 6];
403                d.add_to(&e, &mut out, &mut Vec::new());
404                assert!((out[r] - row[c]).abs() < 1e-5);
405            }
406        }
407    }
408
409    #[test]
410    fn batch_delta_is_the_matvec_delta_per_position() {
411        let d = delta(5, 6, 4, 8.0, 0.25);
412        let batch = 3;
413        let x: Vec<f32> = (0..batch * 6).map(|i| (i as f32 * 0.11).cos()).collect();
414        let mut out = vec![0.0; batch * 5];
415        d.add_batch_to(&x, batch, &mut out);
416        for b in 0..batch {
417            let mut one = vec![0.0; 5];
418            d.add_to(&x[b * 6..(b + 1) * 6], &mut one, &mut Vec::new());
419            close(&out[b * 5..(b + 1) * 5], &one);
420        }
421    }
422
423    #[test]
424    fn a_zero_scale_adds_nothing_bit_for_bit() {
425        let d = delta(5, 6, 4, 8.0, 0.0);
426        let x = vec![1.0; 6];
427        let mut out = vec![0.1, 0.2, 0.3, 0.4, 0.5];
428        let before = out.clone();
429        d.add_to(&x, &mut out, &mut Vec::new());
430        assert_eq!(out, before);
431        let mut batch = vec![0.7; 10];
432        d.add_batch_to(&[x.clone(), x.clone()].concat(), 2, &mut batch);
433        assert_eq!(batch, vec![0.7; 10]);
434    }
435
436    #[test]
437    fn the_scale_is_read_at_apply_time() {
438        let d = delta(5, 6, 4, 0.0, 1.0);
439        let x = vec![1.0; 6];
440        let mut at_one = vec![0.0; 5];
441        d.add_to(&x, &mut at_one, &mut Vec::new());
442        d.scale_handle().set(0.5);
443        let mut at_half = vec![0.0; 5];
444        d.add_to(&x, &mut at_half, &mut Vec::new());
445        let halved: Vec<f32> = at_one.iter().map(|v| v * 0.5).collect();
446        close(&at_half, &halved);
447    }
448
449    #[test]
450    fn a_stack_sums_its_adapters() {
451        let d1 = delta(5, 6, 2, 0.0, 1.0);
452        let d2 = delta(5, 6, 3, 0.0, 0.5);
453        let x: Vec<f32> = (0..6).map(|i| i as f32 * 0.3 - 1.0).collect();
454        let mut want = vec![0.0; 5];
455        d1.add_to(&x, &mut want, &mut Vec::new());
456        d2.add_to(&x, &mut want, &mut Vec::new());
457        let mut stack = LoraStack::new(d1);
458        stack.push(d2);
459        let mut got = vec![0.0; 5];
460        stack.add_to(&x, &mut got);
461        close(&got, &want);
462    }
463
464    #[test]
465    fn a_pair_of_the_wrong_shape_is_refused_with_every_dimension() {
466        let err = LoraDelta::new(
467            vec![0.0; 6],
468            vec![0.0; 5],
469            2,
470            3,
471            4,
472            1.0,
473            LoraScale::new(1.0),
474        )
475        .unwrap_err();
476        let msg = err.to_string();
477        assert!(msg.contains("[3 x 4]"), "{msg}");
478        assert!(msg.contains("rank 2"), "{msg}");
479        assert!(msg.contains("want rank x cols = 8"), "{msg}");
480        assert!(msg.contains("want rows x rank = 6"), "{msg}");
481    }
482}