Skip to main content

rlx_ir/ops/
reduction.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Reduction builders: reduce, softmax, cumsum, sample
6//! (plan #53).
7
8use crate::op::ReduceOp;
9use crate::{Graph, NodeId, Op, Shape};
10
11impl Graph {
12    /// Reduce.
13    /// Reduce over `axes`.
14    ///
15    /// Non-adjacent axes are emitted as a chain of single-axis reductions,
16    /// highest index first, rather than one node.
17    ///
18    /// The backends only implement a reduction over a *contiguous* axis range
19    /// — the shape folds to `[outer, reduced, inner]` and the kernel walks
20    /// that. Handed `[1, 3]` the CPU backend emits `Thunk::Nop`, so the output
21    /// buffer keeps whatever it held and the reduction silently answers zeros.
22    /// That is not a hypothetical: a hand-written 3-D max pool built this way
23    /// looked correct because it had been written around the bug, and the
24    /// workaround cost 30.8 ms of a 34 ms CUDA forward pass.
25    ///
26    /// Chaining is exact for every `ReduceOp` this accepts. Sum/Max/Min/Prod
27    /// are associative and commutative; `Mean` works because each axis is
28    /// fully reduced, so the divisors multiply out to the same total count.
29    /// Going highest-first keeps the lower axis indices valid as the rank
30    /// drops.
31    pub fn reduce(
32        &mut self,
33        input: NodeId,
34        op: ReduceOp,
35        axes: Vec<usize>,
36        keep_dim: bool,
37        shape: Shape,
38    ) -> NodeId {
39        let mut sorted = axes.clone();
40        sorted.sort_unstable();
41        sorted.dedup();
42        let adjacent = sorted.windows(2).all(|w| w[1] == w[0] + 1);
43        if adjacent || sorted.len() < 2 {
44            return self.push(Op::Reduce { op, axes, keep_dim }, vec![input], shape, None);
45        }
46        let mut cur = input;
47        let mut dims: Vec<usize> = self
48            .shape(input)
49            .dims()
50            .iter()
51            .map(|d| d.unwrap_static())
52            .collect();
53        for &axis in sorted.iter().rev() {
54            if keep_dim {
55                dims[axis] = 1;
56            } else {
57                dims.remove(axis);
58            }
59            let step = Shape::new(&dims, shape.dtype());
60            cur = self.push(
61                Op::Reduce {
62                    op,
63                    axes: vec![axis],
64                    keep_dim,
65                },
66                vec![cur],
67                step,
68                None,
69            );
70        }
71        debug_assert_eq!(
72            dims,
73            shape
74                .dims()
75                .iter()
76                .map(|d| d.unwrap_static())
77                .collect::<Vec<_>>(),
78            "reduce: split over non-adjacent axes {sorted:?} did not land on the \
79             declared output shape"
80        );
81        cur
82    }
83
84    /// Softmax.
85    pub fn softmax(&mut self, input: NodeId, axis: i32, shape: Shape) -> NodeId {
86        self.push(Op::Softmax { axis }, vec![input], shape, None)
87    }
88
89    /// Histogram of `input` into `bins` equal-width buckets over `[min, max]`.
90    /// Output is a 1-D f32 tensor `[bins]` of counts. Elements outside the
91    /// range are dropped and `x == max` lands in the last bin (matches
92    /// `numpy.histogram`). Non-differentiable.
93    pub fn histogram(&mut self, input: NodeId, bins: usize, min: f32, max: f32) -> NodeId {
94        let out = Shape::new(&[bins], crate::DType::F32);
95        self.push(Op::Histogram { bins, min, max }, vec![input], out, None)
96    }
97
98    /// Cumulative sum along an axis (output shape == input shape).
99    pub fn cumsum(&mut self, input: NodeId, axis: i32, exclusive: bool, shape: Shape) -> NodeId {
100        self.push(Op::Cumsum { axis, exclusive }, vec![input], shape, None)
101    }
102
103    /// Cumulative product along an axis (output shape == input shape).
104    pub fn cumprod(&mut self, input: NodeId, axis: i32, exclusive: bool, shape: Shape) -> NodeId {
105        self.push(Op::CumProd { axis, exclusive }, vec![input], shape, None)
106    }
107
108    /// Cumulative maximum along an axis (output shape == input shape).
109    pub fn cummax(&mut self, input: NodeId, axis: i32, exclusive: bool, shape: Shape) -> NodeId {
110        self.push(Op::CumMax { axis, exclusive }, vec![input], shape, None)
111    }
112
113    /// Index of the max along `axis` (f32-encoded indices).
114    pub fn argmax(&mut self, input: NodeId, axis: usize, keep_dim: bool, shape: Shape) -> NodeId {
115        self.push(Op::ArgMax { axis, keep_dim }, vec![input], shape, None)
116    }
117
118    /// Index of the min along `axis` (f32-encoded indices).
119    pub fn argmin(&mut self, input: NodeId, axis: usize, keep_dim: bool, shape: Shape) -> NodeId {
120        self.push(Op::ArgMin { axis, keep_dim }, vec![input], shape, None)
121    }
122
123    /// Fused sample: logits → token id (one f32-encoded id per row).
124    /// `output_shape` should be `[batch]` (one id per logit row).
125    pub fn sample(
126        &mut self,
127        logits: NodeId,
128        top_k: usize,
129        top_p: f32,
130        temperature: f32,
131        seed: u64,
132        output_shape: Shape,
133    ) -> NodeId {
134        self.push(
135            Op::Sample {
136                top_k,
137                top_p,
138                temperature,
139                seed,
140            },
141            vec![logits],
142            output_shape,
143            None,
144        )
145    }
146}