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}