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, ¶ms, &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, ¶ms, &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}