Skip to main content

qualia_core_db/solvers/linear_algebra/
gemm.rs

1//! Dynamic-size general matrix multiply (GEMM) — the canonical dense-linear-algebra core.
2//!
3//! This is the **one** dynamic GEMM for the engine. Before this, three private
4//! re-implementations competed (`specialized_libs/linear_algebra` heap GEMM,
5//! `gguf_bridge` GPU `coop_gemv`, and the fixed-size [`super::Matrix4x4`]). The
6//! specialized libs route their dynamic matmul here; the GPU `coop_gemv` kernel is
7//! the *same contract* executed on `wgpu` and is checked against this code as its
8//! CPU parity reference (`gemm_parity_probe`).
9//!
10//! Idiom (matches [`super::cholesky`]): **zero allocation**, caller-owned **row-major**
11//! slices with explicit dimensions, fail-closed on a dimension mismatch
12//! ([`SolversError::InvalidDimension`]). No `DMatrix`, no heap, no dependency.
13
14use crate::solvers::SolversError;
15
16/// Whether a GEMM operand is used as stored (`No`) or transposed (`Yes`).
17///
18/// `op(X)` below means `X` when `No` and `Xᵀ` when `Yes`. Transpose is expressed
19/// by index arithmetic only — no operand is ever materialised.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Transpose {
22    /// Use the operand as stored.
23    No,
24    /// Use the transpose of the operand.
25    Yes,
26}
27
28/// General matrix multiply (BLAS-3 `gemm` shape), row-major, zero-heap:
29///
30/// ```text
31/// C := alpha · op(A) · op(B) + beta · C
32/// ```
33///
34/// where `op(A)` is `m×k`, `op(B)` is `k×n`, and `C` is `m×n`. Operands are
35/// caller-owned row-major slices:
36/// - `a` holds `op==No ? m×k : k×m` → always `m*k` elements.
37/// - `b` holds `op==No ? k×n : n×k` → always `k*n` elements.
38/// - `c` is `m*n` elements, read (when `beta != 0`) and overwritten in place.
39///
40/// `beta == 0.0` is honoured as a hard zero (the existing contents of `c`, which
41/// may be uninitialised garbage, are *not* read) — matching BLAS semantics so a
42/// fresh output buffer need not be zeroed first.
43///
44/// Returns [`SolversError::InvalidDimension`] if any slice length disagrees with
45/// `m`, `n`, `k`.
46pub fn gemm(
47    transa: Transpose,
48    transb: Transpose,
49    m: usize,
50    n: usize,
51    k: usize,
52    alpha: f64,
53    a: &[f64],
54    b: &[f64],
55    beta: f64,
56    c: &mut [f64],
57) -> Result<(), SolversError> {
58    if a.len() != m * k || b.len() != k * n || c.len() != m * n {
59        return Err(SolversError::InvalidDimension);
60    }
61
62    // ── Best-path-with-CPU-floor offload (additive; behaviour-preserving) ──────────
63    //
64    // The capability-aware forge dispatcher (`wgsl_forge::dispatch::gemm_f64`) picks
65    // the best compute path *actually present on this machine* for the raw product
66    // `P = op(A)·op(B)`, while always keeping this CPU triple-loop as the floor. We hand
67    // off the product whenever:
68    //   1. an accelerator actually exists (`caps().cuda || caps().wgpu`) — so a no-GPU
69    //      machine takes the EXACT existing CPU path below with zero behaviour change;
70    //   2. the problem is large enough (`m·n·k >= GEMM_GPU_THRESHOLD`) to amortise
71    //      dispatch/transfer overhead — small GEMMs stay on the CPU.
72    //
73    // `gemm_f64` computes only the *plain* row-major product (no alpha/beta/transpose),
74    // so the full BLAS shape is reconstructed around it:
75    //   • a transpose flag is honoured by *materialising* that operand once into a
76    //     row-major scratch buffer (`op(A)` as `m×k`, `op(B)` as `k×n`) — an O(m·k)/O(k·n)
77    //     copy dominated by the O(m·n·k) GEMM being offloaded. This unlocks the hot
78    //     covariance `XᵀX` (PCA/ridge/linear) and attention `Q·Kᵀ`.
79    //   • `alpha`/`beta` are applied in the O(m·n) CPU combine afterward, exactly as the
80    //     loop below would (`beta == 0` is a hard zero, so `c` is not read — BLAS rule).
81    // The materialised operand equals exactly what `a_at`/`b_at` would read, and the
82    // dispatcher's CPU floor uses the same increasing-index accumulation as the loop
83    // below, so the answers agree to f64 summation precision.
84    //
85    // f64 on the GPU is **CUDA-only**: WGSL has no `f64`, so for a non-NVIDIA GPU the
86    // dispatcher itself falls back to its own f64 CPU floor (the `caps().wgpu` term just
87    // means "an accelerator is present"; the actual f64 GPU kernel is native CUDA-f64).
88    // Crucially, a forge error is NEVER propagated out of the solver: on `Err(_)` we fall
89    // through to the CPU path below, which is always correct. Sub-threshold or
90    // off-accelerator, the unchanged CPU code runs — byte-identical to before.
91    // GPU best-path GEMM via the forge — only when it's compiled in (native +
92    // wgsl-forge). On wasm32 the forge module doesn't exist; the CPU floor below runs.
93    #[cfg(all(not(target_arch = "wasm32"), feature = "wgsl-forge"))]
94    {
95        use crate::wgsl_forge::dispatch::{caps, GEMM_GPU_THRESHOLD};
96        use std::borrow::Cow;
97        let work = m.saturating_mul(n).saturating_mul(k);
98        let caps = caps();
99        if (caps.cuda || caps.wgpu) && work >= GEMM_GPU_THRESHOLD {
100            // op(A): row-major m×k — stored m×k already (No) or k×m (Yes → transpose).
101            let a_eff: Cow<[f64]> = match transa {
102                Transpose::No => Cow::Borrowed(a),
103                Transpose::Yes => {
104                    let mut t = vec![0.0_f64; m * k];
105                    for i in 0..m {
106                        for l in 0..k {
107                            t[i * k + l] = a[l * m + i];
108                        }
109                    }
110                    Cow::Owned(t)
111                }
112            };
113            // op(B): row-major k×n — stored k×n already (No) or n×k (Yes → transpose).
114            let b_eff: Cow<[f64]> = match transb {
115                Transpose::No => Cow::Borrowed(b),
116                Transpose::Yes => {
117                    let mut t = vec![0.0_f64; k * n];
118                    for l in 0..k {
119                        for j in 0..n {
120                            t[l * n + j] = b[j * k + l];
121                        }
122                    }
123                    Cow::Owned(t)
124                }
125            };
126            // BLAS gemm dims are (m, n, k); the dispatcher takes (m, k, n): op(A) is m×k,
127            // op(B) is k×n, C is m×n, so the mapping is gemm(m,n,k) → dispatch(m,k,n).
128            if let Ok(product) = crate::wgsl_forge::dispatch::gemm_f64(m, k, n, &a_eff, &b_eff) {
129                // Apply alpha/beta exactly as the CPU loop would (beta==0 ⇒ c not read).
130                if beta == 0.0 {
131                    for (ci, &p) in c.iter_mut().zip(product.iter()) {
132                        *ci = alpha * p;
133                    }
134                } else {
135                    for (ci, &p) in c.iter_mut().zip(product.iter()) {
136                        *ci = alpha * p + beta * *ci;
137                    }
138                }
139                return Ok(());
140            }
141            // Forge path was eligible but errored — fall through to the CPU floor.
142        }
143    }
144
145    // op(A)[i][l] — A stored m×k (No) or k×m (Yes).
146    let a_at = |i: usize, l: usize| -> f64 {
147        match transa {
148            Transpose::No => a[i * k + l],
149            Transpose::Yes => a[l * m + i],
150        }
151    };
152    // op(B)[l][j] — B stored k×n (No) or n×k (Yes).
153    let b_at = |l: usize, j: usize| -> f64 {
154        match transb {
155            Transpose::No => b[l * n + j],
156            Transpose::Yes => b[j * k + l],
157        }
158    };
159    for i in 0..m {
160        for j in 0..n {
161            let mut s = 0.0;
162            for l in 0..k {
163                s += a_at(i, l) * b_at(l, j);
164            }
165            let idx = i * n + j;
166            // beta==0 ⇒ hard zero, so c may be uninitialised on entry (BLAS rule).
167            c[idx] = if beta == 0.0 {
168                alpha * s
169            } else {
170                alpha * s + beta * c[idx]
171            };
172        }
173    }
174    Ok(())
175}
176
177/// Plain product `C := A · B` for `A` (`m×k`), `B` (`k×n`), `C` (`m×n`),
178/// all row-major and caller-owned. Thin wrapper over [`gemm`] with
179/// `alpha = 1`, `beta = 0` and no transposes.
180pub fn matmul(
181    m: usize,
182    k: usize,
183    n: usize,
184    a: &[f64],
185    b: &[f64],
186    c: &mut [f64],
187) -> Result<(), SolversError> {
188    gemm(Transpose::No, Transpose::No, m, n, k, 1.0, a, b, 0.0, c)
189}
190
191/// Matrix–vector product `y := op(A) · x`.
192///
193/// `op(A)` is `m×n`; `a` holds `op==No ? m×n : n×m` (always `m*n` elements),
194/// `x` is length `n`, `y` is length `m` (overwritten). Zero-heap; the dynamic
195/// analogue of [`super::Matrix4x4::multiply_vector`], and the shape the GPU
196/// `coop_gemv` decode kernel computes per output row.
197pub fn matvec(
198    transa: Transpose,
199    m: usize,
200    n: usize,
201    a: &[f64],
202    x: &[f64],
203    y: &mut [f64],
204) -> Result<(), SolversError> {
205    if a.len() != m * n || x.len() != n || y.len() != m {
206        return Err(SolversError::InvalidDimension);
207    }
208
209    // ── Best-path-with-CPU-floor offload (additive; behaviour-preserving) ──────────
210    //
211    // Mirror of the [`gemm`] fast-path above. The forge dispatcher
212    // (`wgsl_forge::dispatch::gemv_f64`) computes the *plain* product `y = A·x` only
213    // (it has no transpose support), so we only hand off when ALL hold:
214    //   1. it IS the plain product — `transa == Transpose::No` — because `gemv_f64`
215    //      cannot express `Aᵀ·x`;
216    //   2. an accelerator actually exists (`caps().cuda || caps().wgpu`) — so a no-GPU
217    //      machine takes the EXACT existing CPU loop below with zero behaviour change;
218    //   3. the problem is large enough (`m·n >= GEMM_GPU_THRESHOLD`) to amortise
219    //      dispatch/transfer overhead — small GEMVs stay on the CPU.
220    //
221    // Dimensions map directly: here `A` is m×n (row-major), `x` is length n, `y` is
222    // length m — exactly `gemv_f64(m, n, a, x)`'s `y[M] = A[M×N]·x[N]` contract, so no
223    // re-mapping is needed (unlike gemm's (m,n,k)→(m,k,n) swap). f64 on the GPU is
224    // CUDA-only (WGSL has no f64); on a non-NVIDIA GPU the dispatcher itself falls back
225    // to its own f64 CPU floor, whose increasing-`j` summation order matches this loop,
226    // so answers agree to f64 summation precision.
227    //
228    // A forge error is NEVER propagated: on `Err(_)` we fall through to the CPU loop
229    // below, which is always correct. Any transpose, sub-threshold size, or
230    // off-accelerator run executes the unchanged CPU code — byte-identical to before.
231    #[cfg(all(not(target_arch = "wasm32"), feature = "wgsl-forge"))]
232    if transa == Transpose::No {
233        use crate::wgsl_forge::dispatch::{caps, GEMM_GPU_THRESHOLD};
234        let work = m.saturating_mul(n);
235        let caps = caps();
236        if (caps.cuda || caps.wgpu) && work >= GEMM_GPU_THRESHOLD {
237            if let Ok(result) = crate::wgsl_forge::dispatch::gemv_f64(m, n, a, x) {
238                y.copy_from_slice(&result);
239                return Ok(());
240            }
241            // Forge path was eligible but errored — fall through to the CPU floor.
242        }
243    }
244
245    let a_at = |i: usize, j: usize| -> f64 {
246        match transa {
247            Transpose::No => a[i * n + j],
248            Transpose::Yes => a[j * m + i],
249        }
250    };
251    for i in 0..m {
252        let mut s = 0.0;
253        for j in 0..n {
254            s += a_at(i, j) * x[j];
255        }
256        y[i] = s;
257    }
258    Ok(())
259}
260
261/// Transpose the `m×n` row-major matrix `a` into the `n×m` row-major buffer `out`.
262/// Caller-owned, zero-heap. `a` is `m*n`, `out` is `n*m`.
263pub fn transpose(m: usize, n: usize, a: &[f64], out: &mut [f64]) -> Result<(), SolversError> {
264    if a.len() != m * n || out.len() != m * n {
265        return Err(SolversError::InvalidDimension);
266    }
267    for i in 0..m {
268        for j in 0..n {
269            out[j * m + i] = a[i * n + j];
270        }
271    }
272    Ok(())
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    fn approx(a: &[f64], b: &[f64]) {
280        assert_eq!(a.len(), b.len());
281        for i in 0..a.len() {
282            assert!((a[i] - b[i]).abs() < 1e-9, "idx {i}: {} != {}", a[i], b[i]);
283        }
284    }
285
286    #[test]
287    fn matmul_rectangular() {
288        // A (2×3) · B (3×2) = C (2×2)
289        let a = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
290        let b = [7.0, 8.0, 9.0, 10.0, 11.0, 12.0];
291        let mut c = [0.0; 4];
292        matmul(2, 3, 2, &a, &b, &mut c).unwrap();
293        // [[1*7+2*9+3*11, 1*8+2*10+3*12],[4*7+5*9+6*11, 4*8+5*10+6*12]]
294        approx(&c, &[58.0, 64.0, 139.0, 154.0]);
295    }
296
297    #[test]
298    fn matmul_identity_is_noop() {
299        let a = [1.0, 2.0, 3.0, 4.0];
300        let id = [1.0, 0.0, 0.0, 1.0];
301        let mut c = [0.0; 4];
302        matmul(2, 2, 2, &a, &id, &mut c).unwrap();
303        approx(&c, &a);
304    }
305
306    #[test]
307    fn gemm_alpha_beta_accumulate() {
308        // C := 2·A·B + 3·C
309        let a = [1.0, 2.0, 3.0, 4.0];
310        let b = [1.0, 0.0, 0.0, 1.0];
311        let mut c = [1.0, 1.0, 1.0, 1.0];
312        gemm(
313            Transpose::No,
314            Transpose::No,
315            2,
316            2,
317            2,
318            2.0,
319            &a,
320            &b,
321            3.0,
322            &mut c,
323        )
324        .unwrap();
325        // 2·A + 3·C0 = 2·[1,2,3,4] + 3·[1,1,1,1]
326        approx(&c, &[5.0, 7.0, 9.0, 11.0]);
327    }
328
329    #[test]
330    fn gemm_beta_zero_ignores_garbage() {
331        // beta=0 must not read c (here pre-filled with NaN-ish garbage).
332        let a = [1.0, 2.0, 3.0, 4.0];
333        let b = [1.0, 0.0, 0.0, 1.0];
334        let mut c = [f64::NAN, f64::NAN, f64::NAN, f64::NAN];
335        gemm(
336            Transpose::No,
337            Transpose::No,
338            2,
339            2,
340            2,
341            1.0,
342            &a,
343            &b,
344            0.0,
345            &mut c,
346        )
347        .unwrap();
348        approx(&c, &a);
349    }
350
351    #[test]
352    fn gemm_transpose_a_normal_equations() {
353        // AᵀA for A (3×2): expect symmetric 2×2. (op(A)=Aᵀ is 2×3, op(A)=A... )
354        // m=2, n=2, k=3: C = Aᵀ(2×3) · A(3×2).
355        let a = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; // 3×2, row-major
356        let mut c = [0.0; 4];
357        gemm(
358            Transpose::Yes,
359            Transpose::No,
360            2,
361            2,
362            3,
363            1.0,
364            &a,
365            &a,
366            0.0,
367            &mut c,
368        )
369        .unwrap();
370        // AᵀA = [[1+9+25, 2+12+30],[2+12+30, 4+16+36]] = [[35,44],[44,56]]
371        approx(&c, &[35.0, 44.0, 44.0, 56.0]);
372    }
373
374    #[test]
375    fn gemm_transpose_b() {
376        // A (2×3) · Bᵀ where B is (2×3) → C (2×2)
377        let a = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
378        let b = [1.0, 0.0, 1.0, 0.0, 1.0, 0.0]; // 2×3
379        let mut c = [0.0; 4];
380        gemm(
381            Transpose::No,
382            Transpose::Yes,
383            2,
384            2,
385            3,
386            1.0,
387            &a,
388            &b,
389            0.0,
390            &mut c,
391        )
392        .unwrap();
393        // op(B) = Bᵀ (3×2): rows of A dotted with rows of B.
394        // C[0][0]=1*1+2*0+3*1=4 ; C[0][1]=1*0+2*1+3*0=2
395        // C[1][0]=4*1+5*0+6*1=10; C[1][1]=4*0+5*1+6*0=5
396        approx(&c, &[4.0, 2.0, 10.0, 5.0]);
397    }
398
399    #[test]
400    fn matvec_basic() {
401        let a = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; // 2×3
402        let x = [1.0, 1.0, 1.0];
403        let mut y = [0.0; 2];
404        matvec(Transpose::No, 2, 3, &a, &x, &mut y).unwrap();
405        approx(&y, &[6.0, 15.0]);
406    }
407
408    #[test]
409    fn matvec_transposed_matches_dense_transpose() {
410        let a = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; // 2×3
411        let x = [1.0, 1.0];
412        let mut y = [0.0; 3];
413        // op(A) = Aᵀ (3×2) · x(2)
414        matvec(Transpose::Yes, 3, 2, &a, &x, &mut y).unwrap();
415        // Aᵀ rows: [1,4],[2,5],[3,6] dotted with [1,1] = 5,7,9
416        approx(&y, &[5.0, 7.0, 9.0]);
417    }
418
419    #[test]
420    fn transpose_roundtrip() {
421        let a = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; // 2×3
422        let mut t = [0.0; 6];
423        transpose(2, 3, &a, &mut t).unwrap();
424        approx(&t, &[1.0, 4.0, 2.0, 5.0, 3.0, 6.0]); // 3×2
425        let mut back = [0.0; 6];
426        transpose(3, 2, &t, &mut back).unwrap();
427        approx(&back, &a);
428    }
429
430    #[test]
431    fn gemm_via_transpose_equals_matvec() {
432        // A·x as a GEMM with n=1 must equal matvec.
433        let a = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; // 2×3
434        let x = [2.0, 0.0, 1.0];
435        let mut y_mv = [0.0; 2];
436        matvec(Transpose::No, 2, 3, &a, &x, &mut y_mv).unwrap();
437        let mut y_gemm = [0.0; 2];
438        gemm(
439            Transpose::No,
440            Transpose::No,
441            2,
442            1,
443            3,
444            1.0,
445            &a,
446            &x,
447            0.0,
448            &mut y_gemm,
449        )
450        .unwrap();
451        approx(&y_mv, &y_gemm);
452    }
453
454    #[test]
455    fn plain_gemm_matches_cpu_above_threshold() {
456        // A large plain product (48×48×48 = 110_592 ≥ GEMM_GPU_THRESHOLD) routed
457        // through `gemm(No, No, .., 1.0, a, b, 0.0, c)`. On an accelerator box this
458        // exercises the forge offload; on CI (no accelerator) it exercises the CPU
459        // floor. EITHER WAY the result must equal a SEPARATE pure-CPU triple-loop
460        // reference computed inline here, so the test is hermetic and GPU-agnostic.
461        const M: usize = 48;
462        const K: usize = 48;
463        const N: usize = 48;
464
465        // Deterministic data — no RNG, no GPU assumption.
466        let mut a = vec![0.0f64; M * K];
467        let mut b = vec![0.0f64; K * N];
468        for i in 0..M {
469            for l in 0..K {
470                a[i * K + l] = ((i * 7 + l * 3) % 11) as f64 * 0.25 - 1.0;
471            }
472        }
473        for l in 0..K {
474            for j in 0..N {
475                b[l * N + j] = ((l * 5 + j * 2) % 13) as f64 * 0.125 - 0.75;
476            }
477        }
478
479        // Reference: independent triple loop, increasing-k accumulation order.
480        let mut reference = vec![0.0f64; M * N];
481        for i in 0..M {
482            for j in 0..N {
483                let mut acc = 0.0f64;
484                for l in 0..K {
485                    acc += a[i * K + l] * b[l * N + j];
486                }
487                reference[i * N + j] = acc;
488            }
489        }
490
491        // Through the wired gemm (plain product → eligible for offload).
492        let mut c = vec![0.0f64; M * N];
493        gemm(
494            Transpose::No,
495            Transpose::No,
496            M,
497            N,
498            K,
499            1.0,
500            &a,
501            &b,
502            0.0,
503            &mut c,
504        )
505        .unwrap();
506
507        approx(&c, &reference);
508    }
509
510    #[test]
511    fn plain_gemv_matches_cpu_above_threshold() {
512        // A large plain matrix–vector product (m=n=256 → work = 65_536 ≥
513        // GEMM_GPU_THRESHOLD=32_768) routed through `matvec(No, ..)`. On an
514        // accelerator box this exercises the forge offload (`dispatch::gemv_f64`);
515        // on CI (no accelerator) it exercises the CPU floor. EITHER WAY the result
516        // must equal a SEPARATE pure-CPU reference computed inline here, so the test
517        // is hermetic and GPU-agnostic.
518        const M: usize = 256;
519        const N: usize = 256;
520
521        // Deterministic data — no RNG, no GPU assumption.
522        let mut a = vec![0.0f64; M * N];
523        for i in 0..M {
524            for j in 0..N {
525                a[i * N + j] = ((i * 7 + j * 3) % 11) as f64 * 0.25 - 1.0;
526            }
527        }
528        let mut x = vec![0.0f64; N];
529        for (j, xj) in x.iter_mut().enumerate() {
530            *xj = ((j * 5) % 13) as f64 * 0.125 - 0.75;
531        }
532
533        // Reference: independent dot-product per row, increasing-j accumulation order.
534        let mut reference = vec![0.0f64; M];
535        for i in 0..M {
536            let row = i * N;
537            let mut acc = 0.0f64;
538            for j in 0..N {
539                acc += a[row + j] * x[j];
540            }
541            reference[i] = acc;
542        }
543
544        // Through the wired matvec (plain product → eligible for offload).
545        let mut y = vec![0.0f64; M];
546        matvec(Transpose::No, M, N, &a, &x, &mut y).unwrap();
547
548        approx(&y, &reference);
549    }
550
551    #[test]
552    fn transposed_covariance_matches_cpu_above_threshold() {
553        // The hot ML op: covariance `Cov = Xᵀ·X` for tall-skinny `X` (n≫p), expressed
554        // as `gemm(Transpose::Yes, Transpose::No, p, p, n, 1.0, x, x, 0.0, cov)`. With
555        // n=4096, p=8 the work is p·p·n = 262_144 ≥ GEMM_GPU_THRESHOLD, so on an
556        // accelerator box this exercises the NEW transpose-materialising offload path;
557        // on CI (no accelerator) it exercises the CPU floor. EITHER WAY the result must
558        // equal a SEPARATE pure-CPU `XᵀX` reference computed inline here — hermetic,
559        // GPU-agnostic, and the regression guard that materialise-then-dispatch equals
560        // the index-arithmetic CPU loop.
561        const NSAMP: usize = 4096; // k (contraction dim)
562        const P: usize = 8; // m == n (feature dim)
563
564        // X is NSAMP×P row-major (stored as op==Yes's k×m). Deterministic, no RNG.
565        let mut x = vec![0.0f64; NSAMP * P];
566        for r in 0..NSAMP {
567            for c in 0..P {
568                x[r * P + c] = ((r * 3 + c * 7) % 17) as f64 * 0.125 - 1.0;
569            }
570        }
571
572        // Reference: Cov[i][j] = Σ_r X[r][i]·X[r][j], increasing-r accumulation.
573        let mut reference = vec![0.0f64; P * P];
574        for i in 0..P {
575            for j in 0..P {
576                let mut acc = 0.0f64;
577                for r in 0..NSAMP {
578                    acc += x[r * P + i] * x[r * P + j];
579                }
580                reference[i * P + j] = acc;
581            }
582        }
583
584        // Through the wired gemm: op(A)=Xᵀ (P×NSAMP), op(B)=X (NSAMP×P) → C (P×P).
585        let mut cov = vec![0.0f64; P * P];
586        gemm(
587            Transpose::Yes,
588            Transpose::No,
589            P,
590            P,
591            NSAMP,
592            1.0,
593            &x,
594            &x,
595            0.0,
596            &mut cov,
597        )
598        .unwrap();
599
600        approx(&cov, &reference);
601        // Covariance must be symmetric.
602        for i in 0..P {
603            for j in 0..P {
604                assert!((cov[i * P + j] - cov[j * P + i]).abs() < 1e-9);
605            }
606        }
607    }
608
609    #[test]
610    fn scaled_accumulate_transposed_matches_cpu_above_threshold() {
611        // Exercises the offload's `alpha`/`beta` combine *together with* a transpose, on
612        // the real PCA covariance shape: `C := alpha·(Xᵀ·X) + beta·C0` with
613        // alpha=1/(NSAMP−1) (exactly what `pca::fit` passes) and a non-zero beta so the
614        // `c`-read branch is hit. Above threshold (P²·NSAMP ≥ GEMM_GPU_THRESHOLD) → on an
615        // accelerator this is the materialise→dispatch→combine path; on CI the CPU floor.
616        // Either way it must equal the inline reference.
617        const NSAMP: usize = 4096;
618        const P: usize = 8;
619        let alpha = 1.0 / (NSAMP as f64 - 1.0);
620        let beta = 0.5;
621
622        let mut x = vec![0.0f64; NSAMP * P];
623        for r in 0..NSAMP {
624            for c in 0..P {
625                x[r * P + c] = ((r * 5 + c * 3) % 13) as f64 * 0.1 - 0.6;
626            }
627        }
628        // Initial C0 — read because beta != 0.
629        let mut c = vec![0.0f64; P * P];
630        for (i, ci) in c.iter_mut().enumerate() {
631            *ci = (i % 5) as f64 * 0.25;
632        }
633        let c0 = c.clone();
634
635        // Reference: alpha·Σ_r X[r][i]·X[r][j] + beta·C0[i][j].
636        let mut reference = vec![0.0f64; P * P];
637        for i in 0..P {
638            for j in 0..P {
639                let mut acc = 0.0f64;
640                for r in 0..NSAMP {
641                    acc += x[r * P + i] * x[r * P + j];
642                }
643                reference[i * P + j] = alpha * acc + beta * c0[i * P + j];
644            }
645        }
646
647        gemm(
648            Transpose::Yes,
649            Transpose::No,
650            P,
651            P,
652            NSAMP,
653            alpha,
654            &x,
655            &x,
656            beta,
657            &mut c,
658        )
659        .unwrap();
660        approx(&c, &reference);
661    }
662
663    #[test]
664    fn rejects_bad_dims() {
665        let a = [1.0, 2.0, 3.0, 4.0];
666        let b = [1.0, 2.0];
667        let mut c = [0.0; 4];
668        assert_eq!(
669            matmul(2, 2, 2, &a, &b, &mut c),
670            Err(SolversError::InvalidDimension)
671        );
672    }
673}