Skip to main content

oxmera_tensor/
ops.rs

1//! The differentiable operation layer: every method dispatches to the
2//! device backend for the forward pass and, when recording is on and an
3//! input is tracked, attaches the exact vector-Jacobian product to the
4//! output's tape node.
5
6use oxmera_core::{Device, Error, Result, Shape};
7
8use crate::autograd::{GradFn, is_recording};
9use crate::backend::{Backend, BinaryOp, ReduceOp, UnaryOp, backend_for};
10use crate::tensor::{Tensor, ViewKind};
11
12use std::sync::Arc;
13
14fn same_device(a: &Tensor, b: &Tensor, op: &'static str) -> Result<Device> {
15    if a.device() != b.device() {
16        return Err(Error::DeviceMismatch {
17            lhs: a.device(),
18            rhs: b.device(),
19            op,
20        });
21    }
22    Ok(a.device())
23}
24
25fn record(
26    out: Tensor,
27    inputs: Vec<Tensor>,
28    vjp: impl Fn(&Tensor) -> Result<Vec<Option<Tensor>>> + Send + Sync + 'static,
29) -> Tensor {
30    if is_recording() && inputs.iter().any(Tensor::is_tracked) {
31        out.with_grad_fn(GradFn {
32            inputs,
33            vjp: Box::new(vjp),
34        })
35    } else {
36        out
37    }
38}
39
40/// Sum `grad` down to `shape` (undo broadcasting): reduce the leading
41/// extra axes and every axis the target holds as 1, then reshape.
42pub(crate) fn reduce_to_shape(grad: &Tensor, shape: &Shape) -> Result<Tensor> {
43    if grad.shape() == shape {
44        return Ok(grad.clone());
45    }
46    let gdims = grad.dims().to_vec();
47    let tdims = shape.dims();
48    let lead = gdims.len() - tdims.len();
49    let mut axes: Vec<usize> = (0..lead).collect();
50    for (i, &td) in tdims.iter().enumerate() {
51        if td == 1 && gdims[lead + i] != 1 {
52            axes.push(lead + i);
53        }
54    }
55    let reduced = if axes.is_empty() {
56        grad.clone()
57    } else {
58        grad.sum_keepdim(&axes, true)?
59    };
60    reduced.reshape(shape.clone())
61}
62
63/// Attach the view VJP to a freshly built view (called from `tensor.rs`).
64pub(crate) fn record_view(input: &Tensor, out: Tensor, kind: ViewKind) -> Tensor {
65    let in_shape = input.shape().clone();
66    record(out, vec![input.clone()], move |g| {
67        let gi = match &kind {
68            ViewKind::Reshape | ViewKind::Contiguous => g.reshape(in_shape.clone())?,
69            ViewKind::Permute(perm) => {
70                let mut inverse = vec![0usize; perm.len()];
71                for (i, &p) in perm.iter().enumerate() {
72                    inverse[p] = i;
73                }
74                g.permute(&inverse)?
75            }
76            ViewKind::Narrow { dim, start, len } => {
77                let indices: Vec<i64> = (*start..start + len).map(|i| i as i64).collect();
78                let indices = Tensor::from_vec_i64(indices, Shape::from([*len]))?;
79                Tensor::zeros(in_shape.clone())
80                    .to_device(g.device())?
81                    .index_add(*dim, &indices, g)?
82            }
83            ViewKind::Broadcast => reduce_to_shape(g, &in_shape)?,
84        };
85        Ok(vec![Some(gi)])
86    })
87}
88
89impl Tensor {
90    fn backend(&self) -> Result<Arc<dyn Backend>> {
91        backend_for(self.device())
92    }
93
94    // ---- unary -----------------------------------------------------------
95
96    fn unary_op(&self, op: UnaryOp) -> Result<Tensor> {
97        let out = self.backend()?.unary(op, self)?;
98        let a = self.clone();
99        let o = out.clone();
100        Ok(record(out, vec![self.clone()], move |g| {
101            let gi = match op {
102                UnaryOp::Neg => g.neg()?,
103                UnaryOp::Exp => g.mul(&o)?,
104                UnaryOp::Ln => g.div(&a)?,
105                UnaryOp::Abs => {
106                    let sign = a
107                        .gt_mask(&Tensor::scalar_on(&a, 0.0)?)?
108                        .sub(&Tensor::scalar_on(&a, 0.0)?.gt_mask(&a)?)?;
109                    g.mul(&sign)?
110                }
111                UnaryOp::Sqrt => g.mul(&Tensor::scalar_on(&a, 0.5)?)?.div(&o)?,
112                UnaryOp::Sin => g.mul(&a.cos()?)?,
113                UnaryOp::Cos => g.mul(&a.sin()?.neg()?)?,
114                UnaryOp::Tanh => {
115                    let one = Tensor::scalar_on(&a, 1.0)?;
116                    g.mul(&one.sub(&o.mul(&o)?)?)?
117                }
118                UnaryOp::Relu => g.mul(&a.gt_mask(&Tensor::scalar_on(&a, 0.0)?)?)?,
119                UnaryOp::Gelu => {
120                    // d/dx [0.5x(1+tanh(u))], u = c(x + 0.044715 x^3),
121                    // c = sqrt(2/pi).
122                    let c = Tensor::scalar_on(&a, 0.797_884_6)?;
123                    let k = Tensor::scalar_on(&a, 0.044_715)?;
124                    let one = Tensor::scalar_on(&a, 1.0)?;
125                    let half = Tensor::scalar_on(&a, 0.5)?;
126                    let three_k = Tensor::scalar_on(&a, 3.0 * 0.044_715)?;
127                    let x2 = a.mul(&a)?;
128                    let u = c.mul(&a.add(&k.mul(&x2.mul(&a)?)?)?)?;
129                    let t = u.tanh()?;
130                    let sech2 = one.sub(&t.mul(&t)?)?;
131                    let du = c.mul(&one.add(&three_k.mul(&x2)?)?)?;
132                    let d = half
133                        .mul(&one.add(&t)?)?
134                        .add(&half.mul(&a)?.mul(&sech2)?.mul(&du)?)?;
135                    g.mul(&d)?
136                }
137                UnaryOp::Sigmoid => {
138                    let one = Tensor::scalar_on(&a, 1.0)?;
139                    g.mul(&o)?.mul(&one.sub(&o)?)?
140                }
141            };
142            Ok(vec![Some(gi)])
143        }))
144    }
145
146    /// Elementwise negation.
147    pub fn neg(&self) -> Result<Tensor> {
148        self.unary_op(UnaryOp::Neg)
149    }
150    /// Elementwise `e^x`.
151    pub fn exp(&self) -> Result<Tensor> {
152        self.unary_op(UnaryOp::Exp)
153    }
154    /// Elementwise natural logarithm.
155    pub fn ln(&self) -> Result<Tensor> {
156        self.unary_op(UnaryOp::Ln)
157    }
158    /// Elementwise absolute value.
159    pub fn abs(&self) -> Result<Tensor> {
160        self.unary_op(UnaryOp::Abs)
161    }
162    /// Elementwise square root.
163    pub fn sqrt(&self) -> Result<Tensor> {
164        self.unary_op(UnaryOp::Sqrt)
165    }
166    /// Elementwise sine.
167    pub fn sin(&self) -> Result<Tensor> {
168        self.unary_op(UnaryOp::Sin)
169    }
170    /// Elementwise cosine.
171    pub fn cos(&self) -> Result<Tensor> {
172        self.unary_op(UnaryOp::Cos)
173    }
174    /// Elementwise hyperbolic tangent.
175    pub fn tanh(&self) -> Result<Tensor> {
176        self.unary_op(UnaryOp::Tanh)
177    }
178    /// Elementwise rectified linear unit.
179    pub fn relu(&self) -> Result<Tensor> {
180        self.unary_op(UnaryOp::Relu)
181    }
182    /// Elementwise GELU (tanh approximation).
183    pub fn gelu(&self) -> Result<Tensor> {
184        self.unary_op(UnaryOp::Gelu)
185    }
186    /// Elementwise logistic sigmoid.
187    pub fn sigmoid(&self) -> Result<Tensor> {
188        self.unary_op(UnaryOp::Sigmoid)
189    }
190
191    /// A scalar constant on the same device as `like` (plumbing for VJPs
192    /// and scalar operator overloads).
193    pub fn scalar_on(like: &Tensor, value: f32) -> Result<Tensor> {
194        Tensor::scalar(value).to_device(like.device())
195    }
196
197    // ---- binary ----------------------------------------------------------
198
199    fn binary_op(&self, op: BinaryOp, rhs: &Tensor) -> Result<Tensor> {
200        let device = same_device(self, rhs, "binary")?;
201        let out = backend_for(device)?.binary(op, self, rhs)?;
202        let (a, b) = (self.clone(), rhs.clone());
203        let o = out.clone();
204        Ok(record(out, vec![self.clone(), rhs.clone()], move |g| {
205            let (ga, gb): (Option<Tensor>, Option<Tensor>) = match op {
206                BinaryOp::Add => (Some(g.clone()), Some(g.clone())),
207                BinaryOp::Sub => (Some(g.clone()), Some(g.neg()?)),
208                BinaryOp::Mul => (Some(g.mul_raw(&b)?), Some(g.mul_raw(&a)?)),
209                BinaryOp::Div => {
210                    let ga = g.div_raw(&b)?;
211                    let gb = g.mul_raw(&o)?.div_raw(&b)?.neg()?;
212                    (Some(ga), Some(gb))
213                }
214                BinaryOp::Pow => {
215                    let one = Tensor::scalar_on(&a, 1.0)?;
216                    let ga = g.mul_raw(&b)?.mul_raw(&a.pow(&b.sub(&one)?)?)?;
217                    let gb = g.mul_raw(&o)?.mul_raw(&a.ln()?)?;
218                    (Some(ga), Some(gb))
219                }
220                BinaryOp::Maximum => {
221                    let mask = a.gt_mask(&b)?;
222                    let one = Tensor::scalar_on(&a, 1.0)?;
223                    let ga = g.mul_raw(&mask)?;
224                    let gb = g.mul_raw(&one.sub(&mask)?)?;
225                    (Some(ga), Some(gb))
226                }
227                BinaryOp::Minimum => {
228                    let mask = b.gt_mask(&a)?;
229                    let one = Tensor::scalar_on(&a, 1.0)?;
230                    let ga = g.mul_raw(&mask)?;
231                    let gb = g.mul_raw(&one.sub(&mask)?)?;
232                    (Some(ga), Some(gb))
233                }
234                BinaryOp::Gt | BinaryOp::Eq => (None, None),
235            };
236            let ga = match ga {
237                Some(t) => Some(reduce_to_shape(&t, a.shape())?),
238                None => None,
239            };
240            let gb = match gb {
241                Some(t) => Some(reduce_to_shape(&t, b.shape())?),
242                None => None,
243            };
244            Ok(vec![ga, gb])
245        }))
246    }
247
248    /// Untracked multiply, for use inside VJP closures (recording is
249    /// already off during backward; this is belt and braces).
250    fn mul_raw(&self, rhs: &Tensor) -> Result<Tensor> {
251        let device = same_device(self, rhs, "mul")?;
252        backend_for(device)?.binary(BinaryOp::Mul, self, rhs)
253    }
254
255    fn div_raw(&self, rhs: &Tensor) -> Result<Tensor> {
256        let device = same_device(self, rhs, "div")?;
257        backend_for(device)?.binary(BinaryOp::Div, self, rhs)
258    }
259
260    /// Elementwise addition, broadcasting.
261    pub fn add(&self, rhs: &Tensor) -> Result<Tensor> {
262        self.binary_op(BinaryOp::Add, rhs)
263    }
264    /// Elementwise subtraction, broadcasting.
265    pub fn sub(&self, rhs: &Tensor) -> Result<Tensor> {
266        self.binary_op(BinaryOp::Sub, rhs)
267    }
268    /// Elementwise multiplication, broadcasting.
269    pub fn mul(&self, rhs: &Tensor) -> Result<Tensor> {
270        self.binary_op(BinaryOp::Mul, rhs)
271    }
272    /// Elementwise division, broadcasting.
273    pub fn div(&self, rhs: &Tensor) -> Result<Tensor> {
274        self.binary_op(BinaryOp::Div, rhs)
275    }
276    /// Elementwise power, broadcasting.
277    pub fn pow(&self, rhs: &Tensor) -> Result<Tensor> {
278        self.binary_op(BinaryOp::Pow, rhs)
279    }
280    /// Elementwise maximum, broadcasting.
281    pub fn maximum(&self, rhs: &Tensor) -> Result<Tensor> {
282        self.binary_op(BinaryOp::Maximum, rhs)
283    }
284    /// Elementwise minimum, broadcasting.
285    pub fn minimum(&self, rhs: &Tensor) -> Result<Tensor> {
286        self.binary_op(BinaryOp::Minimum, rhs)
287    }
288    /// Elementwise `a > b` as a 0.0/1.0 mask. Not differentiable.
289    pub fn gt_mask(&self, rhs: &Tensor) -> Result<Tensor> {
290        self.binary_op(BinaryOp::Gt, rhs)
291    }
292    /// Elementwise `a == b` as a 0.0/1.0 mask. Not differentiable.
293    pub fn eq_mask(&self, rhs: &Tensor) -> Result<Tensor> {
294        self.binary_op(BinaryOp::Eq, rhs)
295    }
296
297    /// Add a scalar, broadcasting.
298    pub fn add_scalar(&self, s: f32) -> Result<Tensor> {
299        self.add(&Tensor::scalar_on(self, s)?)
300    }
301    /// Multiply by a scalar, broadcasting.
302    pub fn mul_scalar(&self, s: f32) -> Result<Tensor> {
303        self.mul(&Tensor::scalar_on(self, s)?)
304    }
305
306    // ---- matmul ----------------------------------------------------------
307
308    /// Matrix product with NumPy/PyTorch batch semantics.
309    ///
310    /// Operands are rank 2 (`[m, k]`) or rank 3 (`[b, m, k]`); a rank-2
311    /// operand behaves as batch 1, and batch dimensions broadcast (1
312    /// against `b`). The result is rank 2 only when both operands are.
313    /// `[2, 2, 3] x [1, 3, 2]` is `[2, 2, 2]`; `[m, k] x [b, k, n]` is
314    /// `[b, m, n]`. See [`plan_matmul`](crate::backend::plan_matmul) for
315    /// the exact contract every backend implements.
316    pub fn matmul(&self, rhs: &Tensor) -> Result<Tensor> {
317        let device = same_device(self, rhs, "matmul")?;
318        let out = backend_for(device)?.matmul(self, rhs)?;
319        let (a, b) = (self.clone(), rhs.clone());
320        Ok(record(out, vec![self.clone(), rhs.clone()], move |g| {
321            // g is [.., m, n]; the operand grads carry g's batch, which is
322            // then summed down onto a broadcast (or rank-2) operand.
323            let ga = backend_for(g.device())?.matmul(g, &b.t()?)?;
324            let gb = backend_for(g.device())?.matmul(&a.t()?, g)?;
325            Ok(vec![
326                Some(reduce_to_shape(&ga, a.shape())?),
327                Some(reduce_to_shape(&gb, b.shape())?),
328            ])
329        }))
330    }
331
332    // ---- reductions --------------------------------------------------------
333
334    fn reduce_op(&self, op: ReduceOp, axes: &[usize], keepdim: bool) -> Result<Tensor> {
335        let axes = normalize_axes(axes, self.ndim(), "reduce")?;
336        let out = self.backend()?.reduce(op, self, &axes, keepdim)?;
337        let a = self.clone();
338        let o = out.clone();
339        let axes_c = axes.clone();
340        Ok(record(out, vec![self.clone()], move |g| {
341            // Re-insert reduced axes as size 1 so broadcasting lines up.
342            let g_keep = if keepdim {
343                g.clone()
344            } else {
345                unsqueeze_axes(g, &axes_c)?
346            };
347            let gi = match op {
348                ReduceOp::Sum => g_keep.broadcast_to(a.shape().clone())?.contiguous()?,
349                ReduceOp::Max | ReduceOp::Min => {
350                    let o_keep = if keepdim {
351                        o.clone()
352                    } else {
353                        unsqueeze_axes(&o, &axes_c)?
354                    };
355                    let mask = a.eq_mask(&o_keep.broadcast_to(a.shape().clone())?)?;
356                    let count = mask.sum_keepdim(&axes_c, true)?;
357                    g_keep
358                        .broadcast_to(a.shape().clone())?
359                        .mul_raw(&mask)?
360                        .div_raw(&count.broadcast_to(a.shape().clone())?.contiguous()?)?
361                }
362            };
363            Ok(vec![Some(gi)])
364        }))
365    }
366
367    /// Sum over `axes` (empty means all), removing them from the shape.
368    pub fn sum(&self, axes: &[usize]) -> Result<Tensor> {
369        self.reduce_op(ReduceOp::Sum, axes, false)
370    }
371
372    /// Sum over `axes` with explicit `keepdim`.
373    pub fn sum_keepdim(&self, axes: &[usize], keepdim: bool) -> Result<Tensor> {
374        self.reduce_op(ReduceOp::Sum, axes, keepdim)
375    }
376
377    /// Maximum over `axes` (empty means all).
378    pub fn max(&self, axes: &[usize]) -> Result<Tensor> {
379        self.reduce_op(ReduceOp::Max, axes, false)
380    }
381
382    /// Maximum over `axes` with explicit `keepdim`.
383    pub fn max_keepdim(&self, axes: &[usize], keepdim: bool) -> Result<Tensor> {
384        self.reduce_op(ReduceOp::Max, axes, keepdim)
385    }
386
387    /// Minimum over `axes` (empty means all).
388    pub fn min(&self, axes: &[usize]) -> Result<Tensor> {
389        self.reduce_op(ReduceOp::Min, axes, false)
390    }
391
392    /// Mean over `axes` (empty means all) — composite, so its gradient
393    /// flows through `sum` and scalar multiply.
394    pub fn mean(&self, axes: &[usize]) -> Result<Tensor> {
395        self.mean_keepdim(axes, false)
396    }
397
398    /// Mean over `axes` with explicit `keepdim`.
399    pub fn mean_keepdim(&self, axes: &[usize], keepdim: bool) -> Result<Tensor> {
400        let axes_n = normalize_axes(axes, self.ndim(), "mean")?;
401        let n: usize = axes_n.iter().map(|&ax| self.dims()[ax]).product();
402        self.sum_keepdim(&axes_n, keepdim)?
403            .mul_scalar(1.0 / n as f32)
404    }
405
406    /// Index of the maximum along `dim`, as an `I64` tensor. Not
407    /// differentiable.
408    pub fn argmax(&self, dim: usize, keepdim: bool) -> Result<Tensor> {
409        if dim >= self.ndim() {
410            return Err(Error::InvalidArgument {
411                op: "argmax",
412                detail: format!("dim {dim} out of range for rank {}", self.ndim()),
413            });
414        }
415        self.backend()?.argmax(self, dim, keepdim)
416    }
417
418    /// Numerically stable softmax along `dim` — composite.
419    pub fn softmax(&self, dim: usize) -> Result<Tensor> {
420        // The row max and row sum stay broadcast views: the binary kernels
421        // consume a stride-0 operand directly, so nothing is materialized.
422        let shifted = self.sub(&self.max_keepdim(&[dim], true)?.detach())?;
423        let e = shifted.exp()?;
424        let denom = e.sum_keepdim(&[dim], true)?;
425        e.div(&denom)
426    }
427
428    /// Numerically stable log-softmax along `dim` — composite.
429    pub fn log_softmax(&self, dim: usize) -> Result<Tensor> {
430        let shifted = self.sub(&self.max_keepdim(&[dim], true)?.detach())?;
431        let lse = shifted.exp()?.sum_keepdim(&[dim], true)?.ln()?;
432        shifted.sub(&lse)
433    }
434
435    // ---- indexing -----------------------------------------------------------
436
437    /// Rows of `self` along `dim` selected by `indices` (`I64`).
438    pub fn index_select(&self, dim: usize, indices: &Tensor) -> Result<Tensor> {
439        let out = dispatch_index(self, |be, t| be.index_select(t, dim, indices))?;
440        let in_shape = self.shape().clone();
441        let idx = indices.clone();
442        Ok(record(out, vec![self.clone()], move |g| {
443            let zeros = Tensor::zeros(in_shape.clone()).to_device(g.device())?;
444            Ok(vec![Some(zeros.index_add(dim, &idx, g)?)])
445        }))
446    }
447
448    /// `out[indices[i]] += src[i]` along `dim`, on a fresh copy of `self`.
449    pub fn index_add(&self, dim: usize, indices: &Tensor, src: &Tensor) -> Result<Tensor> {
450        let out = dispatch_index(self, |be, t| be.index_add(t, dim, indices, src))?;
451        let idx = indices.clone();
452        Ok(record(out, vec![self.clone(), src.clone()], move |g| {
453            Ok(vec![Some(g.clone()), Some(g.index_select(dim, &idx)?)])
454        }))
455    }
456
457    // ---- device movement ------------------------------------------------------
458
459    /// This tensor's data on `device` (a cheap clone when already there).
460    pub fn to_device(&self, device: Device) -> Result<Tensor> {
461        if self.device() == device {
462            return Ok(self.clone());
463        }
464        let out = match (self.device(), device) {
465            (Device::Cpu, target) => backend_for(target)?.upload(&self.contiguous_data()?)?,
466            (_, Device::Cpu) => self.backend()?.download(self)?,
467            (_, target) => {
468                let host = self.backend()?.download(self)?;
469                backend_for(target)?.upload(&host)?
470            }
471        };
472        let source = self.device();
473        Ok(record(out, vec![self.clone()], move |g| {
474            Ok(vec![Some(g.to_device(source)?)])
475        }))
476    }
477}
478
479/// Insert size-1 axes at `axes` (sorted ascending) — plumbing for reduce
480/// VJPs.
481fn unsqueeze_axes(t: &Tensor, axes: &[usize]) -> Result<Tensor> {
482    let mut out = t.clone();
483    let mut sorted = axes.to_vec();
484    sorted.sort_unstable();
485    for &ax in &sorted {
486        out = out.unsqueeze(ax)?;
487    }
488    Ok(out)
489}
490
491/// Validate and canonicalize reduce axes; empty means all axes.
492fn normalize_axes(axes: &[usize], ndim: usize, op: &'static str) -> Result<Vec<usize>> {
493    let mut axes: Vec<usize> = if axes.is_empty() {
494        (0..ndim).collect()
495    } else {
496        axes.to_vec()
497    };
498    axes.sort_unstable();
499    axes.dedup();
500    if let Some(&bad) = axes.iter().find(|&&a| a >= ndim) {
501        return Err(Error::InvalidArgument {
502            op,
503            detail: format!("axis {bad} out of range for rank {ndim}"),
504        });
505    }
506    Ok(axes)
507}
508
509/// Run an index op on the tensor's backend, falling back to a CPU
510/// round-trip when the backend declines.
511fn dispatch_index(
512    t: &Tensor,
513    f: impl Fn(&dyn Backend, &Tensor) -> Result<Tensor>,
514) -> Result<Tensor> {
515    let backend = backend_for(t.device())?;
516    match f(backend.as_ref(), t) {
517        Err(Error::NotImplemented { .. }) if t.device() != Device::Cpu => {
518            let cpu = backend.download(t)?;
519            let cpu_backend = backend_for(Device::Cpu)?;
520            let out = f(cpu_backend.as_ref(), &cpu)?;
521            backend_for(t.device())?.upload(&out)
522        }
523        other => other,
524    }
525}