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}