Skip to main content

dspk_core/compute/
launch.rs

1use cubecl::prelude::*;
2
3/// Kernel launch geometry for the access patterns used by the DSP kernels.
4///
5/// Cube sizes come from the runtime ([`CubeDim::new`]: its plane size along x, a plane count set
6/// by the runtime's cores or limits along y), never from constants, and cube counts from the
7/// problem size within the runtime's grid limits. CubeCL maps cubes and units to whatever executes
8/// the kernel (CPU threads, GPU workgroups).
9#[derive(Debug, Clone)]
10pub struct LaunchGeometry {
11    pub cube_dim: CubeDim,
12    pub cube_count: CubeCount,
13}
14
15/// Element of the unit in a [`LaunchGeometry::elementwise`] launch; kernels bound-check it.
16///
17/// - **GPUs** (a cube is a plane or more wide): `ABSOLUTE_POS`, neighbouring units on neighbouring
18///   elements, so a plane's reads coalesce.
19/// - **Runtimes that run each unit of a cube on its own thread and loop it over every cube**
20///   (CubeCL's CPU runtime: cubes one unit wide along x, one unit per core along y): unit `u` takes
21///   the contiguous run `u · cubes .. (u + 1) · cubes`. With `ABSOLUTE_POS` the threads would
22///   interleave element by element (`u, u + units, …`), sharing cache lines and leaving nothing
23///   contiguous to vectorize: the CPU's streaming kernels ran at 3 GB/s (2026-10-28, 12 cores).
24///
25/// Both cover `0..units · cubes`, so the results are the same; only the order differs.
26#[cube]
27pub fn element_position() -> usize {
28    if CUBE_DIM_X == 1 && CUBE_DIM > 1 {
29        UNIT_POS as usize * CUBE_COUNT + CUBE_POS
30    } else {
31        ABSOLUTE_POS
32    }
33}
34
35/// Sample index of the unit in a [`LaunchGeometry::channels_samples`] or
36/// [`LaunchGeometry::per_sample`] launch. Samples run along x and continue along z when they need
37/// more cubes than one grid dimension holds; kernels bound-check the result.
38#[cube]
39pub fn sample_position() -> u32 {
40    (CUBE_POS_Z * CUBE_COUNT_X + CUBE_POS_X) * CUBE_DIM_X + UNIT_POS_X
41}
42
43/// Channel (row) index of the unit in a [`LaunchGeometry::channels_samples`] launch.
44#[cube]
45pub fn channel_position() -> u32 {
46    ABSOLUTE_POS_Y
47}
48
49/// The columns `start..end` (every `step`-th) a unit walks in a [`LaunchGeometry::per_row`] cube
50/// of `units` units over a row of `cols`:
51///
52/// - **GPUs**: `unit, unit + units, …` — neighbouring units on neighbouring columns (coalesced).
53/// - **CPU runtime** (`PLANE_DIM == 1`, every unit its own thread): one contiguous run of
54///   `⌈cols / units⌉` columns per unit, for the same reason as [`fn@element_position`].
55///
56/// Either way the units cover every column once, so per-unit partial results merge the same.
57#[derive(CubeType)]
58pub struct RowShare {
59    pub start: u32,
60    pub end: u32,
61    pub step: u32,
62}
63
64/// This unit's [`RowShare`] of a row of `cols` columns.
65#[cube]
66pub fn row_share(cols: u32, #[comptime] units: u32) -> RowShare {
67    let unit = UNIT_POS_X;
68    // Field by field: a CubeCL `if` cannot yield a struct
69    let contiguous = PLANE_DIM == 1u32;
70    let chunk = (cols + units - 1u32) / units;
71    let start = if contiguous { u32::min(unit * chunk, cols) } else { unit };
72    let end = if contiguous { u32::min(start + chunk, cols) } else { cols };
73    let step = if contiguous { 1u32 } else { units };
74    RowShare { start, end, step }
75}
76
77/// Row reduced by the cube in a [`LaunchGeometry::per_row`] launch (uniform across the cube).
78#[cube]
79pub fn row_position() -> u32 {
80    CUBE_POS_Y * CUBE_COUNT_X + CUBE_POS_X
81}
82
83impl LaunchGeometry {
84    /// One unit per element, indexed with [`fn@element_position`] (contiguous runs per thread on
85    /// the CPU runtime, `ABSOLUTE_POS` elsewhere). The cubes are spread over the runtime's
86    /// cube-count dimensions, so kernels must bound-check the position.
87    pub fn elementwise(client: &Client, num_elements: usize) -> Self {
88        let n = num_elements.max(1);
89        let cube_dim = CubeDim::new(client, n);
90        let cube_count = cubecl::calculate_cube_count_elemwise(client, n, cube_dim);
91        Self { cube_dim, cube_count }
92    }
93
94    /// One unit per `(channel, sample)` of a `[channels, samples]` buffer: [`fn@sample_position`]
95    /// and [`fn@channel_position`]. The cube's x size is the runtime's plane, so a plane walks
96    /// neighbouring samples of one channel.
97    ///
98    /// # Panics
99    /// If the channels or samples need more cubes than the runtime's whole grid holds.
100    pub fn channels_samples(client: &Client, channels: usize, samples: usize) -> Self {
101        let cube_dim = CubeDim::new(client, channels.max(1) * samples.max(1));
102        let (x, z) = Self::spill(client, samples, cube_dim.x);
103        let y = (channels.max(1) as u32).div_ceil(cube_dim.y);
104        assert!(
105            y <= client.properties().hardware.max_cube_count.1,
106            "{channels} channels exceed the runtime's cube grid"
107        );
108        Self { cube_dim, cube_count: CubeCount::Static(x, y, z) }
109    }
110
111    /// One unit per sample for kernels that walk all channels of a sample ([`fn@sample_position`]).
112    pub fn per_sample(client: &Client, samples: usize) -> Self {
113        let cube_dim = Self::flat(client, samples);
114        let (x, z) = Self::spill(client, samples, cube_dim.x);
115        Self { cube_dim, cube_count: CubeCount::Static(x, 1, z) }
116    }
117
118    /// One unit per channel for kernels that walk each channel sequentially in time:
119    /// `ABSOLUTE_POS_X` = channel, bound-checked by the kernel.
120    pub fn per_channel(client: &Client, channels: usize) -> Self {
121        let cube_dim = Self::flat(client, channels);
122        Self { cube_dim, cube_count: CubeCount::Static((channels.max(1) as u32).div_ceil(cube_dim.x), 1, 1) }
123    }
124
125    /// One cube per row of a `[rows, cols]` buffer, for cube-wide reductions: [`fn@row_position`] is the
126    /// row (cubes along x, continuing along y past the grid's x limit) and `UNIT_POS_X` strides along
127    /// it. The cube is the runtime's cube for `cols` units flattened onto x and rounded down to a
128    /// power of two, so tree reductions can halve it; kernels size shared memory with
129    /// `cube_dim.x` (passed as a comptime value). Kernels bound-check the row.
130    ///
131    /// # Panics
132    /// If the rows need more cubes than the runtime's whole grid holds.
133    pub fn per_row(client: &Client, rows: usize, cols: usize) -> Self {
134        let units = Self::flat(client, cols).x;
135        let cube_dim = CubeDim::new_1d(1 << (u32::BITS - 1 - units.max(1).leading_zeros()));
136        let (x, y) = Self::spill(client, rows, 1);
137        assert!(y <= client.properties().hardware.max_cube_count.1, "{rows} rows exceed the runtime's cube grid");
138        Self { cube_dim, cube_count: CubeCount::Static(x, y, 1) }
139    }
140
141    /// One unit per point of `points`, in 1-D cubes of a power-of-two number of units (the
142    /// runtime's cube flattened onto x, shrunk until `shared_bytes_per_unit` per unit fits the
143    /// device's shared memory) for kernels that share tiles of points in shared memory: the point
144    /// is `row_position() * CUBE_DIM_X + UNIT_POS_X`, and kernels size shared memory with
145    /// `cube_dim.x` (passed as a comptime value). Every unit of a cube takes part in the tile loads,
146    /// so kernels bound-check the point only around their own work.
147    ///
148    /// # Panics
149    /// If one unit's shared memory exceeds the device's, or the points need more cubes than the
150    /// runtime's whole grid holds.
151    pub fn tiles(client: &Client, points: usize, shared_bytes_per_unit: usize) -> Self {
152        let shared = client.properties().hardware.max_shared_memory_size;
153        let fit = shared / shared_bytes_per_unit.max(1);
154        assert!(fit >= 1, "{shared_bytes_per_unit} bytes of shared memory per unit exceed the device's {shared}");
155        let units = Self::flat(client, points).x.min(u32::try_from(fit).unwrap_or(u32::MAX));
156        let units = 1 << (u32::BITS - 1 - units.max(1).leading_zeros());
157        let (x, y) = Self::spill(client, points, units);
158        assert!(y <= client.properties().hardware.max_cube_count.1, "{points} points exceed the runtime's cube grid");
159        Self { cube_dim: CubeDim::new_1d(units), cube_count: CubeCount::Static(x, y, 1) }
160    }
161
162    /// Units that execute in lock-step (the runtime's plane / warp / subgroup size; 1 where every
163    /// unit runs on its own, as on the CPU runtime). It is the x size of
164    /// [`Self::channels_samples`] cubes, so consecutive `sample_position`s of a plane share a cube.
165    pub fn plane_lanes(client: &Client) -> u32 {
166        client.properties().hardware.plane_size_max.max(1)
167    }
168
169    /// The runtime's cube for `work` units, flattened onto x (for kernels indexed along x only).
170    fn flat(client: &Client, work: usize) -> CubeDim {
171        CubeDim::new_1d(CubeDim::new(client, work.max(1)).num_elems())
172    }
173
174    /// Cube counts `(x, overflow)` covering `units` along x with `per_cube` units per cube; the
175    /// overflow count continues past the grid's x limit (along z for samples, y for rows).
176    fn spill(client: &Client, units: usize, per_cube: u32) -> (u32, u32) {
177        let max = client.properties().hardware.max_cube_count;
178        let cubes = (units.max(1) as u64).div_ceil(per_cube as u64);
179        let x = cubes.min(max.0 as u64);
180        let overflow = cubes.div_ceil(x);
181        assert!(overflow <= max.2.min(max.1) as u64, "{units} units exceed the runtime's cube grid");
182        (x as u32, overflow as u32)
183    }
184}
185
186#[cfg(all(test, feature = "wgpu"))]
187mod tests {
188    use super::*;
189
190    fn cubes(count: &CubeCount) -> u64 {
191        match count {
192            CubeCount::Static(x, y, z) => *x as u64 * *y as u64 * *z as u64,
193            _ => unreachable!(),
194        }
195    }
196
197    #[test]
198    fn geometries_cover_their_ranges_within_limits() {
199        let targets = crate::ComputeTarget::for_tests();
200        assert!(!targets.is_empty(), "no CubeCL runtime to test (compiled in and allowed by DSPK_TEST_RUNTIMES)");
201        for target in targets {
202            geometries_cover(&target.client().expect("compiled-in runtime"));
203        }
204    }
205
206    fn geometries_cover(client: &Client) {
207        let max = client.properties().hardware.max_cube_count;
208        let within = |c: &CubeCount| matches!(c, CubeCount::Static(x, y, z) if *x <= max.0 && *y <= max.1 && *z <= max.2);
209
210        // 384 channels × 10 s at 30 kHz: more cubes than one dimension allows.
211        let n = 384 * 330_000;
212        let geom = LaunchGeometry::elementwise(client, n);
213        assert!(within(&geom.cube_count) && cubes(&geom.cube_count) * geom.cube_dim.num_elems() as u64 >= n as u64);
214
215        // Samples beyond one grid dimension spill into z.
216        for samples in [30_001usize, 5_000_000] {
217            let geom = LaunchGeometry::channels_samples(client, 385, samples);
218            let CubeCount::Static(x, y, z) = geom.cube_count else { unreachable!() };
219            assert!(within(&geom.cube_count));
220            assert!(x as u64 * z as u64 * geom.cube_dim.x as u64 >= samples as u64 && y * geom.cube_dim.y >= 385);
221            assert_eq!(geom.cube_dim.x, LaunchGeometry::plane_lanes(client), "{}", client.name());
222        }
223        let geom = LaunchGeometry::per_channel(client, 385);
224        assert!(cubes(&geom.cube_count) * geom.cube_dim.num_elems() as u64 >= 385);
225    }
226}