Skip to main content

cram_codec/
linalg.rs

1//! Small dense helpers on row-major `f64` buffers.
2//!
3//! Heavy products and symmetric eigendecompositions go through faer
4//! (`Par::Seq`). `tang_la::gemm_*` and `tang_la::SymmetricEigen` were the
5//! first choice; on the codec's shapes (m ≈ 700, k ≈ 10⁴) the eigensolver
6//! took >95% of encode time and the GEMMs were the next bottleneck, so the
7//! hot paths moved to faer, which tang-la already bridges behind a feature. Truncated SVDs of the
8//! `m × n` TT unfoldings are computed from the Gram matrix of the *smaller*
9//! side (`min(m,n) ≤ 2·r_max`), which is what makes TT-SVD of a 2^24-entry
10//! tensor cheap: one-sided Jacobi (`tang_la::Svd`) on a 2 × 2^23 unfolding
11//! would be far slower. The Gram route squares the condition number; in f64
12//! that leaves singular values down to ~1e-7·σ₁ resolved, well below any
13//! truncation tolerance the codec uses (see the crate docs).
14
15use tang_la::{DMat, SymmetricEigen};
16
17fn fmat(a: &[f64], m: usize, n: usize) -> faer::MatRef<'_, f64> {
18    faer::MatRef::from_row_major_slice(a, m, n)
19}
20
21/// `C = op(A)·op(B)` into a fresh row-major buffer, single threaded.
22fn gemm(lhs: faer::MatRef<'_, f64>, rhs: faer::MatRef<'_, f64>) -> Vec<f64> {
23    let (m, n) = (lhs.nrows(), rhs.ncols());
24    let mut c = vec![0.0; m * n];
25    if m == 0 || n == 0 || lhs.ncols() == 0 {
26        return c;
27    }
28    faer::linalg::matmul::matmul(
29        faer::MatMut::from_row_major_slice_mut(&mut c, m, n),
30        faer::Accum::Replace,
31        lhs,
32        rhs,
33        1.0,
34        faer::Par::Seq,
35    );
36    c
37}
38
39/// `A·Aᵀ` for row-major `a` (m × n).
40pub fn gram_rows(a: &[f64], m: usize, n: usize) -> Vec<f64> {
41    let mut g = gemm(fmat(a, m, n), fmat(a, m, n).transpose());
42    symmetrize(&mut g, m);
43    g
44}
45
46/// `Aᵀ·A` for row-major `a` (m × n).
47pub fn gram_cols(a: &[f64], m: usize, n: usize) -> Vec<f64> {
48    let mut g = gemm(fmat(a, m, n).transpose(), fmat(a, m, n));
49    symmetrize(&mut g, n);
50    g
51}
52
53fn symmetrize(g: &mut [f64], n: usize) {
54    for i in 0..n {
55        for j in i + 1..n {
56            let v = 0.5 * (g[i * n + j] + g[j * n + i]);
57            g[i * n + j] = v;
58            g[j * n + i] = v;
59        }
60    }
61}
62
63/// `C = A·B` (A: m×k, B: k×n), row-major.
64pub fn matmul(a: &[f64], b: &[f64], m: usize, k: usize, n: usize) -> Vec<f64> {
65    gemm(fmat(a, m, k), fmat(b, k, n))
66}
67
68/// `C = Aᵀ·B` (A: k×m, B: k×n), row-major.
69pub fn matmul_tn(a: &[f64], b: &[f64], k: usize, m: usize, n: usize) -> Vec<f64> {
70    gemm(fmat(a, k, m).transpose(), fmat(b, k, n))
71}
72
73/// `(H ⊗ I_n)·X` for `H` r×r and `X` (r·n)×p, row-major — computed as
74/// `H · X'` with `X'` the free r×(n·p) reshape, `n`× cheaper than the dense
75/// Kronecker product.
76pub fn kron_eye_apply(h: &[f64], r: usize, n: usize, x: &[f64], p: usize) -> Vec<f64> {
77    matmul(h, x, r, r, n * p)
78}
79
80/// Eigenpairs of a symmetric matrix, **descending**: (values, vectors as
81/// row-major n×n with eigenvector j in column j).
82///
83/// Uses faer's blocked self-adjoint EVD with `Par::Seq`. `tang_la::SymmetricEigen`
84/// (unblocked Householder + implicit QR) was tried first and spent >95% of
85/// encode time at n ≈ 700; it is kept as the fallback if faer reports failure.
86pub fn sym_eig_desc(g: &[f64], n: usize) -> (Vec<f64>, Vec<f64>) {
87    use faer::dyn_stack::{MemBuffer, MemStack};
88    use faer::linalg::evd;
89    let a = faer::Mat::<f64>::from_fn(n, n, |i, j| g[i * n + j]);
90    let mut u = faer::Mat::<f64>::zeros(n, n);
91    let mut s = faer::diag::Diag::<f64>::zeros(n);
92    let par = faer::Par::Seq;
93    let scratch = evd::self_adjoint_evd_scratch::<f64>(
94        n,
95        evd::ComputeEigenvectors::Yes,
96        par,
97        Default::default(),
98    );
99    let ok = evd::self_adjoint_evd(
100        a.as_ref(),
101        s.as_mut(),
102        Some(u.as_mut()),
103        par,
104        MemStack::new(&mut MemBuffer::new(scratch)),
105        Default::default(),
106    );
107    if ok.is_err() {
108        return sym_eig_desc_tang(g, n);
109    }
110    let mut vals = Vec::with_capacity(n);
111    let mut vecs = vec![0.0; n * n];
112    for (jj, j) in (0..n).rev().enumerate() {
113        vals.push(s[j]);
114        for i in 0..n {
115            vecs[i * n + jj] = u[(i, j)];
116        }
117    }
118    (vals, vecs)
119}
120
121fn sym_eig_desc_tang(g: &[f64], n: usize) -> (Vec<f64>, Vec<f64>) {
122    // Symmetric, so the column-major DMat sees the same matrix.
123    let e = SymmetricEigen::new(&DMat::from_raw(n, n, g.to_vec()));
124    let mut vals = Vec::with_capacity(n);
125    let mut vecs = vec![0.0; n * n];
126    for (jj, j) in (0..n).rev().enumerate() {
127        vals.push(e.eigenvalues[j]);
128        for i in 0..n {
129            vecs[i * n + jj] = e.eigenvectors.get(i, j);
130        }
131    }
132    (vals, vecs)
133}
134
135/// Lower Cholesky factor of SPD `a` (n×n), or `None` if not positive definite.
136pub fn cholesky(a: &[f64], n: usize) -> Option<Vec<f64>> {
137    let mut l = vec![0.0; n * n];
138    for i in 0..n {
139        for j in 0..=i {
140            let mut s = a[i * n + j];
141            for k in 0..j {
142                s -= l[i * n + k] * l[j * n + k];
143            }
144            if i == j {
145                if s <= 0.0 || !s.is_finite() {
146                    return None;
147                }
148                l[i * n + i] = s.sqrt();
149            } else {
150                l[i * n + j] = s / l[j * n + j];
151            }
152        }
153    }
154    Some(l)
155}
156
157/// Solve `L·X = B` in place (L lower n×n, B n×m row-major).
158pub fn solve_lower(l: &[f64], b: &mut [f64], n: usize, m: usize) {
159    for i in 0..n {
160        for k in 0..i {
161            let lik = l[i * n + k];
162            if lik != 0.0 {
163                let (head, tail) = b.split_at_mut(i * m);
164                let src = &head[k * m..k * m + m];
165                for (t, s) in tail[..m].iter_mut().zip(src) {
166                    *t -= lik * s;
167                }
168            }
169        }
170        let d = l[i * n + i];
171        for t in &mut b[i * m..i * m + m] {
172            *t /= d;
173        }
174    }
175}
176
177/// Solve `Lᵀ·X = B` in place (L lower n×n, B n×m row-major).
178pub fn solve_lower_t(l: &[f64], b: &mut [f64], n: usize, m: usize) {
179    for i in (0..n).rev() {
180        for k in i + 1..n {
181            let lki = l[k * n + i];
182            if lki != 0.0 {
183                let (head, tail) = b.split_at_mut(k * m);
184                for (t, s) in head[i * m..i * m + m].iter_mut().zip(&tail[..m]) {
185                    *t -= lki * s;
186                }
187            }
188        }
189        let d = l[i * n + i];
190        for t in &mut b[i * m..i * m + m] {
191            *t /= d;
192        }
193    }
194}
195
196/// Transpose a row-major m×n matrix.
197pub fn transpose(a: &[f64], m: usize, n: usize) -> Vec<f64> {
198    let mut t = vec![0.0; m * n];
199    for i in 0..m {
200        for j in 0..n {
201            t[j * m + i] = a[i * n + j];
202        }
203    }
204    t
205}
206
207/// Truncated SVD of a row-major m×n matrix via the Gram matrix of the
208/// smaller side. Returns all singular values (descending, length min(m,n))
209/// and a closure-free pair: `u` (m × k, row-major) for the leading `k`
210/// columns requested by `pick` (which sees the singular values).
211pub fn svd_left(
212    a: &[f64],
213    m: usize,
214    n: usize,
215    pick: impl FnOnce(&[f64]) -> usize,
216) -> (Vec<f64>, Vec<f64>, usize) {
217    if m <= n {
218        let g = gram_rows(a, m, n);
219        let (lam, v) = sym_eig_desc(&g, m);
220        let s: Vec<f64> = lam.iter().map(|&l| l.max(0.0).sqrt()).collect();
221        let k = pick(&s).min(m);
222        let mut u = vec![0.0; m * k];
223        for i in 0..m {
224            u[i * k..i * k + k].copy_from_slice(&v[i * m..i * m + k]);
225        }
226        (s, u, k)
227    } else {
228        let g = gram_cols(a, m, n);
229        let (lam, v) = sym_eig_desc(&g, n);
230        let s: Vec<f64> = lam.iter().map(|&l| l.max(0.0).sqrt()).collect();
231        let smax = s.first().copied().unwrap_or(0.0);
232        let k = pick(&s).min(n).min(
233            s.iter()
234                .take_while(|&&x| x > 1e-13 * smax && x > 0.0)
235                .count(),
236        );
237        // U = A V Σ⁻¹, then re-orthonormalise (modified Gram–Schmidt, twice).
238        let mut vk = vec![0.0; n * k];
239        for i in 0..n {
240            vk[i * k..i * k + k].copy_from_slice(&v[i * n..i * n + k]);
241        }
242        let mut u = matmul(a, &vk, m, n, k);
243        for j in 0..k {
244            for i in 0..m {
245                u[i * k + j] /= s[j];
246            }
247        }
248        orthonormalize_cols(&mut u, m, k);
249        (s, u, k)
250    }
251}
252
253/// Modified Gram–Schmidt (two passes) on the columns of row-major m×k `u`.
254pub fn orthonormalize_cols(u: &mut [f64], m: usize, k: usize) {
255    for _pass in 0..2 {
256        for j in 0..k {
257            for p in 0..j {
258                let mut dot = 0.0;
259                for i in 0..m {
260                    dot += u[i * k + j] * u[i * k + p];
261                }
262                for i in 0..m {
263                    u[i * k + j] -= dot * u[i * k + p];
264                }
265            }
266            let nrm = (0..m)
267                .map(|i| u[i * k + j] * u[i * k + j])
268                .sum::<f64>()
269                .sqrt();
270            if nrm > 0.0 {
271                for i in 0..m {
272                    u[i * k + j] /= nrm;
273                }
274            }
275        }
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn svd_left_reconstructs_both_orientations() {
285        for (m, n) in [(4usize, 9usize), (9, 4)] {
286            let a: Vec<f64> = (0..m * n).map(|i| ((i * 37 % 11) as f64).cos()).collect();
287            let (s, u, k) = svd_left(&a, m, n, |s| s.len());
288            assert_eq!(k, m.min(n));
289            // U Uᵀ A == A when k = rank-full.
290            let uta = matmul_tn(&u, &a, m, k, n);
291            let back = matmul(&u, &uta, m, k, n);
292            for (x, y) in a.iter().zip(&back) {
293                assert!((x - y).abs() < 1e-9);
294            }
295            let fro: f64 = a.iter().map(|x| x * x).sum();
296            let ss: f64 = s.iter().map(|x| x * x).sum();
297            assert!((fro - ss).abs() < 1e-9 * fro);
298        }
299    }
300
301    #[test]
302    fn cholesky_solves() {
303        let a = [4.0, 2.0, 0.6, 2.0, 5.0, 1.0, 0.6, 1.0, 3.0];
304        let l = cholesky(&a, 3).unwrap();
305        let mut b = vec![1.0, 2.0, 3.0];
306        solve_lower(&l, &mut b, 3, 1);
307        solve_lower_t(&l, &mut b, 3, 1);
308        let ax: Vec<f64> = (0..3)
309            .map(|i| (0..3).map(|j| a[i * 3 + j] * b[j]).sum())
310            .collect();
311        for (x, y) in ax.iter().zip([1.0, 2.0, 3.0]) {
312            assert!((x - y).abs() < 1e-12);
313        }
314        assert!(cholesky(&[1.0, 2.0, 2.0, 1.0], 2).is_none());
315    }
316}