Skip to main content

optirs_gpu/
occupancy.rs

1//! CUDA Occupancy Calculator (CPU-side, analytical).
2//!
3//! This module implements the standard NVIDIA *occupancy* model: given a
4//! kernel's per-thread/per-block resource usage and the architectural limits of
5//! a streaming multiprocessor (SM), it computes how many thread blocks and warps
6//! can be co-resident on a single SM, the resulting *occupancy* (the ratio of
7//! resident warps to the hardware maximum), and which resource is the binding
8//! constraint. It also provides an [`optimal_block_size`] search analogous to
9//! `cudaOccupancyMaxPotentialBlockSize`.
10//!
11//! Nothing here queries a GPU — every value is derived from documented
12//! architectural constants and integer arithmetic, so the calculator is
13//! deterministic and works on any host.
14//!
15//! # Model
16//!
17//! For a block of `threads_per_block` threads on an SM with `warp_size` threads
18//! per warp:
19//!
20//! ```text
21//! warps_per_block      = ceil(threads_per_block / warp_size)
22//! blocks_by_warps      = max_warps_per_sm / warps_per_block
23//! blocks_by_registers  = registers_per_sm
24//!                        / round_up(registers_per_thread * threads_per_block,
25//!                                   register_alloc_granularity)
26//! blocks_by_shared_mem = shared_mem_per_sm / shared_mem_per_block
27//! blocks_by_cap        = max_blocks_per_sm
28//!
29//! active_blocks = min(blocks_by_warps, blocks_by_registers,
30//!                     blocks_by_shared_mem, blocks_by_cap)
31//! active_warps  = active_blocks * warps_per_block
32//! occupancy     = active_warps / max_warps_per_sm
33//! ```
34//!
35//! The register term uses a *per-block* allocation rounded up to
36//! `register_alloc_granularity` (256 32-bit registers on every architecture
37//! modelled here). This is a deliberately simple approximation of the hardware's
38//! per-warp register allocation; for block sizes that are whole multiples of the
39//! warp size (the common case) it coincides with the per-warp model.
40//!
41//! A `registers_per_thread` of `0` is treated as "no register pressure"
42//! (unlimited), and a `shared_mem_per_block` of `0` is treated as "no shared
43//! memory pressure" (unlimited), so those resources never bound occupancy.
44//!
45//! # Example
46//!
47//! ```
48//! use optirs_gpu::{calculate_occupancy, KernelResourceUsage, SmResourceLimits};
49//!
50//! let limits = SmResourceLimits::sm_80();
51//! let usage = KernelResourceUsage::new(32, 0, 256);
52//! let result = calculate_occupancy(&usage, &limits).expect("valid configuration");
53//! assert_eq!(result.active_blocks_per_sm, 8);
54//! assert!((result.occupancy - 1.0).abs() < 1e-9);
55//! ```
56
57use crate::GpuOptimError;
58
59/// Architectural per-SM resource limits for a streaming multiprocessor.
60///
61/// Instances are normally created with one of the named compute-capability
62/// constructors ([`SmResourceLimits::sm_70`] … [`SmResourceLimits::sm_90`]) or
63/// derived from a device report via
64/// [`SmResourceLimits::from_compute_capability`] /
65/// [`SmResourceLimits::from_device_capabilities`].
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct SmResourceLimits {
68    /// CUDA compute capability `(major, minor)` that this model represents.
69    pub compute_capability: (u32, u32),
70
71    /// Threads per warp (32 on every NVIDIA architecture to date).
72    pub warp_size: u32,
73
74    /// Maximum threads that may be launched in a single thread block.
75    pub max_threads_per_block: u32,
76
77    /// Maximum number of resident threads per SM (`max_warps_per_sm * warp_size`).
78    pub max_threads_per_sm: u32,
79
80    /// Maximum number of resident warps per SM.
81    pub max_warps_per_sm: u32,
82
83    /// Maximum number of resident thread blocks per SM (hard architectural cap).
84    pub max_blocks_per_sm: u32,
85
86    /// Number of 32-bit registers in the SM register file.
87    pub registers_per_sm: u32,
88
89    /// Granularity (in 32-bit registers) at which a block's register usage is
90    /// rounded up.
91    pub register_alloc_granularity: u32,
92
93    /// Bytes of shared memory available per SM.
94    pub shared_mem_per_sm_bytes: usize,
95}
96
97impl SmResourceLimits {
98    /// Volta — compute capability 7.0 (e.g. Tesla V100).
99    ///
100    /// Documented per-SM limits (CUDA C Programming Guide, "Technical
101    /// Specifications per Compute Capability"):
102    /// - 64 resident warps / 2048 resident threads per SM
103    /// - 32 resident thread blocks per SM
104    /// - 65536 32-bit registers per SM, allocated with 256-register granularity
105    /// - 96 KiB (98304 bytes) of shared memory per SM
106    /// - 1024 threads per block, 32 threads per warp
107    #[must_use]
108    pub const fn sm_70() -> Self {
109        Self {
110            compute_capability: (7, 0),
111            warp_size: 32,
112            max_threads_per_block: 1024,
113            max_threads_per_sm: 2048,
114            max_warps_per_sm: 64,
115            max_blocks_per_sm: 32,
116            registers_per_sm: 65536,
117            register_alloc_granularity: 256,
118            shared_mem_per_sm_bytes: 98_304,
119        }
120    }
121
122    /// Turing — compute capability 7.5 (e.g. RTX 2080, T4).
123    ///
124    /// Turing halves the resident warp/thread budget relative to Volta:
125    /// - 32 resident warps / 1024 resident threads per SM
126    /// - 16 resident thread blocks per SM
127    /// - 65536 32-bit registers per SM, 256-register granularity
128    /// - 64 KiB (65536 bytes) of shared memory per SM
129    /// - 1024 threads per block, 32 threads per warp
130    #[must_use]
131    pub const fn sm_75() -> Self {
132        Self {
133            compute_capability: (7, 5),
134            warp_size: 32,
135            max_threads_per_block: 1024,
136            max_threads_per_sm: 1024,
137            max_warps_per_sm: 32,
138            max_blocks_per_sm: 16,
139            registers_per_sm: 65536,
140            register_alloc_granularity: 256,
141            shared_mem_per_sm_bytes: 65_536,
142        }
143    }
144
145    /// Ampere A100 — compute capability 8.0 (datacenter GA100).
146    ///
147    /// - 64 resident warps / 2048 resident threads per SM
148    /// - 32 resident thread blocks per SM
149    /// - 65536 32-bit registers per SM, 256-register granularity
150    /// - 164 KiB (167936 bytes) of shared memory per SM (opt-in maximum)
151    /// - 1024 threads per block, 32 threads per warp
152    #[must_use]
153    pub const fn sm_80() -> Self {
154        Self {
155            compute_capability: (8, 0),
156            warp_size: 32,
157            max_threads_per_block: 1024,
158            max_threads_per_sm: 2048,
159            max_warps_per_sm: 64,
160            max_blocks_per_sm: 32,
161            registers_per_sm: 65536,
162            register_alloc_granularity: 256,
163            shared_mem_per_sm_bytes: 167_936,
164        }
165    }
166
167    /// Ampere GA10x — compute capability 8.6 (consumer Ampere, e.g. RTX 3080).
168    ///
169    /// GA10x lowers the resident block/warp budget relative to A100:
170    /// - 48 resident warps / 1536 resident threads per SM
171    /// - 16 resident thread blocks per SM
172    /// - 65536 32-bit registers per SM, 256-register granularity
173    /// - 100 KiB (102400 bytes) of shared memory per SM (opt-in maximum)
174    /// - 1024 threads per block, 32 threads per warp
175    #[must_use]
176    pub const fn sm_86() -> Self {
177        Self {
178            compute_capability: (8, 6),
179            warp_size: 32,
180            max_threads_per_block: 1024,
181            max_threads_per_sm: 1536,
182            max_warps_per_sm: 48,
183            max_blocks_per_sm: 16,
184            registers_per_sm: 65536,
185            register_alloc_granularity: 256,
186            shared_mem_per_sm_bytes: 102_400,
187        }
188    }
189
190    /// Hopper — compute capability 9.0 (datacenter H100/GH100).
191    ///
192    /// - 64 resident warps / 2048 resident threads per SM
193    /// - 32 resident thread blocks per SM
194    /// - 65536 32-bit registers per SM, 256-register granularity
195    /// - 228 KiB (233472 bytes) of shared memory per SM (opt-in maximum)
196    /// - 1024 threads per block, 32 threads per warp
197    #[must_use]
198    pub const fn sm_90() -> Self {
199        Self {
200            compute_capability: (9, 0),
201            warp_size: 32,
202            max_threads_per_block: 1024,
203            max_threads_per_sm: 2048,
204            max_warps_per_sm: 64,
205            max_blocks_per_sm: 32,
206            registers_per_sm: 65536,
207            register_alloc_granularity: 256,
208            shared_mem_per_sm_bytes: 233_472,
209        }
210    }
211
212    /// Build limits from a CUDA compute capability `(major, minor)`.
213    ///
214    /// Exact matches map to the corresponding constructor. Capabilities that
215    /// belong to a known architecture family but are not modelled individually
216    /// are mapped to the *nearest* modelled architecture (documented below). Any
217    /// other capability — including the `(0, 0)` reported by non-CUDA devices —
218    /// yields an honest [`GpuOptimError::UnsupportedOperation`] rather than a
219    /// fabricated guess.
220    ///
221    /// Nearest-architecture mappings:
222    /// - `7.2` (Volta Xavier) → [`Self::sm_70`]
223    /// - `8.7` (Ampere Orin)  → [`Self::sm_86`]
224    /// - `8.9` (Ada Lovelace) → [`Self::sm_86`] (closest documented per-SM budget)
225    pub fn from_compute_capability(compute_capability: (u32, u32)) -> Result<Self, GpuOptimError> {
226        let (major, minor) = compute_capability;
227        match (major, minor) {
228            (7, 0) | (7, 2) => Ok(Self::sm_70()),
229            (7, 5) => Ok(Self::sm_75()),
230            (8, 0) => Ok(Self::sm_80()),
231            (8, 6) | (8, 7) | (8, 9) => Ok(Self::sm_86()),
232            (9, 0) => Ok(Self::sm_90()),
233            _ => Err(GpuOptimError::UnsupportedOperation(format!(
234                "no CUDA occupancy model for compute capability {major}.{minor}"
235            ))),
236        }
237    }
238
239    /// Derive limits from a [`crate::backends::DeviceCapabilities`] report.
240    ///
241    /// All per-SM architectural constants are taken from the model keyed on the
242    /// device's `compute_capability`; the per-block thread cap is overridden with
243    /// the device-reported `max_threads_per_block` when that value is non-zero.
244    /// Non-CUDA devices (which report a `(0, 0)` capability) produce an error,
245    /// because the SM occupancy model does not apply to them.
246    pub fn from_device_capabilities(
247        capabilities: &crate::backends::DeviceCapabilities,
248    ) -> Result<Self, GpuOptimError> {
249        let mut limits = Self::from_compute_capability(capabilities.compute_capability)?;
250        if capabilities.max_threads_per_block > 0 {
251            limits.max_threads_per_block = capabilities.max_threads_per_block;
252        }
253        Ok(limits)
254    }
255}
256
257/// Per-kernel resource usage that drives the occupancy calculation.
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259pub struct KernelResourceUsage {
260    /// 32-bit registers consumed by each thread (`0` means "not register-bound").
261    pub registers_per_thread: u32,
262
263    /// Static shared memory consumed by each block, in bytes
264    /// (`0` means "not shared-memory-bound").
265    pub shared_mem_per_block_bytes: usize,
266
267    /// Threads launched per block.
268    pub threads_per_block: u32,
269}
270
271impl KernelResourceUsage {
272    /// Create a new [`KernelResourceUsage`].
273    #[must_use]
274    pub const fn new(
275        registers_per_thread: u32,
276        shared_mem_per_block_bytes: usize,
277        threads_per_block: u32,
278    ) -> Self {
279        Self {
280            registers_per_thread,
281            shared_mem_per_block_bytes,
282            threads_per_block,
283        }
284    }
285}
286
287/// The resource that bounds occupancy for a given kernel/SM combination.
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub enum OccupancyLimiter {
290    /// Limited by the maximum number of resident warps per SM.
291    Warps,
292    /// Limited by the SM register file.
293    Registers,
294    /// Limited by per-SM shared memory.
295    SharedMemory,
296    /// Limited by the hard cap on resident blocks per SM.
297    BlocksPerSm,
298    /// The block requests more threads than the hardware permits; it cannot
299    /// launch, so occupancy is zero.
300    ThreadsPerBlock,
301}
302
303/// Result of an occupancy calculation for a single SM.
304#[derive(Debug, Clone, Copy, PartialEq)]
305pub struct OccupancyResult {
306    /// Resident warps per SM achieved by this configuration.
307    pub active_warps_per_sm: u32,
308
309    /// Resident blocks per SM achieved by this configuration.
310    pub active_blocks_per_sm: u32,
311
312    /// Hardware maximum resident warps per SM (the occupancy denominator).
313    pub max_warps_per_sm: u32,
314
315    /// Achieved occupancy in `0.0..=1.0` (`active_warps_per_sm / max_warps_per_sm`).
316    pub occupancy: f64,
317
318    /// Resource that bounds occupancy for this configuration.
319    pub limiter: OccupancyLimiter,
320}
321
322/// Round `value` up to the nearest multiple of `granularity`.
323///
324/// `granularity == 0` disables rounding (returns `value` unchanged). The result
325/// saturates to [`u64::MAX`] instead of overflowing for absurd inputs, which
326/// downstream produces a zero block count (an unlaunchable kernel) rather than a
327/// panic.
328const fn round_up_to_multiple(value: u64, granularity: u64) -> u64 {
329    if granularity == 0 {
330        return value;
331    }
332    match value.div_ceil(granularity).checked_mul(granularity) {
333        Some(rounded) => rounded,
334        None => u64::MAX,
335    }
336}
337
338/// Saturating narrowing of a `u64` block count to `u32`.
339const fn clamp_u64_to_u32(value: u64) -> u32 {
340    if value > u32::MAX as u64 {
341        u32::MAX
342    } else {
343        value as u32
344    }
345}
346
347/// Saturating narrowing of a `usize` block count to `u32`.
348const fn clamp_usize_to_u32(value: usize) -> u32 {
349    if value > u32::MAX as usize {
350        u32::MAX
351    } else {
352        value as u32
353    }
354}
355
356/// Compute SM occupancy for a kernel with the given resource usage.
357///
358/// See the [module documentation](crate::occupancy) for the full model. Returns
359/// an error only for degenerate input (`threads_per_block == 0` or
360/// `warp_size == 0`). A block that requests more threads than
361/// `limits.max_threads_per_block` cannot launch and is reported as
362/// [`OccupancyLimiter::ThreadsPerBlock`] with zero active blocks and zero
363/// occupancy. When two resources tie for the binding constraint the priority
364/// order is warps → registers → shared memory → block cap, so a fully occupied
365/// SM is reported as [`OccupancyLimiter::Warps`].
366pub fn calculate_occupancy(
367    usage: &KernelResourceUsage,
368    limits: &SmResourceLimits,
369) -> Result<OccupancyResult, GpuOptimError> {
370    let warp_size = limits.warp_size;
371    let threads_per_block = usage.threads_per_block;
372
373    if warp_size == 0 {
374        return Err(GpuOptimError::InvalidState(
375            "warp_size must be greater than zero".to_string(),
376        ));
377    }
378    if threads_per_block == 0 {
379        return Err(GpuOptimError::InvalidState(
380            "threads_per_block must be greater than zero".to_string(),
381        ));
382    }
383
384    // A block larger than the hardware permits can never launch: report zero
385    // occupancy attributed to the block-size limit rather than silently clamping.
386    if threads_per_block > limits.max_threads_per_block {
387        return Ok(OccupancyResult {
388            active_warps_per_sm: 0,
389            active_blocks_per_sm: 0,
390            max_warps_per_sm: limits.max_warps_per_sm,
391            occupancy: 0.0,
392            limiter: OccupancyLimiter::ThreadsPerBlock,
393        });
394    }
395
396    let warps_per_block = threads_per_block.div_ceil(warp_size);
397
398    // Blocks bounded by the resident-warp budget.
399    let warps_limit = limits.max_warps_per_sm / warps_per_block;
400
401    // Blocks bounded by the register file. Zero registers per thread means the
402    // kernel exerts no register pressure and is never register-limited.
403    let register_limit = if usage.registers_per_thread == 0 {
404        u32::MAX
405    } else {
406        let raw_registers =
407            u64::from(usage.registers_per_thread).saturating_mul(u64::from(threads_per_block));
408        let registers_per_block =
409            round_up_to_multiple(raw_registers, u64::from(limits.register_alloc_granularity))
410                .max(1);
411        clamp_u64_to_u32(u64::from(limits.registers_per_sm) / registers_per_block)
412    };
413
414    // Blocks bounded by shared memory. Zero shared memory per block means the
415    // kernel exerts no shared-memory pressure and is never shared-memory-limited
416    // (a zero divisor yields `None`, i.e. the unlimited sentinel).
417    let shared_mem_limit = match limits
418        .shared_mem_per_sm_bytes
419        .checked_div(usage.shared_mem_per_block_bytes)
420    {
421        Some(blocks) => clamp_usize_to_u32(blocks),
422        None => u32::MAX,
423    };
424
425    // Hard architectural cap on resident blocks.
426    let block_cap_limit = limits.max_blocks_per_sm;
427
428    // Pick the binding constraint. Earlier entries win ties, so a fully occupied
429    // SM is attributed to `Warps`.
430    let candidates = [
431        (warps_limit, OccupancyLimiter::Warps),
432        (register_limit, OccupancyLimiter::Registers),
433        (shared_mem_limit, OccupancyLimiter::SharedMemory),
434        (block_cap_limit, OccupancyLimiter::BlocksPerSm),
435    ];
436
437    let mut active_blocks = candidates[0].0;
438    let mut limiter = candidates[0].1;
439    for &(value, candidate_limiter) in &candidates[1..] {
440        if value < active_blocks {
441            active_blocks = value;
442            limiter = candidate_limiter;
443        }
444    }
445
446    let active_warps = active_blocks.saturating_mul(warps_per_block);
447    let occupancy = if limits.max_warps_per_sm == 0 {
448        0.0
449    } else {
450        f64::from(active_warps) / f64::from(limits.max_warps_per_sm)
451    };
452
453    Ok(OccupancyResult {
454        active_warps_per_sm: active_warps,
455        active_blocks_per_sm: active_blocks,
456        max_warps_per_sm: limits.max_warps_per_sm,
457        occupancy,
458        limiter,
459    })
460}
461
462/// Search for the block size that maximises occupancy (à la
463/// `cudaOccupancyMaxPotentialBlockSize`).
464///
465/// Candidate block sizes are every multiple of `limits.warp_size` from
466/// `warp_size` up to and including `limits.max_threads_per_block`. The usage
467/// model is intentionally simple and documented:
468/// - `registers_per_thread` is a constant independent of block size (the usual
469///   assumption — register usage is a property of the compiled kernel).
470/// - `shared_mem_per_block` is a closure `block_size -> bytes`, which covers both
471///   a constant footprint (`|_| BYTES`) and block-size-dependent allocations such
472///   as a reduction kernel's `|threads| threads as usize * size_of::<f32>()`.
473///
474/// Returns the `(block_size, occupancy_result)` with the highest occupancy.
475/// Ties are broken toward the *larger* block size (i.e. fewer resident blocks),
476/// matching the CUDA runtime's preference.
477pub fn optimal_block_size<F>(
478    registers_per_thread: u32,
479    shared_mem_per_block: F,
480    limits: &SmResourceLimits,
481) -> Result<(u32, OccupancyResult), GpuOptimError>
482where
483    F: Fn(u32) -> usize,
484{
485    if limits.warp_size == 0 {
486        return Err(GpuOptimError::InvalidState(
487            "warp_size must be greater than zero".to_string(),
488        ));
489    }
490
491    let mut best: Option<(u32, OccupancyResult)> = None;
492    let mut block_size = limits.warp_size;
493    while block_size <= limits.max_threads_per_block {
494        let usage = KernelResourceUsage {
495            registers_per_thread,
496            shared_mem_per_block_bytes: shared_mem_per_block(block_size),
497            threads_per_block: block_size,
498        };
499        let result = calculate_occupancy(&usage, limits)?;
500
501        // Occupancy is monotonic in `active_warps_per_sm` for a fixed SM (same
502        // denominator), so we compare the integer warp count to avoid any
503        // floating-point comparison while keeping exact tie detection.
504        let replace = match &best {
505            None => true,
506            Some((best_block_size, best_result)) => {
507                result.active_warps_per_sm > best_result.active_warps_per_sm
508                    || (result.active_warps_per_sm == best_result.active_warps_per_sm
509                        && block_size > *best_block_size)
510            }
511        };
512        if replace {
513            best = Some((block_size, result));
514        }
515
516        block_size += limits.warp_size;
517    }
518
519    best.ok_or_else(|| {
520        GpuOptimError::InvalidState(
521            "no block size that is a multiple of warp_size fits within max_threads_per_block"
522                .to_string(),
523        )
524    })
525}
526
527/// Convenience wrapper computing occupancy for a [`crate::backends::LaunchConfig`].
528///
529/// The total threads per block is the product of the configured block
530/// dimensions, and the per-block shared memory is taken from the launch config's
531/// `shared_memory_size`. `registers_per_thread` must be supplied by the caller
532/// (it is a property of the compiled kernel, not of the launch geometry).
533pub fn occupancy_for_launch(
534    config: &crate::backends::LaunchConfig,
535    registers_per_thread: u32,
536    limits: &SmResourceLimits,
537) -> Result<OccupancyResult, GpuOptimError> {
538    let (block_x, block_y, block_z) = config.block_size;
539    let threads_per_block = block_x
540        .checked_mul(block_y)
541        .and_then(|partial| partial.checked_mul(block_z))
542        .ok_or_else(|| {
543            GpuOptimError::InvalidState("block_size dimension product overflows u32".to_string())
544        })?;
545
546    let usage = KernelResourceUsage {
547        registers_per_thread,
548        shared_mem_per_block_bytes: config.shared_memory_size,
549        threads_per_block,
550    };
551    calculate_occupancy(&usage, limits)
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    /// Absolute-tolerance float comparison (keeps clippy::float_cmp quiet).
559    fn approx(actual: f64, expected: f64) -> bool {
560        (actual - expected).abs() < 1e-9
561    }
562
563    #[test]
564    fn textbook_sm80_256_threads_32_registers() {
565        // 256 threads/block, 32 regs/thread, no shared memory on A100 (sm_80).
566        // warps/block = 8; warps limit = 64/8 = 8; reg limit = 65536/8192 = 8;
567        // block cap = 32 -> active_blocks = 8, active_warps = 64, occupancy = 1.0.
568        let limits = SmResourceLimits::sm_80();
569        let usage = KernelResourceUsage::new(32, 0, 256);
570        let result = calculate_occupancy(&usage, &limits).expect("valid configuration");
571
572        assert_eq!(result.active_blocks_per_sm, 8);
573        assert_eq!(result.active_warps_per_sm, 64);
574        assert_eq!(result.max_warps_per_sm, 64);
575        assert!(approx(result.occupancy, 1.0));
576        // Warps and registers tie at 8; the documented tie-break reports Warps.
577        assert_eq!(result.limiter, OccupancyLimiter::Warps);
578    }
579
580    #[test]
581    fn register_bound_sm80() {
582        // 64 regs/thread doubles the register footprint: reg limit = 65536/16384 = 4.
583        let limits = SmResourceLimits::sm_80();
584        let usage = KernelResourceUsage::new(64, 0, 256);
585        let result = calculate_occupancy(&usage, &limits).expect("valid configuration");
586
587        assert_eq!(result.active_blocks_per_sm, 4);
588        assert_eq!(result.active_warps_per_sm, 32);
589        assert!(approx(result.occupancy, 0.5));
590        assert_eq!(result.limiter, OccupancyLimiter::Registers);
591    }
592
593    #[test]
594    fn shared_memory_bound_sm80() {
595        // 128 threads/block, 16 regs/thread, 48 KiB shared memory per block.
596        // smem limit = 167936 / 49152 = 3 (the binding constraint).
597        let limits = SmResourceLimits::sm_80();
598        let usage = KernelResourceUsage::new(16, 48 * 1024, 128);
599        let result = calculate_occupancy(&usage, &limits).expect("valid configuration");
600
601        assert_eq!(result.active_blocks_per_sm, 3);
602        assert_eq!(result.active_warps_per_sm, 12);
603        assert!(approx(result.occupancy, 12.0 / 64.0));
604        assert_eq!(result.limiter, OccupancyLimiter::SharedMemory);
605    }
606
607    #[test]
608    fn block_cap_dominates_with_tiny_blocks_sm80() {
609        // 32-thread (single-warp) blocks with no register/shared pressure: the
610        // warp budget would allow 64 blocks, but the hard cap is 32 blocks.
611        let limits = SmResourceLimits::sm_80();
612        let usage = KernelResourceUsage::new(0, 0, 32);
613        let result = calculate_occupancy(&usage, &limits).expect("valid configuration");
614
615        assert_eq!(result.active_blocks_per_sm, 32);
616        assert_eq!(result.active_warps_per_sm, 32);
617        assert!(approx(result.occupancy, 0.5));
618        assert_eq!(result.limiter, OccupancyLimiter::BlocksPerSm);
619    }
620
621    #[test]
622    fn register_allocation_granularity_rounds_up() {
623        // 96 threads (3 warps), 33 regs/thread -> raw = 3168 registers, rounded up
624        // to the 256-register granularity = 3328, so reg limit = 65536/3328 = 19.
625        let limits = SmResourceLimits::sm_80();
626        let usage = KernelResourceUsage::new(33, 0, 96);
627        let result = calculate_occupancy(&usage, &limits).expect("valid configuration");
628
629        assert_eq!(result.active_blocks_per_sm, 19);
630        assert_eq!(result.active_warps_per_sm, 57);
631        assert!(approx(result.occupancy, 57.0 / 64.0));
632        assert_eq!(result.limiter, OccupancyLimiter::Registers);
633    }
634
635    #[test]
636    fn warps_per_block_uses_ceiling() {
637        // 100 threads -> ceil(100/32) = 4 warps per block.
638        let limits = SmResourceLimits::sm_80();
639        let usage = KernelResourceUsage::new(0, 0, 100);
640        let result = calculate_occupancy(&usage, &limits).expect("valid configuration");
641        // No register/shared pressure -> block cap (32) binds; 32 * 4 = 128 warps,
642        // but warp budget caps to 16 blocks (64/4) which is below the cap.
643        assert_eq!(result.active_blocks_per_sm, 16);
644        assert_eq!(result.active_warps_per_sm, 64);
645        assert_eq!(result.limiter, OccupancyLimiter::Warps);
646    }
647
648    #[test]
649    fn threads_exceeding_hardware_limit_report_threadsperblock() {
650        let limits = SmResourceLimits::sm_80();
651        let usage = KernelResourceUsage::new(32, 0, 2048); // > 1024 max
652        let result = calculate_occupancy(&usage, &limits).expect("returns zero-occupancy result");
653
654        assert_eq!(result.active_blocks_per_sm, 0);
655        assert_eq!(result.active_warps_per_sm, 0);
656        assert!(approx(result.occupancy, 0.0));
657        assert_eq!(result.limiter, OccupancyLimiter::ThreadsPerBlock);
658    }
659
660    #[test]
661    fn zero_threads_is_an_error() {
662        let limits = SmResourceLimits::sm_80();
663        let usage = KernelResourceUsage::new(32, 0, 0);
664        assert!(calculate_occupancy(&usage, &limits).is_err());
665    }
666
667    #[test]
668    fn optimal_block_size_prefers_full_occupancy_and_largest_block() {
669        // 32 regs/thread, no shared memory: block sizes 64..=1024 all reach 100%
670        // occupancy, so the tie-break selects the largest (1024).
671        let limits = SmResourceLimits::sm_80();
672        let (block_size, result) =
673            optimal_block_size(32, |_| 0, &limits).expect("a candidate exists");
674
675        assert_eq!(block_size % limits.warp_size, 0);
676        assert!(block_size <= limits.max_threads_per_block);
677        assert_eq!(block_size, 1024);
678        assert!(approx(result.occupancy, 1.0));
679
680        // Occupancy of the chosen block size is at least that of any spot-checked
681        // candidate (256 threads also reaches full occupancy here).
682        let spot = calculate_occupancy(&KernelResourceUsage::new(32, 0, 256), &limits)
683            .expect("valid configuration");
684        assert!(result.occupancy >= spot.occupancy);
685    }
686
687    #[test]
688    fn optimal_block_size_finds_register_heavy_sweet_spot() {
689        // 96 regs/thread caps the SM at 21 resident warps (65536 / (96*32) = 21).
690        // Several block sizes tie at 21 warps; the documented tie-break selects the
691        // largest, which is 672 threads (a single 21-warp block).
692        let limits = SmResourceLimits::sm_80();
693        let (block_size, result) =
694            optimal_block_size(96, |_| 0, &limits).expect("a candidate exists");
695
696        assert_eq!(result.active_warps_per_sm, 21);
697        assert!(approx(result.occupancy, 21.0 / 64.0));
698        assert_eq!(result.limiter, OccupancyLimiter::Registers);
699
700        // The chosen block size is the maximum across the candidate set, and no
701        // larger candidate reaches the same warp count (confirming the tie-break).
702        let mut probe = limits.warp_size;
703        while probe <= limits.max_threads_per_block {
704            let candidate = calculate_occupancy(&KernelResourceUsage::new(96, 0, probe), &limits)
705                .expect("valid configuration");
706            assert!(result.active_warps_per_sm >= candidate.active_warps_per_sm);
707            if candidate.active_warps_per_sm == result.active_warps_per_sm {
708                assert!(probe <= block_size);
709            }
710            probe += limits.warp_size;
711        }
712        assert_eq!(block_size, 672);
713    }
714
715    #[test]
716    fn optimal_block_size_supports_block_dependent_shared_memory() {
717        // Reduction-style kernel: 4 bytes of shared memory per thread.
718        let limits = SmResourceLimits::sm_80();
719        let (block_size, result) = optimal_block_size(16, |threads| threads as usize * 4, &limits)
720            .expect("a candidate exists");
721
722        assert_eq!(block_size % limits.warp_size, 0);
723        assert!(block_size <= limits.max_threads_per_block);
724        assert!(result.occupancy > 0.0);
725    }
726
727    #[test]
728    fn from_compute_capability_maps_known_architectures() {
729        assert_eq!(
730            SmResourceLimits::from_compute_capability((7, 0)).expect("known"),
731            SmResourceLimits::sm_70()
732        );
733        assert_eq!(
734            SmResourceLimits::from_compute_capability((7, 5)).expect("known"),
735            SmResourceLimits::sm_75()
736        );
737        assert_eq!(
738            SmResourceLimits::from_compute_capability((8, 0)).expect("known"),
739            SmResourceLimits::sm_80()
740        );
741        assert_eq!(
742            SmResourceLimits::from_compute_capability((8, 6)).expect("known"),
743            SmResourceLimits::sm_86()
744        );
745        assert_eq!(
746            SmResourceLimits::from_compute_capability((9, 0)).expect("known"),
747            SmResourceLimits::sm_90()
748        );
749        // Nearest-architecture aliases.
750        assert_eq!(
751            SmResourceLimits::from_compute_capability((7, 2)).expect("nearest"),
752            SmResourceLimits::sm_70()
753        );
754        assert_eq!(
755            SmResourceLimits::from_compute_capability((8, 9)).expect("nearest"),
756            SmResourceLimits::sm_86()
757        );
758    }
759
760    #[test]
761    fn unknown_compute_capability_is_an_error() {
762        assert!(SmResourceLimits::from_compute_capability((5, 0)).is_err());
763        assert!(SmResourceLimits::from_compute_capability((10, 0)).is_err());
764        assert!(SmResourceLimits::from_compute_capability((0, 0)).is_err());
765    }
766
767    #[test]
768    fn architecture_constants_are_consistent() {
769        for limits in [
770            SmResourceLimits::sm_70(),
771            SmResourceLimits::sm_75(),
772            SmResourceLimits::sm_80(),
773            SmResourceLimits::sm_86(),
774            SmResourceLimits::sm_90(),
775        ] {
776            assert_eq!(limits.warp_size, 32);
777            assert_eq!(limits.max_threads_per_block, 1024);
778            assert_eq!(limits.register_alloc_granularity, 256);
779            assert_eq!(limits.registers_per_sm, 65536);
780            assert!(limits.max_warps_per_sm > 0);
781            assert!(limits.max_blocks_per_sm > 0);
782            // max_threads_per_sm must equal the resident-warp budget in threads.
783            assert_eq!(
784                limits.max_threads_per_sm,
785                limits.max_warps_per_sm * limits.warp_size
786            );
787        }
788        // Spot-check the architecture-specific budgets that differ.
789        assert_eq!(SmResourceLimits::sm_75().max_warps_per_sm, 32);
790        assert_eq!(SmResourceLimits::sm_75().max_blocks_per_sm, 16);
791        assert_eq!(SmResourceLimits::sm_86().max_warps_per_sm, 48);
792        assert_eq!(SmResourceLimits::sm_90().shared_mem_per_sm_bytes, 233_472);
793    }
794
795    #[test]
796    fn from_device_capabilities_uses_compute_capability() {
797        let caps = crate::backends::DeviceCapabilities {
798            name: "A100 (test)".to_string(),
799            total_memory: 0,
800            available_memory: 0,
801            supports_f16: true,
802            supports_bf16: true,
803            supports_tensor_cores: true,
804            max_threads_per_block: 1024,
805            max_shared_memory_per_block: 49152,
806            multiprocessor_count: 108,
807            compute_capability: (8, 0),
808        };
809        let limits = SmResourceLimits::from_device_capabilities(&caps).expect("cuda device");
810        assert_eq!(limits.max_warps_per_sm, 64);
811        assert_eq!(limits.max_blocks_per_sm, 32);
812        assert_eq!(limits.max_threads_per_block, 1024);
813    }
814
815    #[test]
816    fn from_device_capabilities_rejects_non_cuda_devices() {
817        let caps = crate::backends::DeviceCapabilities {
818            name: "CPU (test)".to_string(),
819            total_memory: 0,
820            available_memory: 0,
821            supports_f16: false,
822            supports_bf16: false,
823            supports_tensor_cores: false,
824            max_threads_per_block: 1,
825            max_shared_memory_per_block: 0,
826            multiprocessor_count: 1,
827            compute_capability: (0, 0),
828        };
829        assert!(SmResourceLimits::from_device_capabilities(&caps).is_err());
830    }
831
832    #[test]
833    fn occupancy_for_launch_matches_direct_calculation() {
834        let limits = SmResourceLimits::sm_80();
835        let config = crate::backends::LaunchConfig {
836            grid_size: (128, 1, 1),
837            block_size: (256, 1, 1),
838            shared_memory_size: 0,
839            stream: None,
840        };
841        let result = occupancy_for_launch(&config, 32, &limits).expect("valid launch");
842        assert_eq!(result.active_blocks_per_sm, 8);
843        assert!(approx(result.occupancy, 1.0));
844    }
845}