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}