Skip to main content

cubecl_tile/space/
base.rs

1//! The coordinate space a tile lives in.
2
3use cubecl::prelude::*;
4use cubecl::zspace::SmallVec;
5
6use crate::algebra::gcd;
7use crate::{Axis, Extent, Level, Shape};
8
9/// Every axis with its extent, in canonical order, plus the runtime sizes of dynamic axes.
10#[derive(CubeType, CubeLaunch, Clone, Debug)]
11pub struct Space {
12    #[cube(comptime)]
13    pub(crate) shape: Shape,
14    pub(crate) sizes: Sequence<usize>,
15}
16
17// Identity is the comptime shape only; the sizes are runtime, never a key.
18impl PartialEq for Space {
19    fn eq(&self, other: &Self) -> bool {
20        self.shape == other.shape
21    }
22}
23impl Eq for Space {}
24impl std::hash::Hash for Space {
25    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
26        self.shape.hash(state);
27    }
28}
29
30/// The comptime reads of a runtime space.
31impl SpaceExpand {
32    /// The comptime shape of a runtime `Space`.
33    pub(crate) fn comptime(&self) -> Space {
34        Space::from_shape(self.shape.clone())
35    }
36
37    #[allow(clippy::should_implement_trait)]
38    pub fn clone(&self) -> Space {
39        self.comptime()
40    }
41
42    pub fn shape(&self) -> &Shape {
43        &self.shape
44    }
45
46    pub fn rank(&self) -> usize {
47        self.shape.rank()
48    }
49
50    pub fn axis_at(&self, i: usize) -> Axis {
51        self.shape.axis_at(i)
52    }
53
54    pub fn position(&self, axis: Axis) -> usize {
55        self.shape.position(axis)
56    }
57
58    pub fn contains(&self, axis: Axis) -> bool {
59        self.shape.contains(axis)
60    }
61
62    pub fn axes(&self) -> impl Iterator<Item = Axis> + '_ {
63        self.shape.axes()
64    }
65
66    pub fn extent(&self, axis: Axis) -> usize {
67        self.shape.extent(axis)
68    }
69
70    pub fn extent_at(&self, i: usize) -> usize {
71        self.shape.extent_at(i)
72    }
73
74    pub fn is_dynamic(&self, axis: Axis) -> bool {
75        self.shape.is_dynamic(axis)
76    }
77
78    /// The listed axes with their extents, in the order listed.
79    pub fn subspace(&self, axes: &[Axis]) -> Space {
80        Space::from_shape(self.shape.subspace(axes))
81    }
82}
83
84impl Space {
85    pub fn shape(&self) -> &Shape {
86        &self.shape
87    }
88
89    /// Whether `other` is this space with some one-wide axes dropped, order and extents kept.
90    pub(crate) fn narrows_to(&self, other: &Space) -> bool {
91        self.axes()
92            .filter(|&axis| other.contains(axis))
93            .eq(other.axes())
94            && self.axes().last().is_some_and(|axis| other.contains(axis))
95            && self.axes().all(|axis| match other.contains(axis) {
96                true => self.extent(axis) == other.extent(axis),
97                false => self.extent(axis) == 1,
98            })
99    }
100
101    pub fn rank(&self) -> usize {
102        self.shape.rank()
103    }
104
105    pub fn axis_at(&self, i: usize) -> Axis {
106        self.shape.axis_at(i)
107    }
108
109    pub fn position(&self, axis: Axis) -> usize {
110        self.shape.position(axis)
111    }
112
113    pub fn contains(&self, axis: Axis) -> bool {
114        self.shape.contains(axis)
115    }
116
117    pub fn axes(&self) -> impl Iterator<Item = Axis> + '_ {
118        self.shape.axes()
119    }
120
121    pub(crate) fn extent_raw(&self, axis: Axis) -> Extent {
122        self.shape.extent_raw(axis)
123    }
124
125    /// The axis's comptime size; panics on a `Dynamic` axis.
126    pub fn extent(&self, axis: Axis) -> usize {
127        self.shape.extent(axis)
128    }
129
130    pub fn extent_at(&self, i: usize) -> usize {
131        self.shape.extent_at(i)
132    }
133
134    pub fn is_dynamic(&self, axis: Axis) -> bool {
135        self.shape.is_dynamic(axis)
136    }
137
138    /// The listed axes with their extents, in the order listed.
139    pub fn subspace(&self, axes: &[Axis]) -> Space {
140        Space::from_shape(self.shape.subspace(axes))
141    }
142}
143
144#[cube]
145impl Space {
146    /// The runtime operation space: the comptime space plus the runtime `sizes` of its
147    /// `Dynamic` axes (aligned to axis order; empty when fully `Static`).
148    pub(crate) fn with_sizes(#[comptime] space: Space, sizes: Sequence<usize>) -> Space {
149        Space {
150            shape: comptime!(space.shape.clone()),
151            sizes,
152        }
153    }
154
155    /// Axis `p`'s tile count for a sub-tile `edge`; comptime for a `Static` axis.
156    pub fn count(&self, #[comptime] p: usize, #[comptime] edge: usize) -> usize {
157        match comptime!(self.shape.extent_raw(self.shape.axis_at(p))) {
158            Extent::Static(n) => comptime!(n.div_ceil(edge)).runtime(),
159            Extent::Dynamic => (*self.sizes.index(p)).div_ceil(edge),
160        }
161    }
162}
163
164impl Space {
165    /// The most axes a space holds inline.
166    pub const MAX_RANK: usize = 6;
167
168    pub fn new(extents: &[(Axis, usize)]) -> Self {
169        let extents: Vec<_> = extents
170            .iter()
171            .map(|&(a, n)| (a, Extent::Static(n)))
172            .collect();
173        Space::from_extents(&extents)
174    }
175
176    /// Every axis dynamic, extents resolved in-kernel from the tensors.
177    pub fn dynamic(axes: &[Axis]) -> Self {
178        let extents: Vec<_> = axes.iter().map(|&a| (a, Extent::Dynamic)).collect();
179        Space::from_extents(&extents)
180    }
181
182    /// Construct directly from [`Extent`]s.
183    pub(crate) fn from_extents(extents: &[(Axis, Extent)]) -> Self {
184        Space::from_shape(Shape::new(extents))
185    }
186
187    /// A host space of `shape`, with no runtime sizes.
188    pub(crate) fn from_shape(shape: Shape) -> Self {
189        Space {
190            shape,
191            sizes: Sequence::new(),
192        }
193    }
194
195    /// Flip the listed axes to `Dynamic`.
196    pub fn with_dynamic(mut self, axes: &[Axis]) -> Self {
197        for axis in axes {
198            assert!(
199                self.contains(*axis),
200                "Space::with_dynamic: {axis:?} is not an axis of this space"
201            );
202        }
203        let entries: Vec<_> = self
204            .axes()
205            .map(|a| {
206                let extent = if axes.contains(&a) {
207                    Extent::Dynamic
208                } else {
209                    self.extent_raw(a)
210                };
211                (a, extent)
212            })
213            .collect();
214        self.shape = Shape::new(&entries);
215        self
216    }
217
218    /// Every axis dynamic (see [`with_dynamic`](Space::with_dynamic)).
219    pub fn all_dynamic(self) -> Self {
220        let axes: Vec<_> = self.axes().collect();
221        self.with_dynamic(&axes)
222    }
223
224    /// Every axis is [`Static`](Extent::Static), so the walk is fully comptime.
225    pub(crate) fn is_static(&self) -> bool {
226        self.shape.is_static()
227    }
228
229    /// The static extents, in axis order.
230    pub fn extents(&self) -> Vec<(Axis, usize)> {
231        self.axes().map(|axis| (axis, self.extent(axis))).collect()
232    }
233
234    /// The leaf `levels` reach below this space.
235    pub(crate) fn leaf(&self, levels: &[Level]) -> Space {
236        levels
237            .iter()
238            .fold(self.clone(), |space, level| level.child(&space))
239    }
240
241    /// The smallest space containing every `part`, axes in first-appearance order, shared axes
242    /// broadcast-merged. E.g. `{M,K} ∪ {K,N} ∪ {M,N} = {M,N,K}`.
243    pub fn merge(parts: &[&Space]) -> Space {
244        let mut entries: SmallVec<[(Axis, Extent); Space::MAX_RANK]> = SmallVec::new();
245
246        for part in parts {
247            for axis in part.axes() {
248                let extent = part.extent_raw(axis);
249                match entries.iter_mut().find(|(a, _)| *a == axis) {
250                    Some(slot) => slot.1 = slot.1.broadcast(extent),
251                    None => entries.push((axis, extent)),
252                }
253            }
254        }
255        Space::from_shape(Shape::new(&entries))
256    }
257
258    /// The smallest space every one of `parts` tiles evenly: on each axis the least common
259    /// multiple of the parts' extents, axes in first-appearance order. The box a step covers so
260    /// that each operand's load fills it whole: NVFP4 values loaded `{k 16, n 2}`, their scales
261    /// `{k 16, n 16}` and an `f16` activation `{k 8}` cover `{k 16, n 16}`.
262    ///
263    /// # Panics
264    ///
265    /// On a dynamic extent, which has no multiple.
266    pub fn covering(parts: &[&Space]) -> Space {
267        let mut extents: Vec<(Axis, usize)> = Vec::new();
268        for part in parts {
269            for (axis, extent) in part.extents() {
270                match extents.iter_mut().find(|(a, _)| *a == axis) {
271                    Some((_, held)) => *held = *held / gcd(*held, extent) * extent,
272                    None => extents.push((axis, extent)),
273                }
274            }
275        }
276        Space::new(&extents)
277    }
278
279    /// How many `tile`s this space holds, laid side by side along every axis `tile` names; an axis
280    /// it does not name it spans whole, one tile serving every position along it. An `f16`
281    /// activation loaded `{k 8}` fills a step `{k 16, n 16}` in two loads.
282    ///
283    /// # Panics
284    ///
285    /// Where `tile` names an axis this space lacks, or does not divide its extent there.
286    pub fn tiles(&self, tile: &Space) -> usize {
287        tile.axes()
288            .map(|axis| {
289                let (whole, part) = (self.extent(axis), tile.extent(axis));
290                assert!(
291                    whole.is_multiple_of(part),
292                    "Space::tiles: {part} along {axis:?} does not tile {whole}"
293                );
294                whole / part
295            })
296            .product()
297    }
298
299    /// The axes in this space but not in `output`: those contracted.
300    pub fn difference(&self, output: &Space) -> SmallVec<[Axis; Space::MAX_RANK]> {
301        self.axes().filter(|&axis| !output.contains(axis)).collect()
302    }
303
304    /// How many contracted values one step consumes off a `width`-wide line of this operand.
305    pub(crate) fn contracted_per_step(&self, contracted: &[Axis], width: usize) -> usize {
306        let lined = self.axis_at(self.rank() - 1);
307        let whole_lines = match self.extent_raw(lined) {
308            Extent::Static(extent) => extent.is_multiple_of(width),
309            Extent::Dynamic => true,
310        };
311        let sums_lines = width > 1 && contracted.last() == Some(&lined) && whole_lines;
312        if sums_lines { width } else { 1 }
313    }
314
315    /// The axes `operands` jointly contract against `output`.
316    pub fn contracted(operands: &[&Space], output: &Space) -> SmallVec<[Axis; Space::MAX_RANK]> {
317        let merged = Space::merge(operands);
318        // Read raw: asking a `Dynamic` extent's comptime size panics.
319        let varies = |axis: Axis| merged.extent_raw(axis) != Extent::Static(1);
320        let shared = |axis: Axis| operands.iter().all(|operand| operand.contains(axis));
321        merged
322            .difference(output)
323            .into_iter()
324            .filter(|&axis| varies(axis) || shared(axis))
325            .collect()
326    }
327
328    /// The `k` edge this operand contracts over against `output`.
329    pub(crate) fn contracted_extent(&self, output: &Space) -> usize {
330        self.difference(output)
331            .iter()
332            .map(|&axis| self.extent(axis))
333            .product()
334    }
335
336    /// Whether `lhs` and `rhs` enumerate their jointly contracted axes in the same order.
337    pub(crate) fn contraction_agrees(lhs: &Space, rhs: &Space, output: &Space) -> bool {
338        let joint = Space::contracted(&[lhs, rhs], output);
339        let listed = |operand: &Space| -> SmallVec<[Axis; Space::MAX_RANK]> {
340            operand
341                .difference(output)
342                .into_iter()
343                .filter(|axis| joint.contains(axis))
344                .collect()
345        };
346        listed(lhs) == listed(rhs)
347    }
348
349    /// How many cells the space holds.
350    pub fn cells(&self) -> usize {
351        self.axes().map(|axis| self.extent(axis)).product()
352    }
353
354    /// How many slices along `axis` the space holds: one for every index of its other axes. The
355    /// extent of `axis` itself may be one a walk has yet to cut, which only the launch knows.
356    pub fn slices_along(&self, axis: Axis) -> usize {
357        self.axes()
358            .filter(|&other| other != axis)
359            .map(|other| self.extent(other))
360            .product()
361    }
362
363    /// The widest run along `axis` that divides both `widest` and this space's extent there: the
364    /// widest line a buffer read in boxes of this space can hold without a box starting mid-line.
365    pub(crate) fn whole_run(&self, axis: Axis, widest: usize) -> usize {
366        let (mut run, mut rest) = (widest, self.extent(axis));
367        while rest != 0 {
368            (run, rest) = (rest, run % rest);
369        }
370        run
371    }
372}
373
374#[cube]
375impl Space {
376    /// Axis `i`'s extent, a dynamic extent as its runtime size.
377    pub(crate) fn runtime_extent_at(&self, #[comptime] i: usize) -> usize {
378        match comptime!(self.shape.extent_raw(self.shape.axis_at(i))) {
379            Extent::Static(n) => comptime!(n).runtime(),
380            Extent::Dynamic => *self.sizes.index(i),
381        }
382    }
383}
384
385#[cfg(test)]
386mod contraction_tests {
387    use crate::*;
388
389    const M: Axis = Axis(0);
390    const N: Axis = Axis(1);
391    const K: Axis = Axis(2);
392    const R: Axis = Axis(3);
393
394    /// NVFP4's step: values loaded two words down `k` by two columns, a load of sixteen scales
395    /// each covering sixteen of `k`, and an `f16` activation eight at a time cover sixteen by
396    /// sixteen, which takes eight loads of values, one of scales and two of activation.
397    #[test]
398    fn a_step_covers_every_load_and_counts_each() {
399        let values = Space::new(&[(K, 16), (N, 2)]);
400        let scales = Space::new(&[(K, 16), (N, 16)]);
401        let activation = Space::new(&[(K, 8)]);
402        let step = Space::covering(&[&activation, &scales, &values]);
403        assert_eq!(step, Space::new(&[(K, 16), (N, 16)]));
404        assert_eq!(
405            [&activation, &scales, &values].map(|load| step.tiles(load)),
406            [2, 1, 8]
407        );
408    }
409
410    /// MXFP4's scales cover thirty-two of `k`, so its step is twice as deep and takes twice the
411    /// activation and value loads.
412    #[test]
413    fn a_wider_scale_deepens_the_step() {
414        let values = Space::new(&[(K, 16), (N, 2)]);
415        let scales = Space::new(&[(K, 32), (N, 16)]);
416        let activation = Space::new(&[(K, 8)]);
417        let step = Space::covering(&[&activation, &scales, &values]);
418        assert_eq!(step, Space::new(&[(K, 32), (N, 16)]));
419        assert_eq!(
420            [&activation, &scales, &values].map(|load| step.tiles(load)),
421            [4, 1, 16]
422        );
423    }
424
425    /// A matmul's `k` is its one contracted axis.
426    #[test]
427    fn a_matmul_contracts_its_one_axis() {
428        let lhs = Space::new(&[(M, 8), (K, 4)]);
429        let out = Space::new(&[(M, 8), (N, 8)]);
430        assert_eq!(lhs.contracted_extent(&out), 4);
431    }
432
433    /// A convolution's `k` is taps times channels.
434    #[test]
435    fn a_convolution_contracts_taps_times_channels() {
436        let lhs = Space::new(&[(M, 8), (R, 3), (K, 4)]);
437        let out = Space::new(&[(M, 8), (N, 8)]);
438        assert_eq!(lhs.contracted_extent(&out), 12);
439    }
440
441    /// Contracting nothing is `k = 1`.
442    #[test]
443    fn contracting_nothing_is_a_unit_depth() {
444        let lhs = Space::new(&[(M, 8), (N, 8)]);
445        let out = Space::new(&[(M, 8), (N, 8)]);
446        assert_eq!(lhs.contracted_extent(&out), 1);
447    }
448
449    /// Same contraction order on both operands.
450    #[test]
451    fn operands_listing_one_contraction_order_agree() {
452        let lhs = Space::new(&[(M, 8), (R, 3), (K, 4)]);
453        let rhs = Space::new(&[(R, 3), (K, 4), (N, 8)]);
454        let out = Space::new(&[(M, 8), (N, 8)]);
455        assert!(Space::contraction_agrees(&lhs, &rhs, &out));
456    }
457
458    /// A permuted contraction order disagrees.
459    #[test]
460    fn a_permuted_contraction_order_disagrees() {
461        let lhs = Space::new(&[(M, 8), (R, 3), (K, 4)]);
462        let rhs = Space::new(&[(K, 4), (R, 3), (N, 8)]);
463        let out = Space::new(&[(M, 8), (N, 8)]);
464        assert!(!Space::contraction_agrees(&lhs, &rhs, &out));
465    }
466}