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}