Skip to main content

cubecl_tile/ops/softmax/
online.rs

1//! The online softmax as a tile op: a score taken in along one axis, one block of it at a time,
2//! into each slice's running max and sum, where its holder already keeps the score.
3
4use cubecl::prelude::*;
5
6use crate::launch::Relay;
7use crate::*;
8
9/// The running max `m` and sum `l` of every slice along `axis` of a score, in the registers of
10/// its holder.
11///
12/// The score is cut where its holder keeps it ([`AxisSlices`]): a unit's register block holds
13/// whole slices of its own, and a plane's window of shared memory is the plane's, every unit of
14/// it keeping the state of every slice.
15#[derive(CubeType)]
16pub struct OnlineSoftmax<E: Float> {
17    m: Array<E>,
18    l: Array<E>,
19    #[cube(comptime)]
20    slices: usize,
21    #[cube(comptime)]
22    axis: Axis,
23    #[cube(comptime)]
24    slots: SliceSlots,
25}
26
27/// Which slice slot `i` of an [`OnlineSoftmax`]'s state keeps.
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
29enum SliceSlots {
30    /// Slice `i`, on every unit alike.
31    Every,
32    /// The `i`-th row the unit holds cells of in a manual-mma grid, a different slice on each
33    /// unit: such a state merges with no other plane's slot by slot.
34    Held,
35}
36
37/// What a plane's sum and the carry's rows are multiplied by in its cube's turn at a [`Relay`]
38/// ([`OnlineSoftmax::relayed`]), one factor a slice each, before the sum drains into the carry.
39#[derive(CubeType)]
40pub struct RelayedFactors<E: Float> {
41    /// For the plane's own sum.
42    pub sum: Array<E>,
43    /// For the carry's rows, what the turns before this one summed.
44    pub carried: Array<E>,
45}
46
47/// Every slice's running max and sum as they stand, undivided ([`OnlineSoftmax::state`]): what
48/// a holder writes beside its unnormalized sum against the values where another merges its slices
49/// with other holders' parts of them.
50#[derive(CubeType)]
51pub struct OnlineSoftmaxState<E: Float> {
52    /// Each slice's running max, `E::min_value()` for a slice whose every cell so far is masked.
53    pub max: Array<E>,
54    /// Each slice's running sum of `exp(score − max)`, zero for a slice whose every cell so far is
55    /// masked.
56    pub sum: Array<E>,
57}
58
59#[cube]
60impl<E: Float> OnlineSoftmax<E> {
61    /// The state of every slice along `axis` of a score held as `score` is, before any block is
62    /// taken in: each unit keeps the slices it holds cells of ([`Tile::slices_per_unit`]).
63    pub fn along(score: &Tile, #[comptime] axis: Axis) -> OnlineSoftmax<E> {
64        let slices = score.slices_per_unit(axis);
65        let slots = comptime!(match slices == score.place.space.slices_along(axis) {
66            true => SliceSlots::Every,
67            false => SliceSlots::Held,
68        });
69        OnlineSoftmax::<E>::empty(slices, axis, slots)
70    }
71
72    /// The state of `slices` slices along `axis` before any block is taken in: what a holder opens
73    /// before the walk that hands it its score, as a cube's plane does before the cube's ring.
74    pub fn new(#[comptime] slices: usize, #[comptime] axis: Axis) -> OnlineSoftmax<E> {
75        OnlineSoftmax::<E>::empty(slices, axis, SliceSlots::Every)
76    }
77
78    /// Take one block of `score` into the slices. The block becomes `score · scale + bias`, the
79    /// slices' max and sum move on, and `score` is left holding `exp(score − max)`: the block's
80    /// probabilities, unnormalized. Returns each slice's `exp(max before − max after)`, what
81    /// anything summed against the earlier blocks is multiplied by ([`AxisSlices::mul`]).
82    ///
83    /// `bias` is a procedural tile windowed to the same region as `score`. A masked cell's bias is
84    /// `E::min_value()`; a slice whose every cell so far is masked holds zeros and a zero sum. On a
85    /// plane's window every unit of the plane calls it, and the caller meets the plane before it
86    /// and before the probabilities are read.
87    pub fn step(&mut self, score: &Tile, bias: &Tile, scale: E) -> Array<E> {
88        let mut slices = score.along(self.axis);
89        slices.scale_add::<E>(scale, bias);
90        let max = slices.maxima::<E>(&self.m);
91        slices.exp_minus::<E>(&max);
92        let sum = slices.sums::<E>();
93        let mut correction = Array::<E>::new(self.slices);
94        #[unroll]
95        for i in 0..self.slices {
96            correction[i] = (self.m[i] - max[i]).exp();
97            self.l[i] = correction[i] * self.l[i] + sum[i];
98            self.m[i] = max[i];
99        }
100        correction
101    }
102
103    /// What this plane's sum against the values is multiplied by once its last block is taken in:
104    /// each slice's `1 / l`, or, where the cube's planes split this axis, each holding part of the
105    /// same slices, the correction to the merged max times `1 / L` of the merged sum, so the
106    /// planes' products only remain to be added ([`PlanesOutput`](crate::kind::PlanesOutput)).
107    /// `plane` is this plane's region of the cube; the partitioning it was cut by says how many
108    /// planes share its slices.
109    ///
110    /// Where planes split the axis it is a meeting of the cube: every unit of every plane calls it,
111    /// each holding its plane's state as a plane's window or fragments keep it, and it meets the
112    /// cube three times through shared memory. The planes' values are read in plane order, so the
113    /// merged sum is the same bits from run to run. A slice every plane masked stays at zero.
114    pub fn normalizer(&mut self, plane: &Region) -> Array<E> {
115        let axis = self.axis;
116        let planes = comptime!(
117            plane
118                .path
119                .planes_along(|along| along == axis)
120                .iter()
121                .map(|&(_, count)| count)
122                .product::<usize>()
123        );
124        let mut factor = Array::<E>::new(self.slices);
125        if comptime!(planes == 1) {
126            factor = self.recip_l();
127        } else {
128            let correction = self.merge_planes(plane.coord(axis), planes);
129            let recip = self.recip_l();
130            #[unroll]
131            for i in 0..self.slices {
132                factor[i] = correction[i] * recip[i];
133            }
134        }
135        factor
136    }
137
138    /// Merge this state with those of the `planes` planes, this one at `plane`, that took in other
139    /// parts of the same slices: every plane leaves holding the merged max and sum. Returns each
140    /// slice's `exp(max before − max after)`. The first unit of each plane writes, and the cube
141    /// meets before each read and before the values read are overwritten.
142    fn merge_planes(&mut self, plane: usize, #[comptime] planes: usize) -> Array<E> {
143        self.refuse_merging("OnlineSoftmax::normalizer");
144        let slices = self.slices;
145        let mut shared = Shared::<[E]>::new_slice(comptime!(planes * slices));
146        let writes = UNIT_POS_PLANE == 0;
147        let own = plane * slices;
148
149        if writes {
150            #[unroll]
151            for i in 0..slices {
152                shared[own + i] = self.m[i];
153            }
154        }
155        sync_cube();
156        let mut max = Array::<E>::new(slices);
157        #[unroll]
158        for i in 0..slices {
159            max[i] = E::min_value();
160            #[unroll]
161            for p in 0..planes {
162                max[i] = max[i].max(shared[p * slices + i]);
163            }
164        }
165        // Every plane has read the maxima before any overwrites them with its sum.
166        sync_cube();
167
168        let mut correction = Array::<E>::new(slices);
169        #[unroll]
170        for i in 0..slices {
171            correction[i] = (self.m[i] - max[i]).exp();
172        }
173        if writes {
174            #[unroll]
175            for i in 0..slices {
176                shared[own + i] = correction[i] * self.l[i];
177            }
178        }
179        sync_cube();
180        #[unroll]
181        for i in 0..slices {
182            let mut sum = E::from_int(0);
183            #[unroll]
184            for p in 0..planes {
185                sum += shared[p * slices + i];
186            }
187            self.l[i] = sum;
188            self.m[i] = max[i];
189        }
190        correction
191    }
192
193    /// What this plane's sum and the carry's rows are multiplied by in its cube's turn at `relay`,
194    /// where the cubes of the relay split this axis, each holding part of the same slices: the
195    /// cross-cube [`normalizer`](OnlineSoftmax::normalizer), the carry taking the turns' partials
196    /// one at a time.
197    ///
198    /// `carried_max` and `carried_sum` are this plane's window of the slices' state the relay
199    /// holds beside its carry: the max and sum every turn before this one merged. This plane
200    /// merges its own into them and leaves the result there for the next turn. Each factor is a
201    /// slice's `exp(max − merged max)`, of this plane's state for its sum and of the carried one
202    /// for the carry, both times `1 / L` of the merged sum on the last turn, so the carry that turn
203    /// hands on is the answer. The first turn reads nothing, its drain replacing the carry. A slice
204    /// every turn masked stays at zero.
205    ///
206    /// Every unit of every plane calls it once its cube has [`take`](Relay::take)n the turn; the
207    /// first unit of each plane writes the state, which the relay publishes with the carry. The
208    /// caller rescales the carry ([`Relay::carried`]) and its sum by the factors, then drains.
209    pub fn relayed(
210        &mut self,
211        relay: &Relay<'_>,
212        carried_max: &mut Tile,
213        carried_sum: &mut Tile,
214    ) -> RelayedFactors<E> {
215        self.refuse_merging("OnlineSoftmax::relayed");
216        let slices = self.slices;
217        let (first, last) = (relay.is_first(), relay.is_last());
218        let stored_max = carried_max.cells::<E>(slices);
219        let stored_sum = carried_sum.cells::<E>(slices);
220        let mut own = Array::<E>::new(slices);
221        let mut carried = Array::<E>::new(slices);
222        #[unroll]
223        for i in 0..slices {
224            let before_max = select(first, E::min_value(), stored_max[i]);
225            let before_sum = select(first, E::from_int(0), stored_sum[i]);
226            let merged = before_max.max(self.m[i]);
227            own[i] = AxisSlices::exp_minus_cell::<E>(self.m[i], merged);
228            carried[i] = AxisSlices::exp_minus_cell::<E>(before_max, merged);
229            self.l[i] = carried[i] * before_sum + own[i] * self.l[i];
230            self.m[i] = merged;
231        }
232        carried_max.set_cells(&self.m, slices);
233        carried_sum.set_cells(&self.l, slices);
234        let recip = self.recip_l();
235        #[unroll]
236        for i in 0..slices {
237            let normalize = select(last, recip[i], E::from_int(1));
238            own[i] *= normalize;
239            carried[i] *= normalize;
240        }
241        RelayedFactors::<E> { sum: own, carried }
242    }
243
244    /// Every slice's running max and sum as they stand, copied out: nothing is divided and the
245    /// state walks on unchanged.
246    pub fn state(&self) -> OnlineSoftmaxState<E> {
247        self.refuse_merging("OnlineSoftmax::state");
248        let mut max = Array::<E>::new(self.slices);
249        let mut sum = Array::<E>::new(self.slices);
250        #[unroll]
251        for i in 0..self.slices {
252            max[i] = self.m[i];
253            sum[i] = self.l[i];
254        }
255        OnlineSoftmaxState::<E> { max, sum }
256    }
257
258    /// Every slice's running max and sum as they stand, written into `max` and `sum`, a plane's
259    /// windows of one cell a slice in slice order: what a holder leaves beside its undivided sum
260    /// where its slices merge with other holders' parts of them, and the one way to read out a
261    /// state taken in from a manual-mma grid, whose units each keep the rows they hold cells of.
262    ///
263    /// `score` is the tile the state was taken in from ([`along`](OnlineSoftmax::along)), which
264    /// says which unit holds which row: each writes its own, one cell a row. A state every unit
265    /// holds whole is written by the plane's first unit. Every unit of the plane calls it, and the
266    /// caller meets the plane before the windows are read.
267    pub fn store_rows(&self, score: &Tile, max: &mut Tile, sum: &mut Tile) {
268        if comptime!(self.slots == SliceSlots::Every) {
269            max.set_cells(&self.m, self.slices);
270            sum.set_cells(&self.l, self.slices);
271        } else {
272            let slices = score.along(self.axis);
273            slices.store_held::<E>(&self.m, max);
274            slices.store_held::<E>(&self.l, sum);
275        }
276    }
277
278    /// Every slice's logsumexp, `m + ln(l)`, written into `logsumexp`, a window of one cell a
279    /// slice in slice order that a unit or a plane holds: what a pass that walks the slices again
280    /// reads its probabilities back from ([`AxisSlices::probabilities`]). A slice whose every
281    /// cell was masked keeps the minimum value, which reads back as probabilities of zero.
282    ///
283    /// `score` is the tile the state was taken in from, which says which unit holds which slice,
284    /// as [`store_rows`](OnlineSoftmax::store_rows) reads it. Every unit of the holder calls it.
285    pub fn store_logsumexp(&self, score: &Tile, logsumexp: &mut Tile) {
286        let mut values = Array::<E>::new(self.slices);
287        #[unroll]
288        for i in 0..self.slices {
289            let live = self.l[i] > E::from_int(0);
290            values[i] = select(live, self.m[i] + self.l[i].ln(), E::min_value());
291        }
292        if comptime!(self.slots == SliceSlots::Every) {
293            logsumexp.set_cells(&values, self.slices);
294        } else {
295            score.along(self.axis).store_held::<E>(&values, logsumexp);
296        }
297    }
298
299    /// Each slice's `1 / l`, what its sum of probabilities times values is normalized by; zero
300    /// for a slice whose every cell was masked.
301    pub fn recip_l(&self) -> Array<E> {
302        let mut recip = Array::<E>::new(self.slices);
303        #[unroll]
304        for i in 0..self.slices {
305            let live = self.l[i] > E::from_int(0);
306            recip[i] = select(live, self.l[i].recip(), E::from_int(0));
307        }
308        recip
309    }
310
311    /// The state of `slices` slices before any block is taken in, its slots keeping `slots`.
312    fn empty(
313        #[comptime] slices: usize,
314        #[comptime] axis: Axis,
315        #[comptime] slots: SliceSlots,
316    ) -> OnlineSoftmax<E> {
317        let mut m = Array::<E>::new(slices);
318        let mut l = Array::<E>::new(slices);
319        #[unroll]
320        for i in 0..slices {
321            m[i] = E::min_value();
322            l[i] = E::from_int(0);
323        }
324        OnlineSoftmax::<E> {
325            m,
326            l,
327            slices,
328            axis,
329            slots,
330        }
331    }
332
333    /// Panics where each unit keeps only its own rows ([`SliceSlots::Held`]): merged slot by slot,
334    /// another plane's state would land on other slices.
335    fn refuse_merging(&self, #[comptime] op: &'static str) {
336        comptime!(assert!(
337            self.slots == SliceSlots::Every,
338            "{op}: this state was taken in from a manual-mma grid, each unit keeping the rows it \
339             holds cells of; a state merged with another holder's is taken in from a window or a \
340             cmma grid, or written out row by row first (OnlineSoftmax::store_rows)"
341        ));
342    }
343}