Skip to main content

torsh_tensor/
dim_ops.rs

1//! Dimension-aware tensor operations (reductions, scans, sorting, arg-reductions)
2//!
3//! Every operation in this module follows PyTorch semantics:
4//!
5//! - Negative dimension indices count from the end (`-1` is the last axis).
6//! - Reductions remove the reduced axis unless `keepdim` is requested, in which
7//!   case the axis is kept with extent `1`.
8//! - Scans (`cumsum`, `cumprod`) walk *along* the requested axis and restart the
9//!   accumulator for every independent slice.
10//! - `sort` returns `(values, indices)` where the indices are positions **inside
11//!   the sorted dimension**, typed `i64` like `argmin`/`argmax`.
12//!
13//! The kernels share a single `outer / dim / inner` decomposition: for an axis
14//! `d` of a contiguous tensor, element `(o, i, n)` lives at flat index
15//! `o * dim_size * inner_size + i * inner_size + n`.  This makes every op here
16//! stride-correct for arbitrary rank without materialising transposes.
17
18use torsh_core::{
19    dtype::{FloatElement, TensorElement},
20    error::{Result, TorshError},
21};
22
23use crate::core_ops::Tensor;
24
25/// Normalise a (possibly negative) dimension index against a tensor rank.
26///
27/// Returns `Err(TorshError::InvalidOperation)` when the index is out of range,
28/// including every index for a 0-dimensional tensor.
29pub(crate) fn normalize_dim(dim: i32, ndim: usize) -> Result<usize> {
30    let resolved = if dim < 0 {
31        i64::from(dim) + ndim as i64
32    } else {
33        i64::from(dim)
34    };
35
36    if resolved < 0 || resolved as usize >= ndim {
37        return Err(TorshError::InvalidOperation(format!(
38            "Dimension {} out of range for {}-dimensional tensor",
39            dim, ndim
40        )));
41    }
42
43    Ok(resolved as usize)
44}
45
46/// Split a shape into the `(outer, dim, inner)` extents around `dim`.
47pub(crate) fn split_extents(shape: &[usize], dim: usize) -> (usize, usize, usize) {
48    let outer: usize = shape[..dim].iter().product();
49    let dim_size = shape[dim];
50    let inner: usize = shape[dim + 1..].iter().product();
51    (outer, dim_size, inner)
52}
53
54/// Shape of a reduction result over a single axis.
55pub(crate) fn reduced_shape(shape: &[usize], dim: usize, keepdim: bool) -> Vec<usize> {
56    let mut out = shape.to_vec();
57    if keepdim {
58        out[dim] = 1;
59    } else {
60        out.remove(dim);
61    }
62    out
63}
64
65/// Reduce `data` along the `(outer, dim, inner)` decomposition, keeping the
66/// element selected by `prefer_new` (e.g. `|new, best| new > best` for max).
67fn extremum_values<T: Copy>(
68    data: &[T],
69    outer: usize,
70    dim_size: usize,
71    inner: usize,
72    prefer_new: impl Fn(T, T) -> bool,
73) -> Vec<T> {
74    let mut result = Vec::with_capacity(outer * inner);
75    for o in 0..outer {
76        for n in 0..inner {
77            let base = o * dim_size * inner + n;
78            let mut best = data[base];
79            for d in 1..dim_size {
80                let value = data[base + d * inner];
81                if prefer_new(value, best) {
82                    best = value;
83                }
84            }
85            result.push(best);
86        }
87    }
88    result
89}
90
91/// Index of the extremum along the `(outer, dim, inner)` decomposition.
92/// Ties resolve to the first occurrence, matching PyTorch.
93fn extremum_indices<T: Copy>(
94    data: &[T],
95    outer: usize,
96    dim_size: usize,
97    inner: usize,
98    prefer_new: impl Fn(T, T) -> bool,
99) -> Vec<i64> {
100    let mut result = Vec::with_capacity(outer * inner);
101    for o in 0..outer {
102        for n in 0..inner {
103            let base = o * dim_size * inner + n;
104            let mut best = data[base];
105            let mut best_idx = 0i64;
106            for d in 1..dim_size {
107                let value = data[base + d * inner];
108                if prefer_new(value, best) {
109                    best = value;
110                    best_idx = d as i64;
111                }
112            }
113            result.push(best_idx);
114        }
115    }
116    result
117}
118
119// ---------------------------------------------------------------------------
120// Float-only extremum reductions
121// ---------------------------------------------------------------------------
122
123impl<T: FloatElement + Copy> Tensor<T> {
124    /// Maximum element of the tensor, optionally reduced along a single axis.
125    ///
126    /// `dim == None` reduces every element to a scalar (or to an all-ones shape
127    /// when `keepdim` is set).
128    pub fn max(&self, dim: Option<usize>, keepdim: bool) -> Result<Self> {
129        match dim {
130            None => {
131                let max_val =
132                    self.with_contiguous_data(|data| global_extremum(data, |a, b| a > b, "max"))?;
133                if keepdim {
134                    let shape = vec![1; self.shape().dims().len()];
135                    Self::from_data(vec![max_val], shape, self.device())
136                } else {
137                    Self::scalar(max_val)
138                }
139            }
140            Some(axis) => self.extremum_along_dim(axis, keepdim, true),
141        }
142    }
143
144    /// Maximum along a specified dimension (negative indices allowed).
145    pub fn max_dim(&self, dim: i32, keepdim: bool) -> Result<Self> {
146        let axis = normalize_dim(dim, self.shape().dims().len())?;
147        self.extremum_along_dim(axis, keepdim, true)
148    }
149
150    /// Minimum along a specified dimension (negative indices allowed).
151    pub fn min_dim(&self, dim: i32, keepdim: bool) -> Result<Self> {
152        let axis = normalize_dim(dim, self.shape().dims().len())?;
153        self.extremum_along_dim(axis, keepdim, false)
154    }
155
156    /// Minimum element of the tensor, optionally reduced along a single axis.
157    ///
158    /// Mirror image of [`Tensor::max`]; PyTorch calls this reduction `amin`.
159    /// The parameter-less [`Tensor::min`] remains available for global minima.
160    pub fn amin(&self, dim: Option<usize>, keepdim: bool) -> Result<Self> {
161        match dim {
162            None => {
163                let min_val =
164                    self.with_contiguous_data(|data| global_extremum(data, |a, b| a < b, "min"))?;
165                if keepdim {
166                    let shape = vec![1; self.shape().dims().len()];
167                    Self::from_data(vec![min_val], shape, self.device())
168                } else {
169                    Self::scalar(min_val)
170                }
171            }
172            Some(axis) => self.extremum_along_dim(axis, keepdim, false),
173        }
174    }
175
176    /// Maximum element of the tensor, optionally reduced along a single axis.
177    ///
178    /// PyTorch-compatible alias of [`Tensor::max`], provided so that generic
179    /// code can use the symmetric `amin`/`amax` pair.
180    pub fn amax(&self, dim: Option<usize>, keepdim: bool) -> Result<Self> {
181        self.max(dim, keepdim)
182    }
183
184    /// Shared implementation of the dimension-wise extremum reductions.
185    fn extremum_along_dim(&self, axis: usize, keepdim: bool, want_max: bool) -> Result<Self> {
186        let shape_binding = self.shape();
187        let input_shape = shape_binding.dims();
188
189        if axis >= input_shape.len() {
190            return Err(TorshError::InvalidOperation(format!(
191                "Axis {} out of bounds for {}-dimensional tensor",
192                axis,
193                input_shape.len()
194            )));
195        }
196
197        let (outer, dim_size, inner) = split_extents(input_shape, axis);
198        if dim_size == 0 {
199            return Err(TorshError::InvalidOperation(
200                "Cannot reduce a dimension with zero size".to_string(),
201            ));
202        }
203
204        let output_shape = reduced_shape(input_shape, axis, keepdim);
205
206        // GPU fast path first: it keeps the result device-resident, whereas
207        // `with_contiguous_data` below would download the operand.
208        let kind = if want_max {
209            ReduceKind::Max
210        } else {
211            ReduceKind::Min
212        };
213        if let Some(result) = self.try_device_reduce(kind, axis, &output_shape) {
214            return Ok(result);
215        }
216
217        let result_data = self.with_contiguous_data(|data| {
218            Ok(if want_max {
219                extremum_values(data, outer, dim_size, inner, |new, best| new > best)
220            } else {
221                extremum_values(data, outer, dim_size, inner, |new, best| new < best)
222            })
223        })?;
224
225        Self::from_data(result_data, output_shape, self.device())
226    }
227}
228
229/// Fold a slice to its extremum, returning an error for empty input.
230fn global_extremum<T: Copy>(data: &[T], prefer_new: impl Fn(T, T) -> bool, op: &str) -> Result<T> {
231    let mut iter = data.iter().copied();
232    let first = iter.next().ok_or_else(|| {
233        TorshError::InvalidOperation(format!("Cannot compute {} of empty tensor", op))
234    })?;
235    Ok(iter.fold(first, |best, x| if prefer_new(x, best) { x } else { best }))
236}
237
238// ---------------------------------------------------------------------------
239// Generic dimension-aware operations
240// ---------------------------------------------------------------------------
241
242/// Which single-axis reduction a dimension-wise op wants from the GPU dispatch
243/// layer.
244///
245/// Keeping the `oxicuda` op vocabulary behind this enum is what lets the call
246/// sites stay free of `#[cfg(feature = "gpu")]`.
247#[derive(Clone, Copy)]
248enum ReduceKind {
249    Sum,
250    Max,
251    Min,
252}
253
254impl<T: TensorElement + Copy> Tensor<T> {
255    /// Try the device-resident single-axis reduction.
256    ///
257    /// Returns `None` — meaning "run the host path" — without the `gpu` feature,
258    /// when no backend is active, or whenever the dispatch declines. `output_shape`
259    /// is the caller's already-resolved reduced shape, so `keepdim` never has to
260    /// be re-derived (the element count is the same either way).
261    ///
262    /// # NaN handling
263    /// For [`ReduceKind::Max`] / [`ReduceKind::Min`] the backend kernels use
264    /// `f32::max` / `f32::min` from an infinite seed, which *ignore* NaN, while
265    /// the host path seeds from the first element and therefore propagates it.
266    /// An axis that is entirely NaN consequently reduces to ±inf on the device
267    /// and to NaN on the host. Reconciling that belongs with the phase-2
268    /// real-device numeric validation, not here.
269    fn try_device_reduce(
270        &self,
271        kind: ReduceKind,
272        axis: usize,
273        output_shape: &[usize],
274    ) -> Option<Self> {
275        #[cfg(feature = "gpu")]
276        {
277            let op = match kind {
278                ReduceKind::Sum => crate::gpu_dispatch::ReduceOp::Sum,
279                ReduceKind::Max => crate::gpu_dispatch::ReduceOp::Max,
280                ReduceKind::Min => crate::gpu_dispatch::ReduceOp::Min,
281            };
282            crate::gpu_dispatch::try_reduce_axis_f32(self, op, axis, output_shape)
283        }
284        #[cfg(not(feature = "gpu"))]
285        {
286            let _ = (kind, axis, output_shape);
287            None
288        }
289    }
290
291    /// Record `result` as a dimension-wise sum of `self` in the autograd graph.
292    ///
293    /// `reduce_dims` must be the **normalised** (resolved / sorted / deduplicated)
294    /// axis list the forward pass actually reduced — the backward rule rebuilds
295    /// the keepdim layout from it, so the caller's raw `&[i32]` would be wrong for
296    /// negative or repeated indices. A no-op when gradients are not being tracked,
297    /// so inference keeps building plain leaves.
298    fn record_sum_dim(&self, result: &mut Self, reduce_dims: &[usize], keepdim: bool) {
299        if crate::should_record_grad(self.requires_grad) {
300            result.requires_grad = true;
301            result.operation = crate::core_ops::Operation::SumDim {
302                input: std::sync::Arc::new(self.clone()),
303                dims: reduce_dims.to_vec(),
304                keepdim,
305            };
306        }
307    }
308
309    /// Compute the sum along the specified dimensions.
310    ///
311    /// Every listed dimension is reduced (duplicates and negative indices are
312    /// accepted); with `keepdim` the reduced axes are kept with extent `1`.
313    /// An empty `dims` slice reduces the whole tensor, matching [`Tensor::sum`].
314    pub fn sum_dim(&self, dims: &[i32], keepdim: bool) -> Result<Self>
315    where
316        T: std::ops::Add<Output = T> + num_traits::Zero,
317    {
318        if dims.is_empty() {
319            return self.sum();
320        }
321
322        let shape_binding = self.shape();
323        let input_shape = shape_binding.dims().to_vec();
324        let ndim = input_shape.len();
325
326        let mut reduce_dims = Vec::with_capacity(dims.len());
327        for &dim in dims {
328            reduce_dims.push(normalize_dim(dim, ndim)?);
329        }
330        reduce_dims.sort_unstable();
331        reduce_dims.dedup();
332
333        // Single-axis fast path: contiguous outer/dim/inner walk.
334        if reduce_dims.len() == 1 {
335            let axis = reduce_dims[0];
336            let (outer, dim_size, inner) = split_extents(&input_shape, axis);
337            let output_shape = reduced_shape(&input_shape, axis, keepdim);
338
339            // GPU fast path first: it keeps the result device-resident, whereas
340            // `with_contiguous_data` below would download the operand.
341            let device_result = self.try_device_reduce(ReduceKind::Sum, axis, &output_shape);
342
343            let mut result = match device_result {
344                Some(result) => result,
345                None => {
346                    let result_data = self.with_contiguous_data(|data| {
347                        let mut result = vec![<T as num_traits::Zero>::zero(); outer * inner];
348                        for o in 0..outer {
349                            for n in 0..inner {
350                                let base = o * dim_size * inner + n;
351                                let mut acc = <T as num_traits::Zero>::zero();
352                                for d in 0..dim_size {
353                                    acc = acc + data[base + d * inner];
354                                }
355                                result[o * inner + n] = acc;
356                            }
357                        }
358                        Ok(result)
359                    })?;
360                    Self::from_data(result_data, output_shape, self.device())?
361                }
362            };
363            // Recorded on whichever tensor the dispatch produced, so the device
364            // path joins the autograd graph exactly like the host path.
365            self.record_sum_dim(&mut result, &reduce_dims, keepdim);
366            return Ok(result);
367        }
368
369        // General multi-axis reduction: map every input element onto the flat
370        // index of the (keepdim-shaped) output slot it accumulates into.
371        let mut is_reduced = vec![false; ndim];
372        for &d in &reduce_dims {
373            is_reduced[d] = true;
374        }
375
376        let input_strides = contiguous_strides(&input_shape);
377        let mut output_shape_keepdim = input_shape.clone();
378        for &d in &reduce_dims {
379            output_shape_keepdim[d] = 1;
380        }
381        let output_strides = contiguous_strides(&output_shape_keepdim);
382        let output_size: usize = output_shape_keepdim.iter().product();
383
384        let result_data = self.with_contiguous_data(|data| {
385            let mut acc = vec![<T as num_traits::Zero>::zero(); output_size];
386            for (flat_idx, &value) in data.iter().enumerate() {
387                let mut remaining = flat_idx;
388                let mut out_flat = 0usize;
389                for (dim, &reduced) in is_reduced.iter().enumerate() {
390                    let coord = remaining / input_strides[dim];
391                    remaining %= input_strides[dim];
392                    if !reduced {
393                        out_flat += coord * output_strides[dim];
394                    }
395                }
396                acc[out_flat] = acc[out_flat] + value;
397            }
398            Ok(acc)
399        })?;
400
401        let final_shape = if keepdim {
402            output_shape_keepdim
403        } else {
404            input_shape
405                .into_iter()
406                .zip(is_reduced.iter())
407                .filter(|(_, &reduced)| !reduced)
408                .map(|(size, _)| size)
409                .collect::<Vec<_>>()
410        };
411
412        let mut result = Self::from_data(result_data, final_shape, self.device())?;
413        self.record_sum_dim(&mut result, &reduce_dims, keepdim);
414        Ok(result)
415    }
416
417    /// Compute the mean along the specified dimensions.
418    ///
419    /// `dims == None` averages every element. Out-of-range dimensions return an
420    /// error instead of panicking.
421    pub fn mean(&self, dims: Option<&[usize]>, keepdim: bool) -> Result<Self>
422    where
423        T: std::ops::Add<Output = T>
424            + std::ops::Div<Output = T>
425            + num_traits::Zero
426            + num_traits::One
427            + num_traits::FromPrimitive,
428    {
429        let ndim = self.shape().ndim();
430
431        // Validate and normalise the requested axes before any indexing.
432        let reduce_dims: Option<Vec<usize>> = match dims {
433            Some(requested) => {
434                for &dim in requested {
435                    if dim >= ndim {
436                        return Err(TorshError::InvalidOperation(format!(
437                            "Dimension {} out of range for {}-dimensional tensor",
438                            dim, ndim
439                        )));
440                    }
441                }
442                let mut normalized = requested.to_vec();
443                normalized.sort_unstable();
444                normalized.dedup();
445                Some(normalized)
446            }
447            None => None,
448        };
449
450        let sum = match &reduce_dims {
451            Some(reduce) => {
452                let as_i32: Vec<i32> = reduce.iter().map(|&d| d as i32).collect();
453                self.sum_dim(&as_i32, keepdim)?
454            }
455            None => {
456                let scalar_sum = self.sum()?;
457                if keepdim {
458                    let keepdim_shape = vec![1; ndim];
459                    scalar_sum.view(&keepdim_shape)?
460                } else {
461                    scalar_sum
462                }
463            }
464        };
465
466        let count = match &reduce_dims {
467            Some(reduce) => {
468                let dims_binding = self.shape();
469                let shape = dims_binding.dims();
470                reduce.iter().map(|&d| shape[d]).product::<usize>() as f64
471            }
472            None => self.numel() as f64,
473        };
474
475        let result = sum.div_scalar(
476            <T as num_traits::FromPrimitive>::from_f64(count)
477                .unwrap_or_else(|| <T as num_traits::One>::one()),
478        )?;
479
480        Ok(result)
481    }
482
483    /// Cumulative sum along a dimension.
484    ///
485    /// The accumulator restarts for every independent slice, so `cumsum(0)` on a
486    /// `[2, 3]` tensor accumulates down the rows and `cumsum(-1)` accumulates
487    /// across each row separately.
488    pub fn cumsum(&self, dim: i32) -> Result<Self>
489    where
490        T: std::ops::Add<Output = T> + num_traits::Zero + Copy,
491    {
492        let shape_binding = self.shape();
493        let shape = shape_binding.dims().to_vec();
494        let axis = normalize_dim(dim, shape.len())?;
495        let (outer, dim_size, inner) = split_extents(&shape, axis);
496
497        let mut result_data = self.data()?;
498        for o in 0..outer {
499            for n in 0..inner {
500                let base = o * dim_size * inner + n;
501                let mut acc = <T as num_traits::Zero>::zero();
502                for d in 0..dim_size {
503                    let idx = base + d * inner;
504                    acc = acc + result_data[idx];
505                    result_data[idx] = acc;
506                }
507            }
508        }
509
510        Self::from_data(result_data, shape, self.device())
511    }
512
513    /// Cumulative product along a dimension.
514    pub fn cumprod(&self, dim: i32) -> Result<Self>
515    where
516        T: std::ops::Mul<Output = T> + num_traits::One + Copy,
517    {
518        let shape_binding = self.shape();
519        let shape = shape_binding.dims().to_vec();
520        let axis = normalize_dim(dim, shape.len())?;
521        let (outer, dim_size, inner) = split_extents(&shape, axis);
522
523        // `data()` already yields an owned, view-ordered buffer: mutate it directly.
524        let mut result_data = self.data()?;
525        for o in 0..outer {
526            for n in 0..inner {
527                let base = o * dim_size * inner + n;
528                let mut acc = <T as num_traits::One>::one();
529                for d in 0..dim_size {
530                    let idx = base + d * inner;
531                    acc = acc * result_data[idx];
532                    result_data[idx] = acc;
533                }
534            }
535        }
536
537        Self::from_data(result_data, shape, self.device())
538    }
539
540    /// Sort the tensor along a dimension.
541    ///
542    /// Returns `(values, indices)` where `indices` are positions inside the
543    /// sorted dimension (PyTorch semantics). `dim == None` sorts the last
544    /// dimension. Ties keep their original relative order (stable sort).
545    pub fn sort(&self, dim: Option<i32>, descending: bool) -> Result<(Self, Tensor<i64>)>
546    where
547        T: PartialOrd + Copy,
548    {
549        let shape_binding = self.shape();
550        let shape = shape_binding.dims().to_vec();
551
552        if shape.is_empty() {
553            // 0-d tensors are already sorted; the only valid index is 0.
554            let values = Self::from_data(self.data()?, shape.clone(), self.device())?;
555            let indices = Tensor::<i64>::from_data(vec![0], shape, self.device())?;
556            return Ok((values, indices));
557        }
558
559        let axis = match dim {
560            Some(d) => normalize_dim(d, shape.len())?,
561            None => shape.len() - 1,
562        };
563        let (outer, dim_size, inner) = split_extents(&shape, axis);
564
565        let data = self.data()?;
566        if data.is_empty() {
567            let values = Self::from_data(Vec::new(), shape.clone(), self.device())?;
568            let indices = Tensor::<i64>::from_data(Vec::new(), shape, self.device())?;
569            return Ok((values, indices));
570        }
571
572        let mut sorted_values = vec![data[0]; data.len()];
573        let mut sorted_indices = vec![0i64; data.len()];
574        let mut slice: Vec<(usize, T)> = Vec::with_capacity(dim_size);
575
576        for o in 0..outer {
577            for n in 0..inner {
578                let base = o * dim_size * inner + n;
579                slice.clear();
580                slice.extend((0..dim_size).map(|d| (d, data[base + d * inner])));
581
582                if descending {
583                    slice
584                        .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
585                } else {
586                    slice
587                        .sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
588                }
589
590                for (d, (src_idx, value)) in slice.iter().enumerate() {
591                    let dst = base + d * inner;
592                    sorted_values[dst] = *value;
593                    sorted_indices[dst] = *src_idx as i64;
594                }
595            }
596        }
597
598        let values = Self::from_data(sorted_values, shape.clone(), self.device())?;
599        let indices = Tensor::<i64>::from_data(sorted_indices, shape, self.device())?;
600        Ok((values, indices))
601    }
602
603    /// Indices of the minimum values along a dimension.
604    ///
605    /// `dim == Some(d)` returns a tensor shaped like the input with axis `d`
606    /// removed; `dim == None` returns the flat index of the global minimum as a
607    /// 0-dimensional tensor.
608    pub fn argmin(&self, dim: Option<i32>) -> Result<Tensor<i64>>
609    where
610        T: PartialOrd + Copy,
611    {
612        match dim {
613            Some(d) => self.arg_extremum_dim(d, false, false),
614            None => self.arg_extremum_global(false),
615        }
616    }
617
618    /// Indices of the maximum values along a dimension.
619    pub fn argmax(&self, dim: Option<i32>) -> Result<Tensor<i64>>
620    where
621        T: PartialOrd + Copy,
622    {
623        match dim {
624            Some(d) => self.arg_extremum_dim(d, true, false),
625            None => self.arg_extremum_global(true),
626        }
627    }
628
629    /// Indices of the minimum values along `dim`, with PyTorch's `keepdim`.
630    pub fn argmin_dim(&self, dim: i32, keepdim: bool) -> Result<Tensor<i64>>
631    where
632        T: PartialOrd + Copy,
633    {
634        self.arg_extremum_dim(dim, false, keepdim)
635    }
636
637    /// Indices of the maximum values along `dim`, with PyTorch's `keepdim`.
638    pub fn argmax_dim(&self, dim: i32, keepdim: bool) -> Result<Tensor<i64>>
639    where
640        T: PartialOrd + Copy,
641    {
642        self.arg_extremum_dim(dim, true, keepdim)
643    }
644
645    fn arg_extremum_dim(&self, dim: i32, want_max: bool, keepdim: bool) -> Result<Tensor<i64>>
646    where
647        T: PartialOrd + Copy,
648    {
649        let shape_binding = self.shape();
650        let shape = shape_binding.dims().to_vec();
651        let axis = normalize_dim(dim, shape.len())?;
652        let (outer, dim_size, inner) = split_extents(&shape, axis);
653
654        if dim_size == 0 {
655            return Err(TorshError::InvalidOperation(
656                "Cannot compute arg-reduction over a zero-sized dimension".to_string(),
657            ));
658        }
659
660        let result_data = self.with_contiguous_data(|data| {
661            Ok(if want_max {
662                extremum_indices(data, outer, dim_size, inner, |new, best| new > best)
663            } else {
664                extremum_indices(data, outer, dim_size, inner, |new, best| new < best)
665            })
666        })?;
667
668        Tensor::<i64>::from_data(
669            result_data,
670            reduced_shape(&shape, axis, keepdim),
671            self.device(),
672        )
673    }
674
675    fn arg_extremum_global(&self, want_max: bool) -> Result<Tensor<i64>>
676    where
677        T: PartialOrd + Copy,
678    {
679        let index = self.with_contiguous_data(|data| {
680            let mut iter = data.iter().copied().enumerate();
681            let (mut best_idx, mut best) = iter.next().ok_or_else(|| {
682                TorshError::InvalidOperation(
683                    "Cannot compute arg-reduction on empty tensor".to_string(),
684                )
685            })?;
686            for (idx, value) in iter {
687                let better = if want_max { value > best } else { value < best };
688                if better {
689                    best = value;
690                    best_idx = idx;
691                }
692            }
693            Ok(best_idx as i64)
694        })?;
695
696        Tensor::<i64>::from_data(vec![index], vec![], self.device())
697    }
698}
699
700/// Row-major (C-contiguous) strides for a shape.
701pub(crate) fn contiguous_strides(shape: &[usize]) -> Vec<usize> {
702    let ndim = shape.len();
703    let mut strides = vec![1usize; ndim];
704    for i in (0..ndim.saturating_sub(1)).rev() {
705        strides[i] = strides[i + 1] * shape[i + 1];
706    }
707    strides
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713    use torsh_core::device::DeviceType;
714
715    #[test]
716    fn normalize_dim_handles_negative_and_range() {
717        assert_eq!(normalize_dim(-1, 3).expect("valid dim"), 2);
718        assert_eq!(normalize_dim(0, 3).expect("valid dim"), 0);
719        assert!(normalize_dim(3, 3).is_err());
720        assert!(normalize_dim(-4, 3).is_err());
721        assert!(normalize_dim(0, 0).is_err());
722    }
723
724    #[test]
725    fn amin_amax_are_symmetric() {
726        let t = Tensor::from_data(vec![1.0f32, 5.0, 3.0, 2.0], vec![2, 2], DeviceType::Cpu)
727            .expect("tensor creation should succeed");
728
729        let amax_dim1 = t.amax(Some(1), false).expect("amax should succeed");
730        let amin_dim1 = t.amin(Some(1), false).expect("amin should succeed");
731        assert_eq!(amax_dim1.to_vec().expect("to_vec"), vec![5.0, 3.0]);
732        assert_eq!(amin_dim1.to_vec().expect("to_vec"), vec![1.0, 2.0]);
733
734        let amax_all = t.amax(None, false).expect("amax should succeed");
735        let amin_all = t.amin(None, false).expect("amin should succeed");
736        assert_eq!(amax_all.item().expect("item"), 5.0);
737        assert_eq!(amin_all.item().expect("item"), 1.0);
738
739        // keepdim keeps the rank
740        let kept = t.amin(Some(0), true).expect("amin should succeed");
741        assert_eq!(kept.shape().dims(), &[1, 2]);
742    }
743
744    #[test]
745    fn min_dim_and_max_dim_agree_with_amin_amax() {
746        let t = Tensor::from_data(
747            vec![1.0f32, 5.0, 3.0, 2.0, 8.0, 0.0],
748            vec![2, 3],
749            DeviceType::Cpu,
750        )
751        .expect("tensor creation should succeed");
752
753        assert_eq!(
754            t.min_dim(-1, false).expect("min_dim").to_vec().expect("v"),
755            t.amin(Some(1), false).expect("amin").to_vec().expect("v")
756        );
757        assert_eq!(
758            t.max_dim(-1, false).expect("max_dim").to_vec().expect("v"),
759            t.amax(Some(1), false).expect("amax").to_vec().expect("v")
760        );
761    }
762
763    #[test]
764    fn argmax_dim_keepdim_shape() {
765        let t = Tensor::from_data(
766            vec![1.0f32, 5.0, 3.0, 2.0, 8.0, 0.0],
767            vec![2, 3],
768            DeviceType::Cpu,
769        )
770        .expect("tensor creation should succeed");
771        let out = t.argmax_dim(1, true).expect("argmax_dim should succeed");
772        assert_eq!(out.shape().dims(), &[2, 1]);
773        assert_eq!(out.to_vec().expect("to_vec"), vec![1i64, 1]);
774    }
775
776    #[test]
777    fn sort_indices_are_i64_positions_within_dim() {
778        let t = Tensor::from_data(
779            vec![3.0f32, 1.0, 2.0, 0.0, 5.0, 4.0],
780            vec![2, 3],
781            DeviceType::Cpu,
782        )
783        .expect("tensor creation should succeed");
784        let (values, indices) = t.sort(Some(1), false).expect("sort should succeed");
785        assert_eq!(
786            values.to_vec().expect("v"),
787            vec![1.0, 2.0, 3.0, 0.0, 4.0, 5.0]
788        );
789        assert_eq!(indices.to_vec().expect("v"), vec![1i64, 2, 0, 0, 2, 1]);
790    }
791
792    #[test]
793    fn sort_on_a_view_uses_view_order() {
794        let base = Tensor::from_data(
795            vec![3.0f32, 1.0, 2.0, 0.0, 5.0, 4.0],
796            vec![2, 3],
797            DeviceType::Cpu,
798        )
799        .expect("tensor creation should succeed");
800        let view = base.transpose_view(0, 1).expect("transpose_view");
801        // view = [[3,0],[1,5],[2,4]]; sort along dim 1 -> [[0,3],[1,5],[2,4]]
802        let (values, indices) = view.sort(Some(1), false).expect("sort should succeed");
803        assert_eq!(
804            values.to_vec().expect("v"),
805            vec![0.0, 3.0, 1.0, 5.0, 2.0, 4.0]
806        );
807        assert_eq!(indices.to_vec().expect("v"), vec![1i64, 0, 0, 1, 0, 1]);
808    }
809}