Skip to main content

mnemosyne_core/
kernel_budget.rs

1//! Kernel resource budgets for GPU occupancy planning (atlas ADR 0002).
2//!
3//! GPU register files and shared memory are **not host-allocatable**: the
4//! kernel compiler assigns registers and kernels declare shared memory at
5//! launch. Mnemosyne therefore owns the *budget vocabulary and accounting* —
6//! how many registers per thread and shared bytes per block a kernel
7//! requires — which moirai's occupancy planner intersects with per-unit
8//! capacities (themis `GpuTopology` accessors: `registers_per_unit`,
9//! `shared_mem_per_unit_bytes`, `max_threads_per_unit`) to derive launch
10//! shapes. This module is `no_std`, dependency-free, and fully `const`:
11//! every limiter resolves at compile time when the budget is a constant.
12#![cfg_attr(
13    test,
14    expect(
15        clippy::unwrap_used,
16        reason = "MNEM-UNWRAP-1: test scope, an unmet precondition in a test is a test failure"
17    )
18)]
19
20/// A kernel's per-launch resource requirements.
21///
22/// Constructed with [`KernelResourceBudget::new`], which rejects a zero
23/// thread count (a launch with no threads is meaningless and would poison
24/// the occupancy arithmetic). A `registers_per_thread` or
25/// `shared_mem_per_block_bytes` of zero means the kernel uses none of that
26/// resource and is therefore unconstrained by it.
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
28pub struct KernelResourceBudget {
29    registers_per_thread: u32,
30    shared_mem_per_block_bytes: usize,
31    threads_per_block: u32,
32}
33
34/// Result of intersecting a budget with one compute unit's capacities.
35///
36/// `u32::MAX` in a limiter means "unconstrained by this resource" — either
37/// the kernel uses none of it, or the capacity is unreported (zero) and the
38/// caller must decide policy rather than have a fabricated bound.
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub struct OccupancyLimits {
41    /// Blocks per unit limited by the register file.
42    pub by_registers: u32,
43    /// Blocks per unit limited by shared memory.
44    pub by_shared_mem: u32,
45    /// Blocks per unit limited by resident-thread capacity.
46    pub by_threads: u32,
47}
48
49impl OccupancyLimits {
50    /// The binding constraint: the minimum of the three limiters.
51    ///
52    /// Returns `u32::MAX` only when every dimension is unconstrained, which
53    /// the caller must treat as "no information", not "infinite blocks".
54    #[must_use]
55    #[inline]
56    pub const fn blocks_per_unit(self) -> u32 {
57        let mut limit = self.by_registers;
58        if self.by_shared_mem < limit {
59            limit = self.by_shared_mem;
60        }
61        if self.by_threads < limit {
62            limit = self.by_threads;
63        }
64        limit
65    }
66}
67
68/// Returns `capacity / per_block` clamped to `u32::MAX`, or `u32::MAX` when
69/// either operand is zero. This is the SSOT for the "unconstrained" semantics
70/// shared by `blocks_limited_by_registers` and `blocks_limited_by_shared_mem`.
71#[inline]
72const fn div_capacity_or_max(capacity: u64, per_block: u64) -> u32 {
73    if per_block == 0 || capacity == 0 {
74        return u32::MAX;
75    }
76    let blocks = capacity / per_block;
77    if blocks > u32::MAX as u64 {
78        u32::MAX
79    } else {
80        blocks as u32
81    }
82}
83
84impl KernelResourceBudget {
85    /// Construct a budget. Returns `None` when `threads_per_block` is zero.
86    #[must_use]
87    pub const fn new(
88        registers_per_thread: u32,
89        shared_mem_per_block_bytes: usize,
90        threads_per_block: u32,
91    ) -> Option<Self> {
92        if threads_per_block == 0 {
93            return None;
94        }
95        Some(Self {
96            registers_per_thread,
97            shared_mem_per_block_bytes,
98            threads_per_block,
99        })
100    }
101
102    /// Registers each thread requires (compiler-reported).
103    #[must_use]
104    #[inline]
105    pub const fn registers_per_thread(self) -> u32 {
106        self.registers_per_thread
107    }
108
109    /// Shared-memory bytes each block declares at launch.
110    #[must_use]
111    #[inline]
112    pub const fn shared_mem_per_block_bytes(self) -> usize {
113        self.shared_mem_per_block_bytes
114    }
115
116    /// Threads per block of the planned launch shape.
117    #[must_use]
118    #[inline]
119    pub const fn threads_per_block(self) -> u32 {
120        self.threads_per_block
121    }
122
123    /// Registers one block consumes: `registers_per_thread · threads_per_block`.
124    ///
125    /// The product is computed in `u64` after widening both `u32` factors, so it
126    /// is exact for every input: the maximum product `(2^32 - 1)^2 = 2^64 -
127    /// 2^33 + 1` is strictly less than `u64::MAX`, so the multiplication never
128    /// overflows and no saturation or wrapping is possible.
129    #[must_use]
130    #[inline]
131    pub const fn registers_per_block(self) -> u64 {
132        (self.registers_per_thread as u64) * (self.threads_per_block as u64)
133    }
134
135    /// Blocks per unit limited by a register file of `unit_registers`.
136    ///
137    /// Unconstrained (`u32::MAX`) when the kernel uses no registers or the
138    /// capacity is unreported (zero) — an unreported capacity must surface as
139    /// "no information" for the planner, never a fabricated bound.
140    #[must_use]
141    pub const fn blocks_limited_by_registers(self, unit_registers: u32) -> u32 {
142        div_capacity_or_max(unit_registers as u64, self.registers_per_block())
143    }
144
145    /// Blocks per unit limited by `unit_shared_mem_bytes` of shared memory.
146    /// Same unconstrained semantics as the register limiter.
147    #[must_use]
148    pub const fn blocks_limited_by_shared_mem(self, unit_shared_mem_bytes: usize) -> u32 {
149        div_capacity_or_max(
150            unit_shared_mem_bytes as u64,
151            self.shared_mem_per_block_bytes as u64,
152        )
153    }
154
155    /// Blocks per unit limited by `max_threads_per_unit` resident threads.
156    /// Unconstrained when the capacity is unreported (zero).
157    #[must_use]
158    pub const fn blocks_limited_by_threads(self, max_threads_per_unit: u32) -> u32 {
159        if max_threads_per_unit == 0 {
160            return u32::MAX;
161        }
162        max_threads_per_unit / self.threads_per_block
163    }
164
165    /// Intersect this budget with one compute unit's capacities.
166    ///
167    /// The capacities are the themis `GpuTopology` per-unit accessors
168    /// (`registers_per_unit()`, `shared_mem_per_unit_bytes()`,
169    /// `max_threads_per_unit()`), passed as plain quantities so this crate
170    /// stays `no_std` and dependency-free; the typed pairing lives in
171    /// moirai's occupancy planner.
172    #[must_use]
173    pub const fn occupancy_limits(
174        self,
175        unit_registers: u32,
176        unit_shared_mem_bytes: usize,
177        max_threads_per_unit: u32,
178    ) -> OccupancyLimits {
179        OccupancyLimits {
180            by_registers: self.blocks_limited_by_registers(unit_registers),
181            by_shared_mem: self.blocks_limited_by_shared_mem(unit_shared_mem_bytes),
182            by_threads: self.blocks_limited_by_threads(max_threads_per_unit),
183        }
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    // Ampere-class unit capacities used as closed-form fixtures.
192    const UNIT_REGISTERS: u32 = 65_536;
193    const UNIT_SHARED: usize = 102_400;
194    const UNIT_THREADS: u32 = 1_536;
195
196    #[test]
197    fn zero_thread_budget_is_rejected() {
198        assert!(KernelResourceBudget::new(32, 0, 0).is_none());
199        assert!(KernelResourceBudget::new(32, 0, 1).is_some());
200    }
201
202    #[test]
203    fn register_limiter_matches_closed_form() {
204        // 64 regs/thread × 256 threads = 16384 regs/block; 65536/16384 = 4.
205        let budget = KernelResourceBudget::new(64, 0, 256).unwrap();
206        assert_eq!(budget.registers_per_block(), 16_384);
207        assert_eq!(budget.blocks_limited_by_registers(UNIT_REGISTERS), 4);
208    }
209
210    #[test]
211    fn shared_mem_limiter_matches_closed_form() {
212        // 100 KiB unit / 16 KiB per block = 6 blocks (floor).
213        let budget = KernelResourceBudget::new(0, 16 * 1024, 128).unwrap();
214        assert_eq!(budget.blocks_limited_by_shared_mem(UNIT_SHARED), 6);
215    }
216
217    #[test]
218    fn thread_limiter_matches_closed_form() {
219        // 1536 resident / 256 per block = 6 blocks.
220        let budget = KernelResourceBudget::new(0, 0, 256).unwrap();
221        assert_eq!(budget.blocks_limited_by_threads(UNIT_THREADS), 6);
222    }
223
224    #[test]
225    fn binding_constraint_is_the_minimum() {
226        // Registers bind at 4, shared at 6, threads at 6 -> 4.
227        let budget = KernelResourceBudget::new(64, 16 * 1024, 256).unwrap();
228        let limits = budget.occupancy_limits(UNIT_REGISTERS, UNIT_SHARED, UNIT_THREADS);
229        assert_eq!(limits.by_registers, 4);
230        assert_eq!(limits.by_shared_mem, 6);
231        assert_eq!(limits.by_threads, 6);
232        assert_eq!(limits.blocks_per_unit(), 4);
233    }
234
235    #[test]
236    fn unreported_capacities_are_unconstrained_not_fabricated() {
237        let budget = KernelResourceBudget::new(64, 16 * 1024, 256).unwrap();
238        let limits = budget.occupancy_limits(0, 0, 0);
239        assert_eq!(limits.by_registers, u32::MAX);
240        assert_eq!(limits.by_shared_mem, u32::MAX);
241        assert_eq!(limits.by_threads, u32::MAX);
242        assert_eq!(limits.blocks_per_unit(), u32::MAX);
243    }
244
245    #[test]
246    fn zero_resource_budgets_are_unconstrained_by_that_resource() {
247        let budget = KernelResourceBudget::new(0, 0, 256).unwrap();
248        let limits = budget.occupancy_limits(UNIT_REGISTERS, UNIT_SHARED, UNIT_THREADS);
249        assert_eq!(limits.by_registers, u32::MAX);
250        assert_eq!(limits.by_shared_mem, u32::MAX);
251        assert_eq!(limits.blocks_per_unit(), 6); // threads bind
252    }
253
254    #[test]
255    fn limits_are_const_evaluable() {
256        // The whole pipeline resolves at compile time for constant budgets.
257        const BUDGET: KernelResourceBudget = match KernelResourceBudget::new(64, 16 * 1024, 256) {
258            Some(budget) => budget,
259            None => panic!("non-zero thread count"),
260        };
261        const LIMITS: OccupancyLimits =
262            BUDGET.occupancy_limits(UNIT_REGISTERS, UNIT_SHARED, UNIT_THREADS);
263        const BLOCKS: u32 = LIMITS.blocks_per_unit();
264        assert_eq!(BLOCKS, 4);
265    }
266}