Skip to main content

oxmera_tensor/
backend.rs

1//! The backend seam: the op vocabulary every device implements, and the
2//! registry that resolves a [`Device`] handle to an implementation.
3//!
4//! The traits live here (rather than a separate crate) so that `Tensor`'s
5//! methods and `std::ops` overloads can dispatch through them without
6//! violating the orphan rule; `oxmera-ops` re-exports this vocabulary.
7
8use std::collections::HashMap;
9use std::sync::{Arc, OnceLock, RwLock};
10
11use oxmera_core::{Device, Error, Result, Shape};
12
13use crate::tensor::Tensor;
14
15/// Elementwise unary operations. Float (`f32`) tensors only.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17#[non_exhaustive]
18pub enum UnaryOp {
19    /// `-x`
20    Neg,
21    /// `e^x`
22    Exp,
23    /// `ln(x)`
24    Ln,
25    /// `|x|`
26    Abs,
27    /// `√x`
28    Sqrt,
29    /// `sin(x)`
30    Sin,
31    /// `cos(x)`
32    Cos,
33    /// `tanh(x)`
34    Tanh,
35    /// `max(x, 0)`
36    Relu,
37    /// GELU with the tanh approximation.
38    Gelu,
39    /// `1 / (1 + e^-x)`
40    Sigmoid,
41}
42
43impl UnaryOp {
44    /// Stable lowercase name, used for kernel lookup and error messages.
45    pub fn name(self) -> &'static str {
46        match self {
47            UnaryOp::Neg => "neg",
48            UnaryOp::Exp => "exp",
49            UnaryOp::Ln => "ln",
50            UnaryOp::Abs => "abs",
51            UnaryOp::Sqrt => "sqrt",
52            UnaryOp::Sin => "sin",
53            UnaryOp::Cos => "cos",
54            UnaryOp::Tanh => "tanh",
55            UnaryOp::Relu => "relu",
56            UnaryOp::Gelu => "gelu",
57            UnaryOp::Sigmoid => "sigmoid",
58        }
59    }
60
61    /// Every unary op, for exhaustive backend tests.
62    pub fn all() -> &'static [UnaryOp] {
63        &[
64            UnaryOp::Neg,
65            UnaryOp::Exp,
66            UnaryOp::Ln,
67            UnaryOp::Abs,
68            UnaryOp::Sqrt,
69            UnaryOp::Sin,
70            UnaryOp::Cos,
71            UnaryOp::Tanh,
72            UnaryOp::Relu,
73            UnaryOp::Gelu,
74            UnaryOp::Sigmoid,
75        ]
76    }
77
78    /// Apply the op to one scalar — the CPU reference semantics every
79    /// backend must reproduce.
80    pub fn eval(self, x: f32) -> f32 {
81        match self {
82            UnaryOp::Neg => -x,
83            UnaryOp::Exp => x.exp(),
84            UnaryOp::Ln => x.ln(),
85            UnaryOp::Abs => x.abs(),
86            UnaryOp::Sqrt => x.sqrt(),
87            UnaryOp::Sin => x.sin(),
88            UnaryOp::Cos => x.cos(),
89            UnaryOp::Tanh => x.tanh(),
90            UnaryOp::Relu => x.max(0.0),
91            UnaryOp::Gelu => {
92                const SQRT_2_OVER_PI: f32 = 0.797_884_6;
93                0.5 * x * (1.0 + (SQRT_2_OVER_PI * (x + 0.044_715 * x * x * x)).tanh())
94            }
95            UnaryOp::Sigmoid => 1.0 / (1.0 + (-x).exp()),
96        }
97    }
98}
99
100/// Elementwise binary operations with NumPy broadcasting. `f32` only.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
102#[non_exhaustive]
103pub enum BinaryOp {
104    /// `a + b`
105    Add,
106    /// `a - b`
107    Sub,
108    /// `a * b`
109    Mul,
110    /// `a / b`
111    Div,
112    /// `a ^ b`
113    Pow,
114    /// `max(a, b)`
115    Maximum,
116    /// `min(a, b)`
117    Minimum,
118    /// `a > b` as a 0.0/1.0 mask. Not differentiable.
119    Gt,
120    /// `a == b` as a 0.0/1.0 mask. Not differentiable.
121    Eq,
122}
123
124impl BinaryOp {
125    /// Stable lowercase name, used for kernel lookup and error messages.
126    pub fn name(self) -> &'static str {
127        match self {
128            BinaryOp::Add => "add",
129            BinaryOp::Sub => "sub",
130            BinaryOp::Mul => "mul",
131            BinaryOp::Div => "div",
132            BinaryOp::Pow => "pow",
133            BinaryOp::Maximum => "maximum",
134            BinaryOp::Minimum => "minimum",
135            BinaryOp::Gt => "gt",
136            BinaryOp::Eq => "eq",
137        }
138    }
139
140    /// Every binary op, for exhaustive backend tests.
141    pub fn all() -> &'static [BinaryOp] {
142        &[
143            BinaryOp::Add,
144            BinaryOp::Sub,
145            BinaryOp::Mul,
146            BinaryOp::Div,
147            BinaryOp::Pow,
148            BinaryOp::Maximum,
149            BinaryOp::Minimum,
150            BinaryOp::Gt,
151            BinaryOp::Eq,
152        ]
153    }
154
155    /// Apply the op to one scalar pair — the reference semantics.
156    pub fn eval(self, a: f32, b: f32) -> f32 {
157        match self {
158            BinaryOp::Add => a + b,
159            BinaryOp::Sub => a - b,
160            BinaryOp::Mul => a * b,
161            BinaryOp::Div => a / b,
162            BinaryOp::Pow => a.powf(b),
163            BinaryOp::Maximum => a.max(b),
164            BinaryOp::Minimum => a.min(b),
165            BinaryOp::Gt => f32::from(a > b),
166            BinaryOp::Eq => f32::from(a == b),
167        }
168    }
169}
170
171/// Reductions along axes. `f32` only.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
173#[non_exhaustive]
174pub enum ReduceOp {
175    /// Sum of the reduced elements.
176    Sum,
177    /// Maximum of the reduced elements.
178    Max,
179    /// Minimum of the reduced elements.
180    Min,
181}
182
183impl ReduceOp {
184    /// Stable lowercase name, used for kernel lookup and error messages.
185    pub fn name(self) -> &'static str {
186        match self {
187            ReduceOp::Sum => "sum",
188            ReduceOp::Max => "max",
189            ReduceOp::Min => "min",
190        }
191    }
192
193    /// The identity element the reduction starts from.
194    pub fn identity(self) -> f32 {
195        match self {
196            ReduceOp::Sum => 0.0,
197            ReduceOp::Max => f32::NEG_INFINITY,
198            ReduceOp::Min => f32::INFINITY,
199        }
200    }
201
202    /// Combine an accumulator with one element.
203    pub fn combine(self, acc: f32, x: f32) -> f32 {
204        match self {
205            ReduceOp::Sum => acc + x,
206            // `f32::max`/`min` return the non-NaN operand, which silently
207            // dropped a NaN that `sum` propagates and `argmax` refuses.
208            // A NaN reaching a reduction is a fault the caller needs to see.
209            ReduceOp::Max if acc.is_nan() || x.is_nan() => f32::NAN,
210            ReduceOp::Min if acc.is_nan() || x.is_nan() => f32::NAN,
211            ReduceOp::Max => acc.max(x),
212            ReduceOp::Min => acc.min(x),
213        }
214    }
215}
216
217/// The shape contract of a matmul, resolved once so every backend agrees.
218///
219/// Operands are rank 2 (`[m, k]`) or rank 3 (`[b, m, k]`). Batch
220/// dimensions broadcast: a batch of 1 pairs with a batch of `n`, and a
221/// rank-2 operand is treated as batch 1. The output is rank 2 only when
222/// both inputs are; otherwise `[batch, m, n]`.
223///
224/// Batch strides are in elements of the operand's *contiguous* data and
225/// are 0 for a broadcast operand, so no backend has to materialize the
226/// broadcast — batch `i` of `a` starts at `i * a_batch_stride`.
227#[derive(Debug, Clone, PartialEq, Eq)]
228#[non_exhaustive]
229pub struct MatmulPlan {
230    /// Output batch count (1 for a rank-2 result).
231    pub batch: usize,
232    /// Rows of `a` and of the output.
233    pub m: usize,
234    /// Shared dimension.
235    pub k: usize,
236    /// Columns of `b` and of the output.
237    pub n: usize,
238    /// Element offset between consecutive batches of `a` (0 = broadcast).
239    pub a_batch_stride: usize,
240    /// Element offset between consecutive batches of `b` (0 = broadcast).
241    pub b_batch_stride: usize,
242    /// The result shape.
243    pub out_shape: Shape,
244}
245
246/// Resolve the matmul contract for two shapes, or the typed error a
247/// caller sees for incompatible operands.
248pub fn plan_matmul(a: &Shape, b: &Shape) -> Result<MatmulPlan> {
249    let (ad, bd) = (a.dims(), b.dims());
250    let (ba, m, k) = match ad {
251        [m, k] => (1usize, *m, *k),
252        [b, m, k] => (*b, *m, *k),
253        _ => {
254            return Err(Error::InvalidArgument {
255                op: "matmul",
256                detail: format!(
257                    "needs rank >= 2 on both operands (batched above that); got {}x{}",
258                    ad.len(),
259                    bd.len()
260                ),
261            });
262        }
263    };
264    let (bb, kb, n) = match bd {
265        [k, n] => (1usize, *k, *n),
266        [b, k, n] => (*b, *k, *n),
267        _ => {
268            return Err(Error::InvalidArgument {
269                op: "matmul",
270                detail: format!(
271                    "needs rank >= 2 on both operands (batched above that); got {}x{}",
272                    ad.len(),
273                    bd.len()
274                ),
275            });
276        }
277    };
278    if k != kb {
279        return Err(Error::ShapeMismatch {
280            expected: if bd.len() == 3 {
281                Shape::from([bb, k, n])
282            } else {
283                Shape::from([k, n])
284            },
285            got: b.clone(),
286            op: "matmul",
287        });
288    }
289    let batch = match (ba, bb) {
290        (x, y) if x == y => x,
291        (1, y) => y,
292        (x, 1) => x,
293        // Batch dimensions that are neither equal nor 1 cannot broadcast.
294        _ => {
295            return Err(Error::BroadcastIncompatible {
296                lhs: a.clone(),
297                rhs: b.clone(),
298            });
299        }
300    };
301    let out_shape = if ad.len() == 2 && bd.len() == 2 {
302        Shape::from([m, n])
303    } else {
304        Shape::from([batch, m, n])
305    };
306    Ok(MatmulPlan {
307        batch,
308        m,
309        k,
310        n,
311        a_batch_stride: if ba == 1 { 0 } else { m * k },
312        b_batch_stride: if bb == 1 { 0 } else { k * n },
313        out_shape,
314    })
315}
316
317/// One Adam/AdamW update for a single parameter, for backends that fuse
318/// the ~12 elementwise ops of the composite step into one launch
319/// ([`Backend::adam_step`]). Tensors are `f32` on the backend's device;
320/// `m`/`v` are `None` on the first step.
321#[derive(Debug, Clone, Copy)]
322#[non_exhaustive]
323pub struct AdamStep<'a> {
324    /// Current parameter value.
325    pub param: &'a Tensor,
326    /// Its gradient.
327    pub grad: &'a Tensor,
328    /// First-moment state, if any step has run.
329    pub m: Option<&'a Tensor>,
330    /// Second-moment state, if any step has run.
331    pub v: Option<&'a Tensor>,
332    /// Learning rate.
333    pub lr: f32,
334    /// β₁.
335    pub beta1: f32,
336    /// β₂.
337    pub beta2: f32,
338    /// ε added to the denominator.
339    pub eps: f32,
340    /// Weight decay; `0.0` disables it.
341    pub weight_decay: f32,
342    /// `true` for AdamW (decay the weights), `false` for Adam (add to the
343    /// gradient).
344    pub decoupled: bool,
345    /// `1 - β₁ᵗ` for this step.
346    pub bias_correction1: f32,
347    /// `1 - β₂ᵗ` for this step.
348    pub bias_correction2: f32,
349}
350
351impl<'a> AdamStep<'a> {
352    /// Assemble a fused-step descriptor. Arguments are in declaration
353    /// order; `m`/`v` are `None` before the first step. A constructor
354    /// because the struct is `#[non_exhaustive]` and is built in the optim
355    /// crate.
356    #[allow(clippy::too_many_arguments)]
357    pub fn new(
358        param: &'a Tensor,
359        grad: &'a Tensor,
360        m: Option<&'a Tensor>,
361        v: Option<&'a Tensor>,
362        lr: f32,
363        beta1: f32,
364        beta2: f32,
365        eps: f32,
366        weight_decay: f32,
367        decoupled: bool,
368        bias_correction1: f32,
369        bias_correction2: f32,
370    ) -> Self {
371        Self {
372            param,
373            grad,
374            m,
375            v,
376            lr,
377            beta1,
378            beta2,
379            eps,
380            weight_decay,
381            decoupled,
382            bias_correction1,
383            bias_correction2,
384        }
385    }
386}
387
388/// A complete backend: every primitive the tensor method layer dispatches.
389///
390/// Composite operations (mean, softmax, losses, convolution, …) are built
391/// from these primitives device-generically; only what is listed here is
392/// implemented per device.
393pub trait Backend: Send + Sync {
394    /// The device this backend serves.
395    fn device(&self) -> Device;
396
397    /// A short stable name for reports and `oxmera doctor`.
398    fn name(&self) -> &'static str;
399
400    /// Elementwise unary op over a (possibly strided) `f32` tensor,
401    /// producing a fresh contiguous tensor of the same shape.
402    fn unary(&self, op: UnaryOp, a: &Tensor) -> Result<Tensor>;
403
404    /// Elementwise binary op with broadcasting, producing a fresh
405    /// contiguous tensor of the broadcast shape.
406    fn binary(&self, op: BinaryOp, a: &Tensor, b: &Tensor) -> Result<Tensor>;
407
408    /// Matrix product: rank-2 `[m, k] x [k, n] -> [m, n]`, or batched
409    /// rank-3 `[b, m, k] x [b, k, n] -> [b, m, n]`.
410    fn matmul(&self, a: &Tensor, b: &Tensor) -> Result<Tensor>;
411
412    /// Reduce over `axes` (sorted, deduplicated by the caller; empty means
413    /// all axes). `keepdim` keeps reduced axes as size 1.
414    fn reduce(&self, op: ReduceOp, a: &Tensor, axes: &[usize], keepdim: bool) -> Result<Tensor>;
415
416    /// Index of the maximum along `dim`, as an `I64` tensor.
417    fn argmax(&self, a: &Tensor, dim: usize, keepdim: bool) -> Result<Tensor>;
418
419    /// A fresh contiguous tensor with the same logical elements.
420    fn contiguous(&self, a: &Tensor) -> Result<Tensor>;
421
422    /// Download to a contiguous CPU tensor.
423    fn download(&self, a: &Tensor) -> Result<Tensor>;
424
425    /// Upload a contiguous CPU tensor to this backend's device.
426    fn upload(&self, a: &Tensor) -> Result<Tensor>;
427
428    /// Rows of `a` along `dim` selected by `indices` (`I64`).
429    ///
430    /// Backends may return [`Error::NotImplemented`]; the method layer
431    /// then falls back to the CPU backend with a device round-trip.
432    fn index_select(&self, a: &Tensor, dim: usize, indices: &Tensor) -> Result<Tensor> {
433        let _ = (dim, indices);
434        Err(Error::NotImplemented {
435            op: "index_select",
436            detail: format!("backend {}", a.device().kind_name()),
437        })
438    }
439
440    /// `out[indices[i]] += src[i]` along `dim`, on a fresh copy of `a`.
441    ///
442    /// Same fallback contract as [`Backend::index_select`].
443    fn index_add(&self, a: &Tensor, dim: usize, indices: &Tensor, src: &Tensor) -> Result<Tensor> {
444        let _ = (dim, indices, src);
445        Err(Error::NotImplemented {
446            op: "index_add",
447            detail: format!("backend {}", a.device().kind_name()),
448        })
449    }
450
451    /// Lower-triangular Cholesky factors of a `[.., n, n]` batch of SPD
452    /// matrices (lower triangle read; a non-PD matrix is a typed error).
453    ///
454    /// Same fallback contract as [`Backend::index_select`].
455    fn cholesky(&self, a: &Tensor) -> Result<Tensor> {
456        Err(Error::NotImplemented {
457            op: "cholesky",
458            detail: format!("backend {}", a.device().kind_name()),
459        })
460    }
461
462    /// Symmetric eigen-decomposition of a `[.., n, n]` batch: eigenvalues
463    /// ascending (`[.., n]`) and eigenvectors as columns (`[.., n, n]`).
464    ///
465    /// Same fallback contract as [`Backend::index_select`].
466    fn eigh(&self, a: &Tensor) -> Result<(Tensor, Tensor)> {
467        Err(Error::NotImplemented {
468            op: "eigh",
469            detail: format!("backend {}", a.device().kind_name()),
470        })
471    }
472
473    /// One fused Adam/AdamW update: returns the new `(param, m, v)`.
474    ///
475    /// The optimizer calls this for parameters on a non-CPU device and
476    /// falls back to the composite elementwise step when the backend
477    /// declines, so the observable update is the same either way (within
478    /// `f32` rounding of the same formula).
479    fn adam_step(&self, step: &AdamStep<'_>) -> Result<(Tensor, Tensor, Tensor)> {
480        Err(Error::NotImplemented {
481            op: "adam_step",
482            detail: format!("backend {}", step.param.device().kind_name()),
483        })
484    }
485}
486
487type Registry = RwLock<HashMap<Device, Arc<dyn Backend>>>;
488
489fn registry() -> &'static Registry {
490    static REGISTRY: OnceLock<Registry> = OnceLock::new();
491    REGISTRY.get_or_init(|| RwLock::new(HashMap::new()))
492}
493
494/// Register a backend for its device, replacing any previous registration.
495///
496/// Backend crates expose a `register_default()` that calls this; something
497/// must invoke it — `oxmera_runtime::init()` does for every backend on the
498/// platform. Linking a backend crate does not register it (the pre-`main`
499/// constructor that used to do so was removed in 0.5.0).
500pub fn register_backend(backend: Arc<dyn Backend>) {
501    registry()
502        .write()
503        .expect("backend registry poisoned")
504        .insert(backend.device(), backend);
505}
506
507/// The backend serving `device`, or a typed error when none is registered.
508///
509/// The CPU backend is registered lazily on first use, so it can never be
510/// unavailable; GPU backends register at load time or via
511/// `oxmera_runtime::init()`.
512pub fn backend_for(device: Device) -> Result<Arc<dyn Backend>> {
513    let found = registry()
514        .read()
515        .expect("backend registry poisoned")
516        .get(&device)
517        .cloned();
518    match found {
519        Some(b) => Ok(b),
520        None if device == Device::Cpu => {
521            crate::cpu::register();
522            registry()
523                .read()
524                .expect("backend registry poisoned")
525                .get(&device)
526                .cloned()
527                .ok_or(Error::BackendUnavailable { device })
528        }
529        None => Err(Error::BackendUnavailable { device }),
530    }
531}
532
533/// Every registered device, sorted for stable reporting.
534pub fn registered_devices() -> Vec<Device> {
535    let mut devices: Vec<Device> = registry()
536        .read()
537        .expect("backend registry poisoned")
538        .keys()
539        .copied()
540        .collect();
541    devices.sort_by_key(|d| (d.kind_name(), device_index(*d)));
542    devices
543}
544
545fn device_index(d: Device) -> usize {
546    match d {
547        Device::Cpu => 0,
548        Device::Metal { index } | Device::Cuda { index } => index,
549        _ => 0,
550    }
551}