Skip to main content

entrenar/autograd/ops/
matmul.rs

1//! Matrix multiplication autograd operations
2//!
3//! Uses realizar's CUDA executor for GPU acceleration, falls back to trueno SIMD GEMM on CPU.
4//! Both forward AND backward passes use CUDA GEMM for full GPU acceleration.
5//! Instrumented with TRACER for empirical overhead analysis.
6
7use crate::autograd::{BackwardOp, Tensor};
8use crate::trace::{TraceStep, TRACER};
9use ndarray::Array1;
10use std::cell::RefCell;
11use std::rc::Rc;
12
13#[cfg(all(feature = "realizar", feature = "cuda"))]
14use std::sync::atomic::{AtomicBool, Ordering};
15#[cfg(all(feature = "realizar", feature = "cuda"))]
16use std::sync::{Mutex, OnceLock};
17
18#[cfg(all(feature = "realizar", feature = "cuda"))]
19use realizar::cuda::CudaExecutor;
20
21/// Once a realizador CUDA matmul fails (typically JIT OOM after GPU VRAM is filled
22/// by NF4 block upload), disable all further attempts. Without this flag, every
23/// matmul call re-attempts CUDA, fails, and falls back to CPU — producing thousands
24/// of log lines per training step and adding ~100ms overhead per call.
25#[cfg(all(feature = "realizar", feature = "cuda"))]
26static CUDA_MATMUL_DISABLED: AtomicBool = AtomicBool::new(false);
27
28/// Global CUDA executor (singleton, initialized once)
29#[cfg(all(feature = "realizar", feature = "cuda"))]
30static CUDA_EXECUTOR: OnceLock<Option<Mutex<CudaExecutor>>> = OnceLock::new();
31
32/// Get or initialize CUDA executor
33#[cfg(all(feature = "realizar", feature = "cuda"))]
34fn get_cuda_executor() -> Option<&'static Mutex<CudaExecutor>> {
35    CUDA_EXECUTOR
36        .get_or_init(|| match CudaExecutor::new(0) {
37            Ok(executor) => {
38                TRACER.end(TraceStep::Transfer, "realizar CUDA executor initialized on GPU 0");
39                Some(Mutex::new(executor))
40            }
41            Err(_e) => {
42                CUDA_MATMUL_DISABLED.store(true, Ordering::Relaxed);
43                None
44            }
45        })
46        .as_ref()
47}
48
49/// Transpose a row-major matrix (rows x cols) to (cols x rows)
50/// Uses cache-efficient blocked transpose for large matrices
51#[inline]
52pub fn transpose(data: &[f32], rows: usize, cols: usize) -> Vec<f32> {
53    contract_pre_transpose!(data);
54    TRACER.start(TraceStep::Transpose);
55    let mut transposed = vec![0.0f32; rows * cols];
56
57    const BLOCK_SIZE: usize = 32;
58    if rows >= BLOCK_SIZE && cols >= BLOCK_SIZE {
59        transpose_blocked(data, &mut transposed, rows, cols, BLOCK_SIZE);
60    } else {
61        transpose_simple(data, &mut transposed, rows, cols);
62    }
63
64    TRACER.end(TraceStep::Transpose, format!("{rows}x{cols}"));
65    transposed
66}
67
68/// Autograd-aware transpose that preserves the backward chain (KAIZEN-018).
69///
70/// Creates a new tensor with transposed data AND a backward op that
71/// accumulates the inverse-transposed gradient on the original tensor.
72/// This ensures gradient flow through LoRA weight transposes.
73///
74/// # Contract (C-LORA-GRAD-001)
75///
76/// - **Precondition**: `tensor` has shape (rows, cols) in row-major layout
77/// - **Postcondition**: Returns tensor with shape (cols, rows), backward chain connected
78/// - **Invariant**: `original.grad()` receives the correctly transposed gradient
79pub fn transpose_tracked(tensor: &Tensor, rows: usize, cols: usize) -> Tensor {
80    contract_pre_transpose_tracked!();
81    let data = tensor.data();
82    let slice = data.as_slice().expect("transpose_tracked: tensor must be contiguous");
83    let transposed_data = transpose(slice, rows, cols);
84    let mut result = Tensor::from_vec(transposed_data, tensor.requires_grad());
85
86    if tensor.requires_grad() {
87        let backward_op = Rc::new(TransposeBackward {
88            original: tensor.clone(),
89            rows,
90            cols,
91            result_grad: result.grad_cell(),
92        });
93        result.set_backward_op(backward_op);
94    }
95
96    result
97}
98
99/// Backward op for autograd-aware transpose (KAIZEN-018).
100///
101/// Given forward: result = transpose(original, rows, cols)
102/// Backward: grad_original = transpose(grad_result, cols, rows)
103/// (The inverse of an (r,c) transpose is a (c,r) transpose.)
104struct TransposeBackward {
105    original: Tensor,
106    rows: usize,
107    cols: usize,
108    result_grad: Rc<RefCell<Option<Array1<f32>>>>,
109}
110
111impl BackwardOp for TransposeBackward {
112    fn backward(&self) {
113        if let Some(grad) = self.result_grad.borrow().as_ref() {
114            let grad_slice = grad.as_slice().expect("gradient must be contiguous");
115            // Inverse transpose: (cols, rows) → (rows, cols)
116            let grad_original = transpose(grad_slice, self.cols, self.rows);
117            self.original.accumulate_grad(Array1::from(grad_original));
118            if let Some(op) = self.original.backward_op() {
119                op.backward();
120            }
121        }
122    }
123}
124
125/// Blocked transpose for cache efficiency on large matrices.
126#[inline]
127fn transpose_blocked(src: &[f32], dst: &mut [f32], rows: usize, cols: usize, block: usize) {
128    for r_block in (0..rows).step_by(block) {
129        for c_block in (0..cols).step_by(block) {
130            let r_end = (r_block + block).min(rows);
131            let c_end = (c_block + block).min(cols);
132            for r in r_block..r_end {
133                for c in c_block..c_end {
134                    dst[c * rows + r] = src[r * cols + c];
135                }
136            }
137        }
138    }
139}
140
141/// Simple transpose for small matrices.
142#[inline]
143fn transpose_simple(src: &[f32], dst: &mut [f32], rows: usize, cols: usize) {
144    for r in 0..rows {
145        for c in 0..cols {
146            dst[c * rows + r] = src[r * cols + c];
147        }
148    }
149}
150
151/// Compute matrix multiplication using realizar CUDA if available, else SIMD CPU.
152///
153/// After the first CUDA failure (typically JIT OOM when VRAM is occupied by NF4
154/// block uploads), all subsequent calls skip CUDA entirely and use trueno SIMD.
155#[cfg(all(feature = "realizar", feature = "cuda"))]
156pub fn matmul_compute(a: &[f32], b: &[f32], m: usize, k: usize, n: usize) -> Vec<f32> {
157    contract_pre_matmul!(a);
158    // Fast path: skip CUDA entirely once disabled (common during QLoRA training
159    // where NF4 blocks fill VRAM before realizador can JIT-compile gemm_tiled)
160    if !CUDA_MATMUL_DISABLED.load(Ordering::Relaxed) {
161        if let Some(executor_mutex) = get_cuda_executor() {
162            if let Ok(mut executor) = executor_mutex.lock() {
163                match cuda_matmul(&mut executor, a, b, m, k, n) {
164                    Ok(result) => return result,
165                    Err(_e) => {
166                        // First failure: disable all future CUDA matmul attempts
167                        CUDA_MATMUL_DISABLED.store(true, Ordering::Relaxed);
168                        TRACER.end(
169                            TraceStep::Matmul,
170                            "realizar CUDA matmul disabled (JIT failure), using trueno SIMD",
171                        );
172                    }
173                }
174            }
175        }
176    }
177
178    // wgpu GPU fallback (AMD/Intel/Apple GPUs via Vulkan/Metal/DX12)
179    // KAIZEN-004: Skip per-op wgpu when batched forward pass is active
180    #[cfg(feature = "gpu")]
181    if !WGPU_BATCH_MODE.load(std::sync::atomic::Ordering::Relaxed) && m * k * n > 32_768 {
182        if let Some(result) = wgpu_matmul(a, b, m, k, n) {
183            return result;
184        }
185    }
186
187    // trueno SIMD fallback (rayon-parallel if trueno/parallel enabled)
188    cpu_matmul(a, b, m, k, n)
189}
190
191/// Pre-warm realizador's CUDA GEMM kernels for all training shapes.
192///
193/// Realizador JIT-compiles `gemm_tiled` per unique (M,K,N) shape. If compilation
194/// happens after transformer block upload fills VRAM, JIT fails with
195/// CUDA_ERROR_ILLEGAL_ADDRESS and `CUDA_MATMUL_DISABLED` gets set, forcing ALL
196/// matmul to CPU SIMD (~100x slower).
197///
198/// This function pre-warms with every (M,K,N) triplet used during training:
199/// - Forward: linear projections (Q,K,V,O), FFN (gate,up,down)
200/// - Backward: transposed shapes for grad_A and grad_B
201/// - LoRA: A and B projection shapes
202/// - Classifier head
203///
204/// Call this BEFORE uploading transformer blocks (C-PREWARM-001).
205#[cfg(all(feature = "realizar", feature = "cuda"))]
206pub fn pre_warm_realizador_gemm(
207    seq_len: usize,
208    hidden_size: usize,
209    kv_hidden_size: usize,
210    intermediate_size: usize,
211    lora_rank: usize,
212    num_classes: usize,
213) -> usize {
214    let executor_mutex = match get_cuda_executor() {
215        Some(e) => e,
216        None => return 0,
217    };
218    let mut executor = match executor_mutex.lock() {
219        Ok(e) => e,
220        Err(_) => return 0,
221    };
222
223    // Collect all unique (M, K, N) shapes used during training
224    let s = seq_len;
225    let h = hidden_size;
226    let kv = kv_hidden_size;
227    let i = intermediate_size;
228    let r = lora_rank;
229
230    let mut shapes: Vec<(usize, usize, usize)> = vec![
231        // Forward linear projections
232        (s, h, h),  // Q, O projections
233        (s, h, kv), // K, V projections
234        (s, h, i),  // FFN gate, up
235        (s, i, h),  // FFN down
236        // LoRA forward
237        (s, h, r),  // LoRA A (Q/O/gate/up)
238        (s, r, h),  // LoRA B (Q/O)
239        (s, kv, r), // LoRA A (K/V) — if kv != h
240        (s, r, kv), // LoRA B (K/V)
241        // Backward: grad_A = grad_C @ B^T → (M, N_fwd, K_fwd)
242        // For (s,h,h): grad_A is (s,h,h) — same
243        (s, kv, h), // K/V backward grad_A: (s, kv) @ (kv, h)
244        (s, i, h),  // Gate/Up backward grad_A — same as FFN down forward
245        (s, h, i),  // Down backward grad_A — same as FFN gate forward
246        // Backward: grad_B = A^T @ grad_C → (K_fwd, M, N_fwd)
247        (h, s, h),  // Q/O backward grad_B: (h, s) @ (s, h)
248        (h, s, kv), // K/V backward grad_B: (h, s) @ (s, kv)
249        (h, s, i),  // Gate/Up backward grad_B: (h, s) @ (s, i)
250        (i, s, h),  // Down backward grad_B: (i, s) @ (s, h)
251        // LoRA backward
252        (s, r, h),  // LoRA A backward grad_A — same as LoRA B forward
253        (h, s, r),  // LoRA A backward grad_B
254        (s, h, r),  // LoRA B backward grad_A — same as LoRA A forward
255        (r, s, h),  // LoRA B backward grad_B
256        (r, s, kv), // LoRA B (K/V) backward grad_B
257        // Classifier head
258        (1, h, num_classes),
259    ];
260
261    // Deduplicate
262    shapes.sort_unstable();
263    shapes.dedup();
264    // Remove zero-dimension shapes
265    shapes.retain(|&(m, k, n)| m > 0 && k > 0 && n > 0);
266
267    let mut warmed = 0usize;
268    for &(m, k, n) in &shapes {
269        let a = vec![0.0f32; m * k];
270        let b = vec![0.0f32; k * n];
271        match cuda_matmul(&mut executor, &a, &b, m, k, n) {
272            Ok(_) => warmed += 1,
273            Err(e) => {
274                eprintln!("[CUDA] realizador GEMM pre-warm failed for ({m},{k},{n}): {e}");
275            }
276        }
277    }
278
279    if warmed == 0 {
280        CUDA_MATMUL_DISABLED.store(true, Ordering::Relaxed);
281    }
282
283    warmed
284}
285
286/// CUDA matrix multiplication via realizar's CudaExecutor
287#[cfg(all(feature = "realizar", feature = "cuda"))]
288fn cuda_matmul(
289    executor: &mut CudaExecutor,
290    a: &[f32],
291    b: &[f32],
292    m: usize,
293    k: usize,
294    n: usize,
295) -> Result<Vec<f32>, String> {
296    TRACER.start(TraceStep::Alloc);
297    let mut c = vec![0.0f32; m * n];
298    TRACER.end(TraceStep::Alloc, format!("{m}x{n}"));
299
300    TRACER.start(TraceStep::Matmul);
301    executor.gemm(a, b, &mut c, m as u32, n as u32, k as u32).map_err(|e| format!("{e:?}"))?;
302    TRACER.end(TraceStep::Matmul, format!("{m}x{k}x{n}"));
303    Ok(c)
304}
305
306/// CPU fallback using trueno SIMD GEMM
307fn cpu_matmul(a: &[f32], b: &[f32], m: usize, k: usize, n: usize) -> Vec<f32> {
308    let mut c = vec![0.0f32; m * n];
309
310    if let Err(_e) = trueno::blis::gemm(m, n, k, a, b, &mut c) {
311        // Naive triple-loop fallback (trueno BLIS should never fail in practice)
312        for i in 0..m {
313            for j in 0..n {
314                let mut sum = 0.0;
315                for p in 0..k {
316                    sum += a[i * k + p] * b[p * n + j];
317                }
318                c[i * n + j] = sum;
319            }
320        }
321    }
322
323    c
324}
325
326/// KAIZEN-004: When WgpuForwardPass is handling the forward pass in batch mode,
327/// suppress per-op wgpu matmul. Attention matmuls go to CPU SIMD instead,
328/// avoiding buffer upload/download overhead and GPU contention with the batched FFN path.
329#[cfg(feature = "gpu")]
330static WGPU_BATCH_MODE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
331
332/// Suppress per-op wgpu matmul (use CPU SIMD instead).
333///
334/// Call this before running attention on CPU while WgpuForwardPass handles FFN.
335/// Per-op wgpu adds ~3-5ms overhead per matmul (buffer upload/compute/download).
336/// For 144 attention matmuls per sample, that's 430-720ms of pure overhead.
337/// CPU SIMD is equally fast and doesn't compete for GPU bandwidth.
338#[cfg(feature = "gpu")]
339pub fn suppress_per_op_wgpu() {
340    WGPU_BATCH_MODE.store(true, std::sync::atomic::Ordering::Relaxed);
341}
342
343/// Re-enable per-op wgpu matmul.
344#[cfg(feature = "gpu")]
345pub fn unsuppress_per_op_wgpu() {
346    WGPU_BATCH_MODE.store(false, std::sync::atomic::Ordering::Relaxed);
347}
348
349/// CPU/wgpu path (no CUDA feature)
350///
351/// Tries wgpu GPU matmul first (Vulkan/Metal/DX12), falls back to rayon-parallel
352/// trueno BLIS GEMM on CPU. The wgpu path uses trueno's GpuDevice for cross-platform
353/// GPU compute on AMD, Intel, and Apple GPUs.
354#[cfg(not(all(feature = "realizar", feature = "cuda")))]
355pub fn matmul_compute(a: &[f32], b: &[f32], m: usize, k: usize, n: usize) -> Vec<f32> {
356    #[cfg(feature = "gpu")]
357    {
358        // KAIZEN-004: Skip per-op wgpu when batched forward pass is active.
359        // Attention matmuls use CPU SIMD instead — equally fast, no buffer overhead.
360        // Also skip vector-matrix operations (m=1 or n=1) since they are entirely bandwidth-bound
361        // and copying them to the GPU on every op causes catastrophic overhead (Issue #751).
362        if !WGPU_BATCH_MODE.load(std::sync::atomic::Ordering::Relaxed)
363            && m * k * n > 32_768
364            && m > 1
365            && n > 1
366        {
367            if let Some(result) = wgpu_matmul(a, b, m, k, n) {
368                return result;
369            }
370        }
371    }
372    cpu_matmul(a, b, m, k, n)
373}
374
375/// wgpu GPU matmul via trueno GpuDevice (Vulkan/Metal/DX12)
376///
377/// Uses a singleton GpuDevice to avoid per-call device creation overhead.
378/// Returns None if GPU is unavailable or matmul fails (auto-fallback to CPU).
379#[cfg(feature = "gpu")]
380fn wgpu_matmul(a: &[f32], b: &[f32], m: usize, k: usize, n: usize) -> Option<Vec<f32>> {
381    use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
382    use std::sync::OnceLock;
383    static WGPU_DISABLED: AtomicBool = AtomicBool::new(false);
384    static WGPU_LOGGED: AtomicBool = AtomicBool::new(false);
385    static WGPU_CALLS: AtomicU64 = AtomicU64::new(0);
386    static WGPU_DEVICE: OnceLock<Option<trueno::backends::gpu::GpuDevice>> = OnceLock::new();
387
388    if WGPU_DISABLED.load(Ordering::Relaxed) {
389        return None;
390    }
391
392    let device_opt = WGPU_DEVICE.get_or_init(|| {
393        if !trueno::backends::gpu::GpuBackend::is_available() {
394            eprintln!("[wgpu] No GPU available, using CPU");
395            return None;
396        }
397        match trueno::backends::gpu::GpuDevice::new() {
398            Ok(d) => {
399                eprintln!("[wgpu] GPU device initialized for matmul");
400                Some(d)
401            }
402            Err(e) => {
403                eprintln!("[wgpu] GPU init failed: {e}, using CPU");
404                None
405            }
406        }
407    });
408
409    let device = match device_opt.as_ref() {
410        Some(d) => d,
411        None => {
412            WGPU_DISABLED.store(true, Ordering::Relaxed);
413            return None;
414        }
415    };
416
417    let mut result = vec![0.0f32; m * n];
418    match device.matmul(a, b, &mut result, m, k, n) {
419        Ok(()) => {
420            let calls = WGPU_CALLS.fetch_add(1, Ordering::Relaxed);
421            if !WGPU_LOGGED.swap(true, Ordering::Relaxed) {
422                eprintln!("[wgpu] GPU matmul active ({m}x{k}x{n})");
423            }
424            // KAIZEN-003: Demote to 10k intervals; previous 1k floods logs
425            if calls > 0 && calls.is_multiple_of(10_000) {
426                eprintln!("[wgpu] {calls} GPU matmuls completed");
427            }
428            Some(result)
429        }
430        Err(_e) => {
431            WGPU_DISABLED.store(true, Ordering::Relaxed);
432            None
433        }
434    }
435}
436
437/// Matrix multiplication
438///
439/// Computes C = A @ B where:
440/// - A is m×k (flattened to length m*k)
441/// - B is k×n (flattened to length k*n)
442/// - C is m×n (flattened to length m*n)
443///
444/// Uses GPU acceleration when available (requires `gpu` feature).
445///
446/// # Arguments
447/// * `a` - Left matrix (m×k flattened)
448/// * `b` - Right matrix (k×n flattened)
449/// * `m` - Number of rows in A
450/// * `k` - Number of columns in A (= rows in B)
451/// * `n` - Number of columns in B
452#[provable_contracts_macros::contract("matmul-v1", equation = "matmul")]
453pub fn matmul(a: &Tensor, b: &Tensor, m: usize, k: usize, n: usize) -> Tensor {
454    assert_eq!(a.len(), m * k, "Matrix A size mismatch");
455    assert_eq!(b.len(), k * n, "Matrix B size mismatch");
456
457    // Compute C = A @ B using GPU if available
458    let result_data = matmul_compute(
459        a.data().as_slice().expect("matrix A must be contiguous"),
460        b.data().as_slice().expect("matrix B must be contiguous"),
461        m,
462        k,
463        n,
464    );
465
466    let requires_grad = a.requires_grad() || b.requires_grad();
467    let mut result = Tensor::new(Array1::from(result_data), requires_grad);
468
469    if requires_grad {
470        let a_clone = a.clone();
471        let b_clone = b.clone();
472        let backward_op = Rc::new(MatmulBackward {
473            a: a_clone,
474            b: b_clone,
475            m,
476            k,
477            n,
478            result_grad: result.grad_cell(),
479        });
480        result.set_backward_op(backward_op);
481    }
482
483    contract_post_matmul!(result.data().as_slice().unwrap_or(&[]));
484    result
485}
486
487struct MatmulBackward {
488    a: Tensor,
489    b: Tensor,
490    m: usize,
491    k: usize,
492    n: usize,
493    result_grad: Rc<RefCell<Option<Array1<f32>>>>,
494}
495
496impl BackwardOp for MatmulBackward {
497    fn backward(&self) {
498        if let Some(grad_output) = self.result_grad.borrow().as_ref() {
499            // ∂L/∂A = ∂L/∂C @ B^T  (m×n) @ (n×k) = (m×k)
500            // ∂L/∂B = A^T @ ∂L/∂C  (k×m) @ (m×n) = (k×n)
501
502            let grad_c = grad_output.as_slice().expect("gradient output must be contiguous");
503            let a_data = self.a.data();
504            let b_data = self.b.data();
505            let a_slice = a_data.as_slice().expect("matrix A must be contiguous");
506            let b_slice = b_data.as_slice().expect("matrix B must be contiguous");
507
508            if self.a.requires_grad() {
509                // grad_A = grad_C @ B^T
510                // grad_C is (m, n), B is (k, n), B^T is (n, k)
511                // Result: (m, n) @ (n, k) = (m, k)
512                let b_t = transpose(b_slice, self.k, self.n);
513                let grad_a = matmul_compute(grad_c, &b_t, self.m, self.n, self.k);
514                self.a.accumulate_grad(Array1::from(grad_a));
515            }
516
517            if self.b.requires_grad() {
518                // grad_B = A^T @ grad_C
519                // A is (m, k), A^T is (k, m), grad_C is (m, n)
520                // Result: (k, m) @ (m, n) = (k, n)
521                let a_t = transpose(a_slice, self.m, self.k);
522                let grad_b = matmul_compute(&a_t, grad_c, self.k, self.m, self.n);
523                self.b.accumulate_grad(Array1::from(grad_b));
524            }
525
526            // Recursively call backward on inputs
527            if let Some(op) = self.a.backward_op() {
528                op.backward();
529            }
530            if let Some(op) = self.b.backward_op() {
531                op.backward();
532            }
533        }
534    }
535}
536
537/// Matrix multiply with B transposed: C = A @ B^T (KAIZEN-011)
538///
539/// # Contract (C-MATMUL-NT-001)
540///
541/// - **Precondition**: A is (M, K), B is (N, K) — B's second dim matches A's second dim
542/// - **Postcondition**: Output is (M, N) where C(i,j) = Σ_k A(i,k) * B(j,k)
543/// - **Invariant**: Gradients flow to BOTH A and B (not transposed copies)
544///
545/// This is essential for LoRA where A_lora is stored as (rank, d_in) and we need
546/// `x @ A_lora^T` without creating a transposed copy that breaks gradient flow.
547///
548/// # Backward
549///
550/// - `∂L/∂A = ∂L/∂C @ B`  — (M,N) @ (N,K) = (M,K)
551/// - `∂L/∂B = ∂L/∂C^T @ A` — (N,M) @ (M,K) = (N,K)
552#[provable_contracts_macros::contract("matmul-v1", equation = "matmul_nt")]
553pub fn matmul_nt(a: &Tensor, b: &Tensor, m: usize, k: usize, n: usize) -> Tensor {
554    assert_eq!(
555        a.len(),
556        m * k,
557        "Matrix A size mismatch: expected {}×{} = {}, got {}",
558        m,
559        k,
560        m * k,
561        a.len()
562    );
563    assert_eq!(
564        b.len(),
565        n * k,
566        "Matrix B size mismatch: expected {}×{} = {}, got {}",
567        n,
568        k,
569        n * k,
570        b.len()
571    );
572
573    let a_slice = a.data();
574    let b_slice = b.data();
575    let a_data = a_slice.as_slice().expect("matrix A must be contiguous");
576    let b_data = b_slice.as_slice().expect("matrix B must be contiguous");
577
578    // C = A @ B^T: C(i,j) = Σ_k A(i,k) * B(j,k)
579    let result_data = matmul_nt_compute(a_data, b_data, m, k, n);
580
581    let requires_grad = a.requires_grad() || b.requires_grad();
582    let mut result = Tensor::new(Array1::from(result_data), requires_grad);
583
584    if requires_grad {
585        let a_clone = a.clone();
586        let b_clone = b.clone();
587        let backward_op = Rc::new(MatmulNtBackward {
588            a: a_clone,
589            b: b_clone,
590            m,
591            k,
592            n,
593            result_grad: result.grad_cell(),
594        });
595        result.set_backward_op(backward_op);
596    }
597
598    contract_post_matmul!(result.data().as_slice().unwrap_or(&[]));
599    result
600}
601
602/// Raw compute for C = A @ B^T using trueno SIMD GEMM
603///
604/// A is (M, K), B is (N, K), output is (M, N)
605pub fn matmul_nt_compute(a: &[f32], b: &[f32], m: usize, k: usize, n: usize) -> Vec<f32> {
606    // Transpose B to (K, N) then use standard matmul
607    let b_t = transpose(b, n, k); // (N,K) → (K,N)
608    cpu_matmul(a, &b_t, m, k, n)
609}
610
611struct MatmulNtBackward {
612    a: Tensor,
613    b: Tensor,
614    m: usize,
615    k: usize,
616    n: usize,
617    result_grad: Rc<RefCell<Option<Array1<f32>>>>,
618}
619
620impl BackwardOp for MatmulNtBackward {
621    fn backward(&self) {
622        if let Some(grad_output) = self.result_grad.borrow().as_ref() {
623            // C = A @ B^T where A is (M,K), B is (N,K), C is (M,N)
624            //
625            // ∂L/∂A = ∂L/∂C @ B     (M,N) @ (N,K) = (M,K)
626            // ∂L/∂B = ∂L/∂C^T @ A   (N,M) @ (M,K) = (N,K)
627
628            let grad_c = grad_output.as_slice().expect("gradient output must be contiguous");
629
630            if self.a.requires_grad() {
631                // grad_A = grad_C @ B  (standard matmul)
632                let b_data = self.b.data();
633                let b_slice = b_data.as_slice().expect("matrix B must be contiguous");
634                let grad_a = matmul_compute(grad_c, b_slice, self.m, self.n, self.k);
635                self.a.accumulate_grad(Array1::from(grad_a));
636            }
637
638            if self.b.requires_grad() {
639                // grad_B = grad_C^T @ A
640                let a_data = self.a.data();
641                let a_slice = a_data.as_slice().expect("matrix A must be contiguous");
642                let grad_c_t = transpose(grad_c, self.m, self.n);
643                let grad_b = matmul_compute(&grad_c_t, a_slice, self.n, self.m, self.k);
644                self.b.accumulate_grad(Array1::from(grad_b));
645            }
646
647            // Recursively propagate
648            if let Some(op) = self.a.backward_op() {
649                op.backward();
650            }
651            if let Some(op) = self.b.backward_op() {
652                op.backward();
653            }
654        }
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    #[test]
663    fn test_transpose_identity() {
664        // 1x1 matrix
665        let data = vec![5.0];
666        let result = transpose(&data, 1, 1);
667        assert_eq!(result, vec![5.0]);
668    }
669
670    #[test]
671    fn test_transpose_2x3() {
672        // 2x3 matrix
673        // [1, 2, 3]
674        // [4, 5, 6]
675        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
676        let result = transpose(&data, 2, 3);
677        // Expected 3x2:
678        // [1, 4]
679        // [2, 5]
680        // [3, 6]
681        assert_eq!(result, vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0]);
682    }
683
684    #[test]
685    fn test_transpose_3x2() {
686        // 3x2 matrix
687        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
688        let result = transpose(&data, 3, 2);
689        // Expected 2x3:
690        assert_eq!(result, vec![1.0, 3.0, 5.0, 2.0, 4.0, 6.0]);
691    }
692
693    #[test]
694    fn test_matmul_compute_2x2() {
695        // A = [[1, 2], [3, 4]] (2x2)
696        // B = [[5, 6], [7, 8]] (2x2)
697        // C = A @ B = [[19, 22], [43, 50]]
698        let a = vec![1.0, 2.0, 3.0, 4.0];
699        let b = vec![5.0, 6.0, 7.0, 8.0];
700        let c = matmul_compute(&a, &b, 2, 2, 2);
701        assert_eq!(c, vec![19.0, 22.0, 43.0, 50.0]);
702    }
703
704    #[test]
705    fn test_matmul_compute_2x3_3x2() {
706        // A = [[1, 2, 3], [4, 5, 6]] (2x3)
707        // B = [[7, 8], [9, 10], [11, 12]] (3x2)
708        // C = A @ B (2x2)
709        let a = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
710        let b = vec![7.0, 8.0, 9.0, 10.0, 11.0, 12.0];
711        let c = matmul_compute(&a, &b, 2, 3, 2);
712        // [1*7+2*9+3*11, 1*8+2*10+3*12] = [58, 64]
713        // [4*7+5*9+6*11, 4*8+5*10+6*12] = [139, 154]
714        assert_eq!(c, vec![58.0, 64.0, 139.0, 154.0]);
715    }
716
717    #[test]
718    fn test_matmul_no_grad() {
719        let a = Tensor::new(Array1::from(vec![1.0, 2.0, 3.0, 4.0]), false);
720        let b = Tensor::new(Array1::from(vec![5.0, 6.0, 7.0, 8.0]), false);
721        let c = matmul(&a, &b, 2, 2, 2);
722        assert!(!c.requires_grad());
723        assert_eq!(
724            c.data().as_slice().expect("operation should succeed"),
725            &[19.0, 22.0, 43.0, 50.0]
726        );
727    }
728
729    #[test]
730    fn test_matmul_with_grad() {
731        let a = Tensor::new(Array1::from(vec![1.0, 2.0, 3.0, 4.0]), true);
732        let b = Tensor::new(Array1::from(vec![5.0, 6.0, 7.0, 8.0]), true);
733        let c = matmul(&a, &b, 2, 2, 2);
734        assert!(c.requires_grad());
735        assert!(c.backward_op().is_some());
736    }
737
738    #[test]
739    fn test_matmul_backward() {
740        let a = Tensor::new(Array1::from(vec![1.0, 2.0, 3.0, 4.0]), true);
741        let b = Tensor::new(Array1::from(vec![5.0, 6.0, 7.0, 8.0]), true);
742        let c = matmul(&a, &b, 2, 2, 2);
743
744        // Set gradient of output
745        c.set_grad(Array1::from(vec![1.0, 1.0, 1.0, 1.0]));
746
747        // Trigger backward
748        if let Some(op) = c.backward_op() {
749            op.backward();
750        }
751
752        // Check gradients are accumulated
753        assert!(a.grad().is_some());
754        assert!(b.grad().is_some());
755    }
756
757    #[test]
758    fn test_matmul_a_requires_grad_only() {
759        let a = Tensor::new(Array1::from(vec![1.0, 2.0, 3.0, 4.0]), true);
760        let b = Tensor::new(Array1::from(vec![5.0, 6.0, 7.0, 8.0]), false);
761        let c = matmul(&a, &b, 2, 2, 2);
762        assert!(c.requires_grad());
763
764        c.set_grad(Array1::from(vec![1.0, 1.0, 1.0, 1.0]));
765        if let Some(op) = c.backward_op() {
766            op.backward();
767        }
768
769        assert!(a.grad().is_some());
770        assert!(b.grad().is_none());
771    }
772
773    #[test]
774    fn test_matmul_b_requires_grad_only() {
775        let a = Tensor::new(Array1::from(vec![1.0, 2.0, 3.0, 4.0]), false);
776        let b = Tensor::new(Array1::from(vec![5.0, 6.0, 7.0, 8.0]), true);
777        let c = matmul(&a, &b, 2, 2, 2);
778        assert!(c.requires_grad());
779
780        c.set_grad(Array1::from(vec![1.0, 1.0, 1.0, 1.0]));
781        if let Some(op) = c.backward_op() {
782            op.backward();
783        }
784
785        assert!(a.grad().is_none());
786        assert!(b.grad().is_some());
787    }
788
789    #[test]
790    #[should_panic(expected = "Pre-condition violated")]
791    fn test_matmul_size_mismatch_a() {
792        let a = Tensor::new(Array1::from(vec![1.0, 2.0, 3.0]), false);
793        let b = Tensor::new(Array1::from(vec![5.0, 6.0, 7.0, 8.0]), false);
794        let _ = matmul(&a, &b, 2, 2, 2);
795    }
796
797    #[test]
798    #[should_panic(expected = "Pre-condition violated")]
799    fn test_matmul_size_mismatch_b() {
800        let a = Tensor::new(Array1::from(vec![1.0, 2.0, 3.0, 4.0]), false);
801        let b = Tensor::new(Array1::from(vec![5.0, 6.0, 7.0]), false);
802        let _ = matmul(&a, &b, 2, 2, 2);
803    }
804
805    #[test]
806    fn test_transpose_double_transpose() {
807        // Transpose twice should give original
808        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
809        let t1 = transpose(&data, 2, 3);
810        let t2 = transpose(&t1, 3, 2);
811        assert_eq!(data, t2);
812    }
813
814    // =========================================================================
815    // FALSIFY-MM: matmul-kernel-v1.yaml contract (entrenar autograd matmul)
816    //
817    // Five-Whys (PMAT-354):
818    //   Why 1: entrenar had 10 matmul tests but zero FALSIFY-MM-* tests
819    //   Why 2: unit tests verify 2x2 cases and backward, not invariants
820    //   Why 3: no mapping from matmul-kernel-v1.yaml to entrenar test names
821    //   Why 4: entrenar predates the provable-contracts YAML convention
822    //   Why 5: matmul was "obviously correct" (textbook GEMM + autograd)
823    //
824    // References:
825    //   - provable-contracts/contracts/matmul-kernel-v1.yaml
826    // =========================================================================
827
828    /// FALSIFY-MM-001e: Shape correctness — output is [m, n]
829    #[test]
830    fn falsify_mm_001e_shape_correctness() {
831        for (m, k, n) in [(2, 3, 4), (1, 5, 1), (4, 4, 4), (3, 1, 2)] {
832            let result = matmul_compute(&vec![1.0; m * k], &vec![1.0; k * n], m, k, n);
833            assert_eq!(
834                result.len(),
835                m * n,
836                "FALSIFIED MM-001e: output len = {}, expected {} for ({m}x{k}) @ ({k}x{n})",
837                result.len(),
838                m * n
839            );
840        }
841    }
842
843    /// FALSIFY-MM-005e: Identity matrix — A @ I = A
844    #[test]
845    fn falsify_mm_005e_identity_matrix() {
846        let m = 3;
847        let k = 4;
848        let a: Vec<f32> = (0..m * k).map(|i| (i as f32 + 1.0) * 0.5).collect();
849        let mut identity = vec![0.0; k * k];
850        for i in 0..k {
851            identity[i * k + i] = 1.0;
852        }
853        let result = matmul_compute(&a, &identity, m, k, k);
854        for (i, (&got, &exp)) in result.iter().zip(a.iter()).enumerate() {
855            assert!(
856                (got - exp).abs() < 1e-5,
857                "FALSIFIED MM-005e: (A@I)[{i}] = {got}, expected {exp}"
858            );
859        }
860    }
861
862    /// FALSIFY-MM-002e: Numerical accuracy against reference
863    #[test]
864    fn falsify_mm_002e_numerical_accuracy() {
865        // 2x3 @ 3x2 known result
866        let a = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
867        let b = vec![7.0, 8.0, 9.0, 10.0, 11.0, 12.0];
868        let result = matmul_compute(&a, &b, 2, 3, 2);
869        let expected = [58.0, 64.0, 139.0, 154.0];
870        for (i, (&got, &exp)) in result.iter().zip(expected.iter()).enumerate() {
871            assert!(
872                (got - exp).abs() < 1e-4,
873                "FALSIFIED MM-002e: result[{i}] = {got}, expected {exp}"
874            );
875        }
876    }
877
878    // =========================================================================
879    // matmul_nt tests (KAIZEN-011)
880    // =========================================================================
881
882    #[test]
883    fn test_matmul_nt_compute_2x2() {
884        // A = [[1, 2], [3, 4]] (2x2)
885        // B = [[5, 6], [7, 8]] (2x2)
886        // C = A @ B^T
887        // B^T = [[5, 7], [6, 8]]
888        // C = [[1*5+2*6, 1*7+2*8], [3*5+4*6, 3*7+4*8]]
889        //   = [[17, 23], [39, 53]]
890        let a = vec![1.0, 2.0, 3.0, 4.0];
891        let b = vec![5.0, 6.0, 7.0, 8.0];
892        let c = matmul_nt_compute(&a, &b, 2, 2, 2);
893        assert_eq!(c, vec![17.0, 23.0, 39.0, 53.0]);
894    }
895
896    #[test]
897    fn test_matmul_nt_compute_2x3_4x3() {
898        // A = [[1,2,3],[4,5,6]] (2x3), B = [[1,0,0],[0,1,0],[0,0,1],[1,1,1]] (4x3)
899        // C = A @ B^T (2x4)
900        // B^T cols = rows of B
901        // C(0,0) = 1*1+2*0+3*0 = 1
902        // C(0,1) = 1*0+2*1+3*0 = 2
903        // C(0,2) = 1*0+2*0+3*1 = 3
904        // C(0,3) = 1*1+2*1+3*1 = 6
905        // C(1,0) = 4*1+5*0+6*0 = 4
906        // C(1,1) = 4*0+5*1+6*0 = 5
907        // C(1,2) = 4*0+5*0+6*1 = 6
908        // C(1,3) = 4*1+5*1+6*1 = 15
909        let a = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
910        let b = vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0];
911        let c = matmul_nt_compute(&a, &b, 2, 3, 4);
912        assert_eq!(c, vec![1.0, 2.0, 3.0, 6.0, 4.0, 5.0, 6.0, 15.0]);
913    }
914
915    #[test]
916    fn test_matmul_nt_equivalence_to_transpose_matmul() {
917        // Verify: matmul_nt(A, B, m, k, n) == matmul(A, B^T, m, k, n)
918        let a = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; // 2x3
919        let b = vec![7.0, 8.0, 9.0, 10.0, 11.0, 12.0]; // 2x3
920        let b_t = transpose(&b, 2, 3); // 3x2
921
922        let c_nt = matmul_nt_compute(&a, &b, 2, 3, 2);
923        let c_ref = matmul_compute(&a, &b_t, 2, 3, 2);
924
925        for (i, (&got, &exp)) in c_nt.iter().zip(c_ref.iter()).enumerate() {
926            assert!(
927                (got - exp).abs() < 1e-5,
928                "matmul_nt[{i}] = {got}, matmul(A, B^T)[{i}] = {exp}"
929            );
930        }
931    }
932
933    #[test]
934    fn test_matmul_nt_backward_grad_flows_to_b() {
935        // KAIZEN-011: Verify gradients flow to the ORIGINAL B tensor (not a copy)
936        let a = Tensor::new(Array1::from(vec![1.0, 2.0, 3.0, 4.0]), false); // 2x2
937        let b = Tensor::new(Array1::from(vec![5.0, 6.0, 7.0, 8.0]), true); // 2x2, requires_grad
938
939        let c = matmul_nt(&a, &b, 2, 2, 2);
940        assert!(c.requires_grad());
941
942        c.set_grad(Array1::from(vec![1.0, 1.0, 1.0, 1.0]));
943        if let Some(op) = c.backward_op() {
944            op.backward();
945        }
946
947        // B must have received gradient
948        let b_grad = b.grad().expect("KAIZEN-011: B must receive gradient from matmul_nt");
949
950        // grad_B = grad_C^T @ A = [[1,1],[1,1]]^T @ [[1,2],[3,4]]
951        // = [[1,1],[1,1]] @ [[1,2],[3,4]] = [[4,6],[4,6]]
952        let expected_grad_b = vec![4.0, 6.0, 4.0, 6.0];
953        for (i, (&got, &exp)) in b_grad.iter().zip(expected_grad_b.iter()).enumerate() {
954            assert!((got - exp).abs() < 1e-4, "KAIZEN-011: grad_B[{i}] = {got}, expected {exp}");
955        }
956    }
957
958    #[test]
959    fn test_matmul_nt_backward_grad_flows_to_a() {
960        let a = Tensor::new(Array1::from(vec![1.0, 2.0, 3.0, 4.0]), true); // 2x2
961        let b = Tensor::new(Array1::from(vec![5.0, 6.0, 7.0, 8.0]), false); // 2x2
962
963        let c = matmul_nt(&a, &b, 2, 2, 2);
964        c.set_grad(Array1::from(vec![1.0, 1.0, 1.0, 1.0]));
965        if let Some(op) = c.backward_op() {
966            op.backward();
967        }
968
969        let a_grad = a.grad().expect("A must receive gradient");
970
971        // grad_A = grad_C @ B = [[1,1],[1,1]] @ [[5,6],[7,8]] = [[12,14],[12,14]]
972        let expected_grad_a = vec![12.0, 14.0, 12.0, 14.0];
973        for (i, (&got, &exp)) in a_grad.iter().zip(expected_grad_a.iter()).enumerate() {
974            assert!((got - exp).abs() < 1e-4, "grad_A[{i}] = {got}, expected {exp}");
975        }
976    }
977
978    mod mm_proptest_falsify {
979        use super::*;
980        use proptest::prelude::*;
981
982        // FALSIFY-MM-001e-prop: Shape correctness for random dimensions
983        proptest! {
984            #![proptest_config(ProptestConfig::with_cases(100))]
985
986            #[test]
987            fn falsify_mm_001e_prop_shape(
988                m in 1..=8usize,
989                k in 1..=8usize,
990                n in 1..=8usize,
991            ) {
992                let result = matmul_compute(&vec![1.0; m * k], &vec![1.0; k * n], m, k, n);
993                prop_assert_eq!(result.len(), m * n);
994            }
995        }
996
997        // FALSIFY-MM-005e-prop: Identity matrix for random dimensions
998        proptest! {
999            #![proptest_config(ProptestConfig::with_cases(50))]
1000
1001            #[test]
1002            fn falsify_mm_005e_prop_identity(
1003                m in 1..=6usize,
1004                k in 1..=6usize,
1005                seed in 0..500u32,
1006            ) {
1007                let a: Vec<f32> = (0..m * k)
1008                    .map(|i| ((i as f32 + seed as f32) * 0.37).sin())
1009                    .collect();
1010                let mut identity = vec![0.0; k * k];
1011                for i in 0..k {
1012                    identity[i * k + i] = 1.0;
1013                }
1014                let result = matmul_compute(&a, &identity, m, k, k);
1015                for (i, (&got, &exp)) in result.iter().zip(a.iter()).enumerate() {
1016                    prop_assert!(
1017                        (got - exp).abs() < 1e-4,
1018                        "FALSIFIED MM-005e-prop: (A@I)[{}] = {}, expected {}",
1019                        i, got, exp
1020                    );
1021                }
1022            }
1023        }
1024
1025        // FALSIFY-MM-NT-001: matmul_nt equivalence to manual transpose
1026        proptest! {
1027            #![proptest_config(ProptestConfig::with_cases(50))]
1028
1029            #[test]
1030            fn falsify_mm_nt_equivalence(
1031                m in 1..=6usize,
1032                k in 1..=6usize,
1033                n in 1..=6usize,
1034                seed in 0..500u32,
1035            ) {
1036                let a: Vec<f32> = (0..m * k)
1037                    .map(|i| ((i as f32 + seed as f32) * 0.31).sin())
1038                    .collect();
1039                let b: Vec<f32> = (0..n * k)
1040                    .map(|i| ((i as f32 + seed as f32 + 100.0) * 0.47).cos())
1041                    .collect();
1042
1043                let c_nt = matmul_nt_compute(&a, &b, m, k, n);
1044                let b_t = transpose(&b, n, k);
1045                let c_ref = matmul_compute(&a, &b_t, m, k, n);
1046
1047                for (i, (&got, &exp)) in c_nt.iter().zip(c_ref.iter()).enumerate() {
1048                    prop_assert!(
1049                        (got - exp).abs() < 1e-3,
1050                        "matmul_nt[{}] = {}, expected {}",
1051                        i, got, exp
1052                    );
1053                }
1054            }
1055        }
1056    }
1057
1058    /// KAIZEN-018: Verify transpose_tracked backward propagates gradient
1059    /// to the original tensor through the inverse transpose.
1060    #[test]
1061    fn test_transpose_tracked_backward_gradient_flow() {
1062        // Original tensor A: 2×3 matrix [1,2,3,4,5,6], requires_grad=true
1063        let a = Tensor::from_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], true);
1064
1065        // Tracked transpose: A^T is 3×2
1066        let a_t = transpose_tracked(&a, 2, 3);
1067        assert_eq!(a_t.len(), 6);
1068
1069        // Verify transposed data is correct
1070        let at_data = a_t.data();
1071        let at_slice = at_data.as_slice().expect("contiguous");
1072        assert_eq!(at_slice, &[1.0, 4.0, 2.0, 5.0, 3.0, 6.0]);
1073
1074        // Set gradient on transposed tensor (as if backward computed it)
1075        // Gradient shape matches A^T: 3×2
1076        a_t.set_grad(Array1::from(vec![10.0, 40.0, 20.0, 50.0, 30.0, 60.0]));
1077
1078        // Trigger backward: should transpose grad back (3×2 → 2×3) and accumulate on a
1079        if let Some(op) = a_t.backward_op() {
1080            op.backward();
1081        }
1082
1083        // Check that the original tensor has the correctly transposed gradient
1084        let grad = a.grad().expect("original tensor should have gradient");
1085        let grad_slice = grad.as_slice().expect("contiguous");
1086        // Transpose of 3×2 [10,40,20,50,30,60] = 2×3 [10,20,30,40,50,60]
1087        assert_eq!(grad_slice, &[10.0, 20.0, 30.0, 40.0, 50.0, 60.0]);
1088    }
1089
1090    /// KAIZEN-018: Verify that transpose_tracked + matmul backward flows
1091    /// gradient to the original (non-transposed) LoRA parameter.
1092    #[test]
1093    fn test_transpose_tracked_lora_gradient_chain() {
1094        // Simulate LoRA forward: y = x @ A^T where A is (rank=2, d_in=3)
1095        let lora_a = Tensor::from_vec(vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6], true);
1096        let x = Tensor::from_vec(vec![1.0, 2.0, 3.0], true); // 1×3 input
1097
1098        // Tracked transpose: A^T is (d_in=3, rank=2)
1099        let lora_a_t = transpose_tracked(&lora_a, 2, 3);
1100
1101        // Matmul: (1, 3) @ (3, 2) = (1, 2)
1102        let result = matmul(&x, &lora_a_t, 1, 3, 2);
1103        assert_eq!(result.len(), 2);
1104
1105        // Set gradient on result (as if loss backward computed it)
1106        result.set_grad(Array1::from(vec![1.0, 1.0]));
1107
1108        // Trigger backward chain: result → matmul backward → lora_a_t → transpose backward → lora_a
1109        if let Some(op) = result.backward_op() {
1110            op.backward();
1111        }
1112
1113        // The original lora_a should now have a gradient
1114        let grad = lora_a.grad().expect("LoRA A should receive gradient via transpose_tracked");
1115        assert_eq!(grad.len(), 6);
1116
1117        // Verify gradient is finite and non-zero
1118        for (i, &val) in grad.as_slice().expect("contiguous").iter().enumerate() {
1119            assert!(val.is_finite(), "Gradient element {i} is not finite: {val}");
1120        }
1121        let grad_sum: f32 = grad.iter().sum();
1122        assert!(grad_sum.abs() > 1e-6, "Gradient should be non-zero, got sum={grad_sum}");
1123    }
1124}