Skip to main content

oxicuda_backend/
lib.rs

1//! Abstract compute backend for GPU-accelerated operations.
2//!
3//! The [`ComputeBackend`] trait defines the interface for GPU computation,
4//! allowing higher-level crates (SciRS2, oxionnx, ToRSh, TrustformeRS)
5//! to use GPU acceleration without coupling to specific GPU APIs.
6//!
7//! # Architecture
8//!
9//! ```text
10//! ┌─────────────────────────────┐
11//! │  SciRS2 / ToRSh / oxionnx  │
12//! │         (consumers)         │
13//! └─────────────┬───────────────┘
14//!               │  dyn ComputeBackend
15//! ┌─────────────▼───────────────┐
16//! │   BackendRegistry (select)  │
17//! │       ComputeBackend        │
18//! │     (trait definition)      │
19//! └─────────────┬───────────────┘
20//!               │
21//! ┌─────────────▼───────────────┐
22//! │  CudaBackend / MetalBackend │
23//! │  CpuBackend / NullBackend   │
24//! │    (concrete impls)         │
25//! └─────────────────────────────┘
26//! ```
27//!
28//! # Beyond the trait
29//!
30//! This crate also ships the host-side **control plane** that turns the bare
31//! trait into a usable multi-backend abstraction layer:
32//!
33//! * [`BackendKind`] — names every backend and ranks them by preference.
34//! * [`BackendRegistry`] — registers backends, probes availability, and does
35//!   capability-based selection plus GPU→CPU [`fallback chains`](BackendRegistry::fallback_chain).
36//! * [`Capabilities`] / [`DeviceInfo`] — per-backend feature and device
37//!   discovery.
38//! * [`CpuBackend`] — a genuinely-working pure-Rust reference backend (the
39//!   always-available fallback and the numerical reference for conformance).
40//! * [`NullBackend`] — a scaffold that refuses every op, for testing dispatch.
41//!
42//! None of the control-plane logic touches a GPU, so it is fully testable on
43//! a machine with no accelerator.
44
45mod backend_kind;
46mod capabilities;
47mod cpu;
48mod error;
49mod null;
50mod ops;
51mod precision;
52mod registry;
53
54use std::fmt;
55
56pub use backend_kind::BackendKind;
57pub use capabilities::{Capabilities, DeviceInfo, MemoryKind, TileShape, default_tile_for};
58pub use cpu::CpuBackend;
59pub use error::{BackendError, BackendResult};
60pub use null::NullBackend;
61pub use ops::{BackendTranspose, BinaryOp, MixedPrecision, ReduceOp, UnaryOp};
62pub use precision::{round_to_bf16, round_to_f16};
63pub use registry::{BackendEntry, BackendRegistry, OpClass, SelectionRequest};
64
65// ─── ComputeBackend trait ───────────────────────────────────
66
67/// Abstract compute backend trait.
68///
69/// Implementations provide GPU-accelerated compute operations.
70/// All operations work with opaque device memory pointers (`u64`)
71/// and explicit shape/stride information, making the trait
72/// independent of any particular memory management scheme.
73///
74/// # Object Safety
75///
76/// This trait is object-safe and can be used as `Box<dyn ComputeBackend>`
77/// or `&dyn ComputeBackend` for dynamic dispatch.
78///
79/// # Lifecycle
80///
81/// 1. Create the backend (`CudaBackend::new()`).
82/// 2. Call [`init`](ComputeBackend::init) to select a device and create a context.
83/// 3. Allocate memory with [`alloc`](ComputeBackend::alloc).
84/// 4. Transfer data with [`copy_htod`](ComputeBackend::copy_htod).
85/// 5. Run compute operations ([`gemm`](ComputeBackend::gemm), [`conv2d_forward`](ComputeBackend::conv2d_forward), etc.).
86/// 6. Read results with [`copy_dtoh`](ComputeBackend::copy_dtoh).
87/// 7. Free memory with [`free`](ComputeBackend::free).
88pub trait ComputeBackend: Send + Sync + fmt::Debug {
89    /// Backend name (e.g., `"cuda"`, `"rocm"`, `"metal"`).
90    fn name(&self) -> &str;
91
92    /// Initialize the backend (select device, create context).
93    ///
94    /// Must be called before any other operation. Calling `init` on an
95    /// already-initialized backend is a no-op.
96    fn init(&mut self) -> BackendResult<()>;
97
98    /// Returns `true` if the backend is ready for operations.
99    fn is_initialized(&self) -> bool;
100
101    /// Report this backend's capabilities (precision support, Tensor Cores,
102    /// unified memory, thread/shared-memory limits, …).
103    ///
104    /// The default is the conservative CPU profile; GPU backends override it
105    /// with values read from their driver.
106    fn capabilities(&self) -> Capabilities {
107        Capabilities::default()
108    }
109
110    /// Enumerate the devices this backend exposes, in a backend-agnostic
111    /// [`DeviceInfo`] shape.
112    ///
113    /// The default returns an empty list (a backend that cannot enumerate
114    /// devices, e.g. a pure trait stub). Real backends override it; the
115    /// [`CpuBackend`] reports a single host device.
116    fn available_devices(&self) -> BackendResult<Vec<DeviceInfo>> {
117        Ok(Vec::new())
118    }
119
120    /// Suggest a GEMM tile shape `(tile_m, tile_n, tile_k)` for the given
121    /// problem dimensions, to seed an autotuner.
122    ///
123    /// The default heuristic scales the tile with the problem size and snaps
124    /// to a WMMA-aligned tile when [`capabilities`](Self::capabilities)
125    /// reports Tensor Cores. Backends may override with hardware-specific
126    /// shapes.
127    fn recommended_tile_for(&self, m: usize, n: usize, k: usize) -> TileShape {
128        default_tile_for(m, n, k, &self.capabilities())
129    }
130
131    /// General matrix multiply: `C = alpha * op(A) * op(B) + beta * C`.
132    ///
133    /// # Arguments
134    ///
135    /// * `trans_a`, `trans_b` — transpose modes for A and B.
136    /// * `m`, `n`, `k` — matrix dimensions (C is m×n, A is m×k, B is k×n after transpose).
137    /// * `alpha`, `beta` — scaling factors.
138    /// * `a_ptr`, `b_ptr`, `c_ptr` — device pointers to column-major f64 matrices.
139    /// * `lda`, `ldb`, `ldc` — leading dimensions.
140    #[allow(clippy::too_many_arguments)]
141    fn gemm(
142        &self,
143        trans_a: BackendTranspose,
144        trans_b: BackendTranspose,
145        m: usize,
146        n: usize,
147        k: usize,
148        alpha: f64,
149        a_ptr: u64,
150        lda: usize,
151        b_ptr: u64,
152        ldb: usize,
153        beta: f64,
154        c_ptr: u64,
155        ldc: usize,
156    ) -> BackendResult<()>;
157
158    /// 2D convolution forward pass.
159    ///
160    /// # Arguments
161    ///
162    /// * `input_ptr` — device pointer to input tensor (NCHW layout).
163    /// * `input_shape` — `[N, C, H, W]`.
164    /// * `filter_ptr` — device pointer to filter tensor.
165    /// * `filter_shape` — `[K, C, Fh, Fw]`.
166    /// * `output_ptr` — device pointer to output tensor.
167    /// * `output_shape` — `[N, K, Oh, Ow]`.
168    /// * `stride` — `[sh, sw]`.
169    /// * `padding` — `[ph, pw]`.
170    #[allow(clippy::too_many_arguments)]
171    fn conv2d_forward(
172        &self,
173        input_ptr: u64,
174        input_shape: &[usize],
175        filter_ptr: u64,
176        filter_shape: &[usize],
177        output_ptr: u64,
178        output_shape: &[usize],
179        stride: &[usize],
180        padding: &[usize],
181    ) -> BackendResult<()>;
182
183    /// Scaled dot-product attention.
184    ///
185    /// Computes `softmax(Q * K^T / scale) * V` with optional causal masking.
186    ///
187    /// # Arguments
188    ///
189    /// * `q_ptr`, `k_ptr`, `v_ptr` — device pointers to query, key, value tensors.
190    /// * `o_ptr` — device pointer to output tensor.
191    /// * `batch`, `heads` — batch size and number of attention heads.
192    /// * `seq_q`, `seq_kv` — query and key/value sequence lengths.
193    /// * `head_dim` — dimension of each attention head.
194    /// * `scale` — attention scale factor (typically `1 / sqrt(head_dim)`).
195    /// * `causal` — if `true`, apply causal (lower-triangular) mask.
196    #[allow(clippy::too_many_arguments)]
197    fn attention(
198        &self,
199        q_ptr: u64,
200        k_ptr: u64,
201        v_ptr: u64,
202        o_ptr: u64,
203        batch: usize,
204        heads: usize,
205        seq_q: usize,
206        seq_kv: usize,
207        head_dim: usize,
208        scale: f64,
209        causal: bool,
210    ) -> BackendResult<()>;
211
212    /// Reduction along an axis.
213    ///
214    /// Reduces `input` along `axis` using the specified `op` and writes to `output`.
215    fn reduce(
216        &self,
217        op: ReduceOp,
218        input_ptr: u64,
219        output_ptr: u64,
220        shape: &[usize],
221        axis: usize,
222    ) -> BackendResult<()>;
223
224    /// Element-wise unary operation.
225    ///
226    /// Applies `op` to each of the `n` elements at `input_ptr` and writes to `output_ptr`.
227    fn unary(&self, op: UnaryOp, input_ptr: u64, output_ptr: u64, n: usize) -> BackendResult<()>;
228
229    /// Element-wise binary operation.
230    ///
231    /// Applies `op` element-wise: `output[i] = op(a[i], b[i])` for `n` elements.
232    fn binary(
233        &self,
234        op: BinaryOp,
235        a_ptr: u64,
236        b_ptr: u64,
237        output_ptr: u64,
238        n: usize,
239    ) -> BackendResult<()>;
240
241    /// Mixed-precision GEMM: `C = alpha * op(A) * op(B) + beta * C` where the
242    /// `A`/`B` operands are **stored** in a reduced 16-bit format
243    /// ([`MixedPrecision::F16`] or [`MixedPrecision::Bf16`]) but the dot
244    /// products **accumulate in `f32`** — the Tensor-Core / WMMA contract.
245    ///
246    /// Unlike [`gemm`](Self::gemm) (column-major `f64`), this operates on
247    /// column-major **`f32`** buffers, matching the `f32` storage every GPU
248    /// uploads to a half/bfloat16 GEMM. The CPU reference emulates the 16-bit
249    /// storage by rounding each input element to the target format
250    /// (round-to-nearest, ties-to-even) before the f32 accumulation, so its
251    /// output equals what a real reduced-precision kernel would produce.
252    ///
253    /// # Arguments
254    ///
255    /// * `prec` — input storage format (`f16` or `bf16`); the accumulator and
256    ///   output `C` are always `f32`.
257    /// * `trans_a`, `trans_b` — transpose modes for A and B.
258    /// * `m`, `n`, `k` — matrix dimensions (C is m×n, A is m×k, B is k×n after transpose).
259    /// * `alpha`, `beta` — scaling factors (applied in `f32`).
260    /// * `a_ptr`, `b_ptr`, `c_ptr` — device pointers to column-major `f32` matrices.
261    /// * `lda`, `ldb`, `ldc` — leading dimensions.
262    ///
263    /// The default implementation returns [`BackendError::Unsupported`]; the
264    /// [`CpuBackend`] implements the reference math, and GPU backends override
265    /// it with a Tensor-Core kernel.
266    #[allow(clippy::too_many_arguments)]
267    fn gemm_mixed_precision(
268        &self,
269        prec: MixedPrecision,
270        trans_a: BackendTranspose,
271        trans_b: BackendTranspose,
272        m: usize,
273        n: usize,
274        k: usize,
275        alpha: f32,
276        a_ptr: u64,
277        lda: usize,
278        b_ptr: u64,
279        ldb: usize,
280        beta: f32,
281        c_ptr: u64,
282        ldc: usize,
283    ) -> BackendResult<()> {
284        let _ = (
285            prec, trans_a, trans_b, m, n, k, alpha, a_ptr, lda, b_ptr, ldb, beta, c_ptr, ldc,
286        );
287        Err(BackendError::Unsupported(
288            "gemm_mixed_precision not implemented by this backend".into(),
289        ))
290    }
291
292    /// Backward pass of [`conv2d_forward`](Self::conv2d_forward) w.r.t. the
293    /// **input** (data gradient): given the upstream gradient `grad_output`,
294    /// produce `grad_input` of the same shape as the forward input.
295    ///
296    /// Mathematically this is the *full* convolution of `grad_output` with the
297    /// spatially-flipped filter (equivalently, the transpose of the forward
298    /// im2col matrix applied to the upstream gradient). All tensors are
299    /// row-major `f32` in NCHW / KCHW layout, matching
300    /// [`conv2d_forward`](Self::conv2d_forward).
301    ///
302    /// # Arguments
303    ///
304    /// * `grad_output_ptr` / `grad_output_shape` — upstream gradient, `[N, K, Oh, Ow]`.
305    /// * `filter_ptr` / `filter_shape` — forward filter, `[K, C, Fh, Fw]`.
306    /// * `grad_input_ptr` / `grad_input_shape` — output data gradient, `[N, C, H, W]`.
307    /// * `stride` — `[sh, sw]`; `padding` — `[ph, pw]` (same as the forward pass).
308    ///
309    /// The default returns [`BackendError::Unsupported`]; the [`CpuBackend`]
310    /// implements the reference math.
311    #[allow(clippy::too_many_arguments)]
312    fn conv2d_backward_data(
313        &self,
314        grad_output_ptr: u64,
315        grad_output_shape: &[usize],
316        filter_ptr: u64,
317        filter_shape: &[usize],
318        grad_input_ptr: u64,
319        grad_input_shape: &[usize],
320        stride: &[usize],
321        padding: &[usize],
322    ) -> BackendResult<()> {
323        let _ = (
324            grad_output_ptr,
325            grad_output_shape,
326            filter_ptr,
327            filter_shape,
328            grad_input_ptr,
329            grad_input_shape,
330            stride,
331            padding,
332        );
333        Err(BackendError::Unsupported(
334            "conv2d_backward_data not implemented by this backend".into(),
335        ))
336    }
337
338    /// Backward pass of [`conv2d_forward`](Self::conv2d_forward) w.r.t. the
339    /// **filter** (weight gradient): given the forward input and the upstream
340    /// gradient `grad_output`, produce `grad_filter` of the same shape as the
341    /// forward filter.
342    ///
343    /// Mathematically this is the correlation of `input` with `grad_output`
344    /// (the forward im2col matrix multiplied by the upstream gradient). All
345    /// tensors are row-major `f32` in NCHW / KCHW layout, matching
346    /// [`conv2d_forward`](Self::conv2d_forward).
347    ///
348    /// # Arguments
349    ///
350    /// * `input_ptr` / `input_shape` — forward input, `[N, C, H, W]`.
351    /// * `grad_output_ptr` / `grad_output_shape` — upstream gradient, `[N, K, Oh, Ow]`.
352    /// * `grad_filter_ptr` / `grad_filter_shape` — output weight gradient, `[K, C, Fh, Fw]`.
353    /// * `stride` — `[sh, sw]`; `padding` — `[ph, pw]` (same as the forward pass).
354    ///
355    /// The default returns [`BackendError::Unsupported`]; the [`CpuBackend`]
356    /// implements the reference math.
357    #[allow(clippy::too_many_arguments)]
358    fn conv2d_backward_filter(
359        &self,
360        input_ptr: u64,
361        input_shape: &[usize],
362        grad_output_ptr: u64,
363        grad_output_shape: &[usize],
364        grad_filter_ptr: u64,
365        grad_filter_shape: &[usize],
366        stride: &[usize],
367        padding: &[usize],
368    ) -> BackendResult<()> {
369        let _ = (
370            input_ptr,
371            input_shape,
372            grad_output_ptr,
373            grad_output_shape,
374            grad_filter_ptr,
375            grad_filter_shape,
376            stride,
377            padding,
378        );
379        Err(BackendError::Unsupported(
380            "conv2d_backward_filter not implemented by this backend".into(),
381        ))
382    }
383
384    /// Numerically-stable softmax along `axis` of the tensor described by
385    /// `shape` (row-major, `f32`).
386    ///
387    /// The default implementation returns
388    /// [`BackendError::Unsupported`];
389    /// the [`CpuBackend`] implements it directly, and GPU backends override
390    /// it with a fused kernel. Consumers that need softmax on a backend that
391    /// does not provide it can still compose it from
392    /// `reduce(Max) + unary(Exp) + reduce(Sum) + binary(Div)`.
393    fn softmax(
394        &self,
395        input_ptr: u64,
396        output_ptr: u64,
397        shape: &[usize],
398        axis: usize,
399    ) -> BackendResult<()> {
400        let _ = (input_ptr, output_ptr, shape, axis);
401        Err(BackendError::Unsupported(
402            "softmax not implemented by this backend".into(),
403        ))
404    }
405
406    /// Row-gather: copy the rows named by `indices` out of a `rows × cols`
407    /// (`f32`, row-major) table into a contiguous output of
408    /// `indices.len() × cols`.
409    ///
410    /// Needed by embedding tables and MoE routing. The default returns
411    /// [`BackendError::Unsupported`].
412    fn gather(
413        &self,
414        input_ptr: u64,
415        indices: &[usize],
416        output_ptr: u64,
417        rows: usize,
418        cols: usize,
419    ) -> BackendResult<()> {
420        let _ = (input_ptr, indices, output_ptr, rows, cols);
421        Err(BackendError::Unsupported(
422            "gather not implemented by this backend".into(),
423        ))
424    }
425
426    /// Row-scatter: write each input row (`indices.len() × cols`, `f32`) into
427    /// `output` at the destination row given by `indices`, preserving
428    /// unreferenced rows of the `rows × cols` output table.
429    ///
430    /// The inverse routing primitive to [`gather`](Self::gather). The default
431    /// returns [`BackendError::Unsupported`].
432    fn scatter(
433        &self,
434        input_ptr: u64,
435        indices: &[usize],
436        output_ptr: u64,
437        rows: usize,
438        cols: usize,
439    ) -> BackendResult<()> {
440        let _ = (input_ptr, indices, output_ptr, rows, cols);
441        Err(BackendError::Unsupported(
442            "scatter not implemented by this backend".into(),
443        ))
444    }
445
446    /// Strided batched GEMM: for each batch `b` in `0..batch_count`,
447    /// compute `C_b = alpha * op(A_b) * op(B_b) + beta * C_b`
448    /// where `A_b` starts at `a_ptr + b * stride_a * 4` bytes (f32 elements), etc.
449    ///
450    /// # Arguments
451    ///
452    /// * `trans_a`, `trans_b` — transpose modes for A and B.
453    /// * `m`, `n`, `k` — matrix dimensions (C is m×n).
454    /// * `alpha`, `beta` — scaling factors.
455    /// * `a_ptr`, `b_ptr`, `c_ptr` — device pointers to the first matrix in each batch.
456    /// * `lda`, `ldb`, `ldc` — leading dimensions.
457    /// * `stride_a`, `stride_b`, `stride_c` — element strides between consecutive matrices.
458    /// * `batch_count` — number of GEMM operations in the batch.
459    ///
460    /// The default implementation dispatches `batch_count` individual
461    /// [`gemm`](Self::gemm) calls with pointer offsets.
462    #[allow(clippy::too_many_arguments)]
463    fn batched_gemm(
464        &self,
465        trans_a: BackendTranspose,
466        trans_b: BackendTranspose,
467        m: usize,
468        n: usize,
469        k: usize,
470        alpha: f64,
471        a_ptr: u64,
472        lda: usize,
473        stride_a: usize,
474        b_ptr: u64,
475        ldb: usize,
476        stride_b: usize,
477        beta: f64,
478        c_ptr: u64,
479        ldc: usize,
480        stride_c: usize,
481        batch_count: usize,
482    ) -> BackendResult<()> {
483        // Default: loop over individual gemm calls with byte-offset pointers.
484        // Backends should override with a single batched kernel for efficiency.
485        let elem_bytes: u64 = 4; // f32
486        for b in 0..batch_count {
487            let b64 = b as u64;
488            self.gemm(
489                trans_a,
490                trans_b,
491                m,
492                n,
493                k,
494                alpha,
495                a_ptr + b64 * stride_a as u64 * elem_bytes,
496                lda,
497                b_ptr + b64 * stride_b as u64 * elem_bytes,
498                ldb,
499                beta,
500                c_ptr + b64 * stride_c as u64 * elem_bytes,
501                ldc,
502            )?;
503        }
504        Ok(())
505    }
506
507    /// Synchronize all pending operations on this backend.
508    ///
509    /// Blocks the host until all previously submitted GPU work completes.
510    fn synchronize(&self) -> BackendResult<()>;
511
512    /// Allocate device memory.
513    ///
514    /// Returns an opaque device pointer. The caller is responsible for
515    /// eventually calling [`free`](ComputeBackend::free).
516    fn alloc(&self, bytes: usize) -> BackendResult<u64>;
517
518    /// Free device memory previously allocated with [`alloc`](ComputeBackend::alloc).
519    fn free(&self, ptr: u64) -> BackendResult<()>;
520
521    /// Copy data from host memory to device memory.
522    ///
523    /// * `dst` — device pointer (destination).
524    /// * `src` — host byte slice (source).
525    fn copy_htod(&self, dst: u64, src: &[u8]) -> BackendResult<()>;
526
527    /// Copy data from device memory to host memory.
528    ///
529    /// * `dst` — host byte slice (destination).
530    /// * `src` — device pointer (source).
531    fn copy_dtoh(&self, dst: &mut [u8], src: u64) -> BackendResult<()>;
532}
533
534// ─── Blanket impl for &mut T ─────────────────────────────────
535
536/// Forward every [`ComputeBackend`] method through a mutable reference, so
537/// callers holding `&mut dyn ComputeBackend` (or `&mut T`) can pass it where
538/// a `ComputeBackend` is expected without re-boxing.
539impl<T: ComputeBackend + ?Sized> ComputeBackend for &mut T {
540    fn name(&self) -> &str {
541        (**self).name()
542    }
543
544    fn init(&mut self) -> BackendResult<()> {
545        (**self).init()
546    }
547
548    fn is_initialized(&self) -> bool {
549        (**self).is_initialized()
550    }
551
552    fn capabilities(&self) -> Capabilities {
553        (**self).capabilities()
554    }
555
556    fn available_devices(&self) -> BackendResult<Vec<DeviceInfo>> {
557        (**self).available_devices()
558    }
559
560    fn recommended_tile_for(&self, m: usize, n: usize, k: usize) -> TileShape {
561        (**self).recommended_tile_for(m, n, k)
562    }
563
564    fn gemm(
565        &self,
566        trans_a: BackendTranspose,
567        trans_b: BackendTranspose,
568        m: usize,
569        n: usize,
570        k: usize,
571        alpha: f64,
572        a_ptr: u64,
573        lda: usize,
574        b_ptr: u64,
575        ldb: usize,
576        beta: f64,
577        c_ptr: u64,
578        ldc: usize,
579    ) -> BackendResult<()> {
580        (**self).gemm(
581            trans_a, trans_b, m, n, k, alpha, a_ptr, lda, b_ptr, ldb, beta, c_ptr, ldc,
582        )
583    }
584
585    fn conv2d_forward(
586        &self,
587        input_ptr: u64,
588        input_shape: &[usize],
589        filter_ptr: u64,
590        filter_shape: &[usize],
591        output_ptr: u64,
592        output_shape: &[usize],
593        stride: &[usize],
594        padding: &[usize],
595    ) -> BackendResult<()> {
596        (**self).conv2d_forward(
597            input_ptr,
598            input_shape,
599            filter_ptr,
600            filter_shape,
601            output_ptr,
602            output_shape,
603            stride,
604            padding,
605        )
606    }
607
608    fn attention(
609        &self,
610        q_ptr: u64,
611        k_ptr: u64,
612        v_ptr: u64,
613        o_ptr: u64,
614        batch: usize,
615        heads: usize,
616        seq_q: usize,
617        seq_kv: usize,
618        head_dim: usize,
619        scale: f64,
620        causal: bool,
621    ) -> BackendResult<()> {
622        (**self).attention(
623            q_ptr, k_ptr, v_ptr, o_ptr, batch, heads, seq_q, seq_kv, head_dim, scale, causal,
624        )
625    }
626
627    fn reduce(
628        &self,
629        op: ReduceOp,
630        input_ptr: u64,
631        output_ptr: u64,
632        shape: &[usize],
633        axis: usize,
634    ) -> BackendResult<()> {
635        (**self).reduce(op, input_ptr, output_ptr, shape, axis)
636    }
637
638    fn unary(&self, op: UnaryOp, input_ptr: u64, output_ptr: u64, n: usize) -> BackendResult<()> {
639        (**self).unary(op, input_ptr, output_ptr, n)
640    }
641
642    fn binary(
643        &self,
644        op: BinaryOp,
645        a_ptr: u64,
646        b_ptr: u64,
647        output_ptr: u64,
648        n: usize,
649    ) -> BackendResult<()> {
650        (**self).binary(op, a_ptr, b_ptr, output_ptr, n)
651    }
652
653    fn gemm_mixed_precision(
654        &self,
655        prec: MixedPrecision,
656        trans_a: BackendTranspose,
657        trans_b: BackendTranspose,
658        m: usize,
659        n: usize,
660        k: usize,
661        alpha: f32,
662        a_ptr: u64,
663        lda: usize,
664        b_ptr: u64,
665        ldb: usize,
666        beta: f32,
667        c_ptr: u64,
668        ldc: usize,
669    ) -> BackendResult<()> {
670        (**self).gemm_mixed_precision(
671            prec, trans_a, trans_b, m, n, k, alpha, a_ptr, lda, b_ptr, ldb, beta, c_ptr, ldc,
672        )
673    }
674
675    fn conv2d_backward_data(
676        &self,
677        grad_output_ptr: u64,
678        grad_output_shape: &[usize],
679        filter_ptr: u64,
680        filter_shape: &[usize],
681        grad_input_ptr: u64,
682        grad_input_shape: &[usize],
683        stride: &[usize],
684        padding: &[usize],
685    ) -> BackendResult<()> {
686        (**self).conv2d_backward_data(
687            grad_output_ptr,
688            grad_output_shape,
689            filter_ptr,
690            filter_shape,
691            grad_input_ptr,
692            grad_input_shape,
693            stride,
694            padding,
695        )
696    }
697
698    fn conv2d_backward_filter(
699        &self,
700        input_ptr: u64,
701        input_shape: &[usize],
702        grad_output_ptr: u64,
703        grad_output_shape: &[usize],
704        grad_filter_ptr: u64,
705        grad_filter_shape: &[usize],
706        stride: &[usize],
707        padding: &[usize],
708    ) -> BackendResult<()> {
709        (**self).conv2d_backward_filter(
710            input_ptr,
711            input_shape,
712            grad_output_ptr,
713            grad_output_shape,
714            grad_filter_ptr,
715            grad_filter_shape,
716            stride,
717            padding,
718        )
719    }
720
721    fn softmax(
722        &self,
723        input_ptr: u64,
724        output_ptr: u64,
725        shape: &[usize],
726        axis: usize,
727    ) -> BackendResult<()> {
728        (**self).softmax(input_ptr, output_ptr, shape, axis)
729    }
730
731    fn gather(
732        &self,
733        input_ptr: u64,
734        indices: &[usize],
735        output_ptr: u64,
736        rows: usize,
737        cols: usize,
738    ) -> BackendResult<()> {
739        (**self).gather(input_ptr, indices, output_ptr, rows, cols)
740    }
741
742    fn scatter(
743        &self,
744        input_ptr: u64,
745        indices: &[usize],
746        output_ptr: u64,
747        rows: usize,
748        cols: usize,
749    ) -> BackendResult<()> {
750        (**self).scatter(input_ptr, indices, output_ptr, rows, cols)
751    }
752
753    fn synchronize(&self) -> BackendResult<()> {
754        (**self).synchronize()
755    }
756
757    fn alloc(&self, bytes: usize) -> BackendResult<u64> {
758        (**self).alloc(bytes)
759    }
760
761    fn free(&self, ptr: u64) -> BackendResult<()> {
762        (**self).free(ptr)
763    }
764
765    fn copy_htod(&self, dst: u64, src: &[u8]) -> BackendResult<()> {
766        (**self).copy_htod(dst, src)
767    }
768
769    fn copy_dtoh(&self, dst: &mut [u8], src: u64) -> BackendResult<()> {
770        (**self).copy_dtoh(dst, src)
771    }
772}
773
774// ─── Tests ──────────────────────────────────────────────────
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779
780    // ── Mock backend that records every call, for testing default impls
781    //    and consumer dispatch logic without a GPU. ──
782
783    use std::sync::Mutex;
784    use std::sync::atomic::{AtomicUsize, Ordering};
785
786    /// A `(operation, byte/elem count)` record kept by [`MockBackend`].
787    #[derive(Debug, Clone, PartialEq, Eq)]
788    struct CallRecord {
789        op: &'static str,
790        count: usize,
791    }
792
793    #[derive(Debug)]
794    struct MockBackend {
795        gemm_call_count: AtomicUsize,
796        log: Mutex<Vec<CallRecord>>,
797    }
798
799    impl MockBackend {
800        fn new() -> Self {
801            Self {
802                gemm_call_count: AtomicUsize::new(0),
803                log: Mutex::new(Vec::new()),
804            }
805        }
806
807        fn record(&self, op: &'static str, count: usize) {
808            self.log.lock().unwrap().push(CallRecord { op, count });
809        }
810
811        fn calls(&self) -> Vec<CallRecord> {
812            self.log.lock().unwrap().clone()
813        }
814    }
815
816    impl ComputeBackend for MockBackend {
817        fn name(&self) -> &str {
818            "mock"
819        }
820        fn init(&mut self) -> BackendResult<()> {
821            Ok(())
822        }
823        fn is_initialized(&self) -> bool {
824            true
825        }
826        fn gemm(
827            &self,
828            _trans_a: BackendTranspose,
829            _trans_b: BackendTranspose,
830            _m: usize,
831            _n: usize,
832            _k: usize,
833            _alpha: f64,
834            _a_ptr: u64,
835            _lda: usize,
836            _b_ptr: u64,
837            _ldb: usize,
838            _beta: f64,
839            _c_ptr: u64,
840            _ldc: usize,
841        ) -> BackendResult<()> {
842            self.gemm_call_count.fetch_add(1, Ordering::Relaxed);
843            self.record("gemm", 1);
844            Ok(())
845        }
846        fn conv2d_forward(
847            &self,
848            _: u64,
849            _: &[usize],
850            _: u64,
851            _: &[usize],
852            _: u64,
853            _: &[usize],
854            _: &[usize],
855            _: &[usize],
856        ) -> BackendResult<()> {
857            self.record("conv2d_forward", 1);
858            Ok(())
859        }
860        fn attention(
861            &self,
862            _: u64,
863            _: u64,
864            _: u64,
865            _: u64,
866            _: usize,
867            _: usize,
868            _: usize,
869            _: usize,
870            _: usize,
871            _: f64,
872            _: bool,
873        ) -> BackendResult<()> {
874            self.record("attention", 1);
875            Ok(())
876        }
877        fn reduce(&self, _: ReduceOp, _: u64, _: u64, _: &[usize], _: usize) -> BackendResult<()> {
878            self.record("reduce", 1);
879            Ok(())
880        }
881        fn unary(&self, _: UnaryOp, _: u64, _: u64, n: usize) -> BackendResult<()> {
882            self.record("unary", n);
883            Ok(())
884        }
885        fn binary(&self, _: BinaryOp, _: u64, _: u64, _: u64, n: usize) -> BackendResult<()> {
886            self.record("binary", n);
887            Ok(())
888        }
889        fn synchronize(&self) -> BackendResult<()> {
890            Ok(())
891        }
892        fn alloc(&self, bytes: usize) -> BackendResult<u64> {
893            self.record("alloc", bytes);
894            Ok(0)
895        }
896        fn free(&self, _: u64) -> BackendResult<()> {
897            Ok(())
898        }
899        fn copy_htod(&self, _: u64, src: &[u8]) -> BackendResult<()> {
900            self.record("copy_htod", src.len());
901            Ok(())
902        }
903        fn copy_dtoh(&self, dst: &mut [u8], _: u64) -> BackendResult<()> {
904            self.record("copy_dtoh", dst.len());
905            Ok(())
906        }
907    }
908
909    #[test]
910    fn batched_gemm_zero_batch_is_noop() {
911        let backend = MockBackend::new();
912        let result = backend.batched_gemm(
913            BackendTranspose::NoTrans,
914            BackendTranspose::NoTrans,
915            4,
916            4,
917            4,
918            1.0,
919            0,
920            4,
921            16,
922            0,
923            4,
924            16,
925            0.0,
926            0,
927            4,
928            16,
929            0, // batch_count = 0
930        );
931        assert!(result.is_ok());
932        assert_eq!(backend.gemm_call_count.load(Ordering::Relaxed), 0);
933    }
934
935    #[test]
936    fn batched_gemm_default_calls_gemm_n_times() {
937        let backend = MockBackend::new();
938        let batch_count = 7;
939        let result = backend.batched_gemm(
940            BackendTranspose::NoTrans,
941            BackendTranspose::Trans,
942            8,
943            8,
944            8,
945            1.0,
946            1000,
947            8,
948            64,
949            2000,
950            8,
951            64,
952            0.0,
953            3000,
954            8,
955            64,
956            batch_count,
957        );
958        assert!(result.is_ok());
959        assert_eq!(backend.gemm_call_count.load(Ordering::Relaxed), batch_count);
960    }
961
962    #[test]
963    fn batched_gemm_single_batch() {
964        let backend = MockBackend::new();
965        let result = backend.batched_gemm(
966            BackendTranspose::NoTrans,
967            BackendTranspose::NoTrans,
968            16,
969            16,
970            16,
971            1.0,
972            0,
973            16,
974            256,
975            0,
976            16,
977            256,
978            1.0,
979            0,
980            16,
981            256,
982            1,
983        );
984        assert!(result.is_ok());
985        assert_eq!(backend.gemm_call_count.load(Ordering::Relaxed), 1);
986    }
987
988    #[test]
989    fn mock_records_dispatch_for_consumer_tests() {
990        let backend = MockBackend::new();
991        backend.alloc(128).unwrap();
992        backend.copy_htod(0, &[0u8; 32]).unwrap();
993        backend.unary(UnaryOp::Relu, 0, 0, 64).unwrap();
994        backend.binary(BinaryOp::Add, 0, 0, 0, 64).unwrap();
995        let calls = backend.calls();
996        assert_eq!(
997            calls,
998            vec![
999                CallRecord {
1000                    op: "alloc",
1001                    count: 128
1002                },
1003                CallRecord {
1004                    op: "copy_htod",
1005                    count: 32
1006                },
1007                CallRecord {
1008                    op: "unary",
1009                    count: 64
1010                },
1011                CallRecord {
1012                    op: "binary",
1013                    count: 64
1014                },
1015            ]
1016        );
1017    }
1018
1019    #[test]
1020    fn default_softmax_gather_scatter_are_unsupported() {
1021        let backend = MockBackend::new();
1022        assert!(matches!(
1023            backend.softmax(0, 0, &[2, 2], 1),
1024            Err(BackendError::Unsupported(_))
1025        ));
1026        assert!(matches!(
1027            backend.gather(0, &[0], 0, 1, 1),
1028            Err(BackendError::Unsupported(_))
1029        ));
1030        assert!(matches!(
1031            backend.scatter(0, &[0], 0, 1, 1),
1032            Err(BackendError::Unsupported(_))
1033        ));
1034    }
1035
1036    #[test]
1037    fn default_mixed_precision_and_conv_backward_are_unsupported() {
1038        let backend = MockBackend::new();
1039        assert!(matches!(
1040            backend.gemm_mixed_precision(
1041                MixedPrecision::Bf16,
1042                BackendTranspose::NoTrans,
1043                BackendTranspose::NoTrans,
1044                2,
1045                2,
1046                2,
1047                1.0,
1048                0,
1049                2,
1050                0,
1051                2,
1052                0.0,
1053                0,
1054                2,
1055            ),
1056            Err(BackendError::Unsupported(_))
1057        ));
1058        assert!(matches!(
1059            backend.conv2d_backward_data(
1060                0,
1061                &[1, 1, 2, 2],
1062                0,
1063                &[1, 1, 2, 2],
1064                0,
1065                &[1, 1, 3, 3],
1066                &[1, 1],
1067                &[0, 0],
1068            ),
1069            Err(BackendError::Unsupported(_))
1070        ));
1071        assert!(matches!(
1072            backend.conv2d_backward_filter(
1073                0,
1074                &[1, 1, 3, 3],
1075                0,
1076                &[1, 1, 2, 2],
1077                0,
1078                &[1, 1, 2, 2],
1079                &[1, 1],
1080                &[0, 0],
1081            ),
1082            Err(BackendError::Unsupported(_))
1083        ));
1084    }
1085
1086    #[test]
1087    fn default_capabilities_and_tile_hint() {
1088        let backend = MockBackend::new();
1089        // Default capabilities are the conservative CPU profile.
1090        assert_eq!(backend.capabilities(), Capabilities::cpu());
1091        // Tile hint flows through the default heuristic.
1092        assert_eq!(
1093            backend.recommended_tile_for(32, 32, 32),
1094            TileShape::new(16, 16, 16)
1095        );
1096        assert!(backend.available_devices().unwrap().is_empty());
1097    }
1098
1099    #[test]
1100    fn mut_ref_blanket_forwards() {
1101        // A generic helper that only accepts something implementing the
1102        // trait *by value*. Passing `&mut MockBackend` here only compiles
1103        // because of the blanket `impl ComputeBackend for &mut T`.
1104        fn run_one_gemm<B: ComputeBackend>(mut be: B) -> BackendResult<()> {
1105            be.init()?;
1106            be.gemm(
1107                BackendTranspose::NoTrans,
1108                BackendTranspose::NoTrans,
1109                2,
1110                2,
1111                2,
1112                1.0,
1113                0,
1114                2,
1115                0,
1116                2,
1117                0.0,
1118                0,
1119                2,
1120            )
1121        }
1122
1123        let mut backend = MockBackend::new();
1124        run_one_gemm(&mut backend).unwrap();
1125        assert_eq!(backend.name(), "mock");
1126        assert_eq!(backend.gemm_call_count.load(Ordering::Relaxed), 1);
1127    }
1128
1129    #[test]
1130    fn object_safety_vec_of_mixed_backends() {
1131        // A Vec of heterogeneous backends behind dyn proves object safety.
1132        let backends: Vec<Box<dyn ComputeBackend>> = vec![
1133            Box::new(MockBackend::new()),
1134            Box::new(CpuBackend::new()),
1135            Box::new(NullBackend::new()),
1136        ];
1137        let names: Vec<&str> = backends.iter().map(|b| b.name()).collect();
1138        assert_eq!(names, vec!["mock", "cpu", "null"]);
1139        // Every backend can be synchronized through the trait object.
1140        for b in &backends {
1141            assert!(b.synchronize().is_ok());
1142        }
1143    }
1144}
1145
1146// ─── Cross-backend conformance (CPU reference) ───────────────
1147
1148#[cfg(test)]
1149mod conformance {
1150    //! Conformance tests that pin the documented numerical contract of every
1151    //! op against the [`CpuBackend`] reference implementation. A real GPU
1152    //! backend can run these same property checks against its own output to
1153    //! prove agreement with the host reference (that cross-*hardware* run
1154    //! requires the device and lives in the concrete backend crates).
1155
1156    use super::*;
1157
1158    /// Tiny deterministic LCG producing values in `[0, 1)` using the full
1159    /// 32-bit range (÷2³², never ÷2³¹).
1160    struct Lcg {
1161        state: u64,
1162    }
1163    impl Lcg {
1164        fn new(seed: u64) -> Self {
1165            Self { state: seed }
1166        }
1167        fn next_u32(&mut self) -> u32 {
1168            // Numerical Recipes LCG constants.
1169            self.state = self
1170                .state
1171                .wrapping_mul(6364136223846793005)
1172                .wrapping_add(1442695040888963407);
1173            (self.state >> 32) as u32
1174        }
1175        fn next_unit(&mut self) -> f64 {
1176            f64::from(self.next_u32()) / f64::from(u32::MAX) // full-range, ÷(2³²-1)
1177        }
1178    }
1179
1180    fn upload_f64(be: &CpuBackend, data: &[f64]) -> u64 {
1181        let ptr = be.alloc(data.len() * 8).unwrap();
1182        let mut bytes = Vec::with_capacity(data.len() * 8);
1183        for &v in data {
1184            bytes.extend_from_slice(&v.to_ne_bytes());
1185        }
1186        be.copy_htod(ptr, &bytes).unwrap();
1187        ptr
1188    }
1189    fn download_f64(be: &CpuBackend, ptr: u64, len: usize) -> Vec<f64> {
1190        let mut bytes = vec![0u8; len * 8];
1191        be.copy_dtoh(&mut bytes, ptr).unwrap();
1192        bytes
1193            .chunks_exact(8)
1194            .map(|c| {
1195                let mut b = [0u8; 8];
1196                b.copy_from_slice(c);
1197                f64::from_ne_bytes(b)
1198            })
1199            .collect()
1200    }
1201
1202    /// Naive column-major reference GEMM used as ground truth.
1203    #[allow(clippy::too_many_arguments)]
1204    fn ref_gemm(
1205        ta: BackendTranspose,
1206        tb: BackendTranspose,
1207        m: usize,
1208        n: usize,
1209        k: usize,
1210        a: &[f64],
1211        b: &[f64],
1212    ) -> Vec<f64> {
1213        let at = |row: usize, col: usize| -> f64 {
1214            match ta {
1215                BackendTranspose::NoTrans => a[col * m + row],
1216                _ => a[row * k + col],
1217            }
1218        };
1219        let bt = |row: usize, col: usize| -> f64 {
1220            match tb {
1221                BackendTranspose::NoTrans => b[col * k + row],
1222                _ => b[row * n + col],
1223            }
1224        };
1225        let mut c = vec![0.0f64; m * n];
1226        for j in 0..n {
1227            for i in 0..m {
1228                let mut acc = 0.0;
1229                for p in 0..k {
1230                    acc += at(i, p) * bt(p, j);
1231                }
1232                c[j * m + i] = acc;
1233            }
1234        }
1235        c
1236    }
1237
1238    #[test]
1239    fn gemm_matches_reference_across_all_transpose_combos() {
1240        let be = CpuBackend::new();
1241        let (m, n, k) = (3, 4, 5);
1242        let mut rng = Lcg::new(0xC0FFEE);
1243
1244        for &ta in &[
1245            BackendTranspose::NoTrans,
1246            BackendTranspose::Trans,
1247            BackendTranspose::ConjTrans,
1248        ] {
1249            for &tb in &[
1250                BackendTranspose::NoTrans,
1251                BackendTranspose::Trans,
1252                BackendTranspose::ConjTrans,
1253            ] {
1254                // A is (op rows × op cols) flattened col-major in its stored
1255                // orientation; for NoTrans that is m×k, else k×m.
1256                let a_elems = m * k;
1257                let b_elems = k * n;
1258                let a: Vec<f64> = (0..a_elems).map(|_| rng.next_unit()).collect();
1259                let b: Vec<f64> = (0..b_elems).map(|_| rng.next_unit()).collect();
1260
1261                let (lda, a_cols) = if ta == BackendTranspose::NoTrans {
1262                    (m, k)
1263                } else {
1264                    (k, m)
1265                };
1266                let (ldb, b_cols) = if tb == BackendTranspose::NoTrans {
1267                    (k, n)
1268                } else {
1269                    (n, k)
1270                };
1271                // Reformat A/B into exactly lda*cols / ldb*cols buffers.
1272                assert_eq!(a.len(), lda * a_cols);
1273                assert_eq!(b.len(), ldb * b_cols);
1274
1275                let a_ptr = upload_f64(&be, &a);
1276                let b_ptr = upload_f64(&be, &b);
1277                let c_ptr = upload_f64(&be, &vec![0.0f64; m * n]);
1278
1279                be.gemm(ta, tb, m, n, k, 1.0, a_ptr, lda, b_ptr, ldb, 0.0, c_ptr, m)
1280                    .unwrap();
1281                let got = download_f64(&be, c_ptr, m * n);
1282                let want = ref_gemm(ta, tb, m, n, k, &a, &b);
1283                for (g, w) in got.iter().zip(want.iter()) {
1284                    assert!((g - w).abs() < 1e-9, "gemm({ta},{tb}) mismatch: {g} vs {w}");
1285                }
1286
1287                be.free(a_ptr).unwrap();
1288                be.free(b_ptr).unwrap();
1289                be.free(c_ptr).unwrap();
1290            }
1291        }
1292    }
1293
1294    #[test]
1295    fn reference_backend_is_always_available_via_registry() {
1296        // The end-to-end "device-absent" story: with only defaults, the
1297        // registry selects the CPU backend, which then really runs a gemm.
1298        let reg = BackendRegistry::with_defaults();
1299        let chosen = reg.select_best().unwrap();
1300        assert_eq!(chosen, BackendKind::Cpu);
1301
1302        let be = CpuBackend::new();
1303        let a = upload_f64(&be, &[1.0, 0.0, 0.0, 1.0]);
1304        let b = upload_f64(&be, &[7.0, 8.0, 9.0, 10.0]);
1305        let c = upload_f64(&be, &[0.0; 4]);
1306        be.gemm(
1307            BackendTranspose::NoTrans,
1308            BackendTranspose::NoTrans,
1309            2,
1310            2,
1311            2,
1312            1.0,
1313            a,
1314            2,
1315            b,
1316            2,
1317            0.0,
1318            c,
1319            2,
1320        )
1321        .unwrap();
1322        // Identity * B = B.
1323        assert_eq!(download_f64(&be, c, 4), vec![7.0, 8.0, 9.0, 10.0]);
1324    }
1325}