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            ReduceOp::Max => acc.max(x),
207            ReduceOp::Min => acc.min(x),
208        }
209    }
210}
211
212/// The shape contract of a matmul, resolved once so every backend agrees.
213///
214/// Operands are rank 2 (`[m, k]`) or rank 3 (`[b, m, k]`). Batch
215/// dimensions broadcast: a batch of 1 pairs with a batch of `n`, and a
216/// rank-2 operand is treated as batch 1. The output is rank 2 only when
217/// both inputs are; otherwise `[batch, m, n]`.
218///
219/// Batch strides are in elements of the operand's *contiguous* data and
220/// are 0 for a broadcast operand, so no backend has to materialize the
221/// broadcast — batch `i` of `a` starts at `i * a_batch_stride`.
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct MatmulPlan {
224    /// Output batch count (1 for a rank-2 result).
225    pub batch: usize,
226    /// Rows of `a` and of the output.
227    pub m: usize,
228    /// Shared dimension.
229    pub k: usize,
230    /// Columns of `b` and of the output.
231    pub n: usize,
232    /// Element offset between consecutive batches of `a` (0 = broadcast).
233    pub a_batch_stride: usize,
234    /// Element offset between consecutive batches of `b` (0 = broadcast).
235    pub b_batch_stride: usize,
236    /// The result shape.
237    pub out_shape: Shape,
238}
239
240/// Resolve the matmul contract for two shapes, or the typed error a
241/// caller sees for incompatible operands.
242pub fn plan_matmul(a: &Shape, b: &Shape) -> Result<MatmulPlan> {
243    let (ad, bd) = (a.dims(), b.dims());
244    let (ba, m, k) = match ad {
245        [m, k] => (1usize, *m, *k),
246        [b, m, k] => (*b, *m, *k),
247        _ => {
248            return Err(Error::InvalidArgument {
249                op: "matmul",
250                detail: format!(
251                    "supported ranks are 2 and 3 (batched); got {}x{}",
252                    ad.len(),
253                    bd.len()
254                ),
255            });
256        }
257    };
258    let (bb, kb, n) = match bd {
259        [k, n] => (1usize, *k, *n),
260        [b, k, n] => (*b, *k, *n),
261        _ => {
262            return Err(Error::InvalidArgument {
263                op: "matmul",
264                detail: format!(
265                    "supported ranks are 2 and 3 (batched); got {}x{}",
266                    ad.len(),
267                    bd.len()
268                ),
269            });
270        }
271    };
272    if k != kb {
273        return Err(Error::ShapeMismatch {
274            expected: if bd.len() == 3 {
275                Shape::from([bb, k, n])
276            } else {
277                Shape::from([k, n])
278            },
279            got: b.clone(),
280            op: "matmul",
281        });
282    }
283    let batch = match (ba, bb) {
284        (x, y) if x == y => x,
285        (1, y) => y,
286        (x, 1) => x,
287        // Batch dimensions that are neither equal nor 1 cannot broadcast.
288        _ => {
289            return Err(Error::BroadcastIncompatible {
290                lhs: a.clone(),
291                rhs: b.clone(),
292            });
293        }
294    };
295    let out_shape = if ad.len() == 2 && bd.len() == 2 {
296        Shape::from([m, n])
297    } else {
298        Shape::from([batch, m, n])
299    };
300    Ok(MatmulPlan {
301        batch,
302        m,
303        k,
304        n,
305        a_batch_stride: if ba == 1 { 0 } else { m * k },
306        b_batch_stride: if bb == 1 { 0 } else { k * n },
307        out_shape,
308    })
309}
310
311/// One Adam/AdamW update for a single parameter, for backends that fuse
312/// the ~12 elementwise ops of the composite step into one launch
313/// ([`Backend::adam_step`]). Tensors are `f32` on the backend's device;
314/// `m`/`v` are `None` on the first step.
315#[derive(Debug, Clone, Copy)]
316pub struct AdamStep<'a> {
317    /// Current parameter value.
318    pub param: &'a Tensor,
319    /// Its gradient.
320    pub grad: &'a Tensor,
321    /// First-moment state, if any step has run.
322    pub m: Option<&'a Tensor>,
323    /// Second-moment state, if any step has run.
324    pub v: Option<&'a Tensor>,
325    /// Learning rate.
326    pub lr: f32,
327    /// β₁.
328    pub beta1: f32,
329    /// β₂.
330    pub beta2: f32,
331    /// ε added to the denominator.
332    pub eps: f32,
333    /// Weight decay; `0.0` disables it.
334    pub weight_decay: f32,
335    /// `true` for AdamW (decay the weights), `false` for Adam (add to the
336    /// gradient).
337    pub decoupled: bool,
338    /// `1 - β₁ᵗ` for this step.
339    pub bias_correction1: f32,
340    /// `1 - β₂ᵗ` for this step.
341    pub bias_correction2: f32,
342}
343
344/// A complete backend: every primitive the tensor method layer dispatches.
345///
346/// Composite operations (mean, softmax, losses, convolution, …) are built
347/// from these primitives device-generically; only what is listed here is
348/// implemented per device.
349pub trait Backend: Send + Sync {
350    /// The device this backend serves.
351    fn device(&self) -> Device;
352
353    /// A short stable name for reports and `oxmera doctor`.
354    fn name(&self) -> &'static str;
355
356    /// Elementwise unary op over a (possibly strided) `f32` tensor,
357    /// producing a fresh contiguous tensor of the same shape.
358    fn unary(&self, op: UnaryOp, a: &Tensor) -> Result<Tensor>;
359
360    /// Elementwise binary op with broadcasting, producing a fresh
361    /// contiguous tensor of the broadcast shape.
362    fn binary(&self, op: BinaryOp, a: &Tensor, b: &Tensor) -> Result<Tensor>;
363
364    /// Matrix product: rank-2 `[m, k] x [k, n] -> [m, n]`, or batched
365    /// rank-3 `[b, m, k] x [b, k, n] -> [b, m, n]`.
366    fn matmul(&self, a: &Tensor, b: &Tensor) -> Result<Tensor>;
367
368    /// Reduce over `axes` (sorted, deduplicated by the caller; empty means
369    /// all axes). `keepdim` keeps reduced axes as size 1.
370    fn reduce(&self, op: ReduceOp, a: &Tensor, axes: &[usize], keepdim: bool) -> Result<Tensor>;
371
372    /// Index of the maximum along `dim`, as an `I64` tensor.
373    fn argmax(&self, a: &Tensor, dim: usize, keepdim: bool) -> Result<Tensor>;
374
375    /// A fresh contiguous tensor with the same logical elements.
376    fn contiguous(&self, a: &Tensor) -> Result<Tensor>;
377
378    /// Download to a contiguous CPU tensor.
379    fn download(&self, a: &Tensor) -> Result<Tensor>;
380
381    /// Upload a contiguous CPU tensor to this backend's device.
382    fn upload(&self, a: &Tensor) -> Result<Tensor>;
383
384    /// Rows of `a` along `dim` selected by `indices` (`I64`).
385    ///
386    /// Backends may return [`Error::NotImplemented`]; the method layer
387    /// then falls back to the CPU backend with a device round-trip.
388    fn index_select(&self, a: &Tensor, dim: usize, indices: &Tensor) -> Result<Tensor> {
389        let _ = (dim, indices);
390        Err(Error::NotImplemented {
391            op: "index_select",
392            detail: format!("backend {}", a.device().kind_name()),
393        })
394    }
395
396    /// `out[indices[i]] += src[i]` along `dim`, on a fresh copy of `a`.
397    ///
398    /// Same fallback contract as [`Backend::index_select`].
399    fn index_add(&self, a: &Tensor, dim: usize, indices: &Tensor, src: &Tensor) -> Result<Tensor> {
400        let _ = (dim, indices, src);
401        Err(Error::NotImplemented {
402            op: "index_add",
403            detail: format!("backend {}", a.device().kind_name()),
404        })
405    }
406
407    /// Lower-triangular Cholesky factors of a `[.., n, n]` batch of SPD
408    /// matrices (lower triangle read; a non-PD matrix is a typed error).
409    ///
410    /// Same fallback contract as [`Backend::index_select`].
411    fn cholesky(&self, a: &Tensor) -> Result<Tensor> {
412        Err(Error::NotImplemented {
413            op: "cholesky",
414            detail: format!("backend {}", a.device().kind_name()),
415        })
416    }
417
418    /// Symmetric eigen-decomposition of a `[.., n, n]` batch: eigenvalues
419    /// ascending (`[.., n]`) and eigenvectors as columns (`[.., n, n]`).
420    ///
421    /// Same fallback contract as [`Backend::index_select`].
422    fn eigh(&self, a: &Tensor) -> Result<(Tensor, Tensor)> {
423        Err(Error::NotImplemented {
424            op: "eigh",
425            detail: format!("backend {}", a.device().kind_name()),
426        })
427    }
428
429    /// One fused Adam/AdamW update: returns the new `(param, m, v)`.
430    ///
431    /// The optimizer calls this for parameters on a non-CPU device and
432    /// falls back to the composite elementwise step when the backend
433    /// declines, so the observable update is the same either way (within
434    /// `f32` rounding of the same formula).
435    fn adam_step(&self, step: &AdamStep<'_>) -> Result<(Tensor, Tensor, Tensor)> {
436        Err(Error::NotImplemented {
437            op: "adam_step",
438            detail: format!("backend {}", step.param.device().kind_name()),
439        })
440    }
441}
442
443type Registry = RwLock<HashMap<Device, Arc<dyn Backend>>>;
444
445fn registry() -> &'static Registry {
446    static REGISTRY: OnceLock<Registry> = OnceLock::new();
447    REGISTRY.get_or_init(|| RwLock::new(HashMap::new()))
448}
449
450/// Register a backend for its device, replacing any previous registration.
451///
452/// Backend crates call this from their load-time constructors; linking a
453/// backend crate is what makes its device usable.
454pub fn register_backend(backend: Arc<dyn Backend>) {
455    registry()
456        .write()
457        .expect("backend registry poisoned")
458        .insert(backend.device(), backend);
459}
460
461/// The backend serving `device`, or a typed error when none is registered.
462///
463/// The CPU backend is registered lazily on first use, so it can never be
464/// unavailable; GPU backends register at load time or via
465/// `oxmera_runtime::init()`.
466pub fn backend_for(device: Device) -> Result<Arc<dyn Backend>> {
467    let found = registry()
468        .read()
469        .expect("backend registry poisoned")
470        .get(&device)
471        .cloned();
472    match found {
473        Some(b) => Ok(b),
474        None if device == Device::Cpu => {
475            crate::cpu::register();
476            registry()
477                .read()
478                .expect("backend registry poisoned")
479                .get(&device)
480                .cloned()
481                .ok_or(Error::BackendUnavailable { device })
482        }
483        None => Err(Error::BackendUnavailable { device }),
484    }
485}
486
487/// Every registered device, sorted for stable reporting.
488pub fn registered_devices() -> Vec<Device> {
489    let mut devices: Vec<Device> = registry()
490        .read()
491        .expect("backend registry poisoned")
492        .keys()
493        .copied()
494        .collect();
495    devices.sort_by_key(|d| (d.kind_name(), device_index(*d)));
496    devices
497}
498
499fn device_index(d: Device) -> usize {
500    match d {
501        Device::Cpu => 0,
502        Device::Metal { index } | Device::Cuda { index } => index,
503        _ => 0,
504    }
505}