Skip to main content

oxicuda_backend/
cpu.rs

1//! A genuinely-working pure-Rust **CPU reference backend**.
2//!
3//! Unlike a [`NullBackend`](crate::null::NullBackend) (which refuses every
4//! op), this backend really executes on the host: it owns host buffers,
5//! performs real GEMM / convolution / attention / reduction / element-wise
6//! math, and is therefore the natural fallback when no GPU backend is
7//! available, *and* the numerical reference that cross-backend conformance
8//! tests compare against.
9//!
10//! # Memory model
11//!
12//! "Device" memory is modelled as host `Vec<u8>` allocations stored in an
13//! internal table, keyed by a synthetic non-null `u64` "pointer". This lets
14//! the CPU backend satisfy the exact same `alloc` / `copy_htod` /
15//! `copy_dtoh` / `free` contract as a real GPU backend while running
16//! entirely on the host.
17//!
18//! # Element types
19//!
20//! Following the [`ComputeBackend`](crate::ComputeBackend) trait contract,
21//! [`gemm`](ComputeBackend::gemm) / [`batched_gemm`](ComputeBackend::batched_gemm)
22//! operate on **column-major `f64`** matrices, while
23//! [`conv2d_forward`](ComputeBackend::conv2d_forward),
24//! [`attention`](ComputeBackend::attention),
25//! [`reduce`](ComputeBackend::reduce),
26//! [`unary`](ComputeBackend::unary),
27//! [`binary`](ComputeBackend::binary), and
28//! [`softmax`](ComputeBackend::softmax) operate on **`f32`** buffers (the
29//! 4-byte element size assumed by `batched_gemm`'s default stride math).
30
31use std::collections::HashMap;
32use std::sync::Mutex;
33
34use crate::ComputeBackend;
35use crate::capabilities::{Capabilities, DeviceInfo, MemoryKind};
36use crate::error::{BackendError, BackendResult};
37use crate::ops::{BackendTranspose, BinaryOp, MixedPrecision, ReduceOp, UnaryOp};
38use crate::precision::{round_to_bf16, round_to_f16};
39
40/// Pure-Rust host backend. Always available; never touches a GPU.
41#[derive(Debug)]
42pub struct CpuBackend {
43    initialized: bool,
44    /// Maps synthetic device pointers to host allocations.
45    allocations: Mutex<HashMap<u64, Vec<u8>>>,
46    /// Monotonic source of synthetic pointers; never reuses an address so
47    /// a use-after-free is reliably detected as an unknown pointer.
48    next_ptr: Mutex<u64>,
49}
50
51impl Default for CpuBackend {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57impl CpuBackend {
58    /// Create a new, uninitialized CPU backend.
59    #[must_use]
60    pub fn new() -> Self {
61        Self {
62            initialized: false,
63            allocations: Mutex::new(HashMap::new()),
64            // Start well above 0 so the synthetic pointers never collide
65            // with the conventional null pointer.
66            next_ptr: Mutex::new(0x1000),
67        }
68    }
69
70    /// Number of currently-live allocations (test/diagnostic helper).
71    #[must_use]
72    pub fn live_allocations(&self) -> usize {
73        self.allocations.lock().map(|t| t.len()).unwrap_or_default()
74    }
75
76    /// Read `len` `f32` values starting at `ptr`, returning an owned `Vec`.
77    fn read_f32(&self, ptr: u64, len: usize) -> BackendResult<Vec<f32>> {
78        let table = self
79            .allocations
80            .lock()
81            .map_err(|_| BackendError::DeviceError("allocation table poisoned".into()))?;
82        let buf = table.get(&ptr).ok_or_else(|| {
83            BackendError::InvalidArgument(format!("unknown device pointer {ptr:#x}"))
84        })?;
85        let need = len * 4;
86        if buf.len() < need {
87            return Err(BackendError::InvalidArgument(format!(
88                "buffer at {ptr:#x} holds {} bytes, need {need}",
89                buf.len()
90            )));
91        }
92        let mut out = Vec::with_capacity(len);
93        for chunk in buf[..need].chunks_exact(4) {
94            out.push(f32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
95        }
96        Ok(out)
97    }
98
99    /// Write `data` as `f32` little/native-endian bytes into the buffer at `ptr`.
100    fn write_f32(&self, ptr: u64, data: &[f32]) -> BackendResult<()> {
101        let mut table = self
102            .allocations
103            .lock()
104            .map_err(|_| BackendError::DeviceError("allocation table poisoned".into()))?;
105        let buf = table.get_mut(&ptr).ok_or_else(|| {
106            BackendError::InvalidArgument(format!("unknown device pointer {ptr:#x}"))
107        })?;
108        let need = data.len() * 4;
109        if buf.len() < need {
110            return Err(BackendError::InvalidArgument(format!(
111                "buffer at {ptr:#x} holds {} bytes, need {need}",
112                buf.len()
113            )));
114        }
115        for (slot, &v) in buf[..need].chunks_exact_mut(4).zip(data.iter()) {
116            slot.copy_from_slice(&v.to_ne_bytes());
117        }
118        Ok(())
119    }
120
121    /// Read `len` `f64` values starting at `ptr`.
122    fn read_f64(&self, ptr: u64, len: usize) -> BackendResult<Vec<f64>> {
123        let table = self
124            .allocations
125            .lock()
126            .map_err(|_| BackendError::DeviceError("allocation table poisoned".into()))?;
127        let buf = table.get(&ptr).ok_or_else(|| {
128            BackendError::InvalidArgument(format!("unknown device pointer {ptr:#x}"))
129        })?;
130        let need = len * 8;
131        if buf.len() < need {
132            return Err(BackendError::InvalidArgument(format!(
133                "buffer at {ptr:#x} holds {} bytes, need {need}",
134                buf.len()
135            )));
136        }
137        let mut out = Vec::with_capacity(len);
138        for chunk in buf[..need].chunks_exact(8) {
139            let mut b = [0u8; 8];
140            b.copy_from_slice(chunk);
141            out.push(f64::from_ne_bytes(b));
142        }
143        Ok(out)
144    }
145
146    /// Write `data` as `f64` native-endian bytes into the buffer at `ptr`.
147    fn write_f64(&self, ptr: u64, data: &[f64]) -> BackendResult<()> {
148        let mut table = self
149            .allocations
150            .lock()
151            .map_err(|_| BackendError::DeviceError("allocation table poisoned".into()))?;
152        let buf = table.get_mut(&ptr).ok_or_else(|| {
153            BackendError::InvalidArgument(format!("unknown device pointer {ptr:#x}"))
154        })?;
155        let need = data.len() * 8;
156        if buf.len() < need {
157            return Err(BackendError::InvalidArgument(format!(
158                "buffer at {ptr:#x} holds {} bytes, need {need}",
159                buf.len()
160            )));
161        }
162        for (slot, &v) in buf[..need].chunks_exact_mut(8).zip(data.iter()) {
163            slot.copy_from_slice(&v.to_ne_bytes());
164        }
165        Ok(())
166    }
167}
168
169/// Index into a column-major matrix with leading dimension `ld`.
170#[inline]
171const fn col_major(row: usize, col: usize, ld: usize) -> usize {
172    col * ld + row
173}
174
175/// Fetch `op(M)[row, col]` for a column-major source matrix `m` with leading
176/// dimension `ld`. For [`BackendTranspose::ConjTrans`] this matches `Trans`
177/// because the reference operates on real `f64`.
178#[inline]
179fn at(m: &[f64], trans: BackendTranspose, row: usize, col: usize, ld: usize) -> f64 {
180    match trans {
181        BackendTranspose::NoTrans => m[col_major(row, col, ld)],
182        BackendTranspose::Trans | BackendTranspose::ConjTrans => m[col_major(col, row, ld)],
183    }
184}
185
186/// `f32` analogue of [`at`] for the column-major mixed-precision GEMM
187/// reference. `ConjTrans` matches `Trans` because the reference is real.
188#[inline]
189fn at_f32(m: &[f32], trans: BackendTranspose, row: usize, col: usize, ld: usize) -> f32 {
190    match trans {
191        BackendTranspose::NoTrans => m[col_major(row, col, ld)],
192        BackendTranspose::Trans | BackendTranspose::ConjTrans => m[col_major(col, row, ld)],
193    }
194}
195
196/// Round an `f32` to the chosen reduced-precision *storage* format, returning
197/// the `f32` value the hardware would hold. This is the only place the CPU
198/// mixed-precision GEMM differs from a full-`f32` GEMM: the operands lose
199/// precision to 16-bit storage, but the accumulation stays in `f32`.
200#[inline]
201fn round_store(prec: MixedPrecision, x: f32) -> f32 {
202    match prec {
203        MixedPrecision::F16 => round_to_f16(x),
204        MixedPrecision::Bf16 => round_to_bf16(x),
205    }
206}
207
208impl ComputeBackend for CpuBackend {
209    fn name(&self) -> &str {
210        "cpu"
211    }
212
213    fn init(&mut self) -> BackendResult<()> {
214        self.initialized = true;
215        Ok(())
216    }
217
218    fn is_initialized(&self) -> bool {
219        self.initialized
220    }
221
222    fn capabilities(&self) -> Capabilities {
223        Capabilities::cpu()
224    }
225
226    fn available_devices(&self) -> BackendResult<Vec<DeviceInfo>> {
227        // Model a single host "device" sized to the process's available
228        // address space heuristically (we do not probe physical RAM here;
229        // a fixed conservative figure keeps the value deterministic).
230        Ok(vec![DeviceInfo {
231            ordinal: 0,
232            name: "CPU (reference)".to_string(),
233            compute_capability: (0, 0),
234            total_memory_bytes: 0,
235            memory_kind: MemoryKind::Host,
236            capabilities: Capabilities::cpu(),
237        }])
238    }
239
240    fn gemm(
241        &self,
242        trans_a: BackendTranspose,
243        trans_b: BackendTranspose,
244        m: usize,
245        n: usize,
246        k: usize,
247        alpha: f64,
248        a_ptr: u64,
249        lda: usize,
250        b_ptr: u64,
251        ldb: usize,
252        beta: f64,
253        c_ptr: u64,
254        ldc: usize,
255    ) -> BackendResult<()> {
256        if m == 0 || n == 0 {
257            return Ok(());
258        }
259        // Determine how many source elements each operand spans so we can
260        // load exactly the right amount from the (possibly oversized) buffer.
261        let a_rows = if trans_a == BackendTranspose::NoTrans {
262            m
263        } else {
264            k
265        };
266        let a_cols = if trans_a == BackendTranspose::NoTrans {
267            k
268        } else {
269            m
270        };
271        let b_rows = if trans_b == BackendTranspose::NoTrans {
272            k
273        } else {
274            n
275        };
276        let b_cols = if trans_b == BackendTranspose::NoTrans {
277            n
278        } else {
279            k
280        };
281        if lda < a_rows || ldb < b_rows || ldc < m {
282            return Err(BackendError::InvalidArgument(
283                "leading dimension smaller than matrix extent".into(),
284            ));
285        }
286        let a = if k == 0 {
287            Vec::new()
288        } else {
289            self.read_f64(a_ptr, lda * a_cols)?
290        };
291        let b = if k == 0 {
292            Vec::new()
293        } else {
294            self.read_f64(b_ptr, ldb * b_cols)?
295        };
296        let mut c = self.read_f64(c_ptr, ldc * n)?;
297
298        for j in 0..n {
299            for i in 0..m {
300                let mut acc = 0.0f64;
301                for p in 0..k {
302                    acc += at(&a, trans_a, i, p, lda) * at(&b, trans_b, p, j, ldb);
303                }
304                let dst = &mut c[col_major(i, j, ldc)];
305                *dst = alpha * acc + beta * *dst;
306            }
307        }
308        self.write_f64(c_ptr, &c)
309    }
310
311    fn conv2d_forward(
312        &self,
313        input_ptr: u64,
314        input_shape: &[usize],
315        filter_ptr: u64,
316        filter_shape: &[usize],
317        output_ptr: u64,
318        output_shape: &[usize],
319        stride: &[usize],
320        padding: &[usize],
321    ) -> BackendResult<()> {
322        if input_shape.len() != 4 || filter_shape.len() != 4 || output_shape.len() != 4 {
323            return Err(BackendError::InvalidArgument(
324                "conv2d expects 4-D NCHW shapes".into(),
325            ));
326        }
327        if stride.len() != 2 || padding.len() != 2 {
328            return Err(BackendError::InvalidArgument(
329                "conv2d expects 2-element stride and padding".into(),
330            ));
331        }
332        let (n, c_in, h, w) = (
333            input_shape[0],
334            input_shape[1],
335            input_shape[2],
336            input_shape[3],
337        );
338        let (k_out, c_f, fh, fw) = (
339            filter_shape[0],
340            filter_shape[1],
341            filter_shape[2],
342            filter_shape[3],
343        );
344        let (on, ok, oh, ow) = (
345            output_shape[0],
346            output_shape[1],
347            output_shape[2],
348            output_shape[3],
349        );
350        if c_f != c_in || k_out != ok || on != n {
351            return Err(BackendError::InvalidArgument(
352                "conv2d shape mismatch between input/filter/output".into(),
353            ));
354        }
355        let (sh, sw) = (stride[0], stride[1]);
356        let (ph, pw) = (padding[0], padding[1]);
357        // Validate the output spatial extent against the standard formula.
358        let exp_oh = (h + 2 * ph).saturating_sub(fh) / sh.max(1) + 1;
359        let exp_ow = (w + 2 * pw).saturating_sub(fw) / sw.max(1) + 1;
360        if oh != exp_oh || ow != exp_ow {
361            return Err(BackendError::InvalidArgument(format!(
362                "conv2d output spatial size {oh}x{ow} != expected {exp_oh}x{exp_ow}"
363            )));
364        }
365
366        let input = self.read_f32(input_ptr, n * c_in * h * w)?;
367        let filter = self.read_f32(filter_ptr, k_out * c_in * fh * fw)?;
368        let mut output = vec![0.0f32; n * k_out * oh * ow];
369
370        let in_idx =
371            |ni: usize, ci: usize, hi: usize, wi: usize| ((ni * c_in + ci) * h + hi) * w + wi;
372        let f_idx =
373            |ko: usize, ci: usize, fhi: usize, fwi: usize| ((ko * c_in + ci) * fh + fhi) * fw + fwi;
374        let out_idx = |ni: usize, ko: usize, ohi: usize, owi: usize| {
375            ((ni * k_out + ko) * oh + ohi) * ow + owi
376        };
377
378        for ni in 0..n {
379            for ko in 0..k_out {
380                for ohi in 0..oh {
381                    for owi in 0..ow {
382                        let mut acc = 0.0f32;
383                        for ci in 0..c_in {
384                            for fhi in 0..fh {
385                                // Signed source row, accounting for padding.
386                                let src_h = ohi * sh + fhi;
387                                if src_h < ph || src_h >= h + ph {
388                                    continue;
389                                }
390                                let ih = src_h - ph;
391                                for fwi in 0..fw {
392                                    let src_w = owi * sw + fwi;
393                                    if src_w < pw || src_w >= w + pw {
394                                        continue;
395                                    }
396                                    let iw = src_w - pw;
397                                    acc += input[in_idx(ni, ci, ih, iw)]
398                                        * filter[f_idx(ko, ci, fhi, fwi)];
399                                }
400                            }
401                        }
402                        output[out_idx(ni, ko, ohi, owi)] = acc;
403                    }
404                }
405            }
406        }
407        self.write_f32(output_ptr, &output)
408    }
409
410    fn gemm_mixed_precision(
411        &self,
412        prec: MixedPrecision,
413        trans_a: BackendTranspose,
414        trans_b: BackendTranspose,
415        m: usize,
416        n: usize,
417        k: usize,
418        alpha: f32,
419        a_ptr: u64,
420        lda: usize,
421        b_ptr: u64,
422        ldb: usize,
423        beta: f32,
424        c_ptr: u64,
425        ldc: usize,
426    ) -> BackendResult<()> {
427        if m == 0 || n == 0 {
428            return Ok(());
429        }
430        let a_rows = if trans_a == BackendTranspose::NoTrans {
431            m
432        } else {
433            k
434        };
435        let a_cols = if trans_a == BackendTranspose::NoTrans {
436            k
437        } else {
438            m
439        };
440        let b_rows = if trans_b == BackendTranspose::NoTrans {
441            k
442        } else {
443            n
444        };
445        let b_cols = if trans_b == BackendTranspose::NoTrans {
446            n
447        } else {
448            k
449        };
450        if lda < a_rows || ldb < b_rows || ldc < m {
451            return Err(BackendError::InvalidArgument(
452                "leading dimension smaller than matrix extent".into(),
453            ));
454        }
455        // Load the f32 operands, then round each element to the reduced-
456        // precision *storage* format. This reproduces exactly what a GPU
457        // does: it stores A/B in f16/bf16 before the GEMM. We round the whole
458        // operand once so every dot product reuses the same stored values.
459        let a_raw = if k == 0 {
460            Vec::new()
461        } else {
462            self.read_f32(a_ptr, lda * a_cols)?
463        };
464        let b_raw = if k == 0 {
465            Vec::new()
466        } else {
467            self.read_f32(b_ptr, ldb * b_cols)?
468        };
469        let a: Vec<f32> = a_raw.iter().map(|&v| round_store(prec, v)).collect();
470        let b: Vec<f32> = b_raw.iter().map(|&v| round_store(prec, v)).collect();
471        let mut c = self.read_f32(c_ptr, ldc * n)?;
472
473        for j in 0..n {
474            for i in 0..m {
475                // Accumulate the dot product in f32 (never in f16/bf16) — this
476                // is the whole point of mixed precision: long reductions keep
477                // f32 precision even though the inputs are 16-bit.
478                let mut acc = 0.0f32;
479                for p in 0..k {
480                    acc += at_f32(&a, trans_a, i, p, lda) * at_f32(&b, trans_b, p, j, ldb);
481                }
482                let dst = &mut c[col_major(i, j, ldc)];
483                *dst = alpha * acc + beta * *dst;
484            }
485        }
486        self.write_f32(c_ptr, &c)
487    }
488
489    fn conv2d_backward_data(
490        &self,
491        grad_output_ptr: u64,
492        grad_output_shape: &[usize],
493        filter_ptr: u64,
494        filter_shape: &[usize],
495        grad_input_ptr: u64,
496        grad_input_shape: &[usize],
497        stride: &[usize],
498        padding: &[usize],
499    ) -> BackendResult<()> {
500        if grad_output_shape.len() != 4 || filter_shape.len() != 4 || grad_input_shape.len() != 4 {
501            return Err(BackendError::InvalidArgument(
502                "conv2d_backward_data expects 4-D NCHW shapes".into(),
503            ));
504        }
505        if stride.len() != 2 || padding.len() != 2 {
506            return Err(BackendError::InvalidArgument(
507                "conv2d_backward_data expects 2-element stride and padding".into(),
508            ));
509        }
510        let (n, c_in, h, w) = (
511            grad_input_shape[0],
512            grad_input_shape[1],
513            grad_input_shape[2],
514            grad_input_shape[3],
515        );
516        let (k_out, c_f, fh, fw) = (
517            filter_shape[0],
518            filter_shape[1],
519            filter_shape[2],
520            filter_shape[3],
521        );
522        let (gn, gk, oh, ow) = (
523            grad_output_shape[0],
524            grad_output_shape[1],
525            grad_output_shape[2],
526            grad_output_shape[3],
527        );
528        if c_f != c_in || gk != k_out || gn != n {
529            return Err(BackendError::InvalidArgument(
530                "conv2d_backward_data shape mismatch between grad_output/filter/grad_input".into(),
531            ));
532        }
533        let (sh, sw) = (stride[0], stride[1]);
534        let (ph, pw) = (padding[0], padding[1]);
535        // The grad_output spatial extent must be the forward output extent for
536        // the claimed input/filter/stride/padding.
537        let exp_oh = (h + 2 * ph).saturating_sub(fh) / sh.max(1) + 1;
538        let exp_ow = (w + 2 * pw).saturating_sub(fw) / sw.max(1) + 1;
539        if oh != exp_oh || ow != exp_ow {
540            return Err(BackendError::InvalidArgument(format!(
541                "conv2d_backward_data grad_output spatial size {oh}x{ow} != expected {exp_oh}x{exp_ow}"
542            )));
543        }
544
545        let grad_output = self.read_f32(grad_output_ptr, n * k_out * oh * ow)?;
546        let filter = self.read_f32(filter_ptr, k_out * c_in * fh * fw)?;
547        let mut grad_input = vec![0.0f32; n * c_in * h * w];
548
549        let in_idx =
550            |ni: usize, ci: usize, hi: usize, wi: usize| ((ni * c_in + ci) * h + hi) * w + wi;
551        let f_idx =
552            |ko: usize, ci: usize, fhi: usize, fwi: usize| ((ko * c_in + ci) * fh + fhi) * fw + fwi;
553        let go_idx = |ni: usize, ko: usize, ohi: usize, owi: usize| {
554            ((ni * k_out + ko) * oh + ohi) * ow + owi
555        };
556
557        // grad_input is the transpose of the forward correlation: scatter each
558        // grad_output element back onto the input positions that produced it.
559        // For forward `out[oh,ow] += in[oh*sh+fh-ph, ow*sw+fw-pw] * w[fh,fw]`,
560        // the data gradient is `grad_in[ih,iw] += grad_out[oh,ow] * w[fh,fw]`
561        // over every (oh,ow,fh,fw) that maps onto (ih,iw). This is exactly the
562        // full convolution of grad_output with the flipped filter.
563        for ni in 0..n {
564            for ko in 0..k_out {
565                for ohi in 0..oh {
566                    for owi in 0..ow {
567                        let g = grad_output[go_idx(ni, ko, ohi, owi)];
568                        if g == 0.0 {
569                            continue;
570                        }
571                        for ci in 0..c_in {
572                            for fhi in 0..fh {
573                                let src_h = ohi * sh + fhi;
574                                if src_h < ph || src_h >= h + ph {
575                                    continue;
576                                }
577                                let ih = src_h - ph;
578                                for fwi in 0..fw {
579                                    let src_w = owi * sw + fwi;
580                                    if src_w < pw || src_w >= w + pw {
581                                        continue;
582                                    }
583                                    let iw = src_w - pw;
584                                    grad_input[in_idx(ni, ci, ih, iw)] +=
585                                        g * filter[f_idx(ko, ci, fhi, fwi)];
586                                }
587                            }
588                        }
589                    }
590                }
591            }
592        }
593        self.write_f32(grad_input_ptr, &grad_input)
594    }
595
596    fn conv2d_backward_filter(
597        &self,
598        input_ptr: u64,
599        input_shape: &[usize],
600        grad_output_ptr: u64,
601        grad_output_shape: &[usize],
602        grad_filter_ptr: u64,
603        grad_filter_shape: &[usize],
604        stride: &[usize],
605        padding: &[usize],
606    ) -> BackendResult<()> {
607        if input_shape.len() != 4 || grad_output_shape.len() != 4 || grad_filter_shape.len() != 4 {
608            return Err(BackendError::InvalidArgument(
609                "conv2d_backward_filter expects 4-D NCHW shapes".into(),
610            ));
611        }
612        if stride.len() != 2 || padding.len() != 2 {
613            return Err(BackendError::InvalidArgument(
614                "conv2d_backward_filter expects 2-element stride and padding".into(),
615            ));
616        }
617        let (n, c_in, h, w) = (
618            input_shape[0],
619            input_shape[1],
620            input_shape[2],
621            input_shape[3],
622        );
623        let (k_out, c_f, fh, fw) = (
624            grad_filter_shape[0],
625            grad_filter_shape[1],
626            grad_filter_shape[2],
627            grad_filter_shape[3],
628        );
629        let (gn, gk, oh, ow) = (
630            grad_output_shape[0],
631            grad_output_shape[1],
632            grad_output_shape[2],
633            grad_output_shape[3],
634        );
635        if c_f != c_in || gk != k_out || gn != n {
636            return Err(BackendError::InvalidArgument(
637                "conv2d_backward_filter shape mismatch between input/grad_output/grad_filter"
638                    .into(),
639            ));
640        }
641        let (sh, sw) = (stride[0], stride[1]);
642        let (ph, pw) = (padding[0], padding[1]);
643        let exp_oh = (h + 2 * ph).saturating_sub(fh) / sh.max(1) + 1;
644        let exp_ow = (w + 2 * pw).saturating_sub(fw) / sw.max(1) + 1;
645        if oh != exp_oh || ow != exp_ow {
646            return Err(BackendError::InvalidArgument(format!(
647                "conv2d_backward_filter grad_output spatial size {oh}x{ow} != expected {exp_oh}x{exp_ow}"
648            )));
649        }
650
651        let input = self.read_f32(input_ptr, n * c_in * h * w)?;
652        let grad_output = self.read_f32(grad_output_ptr, n * k_out * oh * ow)?;
653        let mut grad_filter = vec![0.0f32; k_out * c_in * fh * fw];
654
655        let in_idx =
656            |ni: usize, ci: usize, hi: usize, wi: usize| ((ni * c_in + ci) * h + hi) * w + wi;
657        let f_idx =
658            |ko: usize, ci: usize, fhi: usize, fwi: usize| ((ko * c_in + ci) * fh + fhi) * fw + fwi;
659        let go_idx = |ni: usize, ko: usize, ohi: usize, owi: usize| {
660            ((ni * k_out + ko) * oh + ohi) * ow + owi
661        };
662
663        // The weight gradient is the correlation of input with grad_output:
664        // `grad_w[ko,ci,fh,fw] += sum_{n,oh,ow} in[n,ci,oh*sh+fh-ph, ...]
665        //                          * grad_out[n,ko,oh,ow]` — the exact dual of
666        // the forward accumulation, summed over the batch and all output
667        // positions.
668        for ko in 0..k_out {
669            for ci in 0..c_in {
670                for fhi in 0..fh {
671                    for fwi in 0..fw {
672                        let mut acc = 0.0f32;
673                        for ni in 0..n {
674                            for ohi in 0..oh {
675                                let src_h = ohi * sh + fhi;
676                                if src_h < ph || src_h >= h + ph {
677                                    continue;
678                                }
679                                let ih = src_h - ph;
680                                for owi in 0..ow {
681                                    let src_w = owi * sw + fwi;
682                                    if src_w < pw || src_w >= w + pw {
683                                        continue;
684                                    }
685                                    let iw = src_w - pw;
686                                    acc += input[in_idx(ni, ci, ih, iw)]
687                                        * grad_output[go_idx(ni, ko, ohi, owi)];
688                                }
689                            }
690                        }
691                        grad_filter[f_idx(ko, ci, fhi, fwi)] = acc;
692                    }
693                }
694            }
695        }
696        self.write_f32(grad_filter_ptr, &grad_filter)
697    }
698
699    fn attention(
700        &self,
701        q_ptr: u64,
702        k_ptr: u64,
703        v_ptr: u64,
704        o_ptr: u64,
705        batch: usize,
706        heads: usize,
707        seq_q: usize,
708        seq_kv: usize,
709        head_dim: usize,
710        scale: f64,
711        causal: bool,
712    ) -> BackendResult<()> {
713        let total_q = batch * heads * seq_q * head_dim;
714        let total_kv = batch * heads * seq_kv * head_dim;
715        let q = self.read_f32(q_ptr, total_q)?;
716        let k = self.read_f32(k_ptr, total_kv)?;
717        let v = self.read_f32(v_ptr, total_kv)?;
718        let mut o = vec![0.0f32; total_q];
719
720        let scale = scale as f32;
721        for b in 0..batch {
722            for h in 0..heads {
723                let base_q = ((b * heads + h) * seq_q) * head_dim;
724                let base_kv = ((b * heads + h) * seq_kv) * head_dim;
725                for iq in 0..seq_q {
726                    let q_off = base_q + iq * head_dim;
727                    // Scores for this query row over all keys.
728                    let valid = if causal {
729                        // Causal: query iq attends to keys 0..=iq (aligned to
730                        // the right edge when seq_q != seq_kv).
731                        (iq + seq_kv).saturating_sub(seq_q) + 1
732                    } else {
733                        seq_kv
734                    }
735                    .min(seq_kv);
736                    let mut scores = vec![f32::NEG_INFINITY; seq_kv];
737                    let mut max_s = f32::NEG_INFINITY;
738                    for (jk, score) in scores.iter_mut().enumerate().take(valid) {
739                        let k_off = base_kv + jk * head_dim;
740                        let mut dot = 0.0f32;
741                        for d in 0..head_dim {
742                            dot += q[q_off + d] * k[k_off + d];
743                        }
744                        let s = dot * scale;
745                        *score = s;
746                        if s > max_s {
747                            max_s = s;
748                        }
749                    }
750                    // Numerically-stable softmax over the valid prefix.
751                    let mut denom = 0.0f32;
752                    for score in scores.iter_mut().take(valid) {
753                        let e = (*score - max_s).exp();
754                        *score = e;
755                        denom += e;
756                    }
757                    let inv = if denom > 0.0 { 1.0 / denom } else { 0.0 };
758                    // Weighted sum of values.
759                    let o_off = q_off;
760                    for d in 0..head_dim {
761                        let mut acc = 0.0f32;
762                        for (jk, &score) in scores.iter().enumerate().take(valid) {
763                            let v_off = base_kv + jk * head_dim;
764                            acc += score * inv * v[v_off + d];
765                        }
766                        o[o_off + d] = acc;
767                    }
768                }
769            }
770        }
771        self.write_f32(o_ptr, &o)
772    }
773
774    fn reduce(
775        &self,
776        op: ReduceOp,
777        input_ptr: u64,
778        output_ptr: u64,
779        shape: &[usize],
780        axis: usize,
781    ) -> BackendResult<()> {
782        if axis >= shape.len() {
783            return Err(BackendError::InvalidArgument(format!(
784                "reduce axis {axis} out of bounds for {}-D shape",
785                shape.len()
786            )));
787        }
788        let total: usize = shape.iter().product();
789        let input = self.read_f32(input_ptr, total)?;
790        let axis_len = shape[axis];
791        // Sizes of the dimensions before/after the reduced axis (row-major).
792        let outer: usize = shape[..axis].iter().product();
793        let inner: usize = shape[axis + 1..].iter().product();
794        let mut out = vec![0.0f32; outer * inner];
795
796        for o in 0..outer {
797            for i in 0..inner {
798                let mut acc = match op {
799                    ReduceOp::Sum | ReduceOp::Mean => 0.0f32,
800                    ReduceOp::Max => f32::NEG_INFINITY,
801                    ReduceOp::Min => f32::INFINITY,
802                };
803                for a in 0..axis_len {
804                    let idx = (o * axis_len + a) * inner + i;
805                    let v = input[idx];
806                    acc = match op {
807                        ReduceOp::Sum | ReduceOp::Mean => acc + v,
808                        ReduceOp::Max => acc.max(v),
809                        ReduceOp::Min => acc.min(v),
810                    };
811                }
812                if op == ReduceOp::Mean && axis_len > 0 {
813                    acc /= axis_len as f32;
814                }
815                out[o * inner + i] = acc;
816            }
817        }
818        self.write_f32(output_ptr, &out)
819    }
820
821    fn unary(&self, op: UnaryOp, input_ptr: u64, output_ptr: u64, n: usize) -> BackendResult<()> {
822        let input = self.read_f32(input_ptr, n)?;
823        let mut out = Vec::with_capacity(n);
824        for &x in &input {
825            out.push(match op {
826                UnaryOp::Relu => x.max(0.0),
827                UnaryOp::Sigmoid => 1.0 / (1.0 + (-x).exp()),
828                UnaryOp::Tanh => x.tanh(),
829                UnaryOp::Exp => x.exp(),
830                UnaryOp::Log => x.ln(),
831                UnaryOp::Sqrt => x.sqrt(),
832                UnaryOp::Abs => x.abs(),
833                UnaryOp::Neg => -x,
834            });
835        }
836        self.write_f32(output_ptr, &out)
837    }
838
839    fn binary(
840        &self,
841        op: BinaryOp,
842        a_ptr: u64,
843        b_ptr: u64,
844        output_ptr: u64,
845        n: usize,
846    ) -> BackendResult<()> {
847        let a = self.read_f32(a_ptr, n)?;
848        let b = self.read_f32(b_ptr, n)?;
849        let mut out = Vec::with_capacity(n);
850        for i in 0..n {
851            out.push(match op {
852                BinaryOp::Add => a[i] + b[i],
853                BinaryOp::Sub => a[i] - b[i],
854                BinaryOp::Mul => a[i] * b[i],
855                BinaryOp::Div => a[i] / b[i],
856                BinaryOp::Max => a[i].max(b[i]),
857                BinaryOp::Min => a[i].min(b[i]),
858            });
859        }
860        self.write_f32(output_ptr, &out)
861    }
862
863    fn softmax(
864        &self,
865        input_ptr: u64,
866        output_ptr: u64,
867        shape: &[usize],
868        axis: usize,
869    ) -> BackendResult<()> {
870        if axis >= shape.len() {
871            return Err(BackendError::InvalidArgument(format!(
872                "softmax axis {axis} out of bounds for {}-D shape",
873                shape.len()
874            )));
875        }
876        let total: usize = shape.iter().product();
877        let input = self.read_f32(input_ptr, total)?;
878        let axis_len = shape[axis];
879        let outer: usize = shape[..axis].iter().product();
880        let inner: usize = shape[axis + 1..].iter().product();
881        let mut out = vec![0.0f32; total];
882
883        for o in 0..outer {
884            for i in 0..inner {
885                // First pass: max for stability.
886                let mut max_v = f32::NEG_INFINITY;
887                for a in 0..axis_len {
888                    let idx = (o * axis_len + a) * inner + i;
889                    max_v = max_v.max(input[idx]);
890                }
891                // Second pass: exp + sum.
892                let mut denom = 0.0f32;
893                for a in 0..axis_len {
894                    let idx = (o * axis_len + a) * inner + i;
895                    let e = (input[idx] - max_v).exp();
896                    out[idx] = e;
897                    denom += e;
898                }
899                // Third pass: normalize.
900                let inv = if denom > 0.0 { 1.0 / denom } else { 0.0 };
901                for a in 0..axis_len {
902                    let idx = (o * axis_len + a) * inner + i;
903                    out[idx] *= inv;
904                }
905            }
906        }
907        self.write_f32(output_ptr, &out)
908    }
909
910    fn gather(
911        &self,
912        input_ptr: u64,
913        indices: &[usize],
914        output_ptr: u64,
915        rows: usize,
916        cols: usize,
917    ) -> BackendResult<()> {
918        let table = self.read_f32(input_ptr, rows * cols)?;
919        let mut out = Vec::with_capacity(indices.len() * cols);
920        for &row in indices {
921            if row >= rows {
922                return Err(BackendError::InvalidArgument(format!(
923                    "gather index {row} out of bounds for {rows} rows"
924                )));
925            }
926            out.extend_from_slice(&table[row * cols..(row + 1) * cols]);
927        }
928        self.write_f32(output_ptr, &out)
929    }
930
931    fn scatter(
932        &self,
933        input_ptr: u64,
934        indices: &[usize],
935        output_ptr: u64,
936        rows: usize,
937        cols: usize,
938    ) -> BackendResult<()> {
939        // `input` holds one row per index; write each into `output` at the
940        // destination row. Existing destination contents are read first so
941        // unreferenced rows are preserved.
942        let src = self.read_f32(input_ptr, indices.len() * cols)?;
943        let mut dst = self.read_f32(output_ptr, rows * cols)?;
944        for (slot, &row) in indices.iter().enumerate() {
945            if row >= rows {
946                return Err(BackendError::InvalidArgument(format!(
947                    "scatter index {row} out of bounds for {rows} rows"
948                )));
949            }
950            dst[row * cols..(row + 1) * cols].copy_from_slice(&src[slot * cols..(slot + 1) * cols]);
951        }
952        self.write_f32(output_ptr, &dst)
953    }
954
955    fn synchronize(&self) -> BackendResult<()> {
956        // The CPU backend executes synchronously; nothing to wait for.
957        Ok(())
958    }
959
960    fn alloc(&self, bytes: usize) -> BackendResult<u64> {
961        if bytes == 0 {
962            return Err(BackendError::InvalidArgument(
963                "cannot allocate 0 bytes".into(),
964            ));
965        }
966        let mut next = self
967            .next_ptr
968            .lock()
969            .map_err(|_| BackendError::DeviceError("pointer counter poisoned".into()))?;
970        let ptr = *next;
971        // Advance past this allocation, keeping 16-byte alignment between
972        // synthetic addresses for realism, and never reuse an address.
973        let advance = (bytes as u64).div_ceil(16) * 16;
974        *next = next
975            .checked_add(advance.max(16))
976            .ok_or(BackendError::OutOfMemory)?;
977        drop(next);
978
979        let mut table = self
980            .allocations
981            .lock()
982            .map_err(|_| BackendError::DeviceError("allocation table poisoned".into()))?;
983        table.insert(ptr, vec![0u8; bytes]);
984        Ok(ptr)
985    }
986
987    fn free(&self, ptr: u64) -> BackendResult<()> {
988        let mut table = self
989            .allocations
990            .lock()
991            .map_err(|_| BackendError::DeviceError("allocation table poisoned".into()))?;
992        if table.remove(&ptr).is_none() {
993            return Err(BackendError::InvalidArgument(format!(
994                "free of unknown device pointer {ptr:#x}"
995            )));
996        }
997        Ok(())
998    }
999
1000    fn copy_htod(&self, dst: u64, src: &[u8]) -> BackendResult<()> {
1001        let mut table = self
1002            .allocations
1003            .lock()
1004            .map_err(|_| BackendError::DeviceError("allocation table poisoned".into()))?;
1005        let buf = table.get_mut(&dst).ok_or_else(|| {
1006            BackendError::InvalidArgument(format!("unknown device pointer {dst:#x}"))
1007        })?;
1008        if src.len() > buf.len() {
1009            return Err(BackendError::InvalidArgument(format!(
1010                "copy_htod of {} bytes into {}-byte buffer",
1011                src.len(),
1012                buf.len()
1013            )));
1014        }
1015        buf[..src.len()].copy_from_slice(src);
1016        Ok(())
1017    }
1018
1019    fn copy_dtoh(&self, dst: &mut [u8], src: u64) -> BackendResult<()> {
1020        let table = self
1021            .allocations
1022            .lock()
1023            .map_err(|_| BackendError::DeviceError("allocation table poisoned".into()))?;
1024        let buf = table.get(&src).ok_or_else(|| {
1025            BackendError::InvalidArgument(format!("unknown device pointer {src:#x}"))
1026        })?;
1027        if dst.len() > buf.len() {
1028            return Err(BackendError::InvalidArgument(format!(
1029                "copy_dtoh of {} bytes from {}-byte buffer",
1030                dst.len(),
1031                buf.len()
1032            )));
1033        }
1034        dst.copy_from_slice(&buf[..dst.len()]);
1035        Ok(())
1036    }
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041    use super::*;
1042
1043    /// Allocate a device buffer, fill it from `data` (as `f32`), and return
1044    /// the pointer.
1045    fn upload_f32(be: &CpuBackend, data: &[f32]) -> u64 {
1046        let ptr = be.alloc(data.len() * 4).expect("alloc");
1047        let mut bytes = Vec::with_capacity(data.len() * 4);
1048        for &v in data {
1049            bytes.extend_from_slice(&v.to_ne_bytes());
1050        }
1051        be.copy_htod(ptr, &bytes).expect("htod");
1052        ptr
1053    }
1054
1055    fn download_f32(be: &CpuBackend, ptr: u64, len: usize) -> Vec<f32> {
1056        let mut bytes = vec![0u8; len * 4];
1057        be.copy_dtoh(&mut bytes, ptr).expect("dtoh");
1058        bytes
1059            .chunks_exact(4)
1060            .map(|c| f32::from_ne_bytes([c[0], c[1], c[2], c[3]]))
1061            .collect()
1062    }
1063
1064    fn upload_f64(be: &CpuBackend, data: &[f64]) -> u64 {
1065        let ptr = be.alloc(data.len() * 8).expect("alloc");
1066        let mut bytes = Vec::with_capacity(data.len() * 8);
1067        for &v in data {
1068            bytes.extend_from_slice(&v.to_ne_bytes());
1069        }
1070        be.copy_htod(ptr, &bytes).expect("htod");
1071        ptr
1072    }
1073
1074    fn download_f64(be: &CpuBackend, ptr: u64, len: usize) -> Vec<f64> {
1075        let mut bytes = vec![0u8; len * 8];
1076        be.copy_dtoh(&mut bytes, ptr).expect("dtoh");
1077        bytes
1078            .chunks_exact(8)
1079            .map(|c| {
1080                let mut b = [0u8; 8];
1081                b.copy_from_slice(c);
1082                f64::from_ne_bytes(b)
1083            })
1084            .collect()
1085    }
1086
1087    #[test]
1088    fn init_and_name() {
1089        let mut be = CpuBackend::new();
1090        assert_eq!(be.name(), "cpu");
1091        assert!(!be.is_initialized());
1092        be.init().unwrap();
1093        assert!(be.is_initialized());
1094    }
1095
1096    #[test]
1097    fn alloc_copy_roundtrip_and_free() {
1098        let be = CpuBackend::new();
1099        let data = [1.0f32, 2.0, 3.0, 4.0];
1100        let ptr = upload_f32(&be, &data);
1101        assert_eq!(be.live_allocations(), 1);
1102        let back = download_f32(&be, ptr, 4);
1103        assert_eq!(back, data);
1104        be.free(ptr).unwrap();
1105        assert_eq!(be.live_allocations(), 0);
1106        // Use-after-free is detected.
1107        assert!(be.free(ptr).is_err());
1108    }
1109
1110    #[test]
1111    fn alloc_never_reuses_pointer() {
1112        let be = CpuBackend::new();
1113        let p1 = be.alloc(64).unwrap();
1114        be.free(p1).unwrap();
1115        let p2 = be.alloc(64).unwrap();
1116        assert_ne!(p1, p2, "freed address must not be handed out again");
1117    }
1118
1119    #[test]
1120    fn zero_byte_alloc_is_error() {
1121        let be = CpuBackend::new();
1122        assert!(matches!(be.alloc(0), Err(BackendError::InvalidArgument(_))));
1123    }
1124
1125    #[test]
1126    fn gemm_identity_times_matrix() {
1127        let be = CpuBackend::new();
1128        // 2x2 column-major. A = I, B = [[1,2],[3,4]], expect C = B.
1129        let a = [1.0f64, 0.0, 0.0, 1.0]; // col-major identity
1130        let b = [1.0f64, 3.0, 2.0, 4.0]; // col-major [[1,2],[3,4]]
1131        let a_ptr = upload_f64(&be, &a);
1132        let b_ptr = upload_f64(&be, &b);
1133        let c_ptr = upload_f64(&be, &[0.0f64; 4]);
1134        be.gemm(
1135            BackendTranspose::NoTrans,
1136            BackendTranspose::NoTrans,
1137            2,
1138            2,
1139            2,
1140            1.0,
1141            a_ptr,
1142            2,
1143            b_ptr,
1144            2,
1145            0.0,
1146            c_ptr,
1147            2,
1148        )
1149        .unwrap();
1150        let c = download_f64(&be, c_ptr, 4);
1151        assert_eq!(c, b);
1152    }
1153
1154    #[test]
1155    fn gemm_alpha_beta_and_known_product() {
1156        let be = CpuBackend::new();
1157        // A = [[1,2],[3,4]], B = [[5,6],[7,8]] (col-major).
1158        // A*B = [[19,22],[43,50]].
1159        let a = [1.0f64, 3.0, 2.0, 4.0];
1160        let b = [5.0f64, 7.0, 6.0, 8.0];
1161        let c0 = [10.0f64, 10.0, 10.0, 10.0]; // C initial
1162        let a_ptr = upload_f64(&be, &a);
1163        let b_ptr = upload_f64(&be, &b);
1164        let c_ptr = upload_f64(&be, &c0);
1165        // alpha=2, beta=3 → 2*(A*B) + 3*C.
1166        be.gemm(
1167            BackendTranspose::NoTrans,
1168            BackendTranspose::NoTrans,
1169            2,
1170            2,
1171            2,
1172            2.0,
1173            a_ptr,
1174            2,
1175            b_ptr,
1176            2,
1177            3.0,
1178            c_ptr,
1179            2,
1180        )
1181        .unwrap();
1182        let c = download_f64(&be, c_ptr, 4);
1183        // col-major [[19,22],[43,50]] = [19,43,22,50]
1184        let expected = [
1185            2.0 * 19.0 + 3.0 * 10.0,
1186            2.0 * 43.0 + 3.0 * 10.0,
1187            2.0 * 22.0 + 3.0 * 10.0,
1188            2.0 * 50.0 + 3.0 * 10.0,
1189        ];
1190        assert_eq!(c, expected);
1191    }
1192
1193    #[test]
1194    fn gemm_transpose_a() {
1195        let be = CpuBackend::new();
1196        // A stored as [[1,2],[3,4]] col-major; op(A)=A^T = [[1,3],[2,4]].
1197        // B = I. Expect C = A^T.
1198        let a = [1.0f64, 3.0, 2.0, 4.0];
1199        let b = [1.0f64, 0.0, 0.0, 1.0];
1200        let a_ptr = upload_f64(&be, &a);
1201        let b_ptr = upload_f64(&be, &b);
1202        let c_ptr = upload_f64(&be, &[0.0f64; 4]);
1203        be.gemm(
1204            BackendTranspose::Trans,
1205            BackendTranspose::NoTrans,
1206            2,
1207            2,
1208            2,
1209            1.0,
1210            a_ptr,
1211            2,
1212            b_ptr,
1213            2,
1214            0.0,
1215            c_ptr,
1216            2,
1217        )
1218        .unwrap();
1219        let c = download_f64(&be, c_ptr, 4);
1220        // A^T col-major = [[1,3],[2,4]] = [1,2,3,4]
1221        assert_eq!(c, [1.0, 2.0, 3.0, 4.0]);
1222    }
1223
1224    #[test]
1225    fn unary_relu_and_neg() {
1226        let be = CpuBackend::new();
1227        let data = [-2.0f32, -0.5, 0.0, 1.5];
1228        let ip = upload_f32(&be, &data);
1229        let op = be.alloc(4 * 4).unwrap();
1230        be.unary(UnaryOp::Relu, ip, op, 4).unwrap();
1231        assert_eq!(download_f32(&be, op, 4), [0.0, 0.0, 0.0, 1.5]);
1232        be.unary(UnaryOp::Neg, ip, op, 4).unwrap();
1233        assert_eq!(download_f32(&be, op, 4), [2.0, 0.5, 0.0, -1.5]);
1234    }
1235
1236    #[test]
1237    fn binary_ops() {
1238        let be = CpuBackend::new();
1239        let a = [1.0f32, 5.0, 3.0];
1240        let b = [4.0f32, 2.0, 3.0];
1241        let ap = upload_f32(&be, &a);
1242        let bp = upload_f32(&be, &b);
1243        let op = be.alloc(3 * 4).unwrap();
1244        be.binary(BinaryOp::Add, ap, bp, op, 3).unwrap();
1245        assert_eq!(download_f32(&be, op, 3), [5.0, 7.0, 6.0]);
1246        be.binary(BinaryOp::Max, ap, bp, op, 3).unwrap();
1247        assert_eq!(download_f32(&be, op, 3), [4.0, 5.0, 3.0]);
1248    }
1249
1250    #[test]
1251    fn reduce_sum_and_mean_over_axis() {
1252        let be = CpuBackend::new();
1253        // shape [2,3] row-major: [[1,2,3],[4,5,6]]
1254        let data = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0];
1255        let ip = upload_f32(&be, &data);
1256        // Reduce axis 1 (columns) → [6, 15] (sum of each row).
1257        let op = be.alloc(2 * 4).unwrap();
1258        be.reduce(ReduceOp::Sum, ip, op, &[2, 3], 1).unwrap();
1259        assert_eq!(download_f32(&be, op, 2), [6.0, 15.0]);
1260        // Reduce axis 0 (rows) → [5, 7, 9].
1261        let op2 = be.alloc(3 * 4).unwrap();
1262        be.reduce(ReduceOp::Sum, ip, op2, &[2, 3], 0).unwrap();
1263        assert_eq!(download_f32(&be, op2, 3), [5.0, 7.0, 9.0]);
1264        // Mean over axis 1 → [2, 5].
1265        let op3 = be.alloc(2 * 4).unwrap();
1266        be.reduce(ReduceOp::Mean, ip, op3, &[2, 3], 1).unwrap();
1267        assert_eq!(download_f32(&be, op3, 2), [2.0, 5.0]);
1268    }
1269
1270    #[test]
1271    fn softmax_axis_sums_to_one() {
1272        let be = CpuBackend::new();
1273        let data = [1.0f32, 2.0, 3.0, 1.0, 1.0, 1.0];
1274        let ip = upload_f32(&be, &data);
1275        let op = be.alloc(6 * 4).unwrap();
1276        be.softmax(ip, op, &[2, 3], 1).unwrap();
1277        let out = download_f32(&be, op, 6);
1278        let row0: f32 = out[..3].iter().sum();
1279        let row1: f32 = out[3..].iter().sum();
1280        assert!((row0 - 1.0).abs() < 1e-6);
1281        assert!((row1 - 1.0).abs() < 1e-6);
1282        // Uniform row → uniform probabilities.
1283        for &p in &out[3..] {
1284            assert!((p - 1.0 / 3.0).abs() < 1e-6);
1285        }
1286    }
1287
1288    #[test]
1289    fn gather_selects_rows() {
1290        let be = CpuBackend::new();
1291        // 3 rows x 2 cols: [[10,11],[20,21],[30,31]]
1292        let table = [10.0f32, 11.0, 20.0, 21.0, 30.0, 31.0];
1293        let ip = upload_f32(&be, &table);
1294        let op = be.alloc(2 * 2 * 4).unwrap();
1295        be.gather(ip, &[2, 0], op, 3, 2).unwrap();
1296        assert_eq!(download_f32(&be, op, 4), [30.0, 31.0, 10.0, 11.0]);
1297        // Out-of-range index errors.
1298        assert!(be.gather(ip, &[5], op, 3, 2).is_err());
1299    }
1300
1301    #[test]
1302    fn scatter_writes_rows_preserving_others() {
1303        let be = CpuBackend::new();
1304        let dst0 = [0.0f32; 6]; // 3x2 zeros
1305        let op = upload_f32(&be, &dst0);
1306        let src = [99.0f32, 98.0]; // one row
1307        let ip = upload_f32(&be, &src);
1308        be.scatter(ip, &[1], op, 3, 2).unwrap();
1309        // Row 1 written, rows 0 and 2 stay zero.
1310        assert_eq!(download_f32(&be, op, 6), [0.0, 0.0, 99.0, 98.0, 0.0, 0.0]);
1311    }
1312
1313    #[test]
1314    fn conv2d_identity_filter() {
1315        let be = CpuBackend::new();
1316        // 1x1x3x3 input, 1x1x1x1 filter = [2.0], stride 1, pad 0.
1317        // Output = input * 2.
1318        let input: Vec<f32> = (1..=9).map(|x| x as f32).collect();
1319        let ip = upload_f32(&be, &input);
1320        let fp = upload_f32(&be, &[2.0f32]);
1321        let op = be.alloc(9 * 4).unwrap();
1322        be.conv2d_forward(
1323            ip,
1324            &[1, 1, 3, 3],
1325            fp,
1326            &[1, 1, 1, 1],
1327            op,
1328            &[1, 1, 3, 3],
1329            &[1, 1],
1330            &[0, 0],
1331        )
1332        .unwrap();
1333        let out = download_f32(&be, op, 9);
1334        let expected: Vec<f32> = input.iter().map(|x| x * 2.0).collect();
1335        assert_eq!(out, expected);
1336    }
1337
1338    #[test]
1339    fn conv2d_rejects_wrong_output_size() {
1340        let be = CpuBackend::new();
1341        let ip = be.alloc(9 * 4).unwrap();
1342        let fp = be.alloc(4 * 4).unwrap();
1343        let op = be.alloc(9 * 4).unwrap();
1344        // 3x3 input, 2x2 filter, stride 1, pad 0 → output should be 2x2,
1345        // but we claim 3x3.
1346        let err = be.conv2d_forward(
1347            ip,
1348            &[1, 1, 3, 3],
1349            fp,
1350            &[1, 1, 2, 2],
1351            op,
1352            &[1, 1, 3, 3],
1353            &[1, 1],
1354            &[0, 0],
1355        );
1356        assert!(matches!(err, Err(BackendError::InvalidArgument(_))));
1357    }
1358
1359    #[test]
1360    fn attention_uniform_keys_averages_values() {
1361        let be = CpuBackend::new();
1362        // batch=1, heads=1, seq_q=1, seq_kv=2, head_dim=2.
1363        // Q=[0,0] → all scores 0 → uniform softmax → output = mean(V).
1364        let q = [0.0f32, 0.0];
1365        let k = [1.0f32, 1.0, 2.0, 2.0];
1366        let v = [10.0f32, 20.0, 30.0, 40.0];
1367        let qp = upload_f32(&be, &q);
1368        let kp = upload_f32(&be, &k);
1369        let vp = upload_f32(&be, &v);
1370        let op = be.alloc(2 * 4).unwrap();
1371        be.attention(qp, kp, vp, op, 1, 1, 1, 2, 2, 1.0, false)
1372            .unwrap();
1373        let out = download_f32(&be, op, 2);
1374        // mean of rows [10,20] and [30,40] = [20,30].
1375        assert!((out[0] - 20.0).abs() < 1e-5);
1376        assert!((out[1] - 30.0).abs() < 1e-5);
1377    }
1378
1379    #[test]
1380    fn attention_causal_first_query_sees_only_first_key() {
1381        let be = CpuBackend::new();
1382        // seq_q=2, seq_kv=2. Causal: query0 → key0 only; query1 → key0,key1.
1383        let q = [0.0f32, 0.0, 0.0, 0.0]; // both queries zero
1384        let k = [0.0f32, 0.0, 0.0, 0.0];
1385        let v = [1.0f32, 1.0, 5.0, 5.0]; // value rows differ
1386        let qp = upload_f32(&be, &q);
1387        let kp = upload_f32(&be, &k);
1388        let vp = upload_f32(&be, &v);
1389        let op = be.alloc(4 * 4).unwrap();
1390        be.attention(qp, kp, vp, op, 1, 1, 2, 2, 2, 1.0, true)
1391            .unwrap();
1392        let out = download_f32(&be, op, 4);
1393        // query0 sees only value row0 = [1,1].
1394        assert!((out[0] - 1.0).abs() < 1e-5);
1395        assert!((out[1] - 1.0).abs() < 1e-5);
1396        // query1 averages both rows = [3,3].
1397        assert!((out[2] - 3.0).abs() < 1e-5);
1398        assert!((out[3] - 3.0).abs() < 1e-5);
1399    }
1400
1401    #[test]
1402    fn batched_gemm_default_runs_on_cpu() {
1403        // The trait's default batched_gemm should drive the CPU gemm with
1404        // f32 byte strides — but the CPU gemm reads f64. Here we exercise
1405        // the explicit per-batch path with a single batch (stride 0) so the
1406        // default and direct calls coincide, verifying integration.
1407        let be = CpuBackend::new();
1408        let a = [1.0f64, 0.0, 0.0, 1.0];
1409        let b = [2.0f64, 3.0, 4.0, 5.0];
1410        let a_ptr = upload_f64(&be, &a);
1411        let b_ptr = upload_f64(&be, &b);
1412        let c_ptr = upload_f64(&be, &[0.0f64; 4]);
1413        be.batched_gemm(
1414            BackendTranspose::NoTrans,
1415            BackendTranspose::NoTrans,
1416            2,
1417            2,
1418            2,
1419            1.0,
1420            a_ptr,
1421            2,
1422            0,
1423            b_ptr,
1424            2,
1425            0,
1426            0.0,
1427            c_ptr,
1428            2,
1429            0,
1430            1,
1431        )
1432        .unwrap();
1433        assert_eq!(download_f64(&be, c_ptr, 4), b);
1434    }
1435
1436    #[test]
1437    fn unknown_pointer_errors() {
1438        let be = CpuBackend::new();
1439        let mut dst = [0u8; 4];
1440        assert!(be.copy_dtoh(&mut dst, 0xDEAD).is_err());
1441        assert!(be.copy_htod(0xDEAD, &[0u8; 4]).is_err());
1442    }
1443
1444    #[test]
1445    fn capabilities_and_devices() {
1446        let be = CpuBackend::new();
1447        assert_eq!(be.capabilities(), Capabilities::cpu());
1448        let devs = be.available_devices().unwrap();
1449        assert_eq!(devs.len(), 1);
1450        assert_eq!(devs[0].memory_kind, MemoryKind::Host);
1451    }
1452
1453    // ── Mixed-precision GEMM ──────────────────────────────────────────
1454
1455    use crate::ops::MixedPrecision;
1456    use crate::precision::{round_to_bf16, round_to_f16};
1457
1458    /// Plain column-major f32 reference GEMM (full precision) for comparison.
1459    fn ref_gemm_f32(m: usize, n: usize, k: usize, a: &[f32], b: &[f32]) -> Vec<f32> {
1460        let mut c = vec![0.0f32; m * n];
1461        for j in 0..n {
1462            for i in 0..m {
1463                let mut acc = 0.0f32;
1464                for p in 0..k {
1465                    acc += a[p * m + i] * b[j * k + p];
1466                }
1467                c[j * m + i] = acc;
1468            }
1469        }
1470        c
1471    }
1472
1473    #[test]
1474    fn mixed_precision_bf16_matches_f32_within_rounding_tolerance() {
1475        let be = CpuBackend::new();
1476        let (m, n, k) = (4, 3, 5);
1477        // Deterministic operands in a moderate range.
1478        let a: Vec<f32> = (0..m * k)
1479            .map(|i| ((i * 7 % 11) as f32) * 0.25 - 1.0)
1480            .collect();
1481        let b: Vec<f32> = (0..k * n)
1482            .map(|i| ((i * 5 % 13) as f32) * 0.125 - 0.5)
1483            .collect();
1484
1485        let a_ptr = upload_f32(&be, &a);
1486        let b_ptr = upload_f32(&be, &b);
1487        let c_ptr = upload_f32(&be, &vec![0.0f32; m * n]);
1488        be.gemm_mixed_precision(
1489            MixedPrecision::Bf16,
1490            BackendTranspose::NoTrans,
1491            BackendTranspose::NoTrans,
1492            m,
1493            n,
1494            k,
1495            1.0,
1496            a_ptr,
1497            m,
1498            b_ptr,
1499            k,
1500            0.0,
1501            c_ptr,
1502            m,
1503        )
1504        .unwrap();
1505        let got = download_f32(&be, c_ptr, m * n);
1506
1507        // Ground truth: round the *operands* to bf16 (exactly what the kernel
1508        // stores), then accumulate in full f32.
1509        let a_r: Vec<f32> = a.iter().map(|&v| round_to_bf16(v)).collect();
1510        let b_r: Vec<f32> = b.iter().map(|&v| round_to_bf16(v)).collect();
1511        let want = ref_gemm_f32(m, n, k, &a_r, &b_r);
1512        for (g, w) in got.iter().zip(want.iter()) {
1513            assert!((g - w).abs() < 1e-6, "bf16 gemm {g} vs {w}");
1514        }
1515
1516        // And the result is close to the *full*-f32 product within the bf16
1517        // storage tolerance (bf16 has ~3 decimal digits ⇒ rel err ~2^-8).
1518        let exact = ref_gemm_f32(m, n, k, &a, &b);
1519        for (g, e) in got.iter().zip(exact.iter()) {
1520            let tol = 1e-2 * (1.0 + e.abs());
1521            assert!((g - e).abs() < tol, "bf16 gemm {g} vs exact {e}");
1522        }
1523    }
1524
1525    #[test]
1526    fn mixed_precision_f16_matches_f32_within_rounding_tolerance() {
1527        let be = CpuBackend::new();
1528        let (m, n, k) = (3, 4, 6);
1529        let a: Vec<f32> = (0..m * k)
1530            .map(|i| ((i * 3 % 7) as f32) * 0.5 - 1.5)
1531            .collect();
1532        let b: Vec<f32> = (0..k * n)
1533            .map(|i| ((i * 9 % 5) as f32) * 0.25 - 0.5)
1534            .collect();
1535
1536        let a_ptr = upload_f32(&be, &a);
1537        let b_ptr = upload_f32(&be, &b);
1538        let c_ptr = upload_f32(&be, &vec![0.0f32; m * n]);
1539        be.gemm_mixed_precision(
1540            MixedPrecision::F16,
1541            BackendTranspose::NoTrans,
1542            BackendTranspose::NoTrans,
1543            m,
1544            n,
1545            k,
1546            1.0,
1547            a_ptr,
1548            m,
1549            b_ptr,
1550            k,
1551            0.0,
1552            c_ptr,
1553            m,
1554        )
1555        .unwrap();
1556        let got = download_f32(&be, c_ptr, m * n);
1557
1558        let a_r: Vec<f32> = a.iter().map(|&v| round_to_f16(v)).collect();
1559        let b_r: Vec<f32> = b.iter().map(|&v| round_to_f16(v)).collect();
1560        let want = ref_gemm_f32(m, n, k, &a_r, &b_r);
1561        for (g, w) in got.iter().zip(want.iter()) {
1562            assert!((g - w).abs() < 1e-6, "f16 gemm {g} vs {w}");
1563        }
1564    }
1565
1566    #[test]
1567    fn mixed_precision_bf16_exact_for_representable_operands() {
1568        // When every operand element is exactly representable in bf16, the
1569        // mixed-precision GEMM must agree with the full-f32 GEMM bit-for-bit
1570        // (no rounding error anywhere).
1571        let be = CpuBackend::new();
1572        let (m, n, k) = (2, 2, 3);
1573        // Powers-of-two-scaled small integers are exact in both bf16 and f16.
1574        let a = [1.0f32, 2.0, -1.0, 0.5, 4.0, -2.0]; // col-major 2x3
1575        let b = [0.5f32, 1.0, 2.0, -1.0, 0.25, 8.0]; // col-major 3x2
1576        let a_ptr = upload_f32(&be, &a);
1577        let b_ptr = upload_f32(&be, &b);
1578        let c_ptr = upload_f32(&be, &vec![0.0f32; m * n]);
1579        be.gemm_mixed_precision(
1580            MixedPrecision::Bf16,
1581            BackendTranspose::NoTrans,
1582            BackendTranspose::NoTrans,
1583            m,
1584            n,
1585            k,
1586            1.0,
1587            a_ptr,
1588            m,
1589            b_ptr,
1590            k,
1591            0.0,
1592            c_ptr,
1593            m,
1594        )
1595        .unwrap();
1596        let got = download_f32(&be, c_ptr, m * n);
1597        let want = ref_gemm_f32(m, n, k, &a, &b);
1598        assert_eq!(got, want, "exact bf16 operands must match f32 exactly");
1599    }
1600
1601    #[test]
1602    fn mixed_precision_accumulates_in_f32_not_f16() {
1603        // The hallmark of mixed precision: a long dot product of small
1604        // bf16-representable values keeps f32 accumulation precision. We sum
1605        // K copies of 1.0 * (1/256). Each operand element (1.0 and 1/256) is
1606        // exact in bf16, so the *only* possible error is from accumulating in
1607        // a narrow type. With f32 accumulation the result is exact; a pure-
1608        // bf16 accumulator would stall (1 + 1/256 already rounds away the
1609        // increment in bf16), proving the accumulator is genuinely f32.
1610        let be = CpuBackend::new();
1611        let k = 512usize;
1612        let (m, n) = (1, 1);
1613        let a = vec![1.0f32; k]; // 1x k row (col-major: k elements)
1614        let inc = 1.0f32 / 256.0; // exactly representable in bf16 (2^-8)
1615        let b = vec![inc; k]; // k x 1 column
1616        let a_ptr = upload_f32(&be, &a);
1617        let b_ptr = upload_f32(&be, &b);
1618        let c_ptr = upload_f32(&be, &[0.0f32]);
1619        be.gemm_mixed_precision(
1620            MixedPrecision::Bf16,
1621            BackendTranspose::NoTrans,
1622            BackendTranspose::NoTrans,
1623            m,
1624            n,
1625            k,
1626            1.0,
1627            a_ptr,
1628            m,
1629            b_ptr,
1630            k,
1631            0.0,
1632            c_ptr,
1633            m,
1634        )
1635        .unwrap();
1636        let got = download_f32(&be, c_ptr, 1)[0];
1637        let expected = k as f32 * inc; // = 2.0, exact in f32.
1638        assert!((got - expected).abs() < 1e-5, "f32-accumulated dot = {got}");
1639
1640        // Demonstrate that a bf16 *accumulator* would NOT reach 2.0: once the
1641        // running sum exceeds 1.0, adding 2^-8 rounds back to the same bf16
1642        // value, so it saturates far below the true sum.
1643        let mut bf16_acc = 0.0f32;
1644        for _ in 0..k {
1645            bf16_acc = round_to_bf16(bf16_acc + inc);
1646        }
1647        assert!(
1648            bf16_acc < expected - 0.1,
1649            "bf16 accumulation should stall ({bf16_acc} < {expected})"
1650        );
1651        // The real op must beat the broken bf16-accumulator baseline.
1652        assert!(got > bf16_acc + 0.1);
1653    }
1654
1655    #[test]
1656    fn mixed_precision_alpha_beta_and_transpose() {
1657        let be = CpuBackend::new();
1658        // op(A)=A^T with A stored col-major 2x2; B = I; alpha=2, beta=3.
1659        let a = [1.0f32, 3.0, 2.0, 4.0]; // col-major [[1,2],[3,4]]
1660        let b = [1.0f32, 0.0, 0.0, 1.0];
1661        let c0 = [1.0f32, 1.0, 1.0, 1.0];
1662        let a_ptr = upload_f32(&be, &a);
1663        let b_ptr = upload_f32(&be, &b);
1664        let c_ptr = upload_f32(&be, &c0);
1665        be.gemm_mixed_precision(
1666            MixedPrecision::Bf16,
1667            BackendTranspose::Trans,
1668            BackendTranspose::NoTrans,
1669            2,
1670            2,
1671            2,
1672            2.0,
1673            a_ptr,
1674            2,
1675            b_ptr,
1676            2,
1677            3.0,
1678            c_ptr,
1679            2,
1680        )
1681        .unwrap();
1682        let got = download_f32(&be, c_ptr, 4);
1683        // A^T col-major = [1,2,3,4]; 2*(A^T) + 3*C0 = [2+3, 4+3, 6+3, 8+3].
1684        assert_eq!(got, [5.0, 7.0, 9.0, 11.0]);
1685    }
1686
1687    #[test]
1688    fn mixed_precision_rejects_bad_leading_dim() {
1689        let be = CpuBackend::new();
1690        let a_ptr = be.alloc(4 * 4).unwrap();
1691        let b_ptr = be.alloc(4 * 4).unwrap();
1692        let c_ptr = be.alloc(4 * 4).unwrap();
1693        let err = be.gemm_mixed_precision(
1694            MixedPrecision::F16,
1695            BackendTranspose::NoTrans,
1696            BackendTranspose::NoTrans,
1697            2,
1698            2,
1699            2,
1700            1.0,
1701            a_ptr,
1702            1, // lda < m
1703            b_ptr,
1704            2,
1705            0.0,
1706            c_ptr,
1707            2,
1708        );
1709        assert!(matches!(err, Err(BackendError::InvalidArgument(_))));
1710    }
1711
1712    // ── conv2d backward (finite-difference gradient checks) ───────────
1713
1714    /// Run a forward conv2d on the given host tensors and return the output.
1715    fn forward_conv(
1716        be: &CpuBackend,
1717        input: &[f32],
1718        in_shape: [usize; 4],
1719        filter: &[f32],
1720        f_shape: [usize; 4],
1721        out_shape: [usize; 4],
1722        stride: [usize; 2],
1723        pad: [usize; 2],
1724    ) -> Vec<f32> {
1725        let ip = upload_f32(be, input);
1726        let fp = upload_f32(be, filter);
1727        let out_len: usize = out_shape.iter().product();
1728        let op = be.alloc(out_len * 4).unwrap();
1729        be.conv2d_forward(ip, &in_shape, fp, &f_shape, op, &out_shape, &stride, &pad)
1730            .unwrap();
1731        let out = download_f32(be, op, out_len);
1732        be.free(ip).unwrap();
1733        be.free(fp).unwrap();
1734        be.free(op).unwrap();
1735        out
1736    }
1737
1738    /// Scalar loss L = <forward(input, filter), grad_output> used to drive the
1739    /// finite-difference check (its gradient w.r.t. input/filter is exactly
1740    /// what backward_data / backward_filter must compute).
1741    #[allow(clippy::too_many_arguments)]
1742    fn conv_loss(
1743        be: &CpuBackend,
1744        input: &[f32],
1745        in_shape: [usize; 4],
1746        filter: &[f32],
1747        f_shape: [usize; 4],
1748        out_shape: [usize; 4],
1749        stride: [usize; 2],
1750        pad: [usize; 2],
1751        grad_output: &[f32],
1752    ) -> f32 {
1753        let y = forward_conv(be, input, in_shape, filter, f_shape, out_shape, stride, pad);
1754        y.iter().zip(grad_output.iter()).map(|(a, b)| a * b).sum()
1755    }
1756
1757    #[test]
1758    fn conv2d_backward_data_matches_finite_difference() {
1759        let be = CpuBackend::new();
1760        // N=1, C=2, H=4, W=4; K=3, Fh=Fw=3; stride 1, pad 1 → Oh=Ow=4.
1761        let in_shape = [1, 2, 4, 4];
1762        let f_shape = [3, 2, 3, 3];
1763        let out_shape = [1, 3, 4, 4];
1764        let stride = [1, 1];
1765        let pad = [1, 1];
1766        let in_len: usize = in_shape.iter().product();
1767        let f_len: usize = f_shape.iter().product();
1768        let out_len: usize = out_shape.iter().product();
1769
1770        // Deterministic pseudo-random tensors.
1771        let input: Vec<f32> = (0..in_len)
1772            .map(|i| ((i * 13 % 17) as f32) * 0.1 - 0.8)
1773            .collect();
1774        let filter: Vec<f32> = (0..f_len)
1775            .map(|i| ((i * 7 % 11) as f32) * 0.1 - 0.5)
1776            .collect();
1777        let grad_output: Vec<f32> = (0..out_len)
1778            .map(|i| ((i * 5 % 9) as f32) * 0.2 - 0.8)
1779            .collect();
1780
1781        // Analytic data gradient.
1782        let gop = upload_f32(&be, &grad_output);
1783        let fp = upload_f32(&be, &filter);
1784        let gip = be.alloc(in_len * 4).unwrap();
1785        be.conv2d_backward_data(gop, &out_shape, fp, &f_shape, gip, &in_shape, &stride, &pad)
1786            .unwrap();
1787        let analytic = download_f32(&be, gip, in_len);
1788
1789        // Central-difference check on every input element.
1790        let eps = 1e-2f32;
1791        for idx in 0..in_len {
1792            let mut plus = input.clone();
1793            let mut minus = input.clone();
1794            plus[idx] += eps;
1795            minus[idx] -= eps;
1796            let lp = conv_loss(
1797                &be,
1798                &plus,
1799                in_shape,
1800                &filter,
1801                f_shape,
1802                out_shape,
1803                stride,
1804                pad,
1805                &grad_output,
1806            );
1807            let lm = conv_loss(
1808                &be,
1809                &minus,
1810                in_shape,
1811                &filter,
1812                f_shape,
1813                out_shape,
1814                stride,
1815                pad,
1816                &grad_output,
1817            );
1818            let fd = (lp - lm) / (2.0 * eps);
1819            assert!(
1820                (analytic[idx] - fd).abs() < 1e-2,
1821                "grad_input[{idx}] analytic {} vs finite-diff {fd}",
1822                analytic[idx]
1823            );
1824        }
1825    }
1826
1827    #[test]
1828    fn conv2d_backward_filter_matches_finite_difference() {
1829        let be = CpuBackend::new();
1830        // Use a stride-2 case with padding to exercise the index arithmetic.
1831        // N=2, C=2, H=5, W=5; K=2, Fh=Fw=3; stride 2, pad 1 → Oh=Ow=3.
1832        let in_shape = [2, 2, 5, 5];
1833        let f_shape = [2, 2, 3, 3];
1834        let out_shape = [2, 2, 3, 3];
1835        let stride = [2, 2];
1836        let pad = [1, 1];
1837        let in_len: usize = in_shape.iter().product();
1838        let f_len: usize = f_shape.iter().product();
1839        let out_len: usize = out_shape.iter().product();
1840
1841        let input: Vec<f32> = (0..in_len)
1842            .map(|i| ((i * 11 % 19) as f32) * 0.07 - 0.6)
1843            .collect();
1844        let filter: Vec<f32> = (0..f_len)
1845            .map(|i| ((i * 3 % 13) as f32) * 0.1 - 0.6)
1846            .collect();
1847        let grad_output: Vec<f32> = (0..out_len)
1848            .map(|i| ((i * 17 % 7) as f32) * 0.15 - 0.4)
1849            .collect();
1850
1851        // Analytic filter gradient.
1852        let ip = upload_f32(&be, &input);
1853        let gop = upload_f32(&be, &grad_output);
1854        let gfp = be.alloc(f_len * 4).unwrap();
1855        be.conv2d_backward_filter(ip, &in_shape, gop, &out_shape, gfp, &f_shape, &stride, &pad)
1856            .unwrap();
1857        let analytic = download_f32(&be, gfp, f_len);
1858
1859        // Central difference on every filter element.
1860        let eps = 1e-2f32;
1861        for idx in 0..f_len {
1862            let mut plus = filter.clone();
1863            let mut minus = filter.clone();
1864            plus[idx] += eps;
1865            minus[idx] -= eps;
1866            let lp = conv_loss(
1867                &be,
1868                &input,
1869                in_shape,
1870                &plus,
1871                f_shape,
1872                out_shape,
1873                stride,
1874                pad,
1875                &grad_output,
1876            );
1877            let lm = conv_loss(
1878                &be,
1879                &input,
1880                in_shape,
1881                &minus,
1882                f_shape,
1883                out_shape,
1884                stride,
1885                pad,
1886                &grad_output,
1887            );
1888            let fd = (lp - lm) / (2.0 * eps);
1889            assert!(
1890                (analytic[idx] - fd).abs() < 1e-2,
1891                "grad_filter[{idx}] analytic {} vs finite-diff {fd}",
1892                analytic[idx]
1893            );
1894        }
1895    }
1896
1897    #[test]
1898    fn conv2d_backward_data_known_1x1_filter() {
1899        // With a 1x1x1x1 filter [w], grad_input = grad_output * w everywhere
1900        // (no spatial mixing), which is trivial to verify by hand.
1901        let be = CpuBackend::new();
1902        let grad_output: Vec<f32> = (1..=9).map(|x| x as f32).collect();
1903        let gop = upload_f32(&be, &grad_output);
1904        let fp = upload_f32(&be, &[3.0f32]);
1905        let gip = be.alloc(9 * 4).unwrap();
1906        be.conv2d_backward_data(
1907            gop,
1908            &[1, 1, 3, 3],
1909            fp,
1910            &[1, 1, 1, 1],
1911            gip,
1912            &[1, 1, 3, 3],
1913            &[1, 1],
1914            &[0, 0],
1915        )
1916        .unwrap();
1917        let got = download_f32(&be, gip, 9);
1918        let want: Vec<f32> = grad_output.iter().map(|g| g * 3.0).collect();
1919        assert_eq!(got, want);
1920    }
1921
1922    #[test]
1923    fn conv2d_backward_rejects_shape_mismatch() {
1924        let be = CpuBackend::new();
1925        let gop = be.alloc(9 * 4).unwrap();
1926        let fp = be.alloc(4 * 4).unwrap();
1927        let gip = be.alloc(9 * 4).unwrap();
1928        // grad_output claims 3x3 but a 2x2 filter over 3x3 input (stride 1,
1929        // pad 0) yields a 2x2 forward output ⇒ mismatch must be rejected.
1930        let err = be.conv2d_backward_data(
1931            gop,
1932            &[1, 1, 3, 3],
1933            fp,
1934            &[1, 1, 2, 2],
1935            gip,
1936            &[1, 1, 3, 3],
1937            &[1, 1],
1938            &[0, 0],
1939        );
1940        assert!(matches!(err, Err(BackendError::InvalidArgument(_))));
1941
1942        // Same for backward_filter.
1943        let ip = be.alloc(9 * 4).unwrap();
1944        let err2 = be.conv2d_backward_filter(
1945            ip,
1946            &[1, 1, 3, 3],
1947            gop,
1948            &[1, 1, 3, 3],
1949            fp,
1950            &[1, 1, 2, 2],
1951            &[1, 1],
1952            &[0, 0],
1953        );
1954        assert!(matches!(err2, Err(BackendError::InvalidArgument(_))));
1955    }
1956}