Skip to main content

oxicuda_launch/
cluster.rs

1//! Thread block cluster configuration for Hopper+ GPUs (SM 9.0+).
2//!
3//! Thread block clusters are a new level of the CUDA execution hierarchy
4//! introduced with the NVIDIA Hopper architecture (compute capability 9.0).
5//! A cluster groups multiple thread blocks that can cooperate more
6//! efficiently via distributed shared memory and hardware-accelerated
7//! synchronisation.
8//!
9//! # Requirements
10//!
11//! - NVIDIA Hopper (H100) or later GPU (compute capability 9.0+).
12//! - CUDA driver version 12.0 or later.
13//! - The kernel must be compiled with cluster support.
14//!
15//! # Example
16//!
17//! ```rust,no_run
18//! # use oxicuda_launch::cluster::{ClusterDim, ClusterLaunchParams};
19//! # use oxicuda_launch::Dim3;
20//! let cluster_params = ClusterLaunchParams {
21//!     grid: Dim3::x(16),
22//!     block: Dim3::x(256),
23//!     cluster: ClusterDim::new(2, 1, 1),
24//!     shared_mem_bytes: 0,
25//! };
26//! assert_eq!(cluster_params.blocks_per_cluster(), 2);
27//! ```
28
29use oxicuda_driver::error::{CudaError, CudaResult};
30use oxicuda_driver::stream::Stream;
31
32use crate::grid::Dim3;
33use crate::kernel::{Kernel, KernelArgs};
34
35// ---------------------------------------------------------------------------
36// ClusterDim
37// ---------------------------------------------------------------------------
38
39/// Cluster dimensions specifying how many thread blocks form one cluster.
40///
41/// Each dimension specifies the number of blocks in that direction of
42/// the cluster. The total number of blocks in a cluster is
43/// `x * y * z`.
44///
45/// # Constraints
46///
47/// - All dimensions must be non-zero.
48/// - The total blocks per cluster must not exceed the hardware limit
49///   (typically 8 or 16 for Hopper GPUs).
50/// - The grid dimensions must be evenly divisible by the cluster
51///   dimensions.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
53pub struct ClusterDim {
54    /// Number of blocks in the X dimension of the cluster.
55    pub x: u32,
56    /// Number of blocks in the Y dimension of the cluster.
57    pub y: u32,
58    /// Number of blocks in the Z dimension of the cluster.
59    pub z: u32,
60}
61
62impl ClusterDim {
63    /// Creates a new cluster dimension.
64    #[inline]
65    pub fn new(x: u32, y: u32, z: u32) -> Self {
66        Self { x, y, z }
67    }
68
69    /// Creates a 1D cluster (only X dimension used).
70    #[inline]
71    pub fn x(x: u32) -> Self {
72        Self { x, y: 1, z: 1 }
73    }
74
75    /// Creates a 2D cluster.
76    #[inline]
77    pub fn xy(x: u32, y: u32) -> Self {
78        Self { x, y, z: 1 }
79    }
80
81    /// Returns the total number of blocks per cluster.
82    #[inline]
83    pub fn total(&self) -> u32 {
84        self.x * self.y * self.z
85    }
86
87    /// Validates that all dimensions are non-zero.
88    fn validate(&self) -> CudaResult<()> {
89        if self.x == 0 || self.y == 0 || self.z == 0 {
90            return Err(CudaError::InvalidValue);
91        }
92        Ok(())
93    }
94}
95
96impl std::fmt::Display for ClusterDim {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        write!(f, "ClusterDim({}x{}x{})", self.x, self.y, self.z)
99    }
100}
101
102// ---------------------------------------------------------------------------
103// ClusterLaunchParams
104// ---------------------------------------------------------------------------
105
106/// Launch parameters including thread block cluster configuration.
107///
108/// Extends the standard grid/block configuration with a cluster
109/// dimension. The grid dimensions must be evenly divisible by the
110/// cluster dimensions.
111#[derive(Debug, Clone, Copy)]
112pub struct ClusterLaunchParams {
113    /// Grid dimensions (number of thread blocks total).
114    pub grid: Dim3,
115    /// Block dimensions (threads per block).
116    pub block: Dim3,
117    /// Cluster dimensions (blocks per cluster).
118    pub cluster: ClusterDim,
119    /// Dynamic shared memory per block in bytes.
120    pub shared_mem_bytes: u32,
121}
122
123impl ClusterLaunchParams {
124    /// Returns the total number of blocks per cluster.
125    #[inline]
126    pub fn blocks_per_cluster(&self) -> u32 {
127        self.cluster.total()
128    }
129
130    /// Returns the total number of clusters in the grid.
131    ///
132    /// This requires that the grid dimensions be evenly divisible by
133    /// the cluster dimensions.
134    ///
135    /// # Errors
136    ///
137    /// Returns [`CudaError::InvalidValue`] if the grid is not evenly
138    /// divisible by the cluster dimensions, or if any dimension is zero.
139    pub fn cluster_count(&self) -> CudaResult<u32> {
140        self.validate()?;
141        let cx = self.grid.x / self.cluster.x;
142        let cy = self.grid.y / self.cluster.y;
143        let cz = self.grid.z / self.cluster.z;
144        Ok(cx * cy * cz)
145    }
146
147    /// Validates the cluster launch parameters.
148    ///
149    /// Checks that:
150    /// - All grid, block, and cluster dimensions are non-zero.
151    /// - The grid dimensions are evenly divisible by the cluster dimensions.
152    ///
153    /// # Errors
154    ///
155    /// Returns [`CudaError::InvalidValue`] on any violation.
156    pub fn validate(&self) -> CudaResult<()> {
157        // Validate cluster dims
158        self.cluster.validate()?;
159
160        // Validate grid dims are non-zero
161        if self.grid.x == 0 || self.grid.y == 0 || self.grid.z == 0 {
162            return Err(CudaError::InvalidValue);
163        }
164        if self.block.x == 0 || self.block.y == 0 || self.block.z == 0 {
165            return Err(CudaError::InvalidValue);
166        }
167
168        // Grid must be divisible by cluster
169        if self.grid.x % self.cluster.x != 0
170            || self.grid.y % self.cluster.y != 0
171            || self.grid.z % self.cluster.z != 0
172        {
173            return Err(CudaError::InvalidValue);
174        }
175
176        Ok(())
177    }
178}
179
180// ---------------------------------------------------------------------------
181// cluster_launch
182// ---------------------------------------------------------------------------
183
184/// Launches a kernel with thread block cluster configuration.
185///
186/// On Hopper+ GPUs (compute capability 9.0+), this groups thread blocks
187/// into clusters for enhanced cooperation via distributed shared memory.
188///
189/// A degenerate `1x1x1` cluster carries no cluster semantics, so it is
190/// launched via the standard `cuLaunchKernel` path. Any other cluster
191/// dimension is launched via the CUDA 12.x extended launch API
192/// (`cuLaunchKernelEx`) with a `ClusterDimension` launch attribute, so the
193/// requested cluster geometry actually reaches the driver. This requires
194/// a CUDA 12.0+ driver exposing `cuLaunchKernelEx`; on hardware that does
195/// not support thread block clusters (anything below Hopper / compute
196/// capability 9.0, including this workstation's Ampere sm_86 GPU), the
197/// driver itself rejects the launch.
198///
199/// # Parameters
200///
201/// * `kernel` — the kernel to launch.
202/// * `params` — cluster-aware launch parameters.
203/// * `stream` — the stream to launch on.
204/// * `args` — kernel arguments.
205///
206/// # Errors
207///
208/// Returns [`CudaError::InvalidValue`] if the parameters are invalid
209/// (zero dimensions, grid not divisible by cluster, etc.).
210/// Returns [`CudaError::NotSupported`] if the loaded driver does not
211/// expose `cuLaunchKernelEx` (CUDA < 12.0). Returns another [`CudaError`]
212/// from the underlying kernel launch, including driver errors reported
213/// when the GPU does not support thread block clusters.
214pub fn cluster_launch<A: KernelArgs>(
215    kernel: &Kernel,
216    params: &ClusterLaunchParams,
217    stream: &Stream,
218    args: &A,
219) -> CudaResult<()> {
220    params.validate()?;
221
222    // A degenerate 1x1x1 cluster has no cluster semantics — preserve the
223    // existing plain-launch behaviour rather than paying for the extended
224    // launch API.
225    if params.cluster.total() == 1 {
226        let launch_params = crate::params::LaunchParams {
227            grid: params.grid,
228            block: params.block,
229            shared_mem_bytes: params.shared_mem_bytes,
230        };
231        return kernel.launch(&launch_params, stream, args);
232    }
233
234    // Non-degenerate cluster: the cluster dimensions must actually reach
235    // the driver, which requires the CUDA 12.x extended launch API.
236    let driver = oxicuda_driver::loader::try_driver()?;
237    let cu_launch_kernel_ex = driver.cu_launch_kernel_ex.ok_or(CudaError::NotSupported)?;
238
239    let cluster_attr = oxicuda_driver::CuLaunchAttribute {
240        id: oxicuda_driver::CuLaunchAttributeId::ClusterDimension,
241        pad: [0u8; 4],
242        value: oxicuda_driver::CuLaunchAttributeValue {
243            cluster_dim: oxicuda_driver::CuLaunchAttributeClusterDim {
244                x: params.cluster.x,
245                y: params.cluster.y,
246                z: params.cluster.z,
247            },
248        },
249    };
250
251    let config = oxicuda_driver::CuLaunchConfig {
252        grid_dim_x: params.grid.x,
253        grid_dim_y: params.grid.y,
254        grid_dim_z: params.grid.z,
255        block_dim_x: params.block.x,
256        block_dim_y: params.block.y,
257        block_dim_z: params.block.z,
258        shared_mem_bytes: params.shared_mem_bytes,
259        stream: stream.raw(),
260        attrs: &cluster_attr as *const oxicuda_driver::CuLaunchAttribute,
261        num_attrs: 1,
262    };
263
264    let mut param_ptrs = args.as_param_ptrs();
265    oxicuda_driver::error::check(unsafe {
266        cu_launch_kernel_ex(
267            &config,
268            kernel.function().raw(),
269            param_ptrs.as_mut_ptr(),
270            std::ptr::null_mut(),
271        )
272    })
273}
274
275// ---------------------------------------------------------------------------
276// Tests
277// ---------------------------------------------------------------------------
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn cluster_dim_new() {
285        let c = ClusterDim::new(2, 2, 1);
286        assert_eq!(c.x, 2);
287        assert_eq!(c.y, 2);
288        assert_eq!(c.z, 1);
289        assert_eq!(c.total(), 4);
290    }
291
292    #[test]
293    fn cluster_dim_x() {
294        let c = ClusterDim::x(4);
295        assert_eq!(c.total(), 4);
296        assert_eq!(c.y, 1);
297        assert_eq!(c.z, 1);
298    }
299
300    #[test]
301    fn cluster_dim_xy() {
302        let c = ClusterDim::xy(2, 4);
303        assert_eq!(c.total(), 8);
304    }
305
306    #[test]
307    fn cluster_dim_display() {
308        let c = ClusterDim::new(2, 1, 1);
309        assert_eq!(format!("{c}"), "ClusterDim(2x1x1)");
310    }
311
312    #[test]
313    fn cluster_dim_validate_zero() {
314        let c = ClusterDim::new(0, 1, 1);
315        assert!(c.validate().is_err());
316    }
317
318    #[test]
319    fn cluster_launch_params_blocks_per_cluster() {
320        let p = ClusterLaunchParams {
321            grid: Dim3::x(16),
322            block: Dim3::x(256),
323            cluster: ClusterDim::new(2, 1, 1),
324            shared_mem_bytes: 0,
325        };
326        assert_eq!(p.blocks_per_cluster(), 2);
327    }
328
329    #[test]
330    fn cluster_count_valid() {
331        let p = ClusterLaunchParams {
332            grid: Dim3::new(8, 4, 2),
333            block: Dim3::x(256),
334            cluster: ClusterDim::new(2, 2, 1),
335            shared_mem_bytes: 0,
336        };
337        let count = p.cluster_count();
338        assert!(count.is_ok());
339        assert_eq!(count.ok(), Some(4 * 2 * 2));
340    }
341
342    #[test]
343    fn cluster_count_not_divisible() {
344        let p = ClusterLaunchParams {
345            grid: Dim3::x(7),
346            block: Dim3::x(256),
347            cluster: ClusterDim::x(2),
348            shared_mem_bytes: 0,
349        };
350        assert!(p.cluster_count().is_err());
351    }
352
353    #[test]
354    fn validate_rejects_zero_block() {
355        let p = ClusterLaunchParams {
356            grid: Dim3::x(4),
357            block: Dim3::new(0, 1, 1),
358            cluster: ClusterDim::x(2),
359            shared_mem_bytes: 0,
360        };
361        assert!(p.validate().is_err());
362    }
363
364    #[test]
365    fn cluster_launch_signature_compiles() {
366        let _: fn(&Kernel, &ClusterLaunchParams, &Stream, &(u32,)) -> CudaResult<()> =
367            cluster_launch;
368    }
369
370    // ---------------------------------------------------------------------------
371    // Quality gate tests (CPU-only)
372    // ---------------------------------------------------------------------------
373
374    #[test]
375    fn cluster_dim_1x1x1_valid() {
376        let c = ClusterDim::new(1, 1, 1);
377        assert_eq!(c.x, 1);
378        assert_eq!(c.y, 1);
379        assert_eq!(c.z, 1);
380        assert_eq!(c.total(), 1);
381        // validate() must succeed for a 1x1x1 cluster
382        assert!(c.validate().is_ok());
383    }
384
385    #[test]
386    fn cluster_dim_2x2x2_valid() {
387        let c = ClusterDim::new(2, 2, 2);
388        assert_eq!(c.total(), 8);
389        assert!(c.validate().is_ok());
390    }
391
392    #[test]
393    fn cluster_dim_8x1x1_valid() {
394        // Maximum 8 blocks per axis is well within hardware limits
395        let c = ClusterDim::new(8, 1, 1);
396        assert_eq!(c.total(), 8);
397        assert!(c.validate().is_ok());
398    }
399
400    #[test]
401    fn cluster_dim_zero_rejected() {
402        // ClusterDim::new(0, 1, 1) is constructable but validate() must return Err
403        let c = ClusterDim::new(0, 1, 1);
404        assert!(
405            c.validate().is_err(),
406            "ClusterDim with zero x must be rejected by validate()"
407        );
408        // Also test zero in y and z
409        let c_y = ClusterDim::new(1, 0, 1);
410        assert!(c_y.validate().is_err(), "ClusterDim with zero y must fail");
411        let c_z = ClusterDim::new(1, 1, 0);
412        assert!(c_z.validate().is_err(), "ClusterDim with zero z must fail");
413    }
414
415    #[test]
416    fn cluster_total_blocks_product() {
417        // total() == x * y * z for arbitrary values
418        let c = ClusterDim::new(3, 2, 4);
419        assert_eq!(c.total(), 3 * 2 * 4);
420
421        let c2 = ClusterDim::new(1, 7, 2);
422        assert_eq!(c2.total(), 7 * 2);
423    }
424
425    #[test]
426    fn cluster_launch_params_contains_cluster_dim() {
427        let cluster = ClusterDim::new(2, 1, 1);
428        let p = ClusterLaunchParams {
429            grid: Dim3::x(16),
430            block: Dim3::x(256),
431            cluster,
432            shared_mem_bytes: 0,
433        };
434        // The ClusterLaunchParams must expose a .cluster field with the right dims
435        assert_eq!(p.cluster.x, 2);
436        assert_eq!(p.cluster.y, 1);
437        assert_eq!(p.cluster.z, 1);
438        assert_eq!(p.cluster.total(), 2);
439    }
440
441    // ---------------------------------------------------------------------------
442    // On-device regression tests (F031): the cluster dimensions must actually
443    // reach the driver instead of being silently discarded.
444    // ---------------------------------------------------------------------------
445
446    /// A minimal, arch-portable empty kernel (no parameters).
447    #[cfg(feature = "gpu-tests")]
448    const NOOP_PTX: &str = "\
449.version 7.0
450.target sm_70
451.address_size 64
452.visible .entry noop_kernel()
453{
454    ret;
455}
456";
457
458    /// Regression test for the bug where `cluster_launch` never passed the
459    /// cluster dimensions to the driver and always launched via plain
460    /// `cuLaunchKernel`, returning `Ok(())` even though the requested
461    /// cluster geometry was never honored. This workstation's Ampere
462    /// (sm_86) GPU does not support thread block clusters (Hopper sm_90+
463    /// only), so a *correct* implementation must now surface a driver
464    /// error for a non-degenerate cluster instead of silently succeeding
465    /// as if the cluster had been applied. Self-skips if there is no
466    /// GPU/driver.
467    #[cfg(feature = "gpu-tests")]
468    #[test]
469    fn cluster_launch_non_degenerate_on_unsupported_hardware_errors() {
470        use std::sync::Arc;
471
472        let Ok(dev) = oxicuda_driver::device::Device::get(0) else {
473            return;
474        };
475        let ctx = match oxicuda_driver::context::Context::new(&dev) {
476            Ok(c) => Arc::new(c),
477            Err(_) => return,
478        };
479        let stream = match Stream::new(&ctx) {
480            Ok(s) => s,
481            Err(_) => return,
482        };
483        let module = match oxicuda_driver::module::Module::from_ptx(NOOP_PTX) {
484            Ok(m) => Arc::new(m),
485            Err(_) => return,
486        };
487        let kernel = match Kernel::from_module(module, "noop_kernel") {
488            Ok(k) => k,
489            Err(_) => return,
490        };
491
492        let params = ClusterLaunchParams {
493            grid: Dim3::x(4),
494            block: Dim3::x(32),
495            cluster: ClusterDim::x(2),
496            shared_mem_bytes: 0,
497        };
498
499        let result = cluster_launch(&kernel, &params, &stream, &());
500        assert!(
501            result.is_err(),
502            "cluster_launch with a non-degenerate cluster on sm_86 (no cluster \
503             hardware support) must return an error, not silently succeed as a \
504             plain launch: {result:?}"
505        );
506    }
507
508    /// A degenerate `1x1x1` cluster carries no cluster semantics and must
509    /// keep working (via the plain-launch fallback) even on hardware
510    /// without cluster support.
511    #[cfg(feature = "gpu-tests")]
512    #[test]
513    fn cluster_launch_degenerate_cluster_still_succeeds() {
514        use std::sync::Arc;
515
516        let Ok(dev) = oxicuda_driver::device::Device::get(0) else {
517            return;
518        };
519        let ctx = match oxicuda_driver::context::Context::new(&dev) {
520            Ok(c) => Arc::new(c),
521            Err(_) => return,
522        };
523        let stream = match Stream::new(&ctx) {
524            Ok(s) => s,
525            Err(_) => return,
526        };
527        let module = match oxicuda_driver::module::Module::from_ptx(NOOP_PTX) {
528            Ok(m) => Arc::new(m),
529            Err(_) => return,
530        };
531        let kernel = match Kernel::from_module(module, "noop_kernel") {
532            Ok(k) => k,
533            Err(_) => return,
534        };
535
536        let params = ClusterLaunchParams {
537            grid: Dim3::x(4),
538            block: Dim3::x(32),
539            cluster: ClusterDim::new(1, 1, 1),
540            shared_mem_bytes: 0,
541        };
542
543        let result = cluster_launch(&kernel, &params, &stream, &());
544        assert!(
545            result.is_ok(),
546            "degenerate 1x1x1 cluster must launch normally: {result:?}"
547        );
548        stream
549            .synchronize()
550            .expect("stream sync after degenerate cluster launch");
551    }
552}