Skip to main content

cubecl_utils_rs/
limits.rs

1//! Device limits and the dispatch geometry derived from them.
2//!
3//! Everything in here except [`GpuLimits::from_client`] is a pure function of
4//! [`GpuLimits`]. That is deliberate: it means the behaviour on a device with
5//! half the shared memory, a quarter of the units per cube or a smaller plane
6//! can be asserted in a unit test on a machine that has none of those
7//! properties.
8
9use cubecl::prelude::*;
10
11use crate::errors::CubeclUtilsErrors;
12
13///////////////
14// GpuLimits //
15///////////////
16
17/// Every device limit that dispatch geometry and staging decisions depend on.
18///
19/// Read once per client via [`GpuLimits::from_client`] and passed around as
20/// data. Fields mirror `cubecl`'s `HardwareProperties` and
21/// `MemoryDeviceProperties`.
22///
23/// ### Note
24///
25/// The values a backend reports are not uniform. Apple Silicon via wgpu gives
26/// 32768 bytes of shared memory, a plane size pinned to 32/32 and a 4 GiB
27/// binding limit. Integrated parts report as little as 16384 bytes of shared
28/// memory, AMD reports a plane size of 64, and Intel reports a *range* because
29/// the real value depends on register pressure and cannot be queried ahead of
30/// time.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub struct GpuLimits {
33    /// Shared memory available to one workgroup, in bytes
34    pub max_shared_bytes: usize,
35    /// Maximum number of cubes per grid dimension, as `(x, y, z)`
36    pub max_cube_count: (u32, u32, u32),
37    /// Maximum number of units in a single cube
38    pub max_units_per_cube: u32,
39    /// Maximum extent of a cube per dimension, as `(x, y, z)`
40    pub max_cube_dim: (u32, u32, u32),
41    /// Largest single allocation or binding the device accepts, in bytes
42    pub max_binding_bytes: u64,
43    /// Smallest plane size the device may use
44    pub plane_size_min: u32,
45    /// Largest plane size the device may use
46    pub plane_size_max: u32,
47}
48
49impl GpuLimits {
50    /// Read the limits from a live compute client.
51    ///
52    /// `ComputeClient::properties()` is a field borrow rather than a device
53    /// query, so this is cheap enough to call per allocation. It is still
54    /// worth hoisting where several decisions share the same client.
55    ///
56    /// ### Params
57    ///
58    /// * `client` - CubeCL compute client for the target device
59    ///
60    /// ### Returns
61    ///
62    /// A [`GpuLimits`] describing that device.
63    ///
64    /// ### Note
65    ///
66    /// The cube-count limit comes from the client properties rather than from
67    /// `Runtime::max_cube_count()`. The latter is a per-backend constant: the
68    /// wgpu implementation returns `u16::MAX` on every device regardless of
69    /// what the adapter actually supports, which is a safe floor but discards
70    /// headroom on hardware that allows more.
71    pub fn from_client<R: Runtime>(client: &ComputeClient<R>) -> Self {
72        let props = client.properties();
73        let hw = &props.hardware;
74        Self {
75            max_shared_bytes: hw.max_shared_memory_size,
76            max_cube_count: hw.max_cube_count,
77            max_units_per_cube: hw.max_units_per_cube,
78            max_cube_dim: hw.max_cube_dim,
79            max_binding_bytes: props.memory.max_page_size,
80            plane_size_min: hw.plane_size_min,
81            plane_size_max: hw.plane_size_max,
82        }
83    }
84}
85
86///////////////////////
87// Dispatch geometry //
88///////////////////////
89
90/// Split a flat cube count into a 2D grid bounded by `max_dim` per dimension.
91///
92/// The packing is x-fast row-major, so a kernel recovers its flat index with
93/// `CUBE_POS_Y * CUBE_COUNT_X + CUBE_POS_X`. **That layout is a contract**, not
94/// an implementation detail: kernel bodies across several crates decode it by
95/// hand, and changing the shape silently corrupts every one of them.
96///
97/// ### Params
98///
99/// * `total_cubes` - Flat number of cubes the dispatch needs
100/// * `max_dim` - Per-dimension limit to respect
101///
102/// ### Returns
103///
104/// `(x, y)` with `x * y >= total_cubes`, both within `max_dim`, or
105/// `GridTooLarge` when no such pair exists.
106///
107/// ### Note
108///
109/// A `total_cubes` of zero is treated as one. A dispatch of nothing is a
110/// caller-side no-op rather than an error, and the alternative was a division
111/// by zero.
112pub fn grid_2d_limited(total_cubes: u32, max_dim: u32) -> Result<(u32, u32), CubeclUtilsErrors> {
113    let total = total_cubes.max(1);
114    let limit = max_dim.max(1);
115
116    let x = total.min(limit);
117    let y = total.div_ceil(x);
118
119    // y is unbounded by construction, and busts once total exceeds limit^2.
120    if y > limit {
121        return Err(CubeclUtilsErrors::GridTooLarge {
122            total_cubes: total,
123            max_dim: limit,
124        });
125    }
126
127    Ok((x, y))
128}
129
130/// Split a flat cube count into a 2D grid within the device's x/y limits.
131///
132/// Convenience wrapper over [`grid_2d_limited`] using the smaller of the
133/// device's x and y cube-count limits, so the result is valid on either axis.
134///
135/// ### Params
136///
137/// * `total_cubes` - Flat number of cubes the dispatch needs
138/// * `limits` - Device limits from [`GpuLimits::from_client`]
139///
140/// ### Returns
141///
142/// `(x, y)` with `x * y >= total_cubes`, or `GridTooLarge`.
143pub fn grid_2d(total_cubes: u32, limits: &GpuLimits) -> Result<(u32, u32), CubeclUtilsErrors> {
144    let (mx, my, _) = limits.max_cube_count;
145    grid_2d_limited(total_cubes, mx.min(my))
146}
147
148/// Build a static cube count, checked against the device's per-dimension limit.
149///
150/// A dispatch that busts the limit is not a soft failure. The launch is
151/// rejected on the CubeCL server thread, that thread dies, and every subsequent
152/// call on the client returns an unrelated `CallError` from somewhere else
153/// entirely. Catching it here turns that into a typed error naming the kernel.
154///
155/// ### Params
156///
157/// * `kernel` - Kernel name, for the error message only
158/// * `x` - Requested cubes along x
159/// * `y` - Requested cubes along y
160/// * `z` - Requested cubes along z
161/// * `limits` - Device limits from [`GpuLimits::from_client`]
162///
163/// ### Returns
164///
165/// `CubeCount::Static(x, y, z)`, or `CubeCountExceeded` if any dimension is
166/// over the device limit.
167pub fn checked_cube_count(
168    kernel: &'static str,
169    x: u32,
170    y: u32,
171    z: u32,
172    limits: &GpuLimits,
173) -> Result<CubeCount, CubeclUtilsErrors> {
174    let limit = limits.max_cube_count;
175    if x > limit.0 || y > limit.1 || z > limit.2 {
176        return Err(CubeclUtilsErrors::CubeCountExceeded {
177            kernel,
178            requested: (x, y, z),
179            limit,
180        });
181    }
182    Ok(CubeCount::Static(x, y, z))
183}
184
185//////////////////////
186// Workgroup sizing //
187//////////////////////
188
189/// Make a preferred workgroup width legal on the target device.
190///
191/// Caps at `max_units_per_cube` and at the x extent of `max_cube_dim`, then
192/// rounds down to a whole number of planes so no cube runs a partial SIMD
193/// group. Rounding uses `plane_size_max`: on a device reporting a range, a
194/// multiple of the largest candidate is a multiple of the smaller
195/// power-of-two candidates too.
196///
197/// ### Params
198///
199/// * `preferred` - Workgroup width the caller would like
200/// * `limits` - Device limits from [`GpuLimits::from_client`]
201///
202/// ### Returns
203///
204/// A legal workgroup width, never zero. It is a whole number of planes unless
205/// a single plane is already wider than the device allows per cube, in which
206/// case the cap wins and the caller gets a partial plane.
207pub fn resolve_workgroup_size(preferred: u32, limits: &GpuLimits) -> u32 {
208    let cap = limits.max_units_per_cube.min(limits.max_cube_dim.0).max(1);
209    let wanted = preferred.clamp(1, cap);
210    let plane = limits.plane_size_max.max(1);
211
212    // A plane wider than the whole cube cannot be rounded to; the cap is the
213    // harder constraint, so honour that and let the caller run partial.
214    if plane > wanted {
215        return wanted;
216    }
217
218    (wanted / plane) * plane
219}
220
221/////////////////////
222// Plane viability //
223/////////////////////
224
225/// Whether a `wg_size`-wide workgroup is guaranteed to be exactly one plane.
226///
227/// This is the precondition for plane primitives that reduce across the whole
228/// workgroup: `plane_max`, `plane_sum`, `plane_ballot` and friends operate on a
229/// plane, so a workgroup straddling two of them silently reduces over half the
230/// data. Both the reported min and max must equal the width, because a device
231/// reporting a range gives no way to know which value it picked.
232///
233/// ### Params
234///
235/// * `wg_size` - Workgroup width the kernel will be launched at
236/// * `limits` - Device limits from [`GpuLimits::from_client`]
237///
238/// ### Returns
239///
240/// True when the workgroup is exactly one plane on this device.
241pub fn plane_uniform(wg_size: u32, limits: &GpuLimits) -> bool {
242    limits.plane_size_min == wg_size && limits.plane_size_max == wg_size
243}
244
245/// How many whole planes a `wg_size`-wide workgroup divides into.
246///
247/// The weaker sibling of [`plane_uniform`], for kernels that reduce within each
248/// plane and then combine the per-plane results through shared memory. The
249/// plane size still has to be known exactly, but the workgroup may span several
250/// of them.
251///
252/// Callers usually have their own bound on top of this, e.g. a shared-memory
253/// scratch array sized for a maximum number of planes, or a minimum plane width
254/// below which the plane path is not worth taking. Apply those to the returned
255/// count.
256///
257/// ### Params
258///
259/// * `wg_size` - Workgroup width the kernel will be launched at
260/// * `limits` - Device limits from [`GpuLimits::from_client`]
261///
262/// ### Returns
263///
264/// `Some(n_planes)` when the device reports a single plane size that divides
265/// `wg_size`, `None` otherwise.
266pub fn plane_partitions(wg_size: u32, limits: &GpuLimits) -> Option<u32> {
267    let plane = limits.plane_size_min;
268    if plane == 0 || plane != limits.plane_size_max || wg_size == 0 {
269        return None;
270    }
271    if !wg_size.is_multiple_of(plane) {
272        return None;
273    }
274    Some(wg_size / plane)
275}
276
277/////////////////
278// Allocations //
279/////////////////
280
281/// Check a single allocation against the device's per-binding size limit.
282///
283/// Two ceilings exist and they disagree: total device memory, and the largest
284/// buffer that can be bound to one kernel argument. A wave of work can fit the
285/// former while a single tensor busts the latter, and busting it is silent. On
286/// wgpu the limit is `max_storage_buffer_binding_size`, which is 4 GiB on Apple
287/// Silicon but as little as 128 MiB on parts that report only the WebGPU
288/// defaults.
289///
290/// ### Params
291///
292/// * `requested` - Bytes the allocation needs
293/// * `limits` - Device limits from [`GpuLimits::from_client`]
294///
295/// ### Returns
296///
297/// `Ok(())` when it fits, `BindingTooLarge` otherwise.
298pub fn fits_binding(requested: u64, limits: &GpuLimits) -> Result<(), CubeclUtilsErrors> {
299    if requested > limits.max_binding_bytes {
300        return Err(CubeclUtilsErrors::BindingTooLarge {
301            requested,
302            limit: limits.max_binding_bytes,
303        });
304    }
305    Ok(())
306}
307
308///////////////////
309// Shared memory //
310///////////////////
311
312/// Check a kernel's shared-memory footprint against the device budget.
313///
314/// Over-allocating shared memory is silent: the kernel does no work, writes
315/// nothing, and reports no error. Anything whose `SharedMemory::new` argument
316/// depends on a user-facing parameter (a neighbour count, an embedding
317/// dimensionality, a graph degree) needs this before the launch.
318///
319/// ### Params
320///
321/// * `kernel` - Kernel name, for the error message only
322/// * `requested` - Total bytes the kernel's shared allocations add up to
323/// * `limits` - Device limits from [`GpuLimits::from_client`]
324///
325/// ### Returns
326///
327/// `Ok(())` when it fits, `SharedMemoryExceeded` otherwise.
328pub fn fits_shared_memory(
329    kernel: &'static str,
330    requested: usize,
331    limits: &GpuLimits,
332) -> Result<(), CubeclUtilsErrors> {
333    if requested > limits.max_shared_bytes {
334        return Err(CubeclUtilsErrors::SharedMemoryExceeded {
335            kernel,
336            requested,
337            available: limits.max_shared_bytes,
338        });
339    }
340    Ok(())
341}
342
343/// How many workgroups of a given shared-memory footprint stay resident.
344///
345/// Residency is the biggest lever on a latency-bound kernel, and it moves in
346/// integer steps: a footprint of 22 KiB against a 32 KiB budget fits perfectly
347/// and still runs at half the throughput of one that fits twice. Use this to
348/// find which side of a threshold a candidate staging plan lands on.
349///
350/// ### Params
351///
352/// * `footprint_bytes` - Shared memory one workgroup allocates
353/// * `limits` - Device limits from [`GpuLimits::from_client`]
354///
355/// ### Returns
356///
357/// Number of concurrently resident workgroups the shared-memory budget allows,
358/// or 0 when a single workgroup does not fit.
359pub fn resident_workgroups(footprint_bytes: usize, limits: &GpuLimits) -> usize {
360    if footprint_bytes == 0 {
361        return usize::MAX;
362    }
363    limits.max_shared_bytes / footprint_bytes
364}
365
366///////////
367// Tests //
368///////////
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    /// Apple Silicon via wgpu, the machine everything was developed on.
375    ///
376    /// Verbatim from `tests/device_limits.rs` on an M-series part. The binding
377    /// limit really is four bytes short of 4 GiB.
378    fn apple() -> GpuLimits {
379        GpuLimits {
380            max_shared_bytes: 32_768,
381            max_cube_count: (65_535, 65_535, 65_535),
382            max_units_per_cube: 1024,
383            max_cube_dim: (1024, 1024, 1024),
384            max_binding_bytes: 4_294_967_292,
385            plane_size_min: 32,
386            plane_size_max: 32,
387        }
388    }
389
390    /// A deliberately mean device: half the shared memory, a quarter of the
391    /// units per cube, a 128 MiB binding limit and wave64.
392    fn small() -> GpuLimits {
393        GpuLimits {
394            max_shared_bytes: 16_384,
395            max_cube_count: (32_768, 1_024, 64),
396            max_units_per_cube: 256,
397            max_cube_dim: (256, 256, 64),
398            max_binding_bytes: 128 * 1024 * 1024,
399            plane_size_min: 64,
400            plane_size_max: 64,
401        }
402    }
403
404    // -- grid_2d --
405
406    /// The packing `grid_2d` had before it learned about device limits. Kernel
407    /// bodies in several crates decode this exact layout by hand.
408    fn legacy_grid_2d(total_cubes: u32) -> (u32, u32) {
409        let x = total_cubes.min(65535);
410        let y = total_cubes.div_ceil(x);
411        (x, y)
412    }
413
414    #[test]
415    fn test_grid_2d_packing_matches_legacy() {
416        for total in [
417            1u32,
418            2,
419            31,
420            32,
421            1000,
422            65_534,
423            65_535,
424            65_536,
425            65_537,
426            131_070,
427            131_071,
428            1_000_000,
429            10_000_000,
430            100_000_000,
431        ] {
432            assert_eq!(
433                grid_2d_limited(total, 65_535).unwrap(),
434                legacy_grid_2d(total),
435                "packing drifted at total = {total}"
436            );
437        }
438    }
439
440    #[test]
441    fn test_grid_2d_zero_does_not_panic() {
442        // The old implementation divided by zero here.
443        assert_eq!(grid_2d_limited(0, 65_535).unwrap(), (1, 1));
444    }
445
446    #[test]
447    fn test_grid_2d_covers_and_fits() {
448        for max_dim in [65_535u32, 32_768, 1024] {
449            for total in [0u32, 1, 65_535, 65_536, 131_070, 10_000_000] {
450                let capacity = max_dim as u64 * max_dim as u64;
451                match grid_2d_limited(total, max_dim) {
452                    Ok((x, y)) => {
453                        assert!(
454                            x <= max_dim && y <= max_dim,
455                            "over limit at {total}/{max_dim}"
456                        );
457                        assert!(
458                            x as u64 * y as u64 >= total.max(1) as u64,
459                            "uncovered at {total}/{max_dim}"
460                        );
461                    }
462                    Err(_) => assert!(
463                        total as u64 > capacity,
464                        "refused a grid that fits at {total}/{max_dim}"
465                    ),
466                }
467            }
468        }
469    }
470
471    #[test]
472    fn test_grid_2d_errors_past_max_dim_squared() {
473        // 1024^2 = 1_048_576, so one more cube than that cannot be packed.
474        assert!(grid_2d_limited(1024 * 1024, 1024).is_ok());
475        assert!(matches!(
476            grid_2d_limited(1024 * 1024 + 1, 1024),
477            Err(CubeclUtilsErrors::GridTooLarge { .. })
478        ));
479    }
480
481    #[test]
482    fn test_grid_2d_uses_smaller_of_x_and_y() {
483        // small() reports 32768 on x but only 1024 on y, and the decomposition
484        // has to be valid on whichever axis the caller assigns it to.
485        let (x, y) = grid_2d(4096, &small()).unwrap();
486        assert!(x <= 1024 && y <= 1024, "got {x}x{y}");
487        assert!(x as u64 * y as u64 >= 4096);
488    }
489
490    // -- checked_cube_count --
491
492    #[test]
493    fn test_checked_cube_count_accepts_at_the_limit() {
494        assert!(checked_cube_count("k", 65_535, 65_535, 65_535, &apple()).is_ok());
495    }
496
497    #[test]
498    fn test_checked_cube_count_errors_per_axis() {
499        let l = small();
500        for (x, y, z) in [(32_769, 1, 1), (1, 32_769, 1), (1, 1, 65)] {
501            assert!(
502                matches!(
503                    checked_cube_count("k", x, y, z, &l),
504                    Err(CubeclUtilsErrors::CubeCountExceeded { .. })
505                ),
506                "accepted {x},{y},{z}"
507            );
508        }
509    }
510
511    // -- resolve_workgroup_size --
512
513    #[test]
514    fn test_resolve_workgroup_size_apple() {
515        // 256 is already a whole number of 32-wide planes and fits the cap.
516        assert_eq!(resolve_workgroup_size(256, &apple()), 256);
517    }
518
519    #[test]
520    fn test_resolve_workgroup_size_caps_and_rounds() {
521        // small(): cap 256, plane 64. 512 caps to 256, which is 4 planes.
522        assert_eq!(resolve_workgroup_size(512, &small()), 256);
523        // 200 caps to 200, rounds down to 3 planes = 192.
524        assert_eq!(resolve_workgroup_size(200, &small()), 192);
525    }
526
527    #[test]
528    fn test_resolve_workgroup_size_is_whole_planes_and_nonzero() {
529        for plane in [8u32, 16, 32, 64] {
530            for cap in [256u32, 512, 1024] {
531                let l = GpuLimits {
532                    max_units_per_cube: cap,
533                    max_cube_dim: (cap, cap, cap),
534                    plane_size_min: plane,
535                    plane_size_max: plane,
536                    ..apple()
537                };
538                for preferred in [1u32, 32, 100, 256, 4096] {
539                    let wg = resolve_workgroup_size(preferred, &l);
540                    assert!(wg > 0, "zero width at plane {plane}, cap {cap}");
541                    assert!(wg <= cap, "over cap at plane {plane}, cap {cap}");
542                    if preferred >= plane {
543                        assert_eq!(wg % plane, 0, "partial plane at {plane}/{cap}/{preferred}");
544                    }
545                }
546            }
547        }
548    }
549
550    #[test]
551    fn test_resolve_workgroup_size_plane_wider_than_cap() {
552        // wgpu invents 8/128 when a backend reports no subgroup info at all.
553        let l = GpuLimits {
554            max_units_per_cube: 64,
555            max_cube_dim: (64, 64, 64),
556            plane_size_min: 8,
557            plane_size_max: 128,
558            ..apple()
559        };
560        // Cannot round to a 128-wide plane inside a 64-unit cube; cap wins.
561        assert_eq!(resolve_workgroup_size(256, &l), 64);
562    }
563
564    // -- plane viability --
565
566    #[test]
567    fn test_plane_uniform() {
568        assert!(plane_uniform(32, &apple()));
569        assert!(!plane_uniform(64, &apple()));
570        assert!(plane_uniform(64, &small()));
571    }
572
573    #[test]
574    fn test_plane_uniform_false_on_a_range() {
575        let l = GpuLimits {
576            plane_size_min: 8,
577            plane_size_max: 32,
578            ..apple()
579        };
580        assert!(
581            !plane_uniform(32, &l),
582            "a reported range is not a guarantee"
583        );
584    }
585
586    #[test]
587    fn test_plane_partitions() {
588        assert_eq!(plane_partitions(256, &apple()), Some(8));
589        assert_eq!(plane_partitions(256, &small()), Some(4));
590        // Not a multiple of the plane size.
591        assert_eq!(plane_partitions(96, &small()), None);
592        // Device reports a range.
593        let ranged = GpuLimits {
594            plane_size_min: 8,
595            plane_size_max: 32,
596            ..apple()
597        };
598        assert_eq!(plane_partitions(256, &ranged), None);
599    }
600
601    // -- bindings --
602
603    #[test]
604    fn test_fits_binding_boundary() {
605        let l = small();
606        let limit = 128 * 1024 * 1024;
607        assert!(fits_binding(limit, &l).is_ok());
608        assert!(matches!(
609            fits_binding(limit + 1, &l),
610            Err(CubeclUtilsErrors::BindingTooLarge { .. })
611        ));
612    }
613
614    #[test]
615    fn test_fits_binding_across_element_sizes() {
616        // The exhaustive-search transient in ann-search-rs: 8192 queries by a
617        // 16384-row database chunk. 512 MiB for f32, 1 GiB for f64.
618        let elems: u64 = 8192 * 16_384;
619        for (bytes_per_elem, name) in [(4u64, "f32/u32"), (8, "f64")] {
620            let bytes = elems * bytes_per_elem;
621            assert!(fits_binding(bytes, &apple()).is_ok(), "{name} on apple");
622            assert!(fits_binding(bytes, &small()).is_err(), "{name} on small");
623        }
624    }
625
626    // -- shared memory --
627
628    #[test]
629    fn test_fits_shared_memory_boundary() {
630        let l = apple();
631        assert!(fits_shared_memory("k", 32_768, &l).is_ok());
632        assert!(matches!(
633            fits_shared_memory("k", 32_769, &l),
634            Err(CubeclUtilsErrors::SharedMemoryExceeded { .. })
635        ));
636    }
637
638    #[test]
639    fn test_fits_shared_memory_apple_budget_busts_a_small_device() {
640        // A staging plan tuned to 32 KiB is exactly what silently no-ops on a
641        // 16 KiB part.
642        assert!(fits_shared_memory("k", 20_000, &apple()).is_ok());
643        assert!(fits_shared_memory("k", 20_000, &small()).is_err());
644    }
645
646    #[test]
647    fn test_resident_workgroups() {
648        let l = apple();
649        assert_eq!(resident_workgroups(22_024, &l), 1);
650        assert_eq!(resident_workgroups(13_320, &l), 2);
651        assert_eq!(resident_workgroups(8_968, &l), 3);
652        assert_eq!(resident_workgroups(40_000, &l), 0);
653    }
654}