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}