Skip to main content

oxicuda_webgpu/
planner.rs

1//! Host-side dispatch and workgroup planning for WebGPU compute.
2//!
3//! Everything in this module is pure arithmetic over adapter limits and
4//! problem shapes — no `wgpu` device, queue, or adapter is required, so the
5//! logic is fully unit-testable on CPU.  The planners answer questions a
6//! WebGPU backend must resolve *before* recording a compute pass:
7//!
8//! * how large a workgroup may be, given the adapter's
9//!   `max_compute_invocations_per_workgroup` / per-axis caps (auto-tuning);
10//! * how many workgroups to dispatch for a 1-D / 2-D / 3-D problem while
11//!   staying below WebGPU's 65 535-per-axis limit;
12//! * how a linear problem must be folded into a 2-D grid when it would
13//!   otherwise exceed that per-axis cap;
14//! * texture upload row pitch under WebGPU's 256-byte `bytes_per_row`
15//!   alignment rule.
16//!
17//! These mirror the constants in [`crate::shader`] (e.g. the reductions use a
18//! 256-thread workgroup and a 2-D dispatch decode `wgid.y * grid_x + wgid.x`).
19
20/// WebGPU's hard cap on workgroup count per dispatch axis.
21///
22/// The spec guarantees `maxComputeWorkgroupsPerDimension >= 65535`; we treat
23/// it as the conservative portable maximum.
24pub const MAX_WORKGROUPS_PER_DIM: u32 = 65_535;
25
26/// WebGPU's required alignment (in bytes) for the `bytes_per_row` field of a
27/// texture copy (`COPY_BYTES_PER_ROW_ALIGNMENT`).
28pub const COPY_BYTES_PER_ROW_ALIGNMENT: u32 = 256;
29
30/// A conservative, portable subset of the WebGPU compute limits a planner
31/// needs.  Field names follow the WebGPU / `wgpu` limit names.
32///
33/// [`Limits::portable_default`] reflects the **guaranteed** lower bounds every
34/// conformant WebGPU adapter must expose, so planning against it yields
35/// dispatches that run everywhere.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct Limits {
38    /// `maxComputeWorkgroupSizeX`.
39    pub max_workgroup_size_x: u32,
40    /// `maxComputeWorkgroupSizeY`.
41    pub max_workgroup_size_y: u32,
42    /// `maxComputeWorkgroupSizeZ`.
43    pub max_workgroup_size_z: u32,
44    /// `maxComputeInvocationsPerWorkgroup` — product of the three axes must
45    /// not exceed this.
46    pub max_invocations_per_workgroup: u32,
47    /// `maxComputeWorkgroupsPerDimension`.
48    pub max_workgroups_per_dim: u32,
49}
50
51impl Limits {
52    /// The WebGPU **guaranteed** lower bounds (Chrome / Firefox / Safari all
53    /// expose at least these).
54    ///
55    /// `256` invocations, `(256, 256, 64)` per-axis sizes, `65535` workgroups
56    /// per dimension.
57    #[must_use]
58    pub fn portable_default() -> Self {
59        Self {
60            max_workgroup_size_x: 256,
61            max_workgroup_size_y: 256,
62            max_workgroup_size_z: 64,
63            max_invocations_per_workgroup: 256,
64            max_workgroups_per_dim: MAX_WORKGROUPS_PER_DIM,
65        }
66    }
67
68    /// A higher-end desktop profile (e.g. discrete NVIDIA / AMD via Vulkan)
69    /// exposing `1024` invocations per workgroup.
70    #[must_use]
71    pub fn desktop_default() -> Self {
72        Self {
73            max_workgroup_size_x: 1024,
74            max_workgroup_size_y: 1024,
75            max_workgroup_size_z: 64,
76            max_invocations_per_workgroup: 1024,
77            max_workgroups_per_dim: MAX_WORKGROUPS_PER_DIM,
78        }
79    }
80}
81
82/// Integer ceil-division `ceil(a / b)`.  Returns `0` when `b == 0`.
83#[inline]
84#[must_use]
85pub fn div_ceil_u32(a: u32, b: u32) -> u32 {
86    match a.checked_div(b) {
87        Some(q) => q + u32::from(a % b != 0),
88        None => 0,
89    }
90}
91
92/// Round `value` **up** to the next multiple of `align`.
93///
94/// `align` must be non-zero; when it is, the result is the smallest multiple
95/// of `align` that is `>= value`.  Returns `value` unchanged if `align == 0`.
96#[inline]
97#[must_use]
98pub fn align_up_u32(value: u32, align: u32) -> u32 {
99    if align == 0 {
100        return value;
101    }
102    div_ceil_u32(value, align).saturating_mul(align)
103}
104
105/// A planned 1-D workgroup size: a single `@workgroup_size(x)` value.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub struct Workgroup1D {
108    /// Thread count along X (`@workgroup_size(x)`).
109    pub x: u32,
110}
111
112/// A planned 2-D workgroup size for tiled kernels (`@workgroup_size(x, y)`).
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub struct Workgroup2D {
115    /// Thread count along X.
116    pub x: u32,
117    /// Thread count along Y.
118    pub y: u32,
119}
120
121impl Workgroup2D {
122    /// Total invocations (`x * y`).
123    #[must_use]
124    pub fn total(self) -> u32 {
125        self.x.saturating_mul(self.y)
126    }
127}
128
129/// Auto-tune a 1-D workgroup size: the largest power of two `<= preferred`
130/// that fits both `max_workgroup_size_x` and `max_invocations_per_workgroup`.
131///
132/// Power-of-two sizing keeps tree reductions (which halve a stride each step)
133/// exact.  A non-power-of-two `preferred` is rounded **down** to a power of
134/// two first.  The result is always `>= 1`.
135#[must_use]
136pub fn plan_workgroup_1d(limits: &Limits, preferred: u32) -> Workgroup1D {
137    let ceiling = limits
138        .max_workgroup_size_x
139        .min(limits.max_invocations_per_workgroup);
140    let want = preferred.max(1).min(ceiling.max(1));
141    Workgroup1D {
142        x: floor_pow2(want).max(1),
143    }
144}
145
146/// Auto-tune a square 2-D workgroup tile for GEMM-style kernels.
147///
148/// Returns the largest power-of-two tile `t` such that:
149/// * `t <= preferred_tile`,
150/// * `t <= max_workgroup_size_x` and `t <= max_workgroup_size_y`, and
151/// * `t * t <= max_invocations_per_workgroup`.
152///
153/// For the portable default (`256` invocations) this caps the tile at `16`
154/// (`16 * 16 == 256`); a desktop profile (`1024`) allows `32`.
155#[must_use]
156pub fn plan_workgroup_square(limits: &Limits, preferred_tile: u32) -> Workgroup2D {
157    let axis_cap = limits.max_workgroup_size_x.min(limits.max_workgroup_size_y);
158    let mut t = floor_pow2(preferred_tile.max(1)).max(1);
159    t = t.min(floor_pow2(axis_cap.max(1)).max(1));
160    // Shrink until t*t fits the invocation budget.
161    while t > 1 && t.saturating_mul(t) > limits.max_invocations_per_workgroup {
162        t >>= 1;
163    }
164    Workgroup2D { x: t, y: t }
165}
166
167/// Largest power of two `<= v` (for `v >= 1`).  `floor_pow2(0) == 0`.
168#[inline]
169#[must_use]
170pub fn floor_pow2(v: u32) -> u32 {
171    if v == 0 {
172        0
173    } else {
174        1u32 << (31 - v.leading_zeros())
175    }
176}
177
178/// A concrete dispatch grid: the `(x, y, z)` argument to
179/// `dispatch_workgroups`.
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub struct DispatchGrid {
182    /// Workgroup count along X.
183    pub x: u32,
184    /// Workgroup count along Y.
185    pub y: u32,
186    /// Workgroup count along Z.
187    pub z: u32,
188}
189
190impl DispatchGrid {
191    /// Total workgroup count (`x * y * z`).
192    #[must_use]
193    pub fn total(self) -> u64 {
194        u64::from(self.x) * u64::from(self.y) * u64::from(self.z)
195    }
196}
197
198/// Plan a 1-D dispatch covering `total_threads` items with `workgroup_x`
199/// threads each, automatically folding into a 2-D grid when the required
200/// workgroup count would exceed the per-axis limit.
201///
202/// The companion shader must decode its linear slot as
203/// `wgid.y * grid_x + wgid.x` (exactly as [`crate::shader::reduction_nd_wgsl`]
204/// does) and early-return when the slot is out of range.
205///
206/// `grid_x` (the second return value) is the fold width to embed as the
207/// `grid_x` uniform; for a non-folded dispatch it equals the X workgroup
208/// count and the decode is still correct (`wgid.y == 0`).
209///
210/// Returns an error string if `total_threads` is so large that even a square
211/// 2-D grid would exceed the per-axis cap on both axes.
212#[allow(clippy::missing_errors_doc)]
213pub fn plan_dispatch_1d(
214    limits: &Limits,
215    total_threads: u64,
216    workgroup_x: u32,
217) -> Result<(DispatchGrid, u32), String> {
218    let wg = workgroup_x.max(1);
219    let groups = div_ceil_u64(total_threads, u64::from(wg));
220    let max_dim = u64::from(limits.max_workgroups_per_dim.max(1));
221
222    if groups <= max_dim {
223        let gx = u32::try_from(groups).unwrap_or(limits.max_workgroups_per_dim);
224        return Ok((DispatchGrid { x: gx, y: 1, z: 1 }, gx));
225    }
226
227    // Fold into a 2-D grid: grid_x columns, ceil(groups / grid_x) rows.
228    let grid_x = max_dim;
229    let grid_y = div_ceil_u64(groups, grid_x);
230    if grid_y > max_dim {
231        return Err(format!(
232            "dispatch of {groups} workgroups exceeds 2-D capacity ({max_dim} x {max_dim})"
233        ));
234    }
235    let gx = u32::try_from(grid_x).unwrap_or(limits.max_workgroups_per_dim);
236    let gy = u32::try_from(grid_y).unwrap_or(limits.max_workgroups_per_dim);
237    Ok((DispatchGrid { x: gx, y: gy, z: 1 }, gx))
238}
239
240/// Plan a 2-D tiled dispatch for an `m x n` output covered by a
241/// `tile.x * tile.y` workgroup, optionally with a `batch` Z-dimension.
242///
243/// Used by GEMM (`grid.z == 1`) and batched GEMM (`grid.z == batch`).  Returns
244/// an error string if any axis would exceed the per-axis workgroup cap.
245#[allow(clippy::missing_errors_doc)]
246pub fn plan_dispatch_2d(
247    limits: &Limits,
248    rows_m: u32,
249    cols_n: u32,
250    tile: Workgroup2D,
251    batch: u32,
252) -> Result<DispatchGrid, String> {
253    let gx = div_ceil_u32(cols_n, tile.x.max(1));
254    let gy = div_ceil_u32(rows_m, tile.y.max(1));
255    let gz = batch.max(1);
256    let cap = limits.max_workgroups_per_dim.max(1);
257    if gx > cap || gy > cap || gz > cap {
258        return Err(format!(
259            "2-D dispatch ({gx}, {gy}, {gz}) exceeds per-axis cap {cap}"
260        ));
261    }
262    Ok(DispatchGrid {
263        x: gx,
264        y: gy,
265        z: gz,
266    })
267}
268
269/// Integer ceil-division for `u64`.  Returns `0` when `b == 0`.
270#[inline]
271#[must_use]
272pub fn div_ceil_u64(a: u64, b: u64) -> u64 {
273    match a.checked_div(b) {
274        Some(q) => q + u64::from(a % b != 0),
275        None => 0,
276    }
277}
278
279/// Texture upload layout under WebGPU's 256-byte row-alignment rule.
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub struct TextureRowLayout {
282    /// Unpadded bytes of actual pixel data per row (`width * bytes_per_texel`).
283    pub unpadded_bytes_per_row: u32,
284    /// Padded `bytes_per_row` (a multiple of 256) to pass to wgpu.
285    pub padded_bytes_per_row: u32,
286    /// Total bytes for the whole image (`padded_bytes_per_row * height`).
287    pub total_bytes: u64,
288}
289
290/// Compute the 256-aligned row pitch for a texture upload/readback.
291///
292/// WebGPU requires the `bytes_per_row` of a buffer↔texture copy to be a
293/// multiple of [`COPY_BYTES_PER_ROW_ALIGNMENT`] (256).  Callers must allocate
294/// `padded_bytes_per_row * height` bytes of staging and copy each row into its
295/// padded slot.
296#[must_use]
297pub fn texture_row_layout(width: u32, height: u32, bytes_per_texel: u32) -> TextureRowLayout {
298    let unpadded = width.saturating_mul(bytes_per_texel);
299    let padded = align_up_u32(unpadded, COPY_BYTES_PER_ROW_ALIGNMENT);
300    TextureRowLayout {
301        unpadded_bytes_per_row: unpadded,
302        padded_bytes_per_row: padded,
303        total_bytes: u64::from(padded) * u64::from(height),
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn div_ceil_u32_basic() {
313        assert_eq!(div_ceil_u32(0, 4), 0);
314        assert_eq!(div_ceil_u32(1, 4), 1);
315        assert_eq!(div_ceil_u32(4, 4), 1);
316        assert_eq!(div_ceil_u32(5, 4), 2);
317        assert_eq!(div_ceil_u32(8, 4), 2);
318        // Division by zero yields 0 (no panic).
319        assert_eq!(div_ceil_u32(10, 0), 0);
320    }
321
322    #[test]
323    fn align_up_u32_to_256() {
324        assert_eq!(align_up_u32(0, 256), 0);
325        assert_eq!(align_up_u32(1, 256), 256);
326        assert_eq!(align_up_u32(256, 256), 256);
327        assert_eq!(align_up_u32(257, 256), 512);
328        assert_eq!(align_up_u32(513, 256), 768);
329        // align 0 is identity.
330        assert_eq!(align_up_u32(123, 0), 123);
331    }
332
333    #[test]
334    fn floor_pow2_values() {
335        assert_eq!(floor_pow2(0), 0);
336        assert_eq!(floor_pow2(1), 1);
337        assert_eq!(floor_pow2(2), 2);
338        assert_eq!(floor_pow2(3), 2);
339        assert_eq!(floor_pow2(255), 128);
340        assert_eq!(floor_pow2(256), 256);
341        assert_eq!(floor_pow2(257), 256);
342        assert_eq!(floor_pow2(1024), 1024);
343    }
344
345    #[test]
346    fn plan_workgroup_1d_respects_invocation_cap() {
347        let lim = Limits::portable_default(); // 256 invocations
348        // Preferred 256 fits exactly.
349        assert_eq!(plan_workgroup_1d(&lim, 256).x, 256);
350        // Preferred 1024 is clamped down to 256.
351        assert_eq!(plan_workgroup_1d(&lim, 1024).x, 256);
352        // Non-power-of-two rounds down.
353        assert_eq!(plan_workgroup_1d(&lim, 200).x, 128);
354        // Zero is bumped to 1.
355        assert_eq!(plan_workgroup_1d(&lim, 0).x, 1);
356    }
357
358    #[test]
359    fn plan_workgroup_1d_desktop_allows_larger() {
360        let lim = Limits::desktop_default(); // 1024 invocations
361        assert_eq!(plan_workgroup_1d(&lim, 1024).x, 1024);
362        assert_eq!(plan_workgroup_1d(&lim, 2048).x, 1024);
363    }
364
365    #[test]
366    fn plan_workgroup_square_caps_at_16_on_portable() {
367        let lim = Limits::portable_default(); // 256 invocations -> 16x16
368        let wg = plan_workgroup_square(&lim, 32);
369        assert_eq!(wg, Workgroup2D { x: 16, y: 16 });
370        assert!(wg.total() <= lim.max_invocations_per_workgroup);
371    }
372
373    #[test]
374    fn plan_workgroup_square_allows_32_on_desktop() {
375        let lim = Limits::desktop_default(); // 1024 invocations -> 32x32
376        let wg = plan_workgroup_square(&lim, 32);
377        assert_eq!(wg, Workgroup2D { x: 32, y: 32 });
378        assert_eq!(wg.total(), 1024);
379    }
380
381    #[test]
382    fn plan_workgroup_square_respects_preferred_smaller() {
383        let lim = Limits::desktop_default();
384        // Prefer 8 even though 32 would fit.
385        let wg = plan_workgroup_square(&lim, 8);
386        assert_eq!(wg, Workgroup2D { x: 8, y: 8 });
387    }
388
389    #[test]
390    fn plan_dispatch_1d_simple() {
391        let lim = Limits::portable_default();
392        let (grid, grid_x) = plan_dispatch_1d(&lim, 1000, 256).expect("plan");
393        // ceil(1000 / 256) = 4 workgroups.
394        assert_eq!(grid, DispatchGrid { x: 4, y: 1, z: 1 });
395        assert_eq!(grid_x, 4);
396    }
397
398    #[test]
399    fn plan_dispatch_1d_exact_multiple() {
400        let lim = Limits::portable_default();
401        let (grid, _) = plan_dispatch_1d(&lim, 512, 256).expect("plan");
402        assert_eq!(grid.x, 2);
403        assert_eq!(grid.total(), 2);
404    }
405
406    #[test]
407    fn plan_dispatch_1d_folds_into_2d_when_too_wide() {
408        let lim = Limits::portable_default(); // 65535 per dim
409        // Need 65535 * 2 + 1 workgroups -> must fold to 2-D.
410        let total_groups = u64::from(MAX_WORKGROUPS_PER_DIM) * 2 + 1;
411        let total_threads = total_groups; // workgroup_x = 1
412        let (grid, grid_x) = plan_dispatch_1d(&lim, total_threads, 1).expect("plan");
413        assert!(grid.y > 1, "expected 2-D fold, got {grid:?}");
414        assert_eq!(grid.x, MAX_WORKGROUPS_PER_DIM);
415        assert_eq!(grid_x, MAX_WORKGROUPS_PER_DIM);
416        // The grid must cover every workgroup.
417        assert!(grid.total() >= total_groups);
418        // Decode must be able to reach the last slot.
419        assert!(u64::from(grid.x) * u64::from(grid.y) >= total_groups);
420    }
421
422    #[test]
423    fn plan_dispatch_1d_errors_when_beyond_2d_capacity() {
424        let lim = Limits::portable_default();
425        // Beyond 65535 * 65535 workgroups (one thread each) cannot fit a 2-D grid.
426        let beyond = u64::from(MAX_WORKGROUPS_PER_DIM) * u64::from(MAX_WORKGROUPS_PER_DIM) + 1;
427        let err = plan_dispatch_1d(&lim, beyond, 1).unwrap_err();
428        assert!(err.contains("exceeds 2-D capacity"));
429    }
430
431    #[test]
432    fn plan_dispatch_2d_gemm() {
433        let lim = Limits::portable_default();
434        let tile = Workgroup2D { x: 16, y: 16 };
435        // 100x200 output, no batch.
436        let grid = plan_dispatch_2d(&lim, 100, 200, tile, 1).expect("plan");
437        assert_eq!(grid.x, div_ceil_u32(200, 16)); // cols -> x
438        assert_eq!(grid.y, div_ceil_u32(100, 16)); // rows -> y
439        assert_eq!(grid.z, 1);
440    }
441
442    #[test]
443    fn plan_dispatch_2d_batched() {
444        let lim = Limits::portable_default();
445        let tile = Workgroup2D { x: 8, y: 8 };
446        let grid = plan_dispatch_2d(&lim, 32, 32, tile, 7).expect("plan");
447        assert_eq!(grid.z, 7);
448        assert_eq!(grid.x, 4);
449        assert_eq!(grid.y, 4);
450    }
451
452    #[test]
453    fn plan_dispatch_2d_errors_when_axis_overflows() {
454        let lim = Limits::portable_default();
455        let tile = Workgroup2D { x: 1, y: 1 };
456        // cols way beyond the per-axis cap with tile 1.
457        let huge = MAX_WORKGROUPS_PER_DIM + 10;
458        let err = plan_dispatch_2d(&lim, 4, huge, tile, 1).unwrap_err();
459        assert!(err.contains("exceeds per-axis cap"));
460    }
461
462    #[test]
463    fn texture_row_layout_padding() {
464        // RGBA8: 4 bytes/texel, width 100 -> 400 unpadded -> 512 padded.
465        let layout = texture_row_layout(100, 50, 4);
466        assert_eq!(layout.unpadded_bytes_per_row, 400);
467        assert_eq!(layout.padded_bytes_per_row, 512);
468        assert_eq!(layout.total_bytes, 512 * 50);
469        // Padded row must be a multiple of 256.
470        assert_eq!(
471            layout.padded_bytes_per_row % COPY_BYTES_PER_ROW_ALIGNMENT,
472            0
473        );
474    }
475
476    #[test]
477    fn texture_row_layout_already_aligned() {
478        // width 64, 4 bytes -> 256, already aligned.
479        let layout = texture_row_layout(64, 10, 4);
480        assert_eq!(layout.unpadded_bytes_per_row, 256);
481        assert_eq!(layout.padded_bytes_per_row, 256);
482        assert_eq!(layout.total_bytes, 2560);
483    }
484
485    #[test]
486    fn limits_portable_default_is_conservative() {
487        let lim = Limits::portable_default();
488        assert_eq!(lim.max_invocations_per_workgroup, 256);
489        assert_eq!(lim.max_workgroups_per_dim, MAX_WORKGROUPS_PER_DIM);
490        // The desktop profile must be at least as permissive.
491        let desk = Limits::desktop_default();
492        assert!(desk.max_invocations_per_workgroup >= lim.max_invocations_per_workgroup);
493    }
494}