Skip to main content

geographdb_core/algorithms/
kv_cache_mps.rs

1//! MPS-inspired KV Cache via Online Low-Rank Basis Factorisation.
2//!
3//! Represents the KV history `[(K_0,V_0), …, (K_{T-1},V_{T-1})]` as a
4//! rank-`χ` factorisation:
5//!
6//!   K ≈ B_k · C_k^T     (d_k × T via d_k×χ basis · T×χ coefficients)
7//!   V ≈ B_v · C_v^T
8//!
9//! This is the canonical 1-D MPS with bond dimension χ viewed in its SVD
10//! decomposed form: `B_k` contains the left singular vectors (physical-index
11//! basis), and `C_k` contains the right-singular-vector projections scaled by
12//! singular values (the "bond" coordinates of each token).
13//!
14//! **Memory:** O(χ · d_kv + T · χ)  vs O(T · d_kv) flat.
15//! For χ << d_kv and T large the storage ratio ≈ d_kv / χ.
16//!
17//! **Append:** Gram-Schmidt extends each basis independently if the current
18//! rank < chi_max; otherwise the new token is projected (lossy).
19//!
20//! **Attend:** For each token, approximate K[t] ≈ B_k · c_k[t] and
21//! V[t] ≈ B_v · c_v[t]. Compute softmax(Q·K^T) · V entirely in basis space
22//! without materialising individual KV vectors.
23
24// ── Public API ────────────────────────────────────────────────────────────────
25
26/// MPS-compressed KV cache backed by independent online low-rank bases for
27/// keys and values.
28pub struct KvCacheMps {
29    /// Key basis, column-major: `basis_k[col * d_k + row]`.  Shape d_k × chi_k.
30    basis_k: Vec<f32>,
31    /// Value basis, column-major: `basis_v[col * d_v + row]`. Shape d_v × chi_v.
32    basis_v: Vec<f32>,
33    /// Per-token key coefficients: `coeff_k[t]` has length `chi_k` at time t.
34    coeff_k: Vec<Vec<f32>>,
35    /// Per-token value coefficients: `coeff_v[t]` has length `chi_v` at time t.
36    coeff_v: Vec<Vec<f32>>,
37    /// Current number of key basis vectors (≤ chi_max).
38    chi_k: usize,
39    /// Current number of value basis vectors (≤ chi_max).
40    chi_v: usize,
41    chi_max: usize,
42    d_k: usize,
43    d_v: usize,
44}
45
46impl KvCacheMps {
47    /// Create an empty cache.
48    ///
49    /// `d_k` / `d_v`: key / value head dimensions.
50    /// `chi_max`: maximum bond dimension (compression rank).
51    pub fn new(d_k: usize, d_v: usize, chi_max: usize) -> Self {
52        assert!(chi_max >= 1, "chi_max must be ≥ 1");
53        Self {
54            basis_k: Vec::new(),
55            basis_v: Vec::new(),
56            coeff_k: Vec::new(),
57            coeff_v: Vec::new(),
58            chi_k: 0,
59            chi_v: 0,
60            chi_max,
61            d_k,
62            d_v,
63        }
64    }
65
66    /// Append a new `(k, v)` token pair.
67    ///
68    /// If the respective basis is not yet full (`chi < chi_max`), a new
69    /// orthonormal direction is added via Gram-Schmidt; otherwise the token is
70    /// projected onto the existing basis (lossy compression).
71    pub fn append(&mut self, k: &[f32], v: &[f32]) {
72        assert_eq!(k.len(), self.d_k);
73        assert_eq!(v.len(), self.d_v);
74
75        let c_k = project_and_extend(
76            &mut self.basis_k,
77            &mut self.chi_k,
78            self.chi_max,
79            self.d_k,
80            k,
81        );
82        self.coeff_k.push(c_k);
83
84        let c_v = project_and_extend(
85            &mut self.basis_v,
86            &mut self.chi_v,
87            self.chi_max,
88            self.d_v,
89            v,
90        );
91        self.coeff_v.push(c_v);
92    }
93
94    /// Number of tokens stored.
95    pub fn token_count(&self) -> usize {
96        self.coeff_k.len()
97    }
98
99    /// Current bond dimension (max of key and value ranks; ≤ chi_max).
100    pub fn max_bond_dim(&self) -> usize {
101        self.chi_k.max(self.chi_v)
102    }
103
104    /// Compute softmax attention over the compressed KV cache.
105    ///
106    /// `query` has length `d_k`. Returns a vector of length `d_v`.
107    ///
108    /// Scores are computed as `(B_k^T · query) · c_k[t]`, which equals the
109    /// dot product `query · K_approx[t]` exactly (the basis is orthonormal).
110    pub fn attend(&self, query: &[f32], scale: f32) -> Vec<f32> {
111        let n = self.token_count();
112        if n == 0 {
113            return vec![0.0; self.d_v];
114        }
115
116        // Compress the query into the key basis: q_comp[k] = basis_k[:,k] · query
117        let q_comp: Vec<f32> = (0..self.chi_k)
118            .map(|k| {
119                let col = &self.basis_k[k * self.d_k..(k + 1) * self.d_k];
120                col.iter().zip(query).map(|(b, q)| b * q).sum()
121            })
122            .collect();
123
124        // Scores: score[t] = (q_comp · c_k[t]) * scale.
125        // Older tokens may have shorter c_k (basis grew after they were added);
126        // zip stops at the shorter length, which correctly skips basis vectors
127        // that are orthogonal to the token (their true coefficient is 0).
128        let mut scores: Vec<f32> = self
129            .coeff_k
130            .iter()
131            .map(|c| {
132                q_comp
133                    .iter()
134                    .zip(c.iter())
135                    .map(|(q, ci)| q * ci)
136                    .sum::<f32>()
137                    * scale
138            })
139            .collect();
140
141        // Softmax
142        let max_s = scores.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
143        let mut weights: Vec<f32> = scores.iter_mut().map(|s| (*s - max_s).exp()).collect();
144        let sum_w: f32 = weights.iter().sum();
145        for w in &mut weights {
146            *w /= sum_w;
147        }
148
149        // Weighted V sum: out = Σ_t w[t] * (B_v · c_v[t])
150        let mut out = vec![0.0f32; self.d_v];
151        for (w, c_v) in weights.iter().zip(self.coeff_v.iter()) {
152            for (k, &ck) in c_v.iter().enumerate() {
153                let wck = w * ck;
154                let col = &self.basis_v[k * self.d_v..(k + 1) * self.d_v];
155                for (o, b) in out.iter_mut().zip(col) {
156                    *o += wck * b;
157                }
158            }
159        }
160        out
161    }
162
163    /// Compression ratio: flat storage bytes / compressed storage bytes.
164    ///
165    /// Values > 1 mean the cache uses less memory than storing KV pairs flat.
166    /// The ratio grows with T; for large T it approaches `d_kv / chi_max`.
167    pub fn compression_ratio(&self) -> f64 {
168        let n = self.token_count();
169        if n == 0 {
170            return 1.0;
171        }
172        let flat = n * (self.d_k + self.d_v);
173        // Basis storage + per-token coefficient storage
174        let compressed =
175            self.chi_k * self.d_k + self.chi_v * self.d_v + n * self.chi_k + n * self.chi_v;
176        flat as f64 / compressed as f64
177    }
178}
179
180// ── Gram-Schmidt helpers ──────────────────────────────────────────────────────
181
182/// Project `vec` onto the current columns of `basis` (d × chi, column-major),
183/// and — if `chi < chi_max` and the residual is non-trivial — extend the basis
184/// with the normalised residual direction.
185///
186/// Returns the coefficient vector (length = chi after any extension).
187fn project_and_extend(
188    basis: &mut Vec<f32>,
189    chi: &mut usize,
190    chi_max: usize,
191    d: usize,
192    vec: &[f32],
193) -> Vec<f32> {
194    // Project onto existing columns
195    let mut coeff: Vec<f32> = (0..*chi)
196        .map(|k| {
197            let col = &basis[k * d..(k + 1) * d];
198            col.iter().zip(vec).map(|(b, v)| b * v).sum()
199        })
200        .collect();
201
202    if *chi >= chi_max {
203        return coeff;
204    }
205
206    // Residual: r = vec − Σ_k coeff[k] * basis[:,k]
207    let mut residual = vec.to_vec();
208    for k in 0..*chi {
209        let c = coeff[k];
210        let col = &basis[k * d..(k + 1) * d];
211        for (r, b) in residual.iter_mut().zip(col) {
212            *r -= c * b;
213        }
214    }
215
216    let norm: f32 = residual.iter().map(|x| x * x).sum::<f32>().sqrt();
217    // Relative threshold: skip extension if the residual is tiny compared to the
218    // input. f32 Gram-Schmidt accumulates ~O(chi * macheps) relative error, so
219    // the residual of a truly in-span vector can reach ~1e-5 × ‖vec‖ after several
220    // steps. Using 1e-4 provides a generous safety margin without confusing
221    // genuinely small new directions with numerical noise.
222    let vec_norm: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
223    if norm < 1e-4 * vec_norm.max(1e-12) {
224        // vec is already in the span of the basis
225        return coeff;
226    }
227
228    // Add normalised residual as a new basis column
229    for r in residual.iter_mut() {
230        *r /= norm;
231    }
232    basis.extend_from_slice(&residual);
233    *chi += 1;
234
235    // The new coefficient for this token in the new dimension equals the
236    // pre-normalisation residual norm.
237    coeff.push(norm);
238    coeff
239}
240
241// ── Truncated SVD (retained for recompression experiments) ───────────────────
242
243/// Rank-`rank` truncated SVD of `m` (rows × cols, row-major).
244/// Returns `(U [rows×rank], sigma [rank], Vt [rank×cols], rank_kept)`.
245pub fn svd_truncated(
246    m: &[f32],
247    rows: usize,
248    cols: usize,
249    rank: usize,
250) -> (Vec<f32>, Vec<f32>, Vec<f32>, usize) {
251    let rank = rank.min(rows).min(cols);
252    if rows <= cols {
253        svd_via_gram_left(m, rows, cols, rank)
254    } else {
255        svd_via_gram_right(m, rows, cols, rank)
256    }
257}
258
259fn svd_via_gram_left(
260    m: &[f32],
261    rows: usize,
262    cols: usize,
263    rank: usize,
264) -> (Vec<f32>, Vec<f32>, Vec<f32>, usize) {
265    let mut g = vec![0.0f32; rows * rows];
266    for i in 0..rows {
267        for k in 0..cols {
268            let mik = m[i * cols + k];
269            for j in 0..rows {
270                g[i * rows + j] += mik * m[j * cols + k];
271            }
272        }
273    }
274    let (eigvecs, eigvals) = power_iteration_symmetric(&g, rows, rank);
275
276    const SVD_EPS: f32 = 1e-7;
277    let sigma_max = eigvals[0].max(0.0).sqrt();
278    let mut rank_kept = rank;
279    while rank_kept > 1 && eigvals[rank_kept - 1].max(0.0).sqrt() < SVD_EPS * sigma_max {
280        rank_kept -= 1;
281    }
282    rank_kept = rank_kept.max(1);
283
284    let sigma: Vec<f32> = (0..rank_kept).map(|k| eigvals[k].max(0.0).sqrt()).collect();
285
286    let mut u = vec![0.0f32; rows * rank_kept];
287    for i in 0..rows {
288        for k in 0..rank_kept {
289            u[i * rank_kept + k] = eigvecs[i * rank + k];
290        }
291    }
292
293    let mut vt = vec![0.0f32; rank_kept * cols];
294    for k in 0..rank_kept {
295        if sigma[k] < 1e-15 {
296            continue;
297        }
298        for j in 0..cols {
299            let mut val = 0.0f32;
300            for i in 0..rows {
301                val += m[i * cols + j] * u[i * rank_kept + k];
302            }
303            vt[k * cols + j] = val / sigma[k];
304        }
305    }
306    (u, sigma, vt, rank_kept)
307}
308
309fn svd_via_gram_right(
310    m: &[f32],
311    rows: usize,
312    cols: usize,
313    rank: usize,
314) -> (Vec<f32>, Vec<f32>, Vec<f32>, usize) {
315    let mut g = vec![0.0f32; cols * cols];
316    for k in 0..rows {
317        for i in 0..cols {
318            let mki = m[k * cols + i];
319            for j in 0..cols {
320                g[i * cols + j] += mki * m[k * cols + j];
321            }
322        }
323    }
324    let (eigvecs, eigvals) = power_iteration_symmetric(&g, cols, rank);
325
326    const SVD_EPS: f32 = 1e-7;
327    let sigma_max = eigvals[0].max(0.0).sqrt();
328    let mut rank_kept = rank;
329    while rank_kept > 1 && eigvals[rank_kept - 1].max(0.0).sqrt() < SVD_EPS * sigma_max {
330        rank_kept -= 1;
331    }
332    rank_kept = rank_kept.max(1);
333
334    let sigma: Vec<f32> = (0..rank_kept).map(|k| eigvals[k].max(0.0).sqrt()).collect();
335
336    let mut v = vec![0.0f32; cols * rank_kept];
337    for i in 0..cols {
338        for k in 0..rank_kept {
339            v[i * rank_kept + k] = eigvecs[i * rank + k];
340        }
341    }
342
343    let mut vt = vec![0.0f32; rank_kept * cols];
344    for k in 0..rank_kept {
345        for j in 0..cols {
346            vt[k * cols + j] = v[j * rank_kept + k];
347        }
348    }
349
350    let mut u = vec![0.0f32; rows * rank_kept];
351    for k in 0..rank_kept {
352        if sigma[k] < 1e-15 {
353            continue;
354        }
355        for i in 0..rows {
356            let mut val = 0.0f32;
357            for j in 0..cols {
358                val += m[i * cols + j] * v[j * rank_kept + k];
359            }
360            u[i * rank_kept + k] = val / sigma[k];
361        }
362    }
363    (u, sigma, vt, rank_kept)
364}
365
366fn power_iteration_symmetric(g: &[f32], n: usize, rank: usize) -> (Vec<f32>, Vec<f32>) {
367    const MAX_ITER: usize = 64;
368    const TOL: f32 = 1e-6;
369
370    let mut eigvecs = vec![0.0f32; n * rank];
371    let mut eigvals = vec![0.0f32; rank];
372    let mut deflated = g.to_vec();
373
374    for k in 0..rank {
375        let mut v: Vec<f32> = (0..n).map(|i| if i == k % n { 1.0 } else { 0.0 }).collect();
376        normalise(&mut v);
377        let mut lambda = 0.0f32;
378        for _ in 0..MAX_ITER {
379            let w = matvec_sq(&deflated, &v, n);
380            let lambda_new: f32 = v.iter().zip(w.iter()).map(|(vi, wi)| vi * wi).sum();
381            let mut w2 = w;
382            normalise(&mut w2);
383            let diff: f32 = v.iter().zip(w2.iter()).map(|(a, b)| (a - b).abs()).sum();
384            v = w2;
385            lambda = lambda_new;
386            if diff < TOL {
387                break;
388            }
389        }
390        eigvals[k] = lambda;
391        for i in 0..n {
392            eigvecs[i * rank + k] = v[i];
393        }
394        for i in 0..n {
395            for j in 0..n {
396                deflated[i * n + j] -= lambda * v[i] * v[j];
397            }
398        }
399    }
400    (eigvecs, eigvals)
401}
402
403fn matvec_sq(m: &[f32], x: &[f32], n: usize) -> Vec<f32> {
404    let mut y = vec![0.0f32; n];
405    for i in 0..n {
406        for j in 0..n {
407            y[i] += m[i * n + j] * x[j];
408        }
409    }
410    y
411}
412
413fn normalise(v: &mut [f32]) {
414    let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
415    if norm > 1e-12 {
416        for x in v.iter_mut() {
417            *x /= norm;
418        }
419    }
420}
421
422// ── Tests ─────────────────────────────────────────────────────────────────────
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    fn vec_norm(v: &[f32]) -> f32 {
429        v.iter().map(|x| x * x).sum::<f32>().sqrt()
430    }
431    fn vec_err(a: &[f32], b: &[f32]) -> f32 {
432        a.iter()
433            .zip(b)
434            .map(|(x, y)| (x - y).powi(2))
435            .sum::<f32>()
436            .sqrt()
437    }
438    fn approx_eq(a: f32, b: f32, tol: f32) -> bool {
439        (a - b).abs() < tol
440    }
441
442    // ── svd_truncated ─────────────────────────────────────────────────────────
443
444    #[test]
445    fn test_svd_rank1_reconstruction() {
446        let u_vec = [1.0f32, 0.0, 0.0];
447        let s = 3.0f32;
448        let vt_vec = [0.0f32, 1.0, 0.0, 0.0];
449        let mut m = vec![0.0f32; 3 * 4];
450        for i in 0..3 {
451            for j in 0..4 {
452                m[i * 4 + j] = u_vec[i] * s * vt_vec[j];
453            }
454        }
455        let (u_out, sigma_out, vt_out, rank) = svd_truncated(&m, 3, 4, 2);
456        assert!(rank >= 1);
457        assert!(approx_eq(sigma_out[0], s, 0.1), "sigma={}", sigma_out[0]);
458        let mut recon = vec![0.0f32; 3 * 4];
459        for k in 0..rank {
460            for i in 0..3 {
461                for j in 0..4 {
462                    recon[i * 4 + j] += u_out[i * rank + k] * sigma_out[k] * vt_out[k * 4 + j];
463                }
464            }
465        }
466        let err = vec_err(&recon, &m) / (vec_norm(&m) + 1e-8);
467        assert!(err < 0.05, "reconstruction error = {err:.4}");
468    }
469
470    #[test]
471    fn test_svd_identity_singular_values() {
472        let mut eye = vec![0.0f32; 16];
473        for i in 0..4 {
474            eye[i * 4 + i] = 1.0;
475        }
476        let (_, sigma, _, rank) = svd_truncated(&eye, 4, 4, 4);
477        assert_eq!(rank, 4);
478        for s in &sigma {
479            assert!(approx_eq(*s, 1.0, 0.1), "sigma={s}");
480        }
481    }
482
483    // ── KvCacheMps ────────────────────────────────────────────────────────────
484
485    #[test]
486    fn test_empty_cache_attend_zero() {
487        let cache = KvCacheMps::new(4, 4, 8);
488        let out = cache.attend(&[1.0, 0.0, 0.0, 0.0], 1.0);
489        assert_eq!(out, vec![0.0; 4]);
490    }
491
492    #[test]
493    fn test_single_token_attend_returns_value() {
494        let mut cache = KvCacheMps::new(4, 4, 8);
495        let k = vec![1.0f32, 0.0, 0.0, 0.0];
496        let v = vec![0.0f32, 0.0, 1.0, 0.0];
497        cache.append(&k, &v);
498        let out = cache.attend(&[1.0, 0.0, 0.0, 0.0], 1.0);
499        // Single token: softmax weight = 1.0 → output exactly equals v
500        assert!(vec_err(&out, &v) < 1e-4, "expected {v:?}, got {out:?}");
501    }
502
503    #[test]
504    fn test_token_count_increments() {
505        let mut cache = KvCacheMps::new(4, 4, 8);
506        for i in 0..5 {
507            cache.append(&[i as f32, 0.0, 0.0, 0.0], &[0.0, i as f32, 0.0, 0.0]);
508            assert_eq!(cache.token_count(), i + 1);
509        }
510    }
511
512    #[test]
513    fn test_chi_max_bounds_bond_dimension() {
514        let chi_max = 4;
515        let mut cache = KvCacheMps::new(8, 8, chi_max);
516        for i in 0..32 {
517            let k: Vec<f32> = (0..8).map(|j| ((i + j) as f32) * 0.1).collect();
518            let v: Vec<f32> = (0..8).map(|j| ((i * 2 + j) as f32) * 0.1).collect();
519            cache.append(&k, &v);
520        }
521        assert!(
522            cache.max_bond_dim() <= chi_max,
523            "bond dim {} > chi_max {chi_max}",
524            cache.max_bond_dim()
525        );
526    }
527
528    #[test]
529    fn test_compression_ratio_exceeds_one() {
530        // chi_max=1, T=20, d_k=d_v=8:
531        //   flat    = 20 * 16 = 320
532        //   compressed = chi_k*d_k + chi_v*d_v + T*chi_k + T*chi_v
533        //              = 1*8 + 1*8 + 20*1 + 20*1 = 56
534        //   ratio   = 320/56 ≈ 5.7
535        let mut cache = KvCacheMps::new(8, 8, 1);
536        for i in 0..20 {
537            let k: Vec<f32> = (0..8).map(|j| (i + j) as f32).collect();
538            let v: Vec<f32> = (0..8).map(|j| (i * 2 + j) as f32).collect();
539            cache.append(&k, &v);
540        }
541        let ratio = cache.compression_ratio();
542        assert!(ratio > 1.0, "expected ratio > 1, got {ratio:.3}");
543    }
544
545    #[test]
546    fn test_higher_chi_lower_attend_error() {
547        // Diverse KV sequence with many orthogonal-ish directions
548        let d = 8;
549        let tokens: Vec<(Vec<f32>, Vec<f32>)> = (0..16)
550            .map(|i| {
551                let k: Vec<f32> = (0..d)
552                    .map(|j| (((i * 3 + j * 7) % 11) as f32 - 5.0) * 0.3)
553                    .collect();
554                let v: Vec<f32> = (0..d)
555                    .map(|j| (((i * 5 + j * 3) % 7) as f32 - 3.0) * 0.2)
556                    .collect();
557                (k, v)
558            })
559            .collect();
560
561        let query: Vec<f32> = (0..d).map(|i| i as f32 * 0.1).collect();
562        let scale = 1.0 / (d as f32).sqrt();
563
564        // Reference: exact full-rank attention
565        let ref_out = direct_attend(&tokens, &query, scale, d);
566
567        let error_for_chi = |chi: usize| {
568            let mut cache = KvCacheMps::new(d, d, chi);
569            for (k, v) in &tokens {
570                cache.append(k, v);
571            }
572            let out = cache.attend(&query, scale);
573            vec_err(&out, &ref_out) / (vec_norm(&ref_out) + 1e-8)
574        };
575
576        let err1 = error_for_chi(1);
577        let err8 = error_for_chi(8);
578
579        // chi=8 must not be much worse than chi=1 (more rank ≥ less rank)
580        assert!(
581            err8 <= err1 + 0.1,
582            "chi=8 error {err8:.4} should be ≤ chi=1 error {err1:.4} + 0.1"
583        );
584        // Full-rank capture (chi=d) should give near-exact attention
585        let err_full = error_for_chi(d);
586        assert!(
587            err_full < 0.02,
588            "full-rank attend error {err_full:.4} should be < 2%"
589        );
590    }
591
592    /// Reference: exact direct attention without compression (test helper only).
593    fn direct_attend(
594        tokens: &[(Vec<f32>, Vec<f32>)],
595        query: &[f32],
596        scale: f32,
597        d: usize,
598    ) -> Vec<f32> {
599        let scores_raw: Vec<f32> = tokens
600            .iter()
601            .map(|(k, _)| k.iter().zip(query).map(|(ki, qi)| ki * qi).sum::<f32>() * scale)
602            .collect();
603        let max_s = scores_raw.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
604        let exp: Vec<f32> = scores_raw.iter().map(|s| (s - max_s).exp()).collect();
605        let sum_exp: f32 = exp.iter().sum();
606        let weights: Vec<f32> = exp.iter().map(|e| e / sum_exp).collect();
607        let mut out = vec![0.0f32; d];
608        for (w, (_, v)) in weights.iter().zip(tokens.iter()) {
609            for (o, vi) in out.iter_mut().zip(v.iter()) {
610                *o += w * vi;
611            }
612        }
613        out
614    }
615}