Skip to main content

kopitiam_tensor/tensor/
reduce.rs

1//! `sum` and `max` reductions along a single axis, plus `argmax`.
2
3use kopitiam_core::{DType, Error, Result, Shape};
4
5use crate::storage::Storage;
6
7use super::Tensor;
8
9impl Tensor {
10    /// Sums along `axis`. If `keepdim`, `axis` becomes length 1 in the
11    /// result instead of being removed (useful for immediately
12    /// broadcasting the result back against the input, e.g. normalizing by
13    /// a row sum).
14    pub fn sum(&self, axis: usize, keepdim: bool) -> Result<Tensor> {
15        self.reduce(axis, keepdim, 0.0, |acc, v| acc + v)
16    }
17
18    /// Reduces along `axis` by taking the maximum.
19    pub fn max(&self, axis: usize, keepdim: bool) -> Result<Tensor> {
20        self.reduce(axis, keepdim, f32::NEG_INFINITY, f32::max)
21    }
22
23    /// Index of the maximum along `axis`, returned as an [`DType::I32`]
24    /// tensor of positions (not the max *values* — that's [`Tensor::max`]).
25    ///
26    /// This is the last step of greedy decoding: run the forward pass, get
27    /// the final `[.., vocab]` logits, then `argmax(last_axis, false)` gives
28    /// you the chosen token id per position. Read the ids out with
29    /// [`Tensor::to_vec_i32`].
30    ///
31    /// The output dtype is `I32` on purpose — these are *indices* into a
32    /// dimension, the same currency [`Tensor::gather_rows`] consumes as token
33    /// ids, so the next embedding lookup can eat this straight without a cast.
34    /// The result's shape is `self`'s shape with `axis` dropped, or set to
35    /// length 1 if `keepdim` (same rule as [`Tensor::sum`]).
36    ///
37    /// **Tie-break contract (load-bearing, so it's deterministic):** on a tie
38    /// the *first* (lowest-index) maximum wins. We only overwrite the running
39    /// best on a strict `>`, never on `==`, so re-running on the same input
40    /// always yields the same ids. NaN never wins a comparison (`v > best` is
41    /// false for NaN), so a NaN element is skipped rather than selected —
42    /// don't lean on that for correctness though; NaN logits mean the forward
43    /// pass already went wrong upstream.
44    pub fn argmax(&self, axis: usize, keepdim: bool) -> Result<Tensor> {
45        self.require_dtype(DType::F32)?;
46        let rank = self.rank();
47        if axis >= rank {
48            return Err(Error::IndexOutOfBounds { dim: axis, index: axis, len: rank });
49        }
50        let Storage::F32(data) = self.storage.as_ref() else { unreachable!() };
51        let contiguous: Vec<f32> = self.logical_offsets().map(|i| data[i]).collect();
52
53        let dims = self.shape.dims();
54        let axis_len = dims[axis];
55        let outer: usize = dims[..axis].iter().product();
56        let inner: usize = dims[axis + 1..].iter().product();
57
58        let mut out = vec![0i32; outer * inner];
59        for o in 0..outer {
60            for inn in 0..inner {
61                let mut best = f32::NEG_INFINITY;
62                let mut best_idx = 0usize;
63                for a in 0..axis_len {
64                    let v = contiguous[o * axis_len * inner + a * inner + inn];
65                    // Strict `>` only: first occurrence wins ties (see the
66                    // tie-break contract in the doc comment above).
67                    if v > best {
68                        best = v;
69                        best_idx = a;
70                    }
71                }
72                out[o * inner + inn] = best_idx as i32;
73            }
74        }
75
76        let mut new_dims = dims.to_vec();
77        if keepdim {
78            new_dims[axis] = 1;
79        } else {
80            new_dims.remove(axis);
81        }
82        Tensor::from_i32(out, Shape::new(new_dims))
83    }
84
85    fn reduce(&self, axis: usize, keepdim: bool, init: f32, f: impl Fn(f32, f32) -> f32) -> Result<Tensor> {
86        self.require_dtype(DType::F32)?;
87        let rank = self.rank();
88        if axis >= rank {
89            return Err(Error::IndexOutOfBounds { dim: axis, index: axis, len: rank });
90        }
91        let Storage::F32(data) = self.storage.as_ref() else { unreachable!() };
92        let contiguous: Vec<f32> = self.logical_offsets().map(|i| data[i]).collect();
93
94        let dims = self.shape.dims();
95        let axis_len = dims[axis];
96        let outer: usize = dims[..axis].iter().product();
97        let inner: usize = dims[axis + 1..].iter().product();
98
99        let mut out = vec![init; outer * inner];
100        for o in 0..outer {
101            for inn in 0..inner {
102                let mut acc = init;
103                for a in 0..axis_len {
104                    acc = f(acc, contiguous[o * axis_len * inner + a * inner + inn]);
105                }
106                out[o * inner + inn] = acc;
107            }
108        }
109
110        let mut new_dims = dims.to_vec();
111        if keepdim {
112            new_dims[axis] = 1;
113        } else {
114            new_dims.remove(axis);
115        }
116        Tensor::from_f32(out, Shape::new(new_dims))
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn sum_along_last_axis_matches_hand_computation() {
126        let t = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], [2, 3]).unwrap();
127        let s = t.sum(1, false).unwrap();
128        assert_eq!(s.shape().dims(), &[2]);
129        assert_eq!(s.to_vec_f32().unwrap(), vec![6.0, 15.0]);
130    }
131
132    #[test]
133    fn sum_keepdim_preserves_rank() {
134        let t = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], [2, 3]).unwrap();
135        let s = t.sum(1, true).unwrap();
136        assert_eq!(s.shape().dims(), &[2, 1]);
137        assert_eq!(s.to_vec_f32().unwrap(), vec![6.0, 15.0]);
138    }
139
140    #[test]
141    fn sum_along_axis_zero_matches_hand_computation() {
142        let t = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], [2, 3]).unwrap();
143        let s = t.sum(0, false).unwrap();
144        assert_eq!(s.to_vec_f32().unwrap(), vec![5.0, 7.0, 9.0]);
145    }
146
147    #[test]
148    fn max_along_last_axis_matches_hand_computation() {
149        let t = Tensor::from_f32(vec![1.0, 5.0, 3.0, 9.0, 2.0, 4.0], [2, 3]).unwrap();
150        let m = t.max(1, false).unwrap();
151        assert_eq!(m.to_vec_f32().unwrap(), vec![5.0, 9.0]);
152    }
153
154    #[test]
155    fn reduce_rejects_an_axis_beyond_rank() {
156        let t = Tensor::from_f32(vec![1.0, 2.0], [2]).unwrap();
157        assert!(matches!(t.sum(1, false), Err(Error::IndexOutOfBounds { .. })));
158    }
159
160    #[test]
161    fn argmax_last_axis_matches_hand_computation_and_is_i32() {
162        // Row 0 peak is 5.0 at col 1; row 1 peak is 9.0 at col 0.
163        let t = Tensor::from_f32(vec![1.0, 5.0, 3.0, 9.0, 2.0, 4.0], [2, 3]).unwrap();
164        let idx = t.argmax(1, false).unwrap();
165        assert_eq!(idx.dtype(), DType::I32);
166        assert_eq!(idx.shape().dims(), &[2]);
167        assert_eq!(idx.to_vec_i32().unwrap(), vec![1, 0]);
168    }
169
170    #[test]
171    fn argmax_keepdim_preserves_rank() {
172        let t = Tensor::from_f32(vec![1.0, 5.0, 3.0, 9.0, 2.0, 4.0], [2, 3]).unwrap();
173        let idx = t.argmax(1, true).unwrap();
174        assert_eq!(idx.shape().dims(), &[2, 1]);
175        assert_eq!(idx.to_vec_i32().unwrap(), vec![1, 0]);
176    }
177
178    #[test]
179    fn argmax_along_axis_zero_matches_hand_computation() {
180        // Down each column: col0 max is 4>1 at row1; col1 max is 5>2 at row1;
181        // col2 max is 6>3 at row1.
182        let t = Tensor::from_f32(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], [2, 3]).unwrap();
183        let idx = t.argmax(0, false).unwrap();
184        assert_eq!(idx.to_vec_i32().unwrap(), vec![1, 1, 1]);
185    }
186
187    #[test]
188    fn argmax_breaks_ties_towards_the_first_occurrence() {
189        // Three equal maxima: the contract says the lowest index wins.
190        let t = Tensor::from_f32(vec![7.0, 7.0, 7.0], [3]).unwrap();
191        let idx = t.argmax(0, false).unwrap();
192        assert_eq!(idx.to_vec_i32().unwrap(), vec![0]);
193    }
194
195    #[test]
196    fn argmax_picks_the_greedy_token_from_a_vocab_row() {
197        // The realistic shape: a single position's logits over a vocab of 5.
198        // Token 3 has the highest logit, so greedy decoding must pick id 3.
199        let logits = Tensor::from_f32(vec![0.1, -2.0, 0.5, 3.7, 1.2], [5]).unwrap();
200        let id = logits.argmax(0, false).unwrap();
201        assert_eq!(id.to_vec_i32().unwrap(), vec![3]);
202    }
203
204    #[test]
205    fn argmax_rejects_non_f32_input() {
206        let t = Tensor::from_i32(vec![1, 2, 3], [3]).unwrap();
207        assert!(matches!(t.argmax(0, false), Err(Error::DTypeMismatch { .. })));
208    }
209
210    #[test]
211    fn argmax_rejects_an_axis_beyond_rank() {
212        let t = Tensor::from_f32(vec![1.0, 2.0], [2]).unwrap();
213        assert!(matches!(t.argmax(1, false), Err(Error::IndexOutOfBounds { .. })));
214    }
215
216    #[test]
217    fn to_vec_i32_rejects_f32_input() {
218        let t = Tensor::from_f32(vec![1.0, 2.0], [2]).unwrap();
219        assert!(matches!(t.to_vec_i32(), Err(Error::DTypeMismatch { .. })));
220    }
221}