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::{DType, 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_dtype(g.dtype())?
81                    .to_device(g.device())?
82                    .index_add(*dim, &indices, g)?
83            }
84            ViewKind::Broadcast => reduce_to_shape(g, &in_shape)?,
85        };
86        Ok(vec![Some(gi)])
87    })
88}
89
90impl Tensor {
91    fn backend(&self) -> Result<Arc<dyn Backend>> {
92        backend_for(self.device())
93    }
94
95    // ---- unary -----------------------------------------------------------
96
97    fn unary_op(&self, op: UnaryOp) -> Result<Tensor> {
98        let out = self.backend()?.unary(op, self)?;
99        let a = self.clone();
100        let o = out.clone();
101        Ok(record(out, vec![self.clone()], move |g| {
102            let gi = match op {
103                UnaryOp::Neg => g.neg()?,
104                UnaryOp::Exp => g.mul(&o)?,
105                UnaryOp::Ln => g.div(&a)?,
106                UnaryOp::Abs => {
107                    let sign = a
108                        .gt_mask(&Tensor::scalar_on(&a, 0.0)?)?
109                        .sub(&Tensor::scalar_on(&a, 0.0)?.gt_mask(&a)?)?;
110                    g.mul(&sign)?
111                }
112                UnaryOp::Sqrt => g.mul(&Tensor::scalar_on(&a, 0.5)?)?.div(&o)?,
113                UnaryOp::Sin => g.mul(&a.cos()?)?,
114                UnaryOp::Cos => g.mul(&a.sin()?.neg()?)?,
115                UnaryOp::Tanh => {
116                    let one = Tensor::scalar_on(&a, 1.0)?;
117                    g.mul(&one.sub(&o.mul(&o)?)?)?
118                }
119                UnaryOp::Relu => g.mul(&a.gt_mask(&Tensor::scalar_on(&a, 0.0)?)?)?,
120                UnaryOp::Gelu => {
121                    // d/dx [0.5x(1+tanh(u))], u = c(x + 0.044715 x^3),
122                    // c = sqrt(2/pi).
123                    let c = Tensor::scalar_on(&a, 0.797_884_6)?;
124                    let k = Tensor::scalar_on(&a, 0.044_715)?;
125                    let one = Tensor::scalar_on(&a, 1.0)?;
126                    let half = Tensor::scalar_on(&a, 0.5)?;
127                    let three_k = Tensor::scalar_on(&a, 3.0 * 0.044_715)?;
128                    let x2 = a.mul(&a)?;
129                    let u = c.mul(&a.add(&k.mul(&x2.mul(&a)?)?)?)?;
130                    let t = u.tanh()?;
131                    let sech2 = one.sub(&t.mul(&t)?)?;
132                    let du = c.mul(&one.add(&three_k.mul(&x2)?)?)?;
133                    let d = half
134                        .mul(&one.add(&t)?)?
135                        .add(&half.mul(&a)?.mul(&sech2)?.mul(&du)?)?;
136                    g.mul(&d)?
137                }
138                UnaryOp::Sigmoid => {
139                    let one = Tensor::scalar_on(&a, 1.0)?;
140                    g.mul(&o)?.mul(&one.sub(&o)?)?
141                }
142            };
143            Ok(vec![Some(gi)])
144        }))
145    }
146
147    /// Elementwise negation.
148    pub fn neg(&self) -> Result<Tensor> {
149        self.unary_op(UnaryOp::Neg)
150    }
151    /// Elementwise `e^x`.
152    pub fn exp(&self) -> Result<Tensor> {
153        self.unary_op(UnaryOp::Exp)
154    }
155    /// Elementwise natural logarithm.
156    pub fn ln(&self) -> Result<Tensor> {
157        self.unary_op(UnaryOp::Ln)
158    }
159    /// Elementwise absolute value.
160    pub fn abs(&self) -> Result<Tensor> {
161        self.unary_op(UnaryOp::Abs)
162    }
163    /// Elementwise square root.
164    pub fn sqrt(&self) -> Result<Tensor> {
165        self.unary_op(UnaryOp::Sqrt)
166    }
167    /// Elementwise sine.
168    pub fn sin(&self) -> Result<Tensor> {
169        self.unary_op(UnaryOp::Sin)
170    }
171    /// Elementwise cosine.
172    pub fn cos(&self) -> Result<Tensor> {
173        self.unary_op(UnaryOp::Cos)
174    }
175    /// Elementwise hyperbolic tangent.
176    pub fn tanh(&self) -> Result<Tensor> {
177        self.unary_op(UnaryOp::Tanh)
178    }
179    /// Elementwise rectified linear unit.
180    pub fn relu(&self) -> Result<Tensor> {
181        self.unary_op(UnaryOp::Relu)
182    }
183    /// Elementwise GELU (tanh approximation).
184    pub fn gelu(&self) -> Result<Tensor> {
185        self.unary_op(UnaryOp::Gelu)
186    }
187    /// Elementwise logistic sigmoid.
188    pub fn sigmoid(&self) -> Result<Tensor> {
189        self.unary_op(UnaryOp::Sigmoid)
190    }
191
192    /// A scalar constant with the dtype and device of `like` (plumbing for
193    /// VJPs and scalar operator overloads).
194    pub fn scalar_on(like: &Tensor, value: f32) -> Result<Tensor> {
195        let s = match like.dtype() {
196            DType::F64 => Tensor::from_vec_f64(vec![f64::from(value)], Shape::from([]))?,
197            _ => Tensor::scalar(value),
198        };
199        s.to_device(like.device())
200    }
201
202    /// This tensor's elements converted to `dtype` (`F32` ↔ `F64`, or
203    /// `I64` → float). A no-op clone for the same dtype. CPU only for
204    /// `F64`; differentiable (the gradient converts back).
205    pub fn to_dtype(&self, dtype: DType) -> Result<Tensor> {
206        if self.dtype() == dtype {
207            return Ok(self.clone());
208        }
209        if self.device() != Device::Cpu {
210            return Err(Error::UnsupportedDType {
211                dtype,
212                op: "to_dtype (device tensors are f32; convert on the CPU)",
213            });
214        }
215        let shape = self.shape().clone();
216        let out = match (self.dtype(), dtype) {
217            (DType::F32, DType::F64) => Tensor::from_vec_f64(
218                self.to_vec_f32()?.into_iter().map(f64::from).collect(),
219                shape,
220            )?,
221            (DType::F64, DType::F32) => Tensor::from_vec_f32(
222                self.to_vec_f64()?.into_iter().map(|x| x as f32).collect(),
223                shape,
224            )?,
225            (DType::I64, DType::F32) => Tensor::from_vec_f32(
226                self.to_vec_i64()?.into_iter().map(|x| x as f32).collect(),
227                shape,
228            )?,
229            (DType::I64, DType::F64) => Tensor::from_vec_f64(
230                self.to_vec_i64()?.into_iter().map(|x| x as f64).collect(),
231                shape,
232            )?,
233            (_, to) => {
234                return Err(Error::UnsupportedDType {
235                    dtype: to,
236                    op: "to_dtype",
237                });
238            }
239        };
240        let from = self.dtype();
241        Ok(record(out, vec![self.clone()], move |g| {
242            Ok(vec![Some(g.to_dtype(from)?)])
243        }))
244    }
245
246    // ---- binary ----------------------------------------------------------
247
248    fn binary_op(&self, op: BinaryOp, rhs: &Tensor) -> Result<Tensor> {
249        let device = same_device(self, rhs, "binary")?;
250        let out = backend_for(device)?.binary(op, self, rhs)?;
251        let (a, b) = (self.clone(), rhs.clone());
252        let o = out.clone();
253        Ok(record(out, vec![self.clone(), rhs.clone()], move |g| {
254            let (ga, gb): (Option<Tensor>, Option<Tensor>) = match op {
255                BinaryOp::Add => (Some(g.clone()), Some(g.clone())),
256                BinaryOp::Sub => (Some(g.clone()), Some(g.neg()?)),
257                BinaryOp::Mul => (Some(g.mul_raw(&b)?), Some(g.mul_raw(&a)?)),
258                BinaryOp::Div => {
259                    let ga = g.div_raw(&b)?;
260                    let gb = g.mul_raw(&o)?.div_raw(&b)?.neg()?;
261                    (Some(ga), Some(gb))
262                }
263                BinaryOp::Pow => {
264                    let one = Tensor::scalar_on(&a, 1.0)?;
265                    let ga = g.mul_raw(&b)?.mul_raw(&a.pow(&b.sub(&one)?)?)?;
266                    let gb = g.mul_raw(&o)?.mul_raw(&a.ln()?)?;
267                    (Some(ga), Some(gb))
268                }
269                // A tie splits the gradient evenly — the convention
270                // docs/LIMITATIONS.md states. Sending all of it to one
271                // operand made every composite built on `maximum` silently
272                // wrong at a tie: BCEWithLogitsLoss is `max(x, 0) - x*t + …`,
273                // so at a logit of exactly 0 it returned `-t` instead of
274                // `sigmoid(0) - t`, and descent moved uphill.
275                BinaryOp::Maximum => {
276                    let half = a.eq_mask(&b)?.mul_scalar(0.5)?;
277                    let wa = a.gt_mask(&b)?.add(&half)?;
278                    let one = Tensor::scalar_on(&a, 1.0)?;
279                    let ga = g.mul_raw(&wa)?;
280                    let gb = g.mul_raw(&one.sub(&wa)?)?;
281                    (Some(ga), Some(gb))
282                }
283                BinaryOp::Minimum => {
284                    let half = a.eq_mask(&b)?.mul_scalar(0.5)?;
285                    let wa = b.gt_mask(&a)?.add(&half)?;
286                    let one = Tensor::scalar_on(&a, 1.0)?;
287                    let ga = g.mul_raw(&wa)?;
288                    let gb = g.mul_raw(&one.sub(&wa)?)?;
289                    (Some(ga), Some(gb))
290                }
291                BinaryOp::Gt | BinaryOp::Eq => (None, None),
292            };
293            let ga = match ga {
294                Some(t) => Some(reduce_to_shape(&t, a.shape())?),
295                None => None,
296            };
297            let gb = match gb {
298                Some(t) => Some(reduce_to_shape(&t, b.shape())?),
299                None => None,
300            };
301            Ok(vec![ga, gb])
302        }))
303    }
304
305    /// Untracked multiply, for use inside VJP closures (recording is
306    /// already off during backward; this is belt and braces).
307    fn mul_raw(&self, rhs: &Tensor) -> Result<Tensor> {
308        let device = same_device(self, rhs, "mul")?;
309        backend_for(device)?.binary(BinaryOp::Mul, self, rhs)
310    }
311
312    fn div_raw(&self, rhs: &Tensor) -> Result<Tensor> {
313        let device = same_device(self, rhs, "div")?;
314        backend_for(device)?.binary(BinaryOp::Div, self, rhs)
315    }
316
317    /// Elementwise addition, broadcasting.
318    pub fn add(&self, rhs: &Tensor) -> Result<Tensor> {
319        self.binary_op(BinaryOp::Add, rhs)
320    }
321    /// Elementwise subtraction, broadcasting.
322    pub fn sub(&self, rhs: &Tensor) -> Result<Tensor> {
323        self.binary_op(BinaryOp::Sub, rhs)
324    }
325    /// Elementwise multiplication, broadcasting.
326    pub fn mul(&self, rhs: &Tensor) -> Result<Tensor> {
327        self.binary_op(BinaryOp::Mul, rhs)
328    }
329    /// Elementwise division, broadcasting.
330    pub fn div(&self, rhs: &Tensor) -> Result<Tensor> {
331        self.binary_op(BinaryOp::Div, rhs)
332    }
333    /// Elementwise power, broadcasting.
334    pub fn pow(&self, rhs: &Tensor) -> Result<Tensor> {
335        self.binary_op(BinaryOp::Pow, rhs)
336    }
337    /// Elementwise maximum, broadcasting.
338    pub fn maximum(&self, rhs: &Tensor) -> Result<Tensor> {
339        self.binary_op(BinaryOp::Maximum, rhs)
340    }
341    /// Elementwise minimum, broadcasting.
342    pub fn minimum(&self, rhs: &Tensor) -> Result<Tensor> {
343        self.binary_op(BinaryOp::Minimum, rhs)
344    }
345    /// Elementwise `a > b` as a 0.0/1.0 mask. Not differentiable.
346    pub fn gt_mask(&self, rhs: &Tensor) -> Result<Tensor> {
347        self.binary_op(BinaryOp::Gt, rhs)
348    }
349    /// Elementwise `a == b` as a 0.0/1.0 mask. Not differentiable.
350    pub fn eq_mask(&self, rhs: &Tensor) -> Result<Tensor> {
351        self.binary_op(BinaryOp::Eq, rhs)
352    }
353
354    /// Add a scalar, broadcasting.
355    pub fn add_scalar(&self, s: f32) -> Result<Tensor> {
356        self.add(&Tensor::scalar_on(self, s)?)
357    }
358    /// Multiply by a scalar, broadcasting.
359    pub fn mul_scalar(&self, s: f32) -> Result<Tensor> {
360        self.mul(&Tensor::scalar_on(self, s)?)
361    }
362
363    // ---- matmul ----------------------------------------------------------
364
365    /// Matrix product with NumPy/PyTorch batch semantics.
366    ///
367    /// The last two dimensions are the matrix (`[.., m, k] x [.., k, n]`
368    /// → `[.., m, n]`); every leading dimension is a batch dimension, and
369    /// batch dimensions broadcast against each other (1 against `b`, and
370    /// a missing leading dimension counts as 1). A rank-2 operand is one
371    /// matrix for every batch of the other. The result is rank 2 only
372    /// when both operands are: `[2, 2, 3] x [1, 3, 2]` is `[2, 2, 2]`,
373    /// `[m, k] x [b, k, n]` is `[b, m, n]`, `[2, 1, 3, 4] x [5, 4, 6]` is
374    /// `[2, 5, 3, 6]`.
375    ///
376    /// Backends implement the rank-2/rank-3 contract of
377    /// [`plan_matmul`](crate::backend::plan_matmul); higher ranks are
378    /// lowered here — the batch dimensions are broadcast (a zero-stride
379    /// view, materialized only when an operand's batch really has to be
380    /// repeated), flattened to one batch axis, multiplied, and unflattened
381    /// — and every step is a recorded op, so the gradient needs no VJP of
382    /// its own.
383    pub fn matmul(&self, rhs: &Tensor) -> Result<Tensor> {
384        let device = same_device(self, rhs, "matmul")?;
385        if self.ndim() > 3 || rhs.ndim() > 3 {
386            return self.matmul_lowered(rhs);
387        }
388        let out = backend_for(device)?.matmul(self, rhs)?;
389        let (a, b) = (self.clone(), rhs.clone());
390        Ok(record(out, vec![self.clone(), rhs.clone()], move |g| {
391            // g is [.., m, n]; the operand grads carry g's batch, which is
392            // then summed down onto a broadcast (or rank-2) operand.
393            let ga = backend_for(g.device())?.matmul(g, &b.t()?)?;
394            let gb = backend_for(g.device())?.matmul(&a.t()?, g)?;
395            Ok(vec![
396                Some(reduce_to_shape(&ga, a.shape())?),
397                Some(reduce_to_shape(&gb, b.shape())?),
398            ])
399        }))
400    }
401
402    /// Rank ≥ 4 matmul: broadcast the batch dimensions, flatten them to one,
403    /// run the rank-3 contract, unflatten. Composed from recorded view ops.
404    fn matmul_lowered(&self, rhs: &Tensor) -> Result<Tensor> {
405        let (ad, bd) = (self.dims(), rhs.dims());
406        if ad.len() < 2 || bd.len() < 2 {
407            return Err(Error::InvalidArgument {
408                op: "matmul",
409                detail: format!("operands need rank >= 2; got {}x{}", ad.len(), bd.len()),
410            });
411        }
412        let (m, k) = (ad[ad.len() - 2], ad[ad.len() - 1]);
413        let (kb, n) = (bd[bd.len() - 2], bd[bd.len() - 1]);
414        if k != kb {
415            return Err(Error::ShapeMismatch {
416                expected: Shape::new(bd[..bd.len() - 2].iter().copied().chain([k, n]).collect()),
417                got: rhs.shape().clone(),
418                op: "matmul",
419            });
420        }
421        let a_batch = Shape::new(ad[..ad.len() - 2].to_vec());
422        let b_batch = Shape::new(bd[..bd.len() - 2].to_vec());
423        let batch = oxmera_core::shape::broadcast_shapes(&a_batch, &b_batch).map_err(|_| {
424            Error::BroadcastIncompatible {
425                lhs: self.shape().clone(),
426                rhs: rhs.shape().clone(),
427            }
428        })?;
429        let batch_numel = batch.numel();
430        // An operand whose batch is a single matrix stays rank 2 and lets
431        // the backend broadcast it with a zero batch stride; anything else
432        // is expanded to the full batch and flattened.
433        let lower = |t: &Tensor, own: &Shape, rows: usize, cols: usize| -> Result<Tensor> {
434            if own.numel() == 1 {
435                return t.reshape(Shape::from([rows, cols]));
436            }
437            let full: Vec<usize> = batch.dims().iter().copied().chain([rows, cols]).collect();
438            let expanded = if own.dims() == batch.dims() {
439                t.clone()
440            } else {
441                // Right-align the operand's batch dims under the broadcast
442                // batch, then take the (recorded) zero-stride view.
443                let lead = batch.ndim() - own.ndim();
444                let padded: Vec<usize> = std::iter::repeat_n(1usize, lead)
445                    .chain(own.dims().iter().copied())
446                    .chain([rows, cols])
447                    .collect();
448                t.reshape(Shape::new(padded))?
449                    .broadcast_to(Shape::new(full.clone()))?
450                    .contiguous()?
451            };
452            expanded.reshape(Shape::from([batch_numel, rows, cols]))
453        };
454        let a3 = lower(self, &a_batch, m, k)?;
455        let b3 = lower(rhs, &b_batch, k, n)?;
456        let out = a3.matmul(&b3)?;
457        let out_shape: Vec<usize> = batch.dims().iter().copied().chain([m, n]).collect();
458        out.reshape(Shape::new(out_shape))
459    }
460
461    // ---- reductions --------------------------------------------------------
462
463    fn reduce_op(&self, op: ReduceOp, axes: &[usize], keepdim: bool) -> Result<Tensor> {
464        let axes = normalize_axes(axes, self.ndim(), "reduce")?;
465        let out = self.backend()?.reduce(op, self, &axes, keepdim)?;
466        let a = self.clone();
467        let o = out.clone();
468        let axes_c = axes.clone();
469        Ok(record(out, vec![self.clone()], move |g| {
470            // Re-insert reduced axes as size 1 so broadcasting lines up.
471            let g_keep = if keepdim {
472                g.clone()
473            } else {
474                unsqueeze_axes(g, &axes_c)?
475            };
476            let gi = match op {
477                ReduceOp::Sum => g_keep.broadcast_to(a.shape().clone())?.contiguous()?,
478                ReduceOp::Max | ReduceOp::Min => {
479                    let o_keep = if keepdim {
480                        o.clone()
481                    } else {
482                        unsqueeze_axes(&o, &axes_c)?
483                    };
484                    let mask = a.eq_mask(&o_keep.broadcast_to(a.shape().clone())?)?;
485                    let count = mask.sum_keepdim(&axes_c, true)?;
486                    g_keep
487                        .broadcast_to(a.shape().clone())?
488                        .mul_raw(&mask)?
489                        .div_raw(&count.broadcast_to(a.shape().clone())?.contiguous()?)?
490                }
491            };
492            Ok(vec![Some(gi)])
493        }))
494    }
495
496    /// Sum over `axes` (empty means all), removing them from the shape.
497    pub fn sum(&self, axes: &[usize]) -> Result<Tensor> {
498        self.reduce_op(ReduceOp::Sum, axes, false)
499    }
500
501    /// Sum over `axes` with explicit `keepdim`.
502    pub fn sum_keepdim(&self, axes: &[usize], keepdim: bool) -> Result<Tensor> {
503        self.reduce_op(ReduceOp::Sum, axes, keepdim)
504    }
505
506    /// Maximum over `axes` (empty means all).
507    pub fn max(&self, axes: &[usize]) -> Result<Tensor> {
508        self.reduce_op(ReduceOp::Max, axes, false)
509    }
510
511    /// Maximum over `axes` with explicit `keepdim`.
512    pub fn max_keepdim(&self, axes: &[usize], keepdim: bool) -> Result<Tensor> {
513        self.reduce_op(ReduceOp::Max, axes, keepdim)
514    }
515
516    /// Minimum over `axes` (empty means all).
517    pub fn min(&self, axes: &[usize]) -> Result<Tensor> {
518        self.reduce_op(ReduceOp::Min, axes, false)
519    }
520
521    /// Mean over `axes` (empty means all) — composite, so its gradient
522    /// flows through `sum` and scalar multiply.
523    ///
524    /// Reducing over a zero-length extent is a typed error; see
525    /// [`Tensor::mean_keepdim`].
526    pub fn mean(&self, axes: &[usize]) -> Result<Tensor> {
527        self.mean_keepdim(axes, false)
528    }
529
530    /// Mean over `axes` with explicit `keepdim`.
531    ///
532    /// # Errors
533    ///
534    /// Reducing over a zero-length extent is an
535    /// [`Error::InvalidArgument`]: the mean of nothing is `0/0`, and there
536    /// is no value that is the right answer.
537    ///
538    /// Until 0.4.0 this returned `NaN`, which is not an answer either but
539    /// looks like one. A `NaN` from an empty last batch does not fail — it
540    /// flows into the loss, then into every gradient, and surfaces an
541    /// epoch later as a model that stopped learning for no visible reason.
542    /// `argmax` already refused the same input for the same reason; this
543    /// is the pair being made consistent.
544    ///
545    /// `sum` and `max` still return their identities (`0` and `-inf`) over
546    /// an empty extent, and deliberately: those compose correctly under
547    /// further reduction, and `mean` does not. The whole family is
548    /// tabulated in `docs/LIMITATIONS.md`.
549    pub fn mean_keepdim(&self, axes: &[usize], keepdim: bool) -> Result<Tensor> {
550        let axes_n = normalize_axes(axes, self.ndim(), "mean")?;
551        let n: usize = axes_n.iter().map(|&ax| self.dims()[ax]).product();
552        if n == 0 {
553            let empty: Vec<usize> = axes_n
554                .iter()
555                .copied()
556                .filter(|&ax| self.dims()[ax] == 0)
557                .collect();
558            return Err(Error::InvalidArgument {
559                op: "mean",
560                detail: format!(
561                    "dimension(s) {empty:?} have extent 0, so the mean would be 0/0; \
562                     the mean of nothing is undefined — use sum() if an empty \
563                     reduction should be 0, or guard the empty case at the call site"
564                ),
565            });
566        }
567        let summed = self.sum_keepdim(&axes_n, keepdim)?;
568        if summed.dtype() == DType::F64 {
569            // Scaling an f64 sum by an f32 reciprocal threw away every digit
570            // past f32 precision: mean([1,2,3]) came back 2.0000000596046448.
571            // Divide in the tensor's own dtype instead.
572            let divisor = Tensor::from_vec_f64(vec![n as f64], Shape::from([]))?
573                .to_device(summed.device())?;
574            return summed.div(&divisor);
575        }
576        summed.mul_scalar(1.0 / n as f32)
577    }
578
579    /// Index of the maximum along `dim`, as an `I64` tensor. Not
580    /// differentiable.
581    pub fn argmax(&self, dim: usize, keepdim: bool) -> Result<Tensor> {
582        if dim >= self.ndim() {
583            return Err(Error::InvalidArgument {
584                op: "argmax",
585                detail: format!("dim {dim} out of range for rank {}", self.ndim()),
586            });
587        }
588        self.backend()?.argmax(self, dim, keepdim)
589    }
590
591    /// Numerically stable softmax along `dim` — composite.
592    pub fn softmax(&self, dim: usize) -> Result<Tensor> {
593        // The row max and row sum stay broadcast views: the binary kernels
594        // consume a stride-0 operand directly, so nothing is materialized.
595        let shifted = self.sub(&self.max_keepdim(&[dim], true)?.detach())?;
596        let e = shifted.exp()?;
597        let denom = e.sum_keepdim(&[dim], true)?;
598        e.div(&denom)
599    }
600
601    /// Numerically stable log-softmax along `dim` — composite.
602    pub fn log_softmax(&self, dim: usize) -> Result<Tensor> {
603        let shifted = self.sub(&self.max_keepdim(&[dim], true)?.detach())?;
604        let lse = shifted.exp()?.sum_keepdim(&[dim], true)?.ln()?;
605        shifted.sub(&lse)
606    }
607
608    // ---- indexing -----------------------------------------------------------
609
610    /// Rows of `self` along `dim` selected by `indices` (`I64`, on the CPU).
611    pub fn index_select(&self, dim: usize, indices: &Tensor) -> Result<Tensor> {
612        let out = dispatch_index(self, &[indices], |be, t, extra| {
613            be.index_select(t, dim, &extra[0])
614        })?;
615        let in_shape = self.shape().clone();
616        let idx = indices.clone();
617        Ok(record(out, vec![self.clone()], move |g| {
618            let zeros = Tensor::zeros(in_shape.clone())
619                .to_dtype(g.dtype())?
620                .to_device(g.device())?;
621            Ok(vec![Some(zeros.index_add(dim, &idx, g)?)])
622        }))
623    }
624
625    /// `out[indices[i]] += src[i]` along `dim`, on a fresh copy of `self`.
626    /// `indices` is `I64` on the CPU; `src` lives on `self`'s device.
627    pub fn index_add(&self, dim: usize, indices: &Tensor, src: &Tensor) -> Result<Tensor> {
628        same_device(self, src, "index_add")?;
629        let out = dispatch_index(self, &[indices, src], |be, t, extra| {
630            be.index_add(t, dim, &extra[0], &extra[1])
631        })?;
632        let idx = indices.clone();
633        Ok(record(out, vec![self.clone(), src.clone()], move |g| {
634            Ok(vec![Some(g.clone()), Some(g.index_select(dim, &idx)?)])
635        }))
636    }
637
638    // ---- device movement ------------------------------------------------------
639
640    /// This tensor's data on `device` (a cheap clone when already there).
641    /// `F64` tensors are CPU-only: moving one to a GPU is a typed error.
642    pub fn to_device(&self, device: Device) -> Result<Tensor> {
643        if self.device() == device {
644            return Ok(self.clone());
645        }
646        if self.dtype() == DType::F64 {
647            return Err(Error::UnsupportedDType {
648                dtype: DType::F64,
649                op: "to_device (f64 tensors live on the CPU; to_dtype(F32) first)",
650            });
651        }
652        let out = match (self.device(), device) {
653            (Device::Cpu, target) => backend_for(target)?.upload(&self.contiguous_data()?)?,
654            (_, Device::Cpu) => self.backend()?.download(self)?,
655            (_, target) => {
656                let host = self.backend()?.download(self)?;
657                backend_for(target)?.upload(&host)?
658            }
659        };
660        let source = self.device();
661        Ok(record(out, vec![self.clone()], move |g| {
662            Ok(vec![Some(g.to_device(source)?)])
663        }))
664    }
665}
666
667/// Insert size-1 axes at `axes` (sorted ascending) — plumbing for reduce
668/// VJPs.
669fn unsqueeze_axes(t: &Tensor, axes: &[usize]) -> Result<Tensor> {
670    let mut out = t.clone();
671    let mut sorted = axes.to_vec();
672    sorted.sort_unstable();
673    for &ax in &sorted {
674        out = out.unsqueeze(ax)?;
675    }
676    Ok(out)
677}
678
679/// Validate and canonicalize reduce axes; empty means all axes.
680fn normalize_axes(axes: &[usize], ndim: usize, op: &'static str) -> Result<Vec<usize>> {
681    let mut axes: Vec<usize> = if axes.is_empty() {
682        (0..ndim).collect()
683    } else {
684        axes.to_vec()
685    };
686    axes.sort_unstable();
687    axes.dedup();
688    if let Some(&bad) = axes.iter().find(|&&a| a >= ndim) {
689        return Err(Error::InvalidArgument {
690            op,
691            detail: format!("axis {bad} out of range for rank {ndim}"),
692        });
693    }
694    Ok(axes)
695}
696
697/// Run an index op on the tensor's backend, falling back to a CPU
698/// round-trip when the backend declines. Every tensor operand — the
699/// indices and, for `index_add`, the source — takes the round-trip too:
700/// moving only `t` left `src` on the device and failed the CPU backend
701/// with a DeviceMismatch inside the `narrow` VJP (found by oxmega's
702/// k-DPP loss on CUDA).
703fn dispatch_index(
704    t: &Tensor,
705    extra: &[&Tensor],
706    f: impl Fn(&dyn Backend, &Tensor, &[Tensor]) -> Result<Tensor>,
707) -> Result<Tensor> {
708    let backend = backend_for(t.device())?;
709    let on_device: Vec<Tensor> = extra.iter().map(|e| (*e).clone()).collect();
710    match f(backend.as_ref(), t, &on_device) {
711        Err(Error::NotImplemented { .. }) if t.device() != Device::Cpu => {
712            let cpu = backend.download(t)?;
713            let cpu_extra: Vec<Tensor> = extra
714                .iter()
715                .map(|e| e.to_device(Device::Cpu))
716                .collect::<Result<_>>()?;
717            let cpu_backend = backend_for(Device::Cpu)?;
718            let out = f(cpu_backend.as_ref(), &cpu, &cpu_extra)?;
719            backend_for(t.device())?.upload(&out)
720        }
721        other => other,
722    }
723}