Skip to main content

oxicuda_driver/
occupancy_register_count.rs

1//! PTX-parseable SM occupancy from register declarations.
2//!
3//! The CUDA driver's `cuOccupancyMaxActiveBlocksPerMultiprocessorWithFlags`
4//! needs the *registers-per-thread* figure to give an accurate occupancy
5//! answer.  On a live GPU that number comes from `ptxas`; offline, the only
6//! source of truth is the PTX text itself — every kernel begins with a set of
7//! `.reg` directives that declare its virtual register file.
8//!
9//! This module parses those `.reg` directives **per `.entry`** and feeds the
10//! resulting register count into the CPU-side [`OccupancyCalculator`] from
11//! [`crate::occupancy_ext`].  It is entirely host-side — no GPU is required —
12//! which makes it useful for build-time kernel auto-tuning and for occupancy
13//! reports in CI.
14//!
15//! # PTX register declaration grammar
16//!
17//! Inside a kernel body, virtual registers are declared with lines such as:
18//!
19//! ```ptx
20//! .reg .f32   %f<10>;   // 10 single-precision registers
21//! .reg .b32   %r<5>;    //  5 untyped 32-bit registers
22//! .reg .pred  %p<3>;    //  3 predicate registers
23//! .reg .f64   %fd<4>;   //  4 double-precision registers (each 2x 32-bit)
24//! ```
25//!
26//! The bracketed `<N>` is the *vectorised* count: it declares `%f0 .. %f9`.
27//! A bare declaration without brackets (e.g. `.reg .u32 %tid;`) declares a
28//! single register.
29//!
30//! The hardware register file is 32-bit-word addressed, so wide types
31//! (`.f64`, `.b64`, `.s64`, `.u64`) occupy **two** 32-bit slots each, while
32//! `.pred` registers do **not** consume general-purpose registers at all (they
33//! live in a separate condition-code file).  [`PtxRegisterUsage`] models all of
34//! this so the occupancy estimate matches what `ptxas` would compute for the
35//! `-O0` (no register coalescing) lower bound.
36//!
37//! # Example
38//!
39//! ```rust
40//! use oxicuda_driver::occupancy_register_count::OccupancyFromPtx;
41//!
42//! let ptx = r#"
43//! .version 8.0
44//! .target sm_90
45//! .visible .entry saxpy() {
46//!     .reg .f32   %f<8>;
47//!     .reg .b32   %r<4>;
48//!     .reg .pred  %p<2>;
49//!     ret;
50//! }
51//! "#;
52//!
53//! let from_ptx = OccupancyFromPtx::parse(ptx).expect("valid PTX");
54//! // saxpy uses 8 f32 + 4 b32 = 12 general-purpose 32-bit registers.
55//! assert_eq!(from_ptx.kernel("saxpy").unwrap().registers_per_thread(), 12);
56//!
57//! // Feed straight into the CPU occupancy model for an sm_90 device.
58//! let est = from_ptx.estimate_for("saxpy", 9, 0, 256, 0).unwrap();
59//! assert!(est.occupancy_ratio > 0.0);
60//! ```
61
62use crate::error::{CudaError, CudaResult};
63use crate::occupancy_ext::{DeviceOccupancyInfo, OccupancyCalculator, OccupancyEstimate};
64
65// ---------------------------------------------------------------------------
66// PtxRegisterWidth
67// ---------------------------------------------------------------------------
68
69/// Number of 32-bit hardware register slots a PTX type occupies per element.
70///
71/// The NVIDIA hardware register file is addressed in 32-bit words.  A 64-bit
72/// value therefore consumes two slots; sub-word values (`.b8`, `.b16`, `.f16`)
73/// still occupy a full 32-bit slot because the register file is not byte
74/// addressable.
75fn slots_for_type(reg_type: &str) -> RegClass {
76    // Strip a leading '.' if present so both ".f32" and "f32" parse.
77    let t = reg_type.strip_prefix('.').unwrap_or(reg_type);
78    match t {
79        // Predicate registers live in the condition-code file and do not
80        // consume general-purpose registers.
81        "pred" => RegClass::Predicate,
82        // 64-bit scalar / float types — two 32-bit slots each.
83        "b64" | "s64" | "u64" | "f64" => RegClass::General(2),
84        // 128-bit (rare, used for vector loads) — four slots.
85        "b128" => RegClass::General(4),
86        // Everything else (b8/b16/b32, s8..s32, u8..u32, f16/f16x2/f32) — a
87        // single 32-bit slot.
88        _ => RegClass::General(1),
89    }
90}
91
92/// Classification of a register declaration for occupancy accounting.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94enum RegClass {
95    /// Occupies the given number of 32-bit general-purpose register slots.
96    General(u32),
97    /// A predicate register — does not consume general-purpose registers.
98    Predicate,
99}
100
101// ---------------------------------------------------------------------------
102// PtxRegisterUsage
103// ---------------------------------------------------------------------------
104
105/// Register-file usage of a single PTX kernel, derived from its `.reg`
106/// directives.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
108pub struct PtxRegisterUsage {
109    /// Total 32-bit general-purpose register slots consumed per thread.
110    general_slots: u32,
111    /// Number of declared predicate registers (do not consume GP registers).
112    predicate_count: u32,
113    /// Number of distinct `.reg` directives parsed (diagnostic).
114    directive_count: u32,
115}
116
117impl PtxRegisterUsage {
118    /// Registers consumed per thread, as `ptxas` would report under the
119    /// no-coalescing (`-O0`) lower bound.
120    ///
121    /// This is the value to pass to the CUDA occupancy model and to
122    /// `cuOccupancyMaxActiveBlocksPerMultiprocessorWithFlags`.
123    #[must_use]
124    pub fn registers_per_thread(&self) -> u32 {
125        self.general_slots
126    }
127
128    /// Number of predicate registers declared by the kernel.
129    ///
130    /// Predicates are tracked separately because they do not draw from the
131    /// general-purpose register file that bounds occupancy.
132    #[must_use]
133    pub fn predicate_registers(&self) -> u32 {
134        self.predicate_count
135    }
136
137    /// Number of `.reg` directives that contributed to this usage.
138    #[must_use]
139    pub fn directive_count(&self) -> u32 {
140        self.directive_count
141    }
142
143    /// Fold one parsed `.reg` directive into the running totals.
144    fn add(&mut self, class: RegClass, count: u32) {
145        self.directive_count = self.directive_count.saturating_add(1);
146        match class {
147            RegClass::General(slots) => {
148                self.general_slots = self
149                    .general_slots
150                    .saturating_add(slots.saturating_mul(count));
151            }
152            RegClass::Predicate => {
153                self.predicate_count = self.predicate_count.saturating_add(count);
154            }
155        }
156    }
157}
158
159// ---------------------------------------------------------------------------
160// Parsing of a single `.reg` line
161// ---------------------------------------------------------------------------
162
163/// Parse the vectorised count `<N>` out of a register name token such as
164/// `%f<10>` or `%rd<4>`.  A bare name (`%tid`) declares a single register.
165fn parse_reg_count(name_token: &str) -> Option<u32> {
166    if let Some(open) = name_token.find('<') {
167        let close = name_token[open + 1..].find('>')? + open + 1;
168        let digits = name_token[open + 1..close].trim();
169        digits.parse::<u32>().ok()
170    } else {
171        // No bracket → one register.
172        Some(1)
173    }
174}
175
176/// Attempt to parse a single line as a `.reg` directive, returning the type
177/// class and the declared count.  Returns `None` for any line that is not a
178/// register declaration.
179///
180/// Accepts forms:
181/// * `.reg .f32 %f<10>;`
182/// * `.reg .b32 %r<5>, %q<3>;`  (comma-separated multi-declaration)
183/// * `.reg .pred %p;`           (single register)
184fn parse_reg_line(line: &str) -> Option<Vec<(RegClass, u32)>> {
185    let trimmed = strip_line_comment(line).trim();
186    let rest = trimmed.strip_prefix(".reg")?;
187    // The next token must be the type, beginning with a space then '.'.
188    let rest = rest.trim_start();
189    let mut tokens = rest.split_whitespace();
190    let type_token = tokens.next()?;
191    if !type_token.starts_with('.') {
192        return None;
193    }
194    let class = slots_for_type(type_token);
195
196    // The remainder (after the type) is the comma-separated name list ending
197    // in ';'.  Rebuild it so commas split correctly regardless of spacing.
198    let names_part = rest[type_token.len()..].trim().trim_end_matches(';');
199    if names_part.is_empty() {
200        return None;
201    }
202
203    let mut out = Vec::new();
204    for name in names_part.split(',') {
205        let name = name.trim();
206        if name.is_empty() {
207            continue;
208        }
209        let count = parse_reg_count(name)?;
210        out.push((class, count));
211    }
212    if out.is_empty() { None } else { Some(out) }
213}
214
215/// Remove a trailing `//` line comment, respecting that `//` inside the body
216/// is the only comment form PTX uses on a register line.
217fn strip_line_comment(line: &str) -> &str {
218    match line.find("//") {
219        Some(idx) => &line[..idx],
220        None => line,
221    }
222}
223
224// ---------------------------------------------------------------------------
225// Kernel entry extraction
226// ---------------------------------------------------------------------------
227
228/// One parsed kernel: its mangled entry name plus its register usage.
229#[derive(Debug, Clone)]
230pub struct PtxKernel {
231    name: String,
232    usage: PtxRegisterUsage,
233}
234
235impl PtxKernel {
236    /// The kernel entry name exactly as it appears after `.entry` in the PTX.
237    #[must_use]
238    pub fn name(&self) -> &str {
239        &self.name
240    }
241
242    /// The parsed register-file usage of this kernel.
243    #[must_use]
244    pub fn usage(&self) -> PtxRegisterUsage {
245        self.usage
246    }
247
248    /// Convenience accessor for [`PtxRegisterUsage::registers_per_thread`].
249    #[must_use]
250    pub fn registers_per_thread(&self) -> u32 {
251        self.usage.registers_per_thread()
252    }
253}
254
255/// Extract the entry name that begins at or after `.entry` on `line`.
256///
257/// Handles both `.visible .entry name(` and `.entry name(` and `.entry name {`.
258fn extract_entry_name(after_entry: &str) -> Option<String> {
259    let s = after_entry.trim_start();
260    // The name runs until the first '(' , whitespace, or '{'.
261    let end = s
262        .find(|c: char| c == '(' || c == '{' || c.is_whitespace())
263        .unwrap_or(s.len());
264    let name = &s[..end];
265    if name.is_empty() {
266        None
267    } else {
268        Some(name.to_string())
269    }
270}
271
272// ---------------------------------------------------------------------------
273// OccupancyFromPtx
274// ---------------------------------------------------------------------------
275
276/// Parses register usage out of a PTX string and wires it into the CPU-side
277/// occupancy model.
278///
279/// Construct with [`OccupancyFromPtx::parse`], then either inspect per-kernel
280/// register counts via [`OccupancyFromPtx::kernel`] or compute an occupancy
281/// estimate directly with [`OccupancyFromPtx::estimate_for`].
282#[derive(Debug, Clone, Default)]
283pub struct OccupancyFromPtx {
284    kernels: Vec<PtxKernel>,
285}
286
287impl OccupancyFromPtx {
288    /// Parse all kernels and their `.reg` directives out of a PTX module.
289    ///
290    /// Register declarations are attributed to the most recently seen
291    /// `.entry`.  Directives that appear *before* any `.entry` (module-scope
292    /// `.reg`, which PTX permits for globals) are ignored for occupancy
293    /// purposes because they do not occupy per-thread registers.
294    ///
295    /// # Errors
296    ///
297    /// Returns [`CudaError::InvalidValue`] if the PTX contains no `.entry`
298    /// directive at all (there is nothing whose occupancy could be modelled).
299    pub fn parse(ptx: &str) -> CudaResult<Self> {
300        let mut kernels: Vec<PtxKernel> = Vec::new();
301        let mut current: Option<usize> = None;
302        let mut brace_depth: i32 = 0;
303
304        for raw_line in ptx.lines() {
305            let line = strip_line_comment(raw_line);
306
307            // Detect a new `.entry` (with optional `.visible`/`.weak` prefix).
308            if let Some(pos) = line.find(".entry") {
309                let after = &line[pos + ".entry".len()..];
310                if let Some(name) = extract_entry_name(after) {
311                    kernels.push(PtxKernel {
312                        name,
313                        usage: PtxRegisterUsage::default(),
314                    });
315                    current = Some(kernels.len() - 1);
316                }
317            }
318
319            // Track brace nesting so we know when a kernel body ends.  A
320            // kernel's `.reg` directives only live at brace_depth >= 1 inside
321            // its body; once we return to depth 0 the kernel is closed.
322            let opens = line.matches('{').count() as i32;
323            let closes = line.matches('}').count() as i32;
324
325            // Accumulate `.reg` directives for the current kernel while inside
326            // a body.
327            if current.is_some() {
328                if let Some(decls) = parse_reg_line(line) {
329                    if let Some(idx) = current {
330                        for (class, count) in decls {
331                            kernels[idx].usage.add(class, count);
332                        }
333                    }
334                }
335            }
336
337            brace_depth += opens;
338            brace_depth -= closes;
339            if brace_depth <= 0 {
340                brace_depth = 0;
341                // Body closed — stop attributing further `.reg` lines to it,
342                // but only once we have actually entered a body (opens seen).
343                if closes > 0 {
344                    current = None;
345                }
346            }
347        }
348
349        if kernels.is_empty() {
350            return Err(CudaError::InvalidValue);
351        }
352        Ok(Self { kernels })
353    }
354
355    /// All kernels discovered in the PTX, in source order.
356    #[must_use]
357    pub fn kernels(&self) -> &[PtxKernel] {
358        &self.kernels
359    }
360
361    /// Look up a kernel by its entry name.
362    #[must_use]
363    pub fn kernel(&self, name: &str) -> Option<&PtxKernel> {
364        self.kernels.iter().find(|k| k.name == name)
365    }
366
367    /// Register usage for the named kernel, or `None` if it is absent.
368    #[must_use]
369    pub fn register_usage(&self, name: &str) -> Option<PtxRegisterUsage> {
370        self.kernel(name).map(PtxKernel::usage)
371    }
372
373    /// Estimate occupancy for one kernel on a synthetic device of the given
374    /// compute capability, using the register count parsed from the PTX.
375    ///
376    /// This is the offline equivalent of
377    /// `cuOccupancyMaxActiveBlocksPerMultiprocessorWithFlags`: the parsed
378    /// `.reg` count is substituted for the figure `ptxas` would otherwise
379    /// supply.
380    ///
381    /// # Parameters
382    ///
383    /// * `kernel_name` — entry name to model.
384    /// * `sm_major` / `sm_minor` — target compute capability (e.g. `9, 0`).
385    /// * `block_size` — threads per block.
386    /// * `shared_memory` — dynamic shared memory per block in bytes.
387    ///
388    /// # Errors
389    ///
390    /// Returns [`CudaError::NotFound`] if `kernel_name` is not present.
391    pub fn estimate_for(
392        &self,
393        kernel_name: &str,
394        sm_major: u32,
395        sm_minor: u32,
396        block_size: u32,
397        shared_memory: u32,
398    ) -> CudaResult<OccupancyEstimate> {
399        let kernel = self.kernel(kernel_name).ok_or(CudaError::NotFound)?;
400        let info = DeviceOccupancyInfo::for_compute_capability(sm_major, sm_minor);
401        let calc = OccupancyCalculator::new(info);
402        Ok(calc.estimate_occupancy(block_size, kernel.registers_per_thread(), shared_memory))
403    }
404
405    /// Estimate occupancy for one kernel against an explicit device
406    /// description, for callers that already hold a [`DeviceOccupancyInfo`].
407    ///
408    /// # Errors
409    ///
410    /// Returns [`CudaError::NotFound`] if `kernel_name` is not present.
411    pub fn estimate_with_info(
412        &self,
413        kernel_name: &str,
414        info: DeviceOccupancyInfo,
415        block_size: u32,
416        shared_memory: u32,
417    ) -> CudaResult<OccupancyEstimate> {
418        let kernel = self.kernel(kernel_name).ok_or(CudaError::NotFound)?;
419        let calc = OccupancyCalculator::new(info);
420        Ok(calc.estimate_occupancy(block_size, kernel.registers_per_thread(), shared_memory))
421    }
422}
423
424// ---------------------------------------------------------------------------
425// Tests
426// ---------------------------------------------------------------------------
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use crate::occupancy_ext::LimitingFactor;
432
433    const SAXPY: &str = r#"
434.version 8.0
435.target sm_90
436.address_size 64
437
438.visible .entry saxpy(
439    .param .u64 saxpy_param_0
440)
441{
442    .reg .f32   %f<8>;
443    .reg .b32   %r<4>;
444    .reg .pred  %p<2>;
445    ret;
446}
447"#;
448
449    #[test]
450    fn parse_reg_count_handles_brackets_and_bare() {
451        assert_eq!(parse_reg_count("%f<10>"), Some(10));
452        assert_eq!(parse_reg_count("%rd<4>"), Some(4));
453        assert_eq!(parse_reg_count("%tid"), Some(1));
454        assert_eq!(parse_reg_count("%p<3>"), Some(3));
455    }
456
457    #[test]
458    fn parse_reg_count_rejects_garbage() {
459        assert_eq!(parse_reg_count("%f<abc>"), None);
460        assert_eq!(parse_reg_count("%f<"), None);
461    }
462
463    #[test]
464    fn slots_for_type_matches_register_widths() {
465        assert_eq!(slots_for_type(".f32"), RegClass::General(1));
466        assert_eq!(slots_for_type(".b32"), RegClass::General(1));
467        assert_eq!(slots_for_type(".f64"), RegClass::General(2));
468        assert_eq!(slots_for_type(".u64"), RegClass::General(2));
469        assert_eq!(slots_for_type(".b128"), RegClass::General(4));
470        assert_eq!(slots_for_type(".pred"), RegClass::Predicate);
471        // works without the leading dot too
472        assert_eq!(slots_for_type("f64"), RegClass::General(2));
473    }
474
475    #[test]
476    fn parse_reg_line_single_typed() {
477        let decls = parse_reg_line(".reg .f32 %f<8>;").expect("should parse");
478        assert_eq!(decls, vec![(RegClass::General(1), 8)]);
479    }
480
481    #[test]
482    fn parse_reg_line_multi_declaration() {
483        let decls = parse_reg_line(".reg .b32 %r<4>, %q<3>;").expect("should parse");
484        assert_eq!(
485            decls,
486            vec![(RegClass::General(1), 4), (RegClass::General(1), 3)]
487        );
488    }
489
490    #[test]
491    fn parse_reg_line_strips_comment() {
492        let decls = parse_reg_line(".reg .f64 %fd<2>; // doubles").expect("should parse");
493        assert_eq!(decls, vec![(RegClass::General(2), 2)]);
494    }
495
496    #[test]
497    fn parse_reg_line_rejects_non_reg() {
498        assert!(parse_reg_line(".visible .entry foo() {").is_none());
499        assert!(parse_reg_line("ret;").is_none());
500        assert!(parse_reg_line(".version 8.0").is_none());
501    }
502
503    #[test]
504    fn register_usage_sums_general_slots() {
505        let from_ptx = OccupancyFromPtx::parse(SAXPY).expect("valid PTX");
506        let usage = from_ptx.register_usage("saxpy").expect("kernel present");
507        // 8 f32 (1 slot) + 4 b32 (1 slot) = 12 general; 2 predicates separate.
508        assert_eq!(usage.registers_per_thread(), 12);
509        assert_eq!(usage.predicate_registers(), 2);
510        assert_eq!(usage.directive_count(), 3);
511    }
512
513    #[test]
514    fn f64_registers_count_double() {
515        let ptx = r#"
516.visible .entry dgemm() {
517    .reg .f64 %fd<10>;
518    .reg .b32 %r<2>;
519    ret;
520}
521"#;
522        let from_ptx = OccupancyFromPtx::parse(ptx).expect("valid");
523        // 10 f64 * 2 slots = 20, + 2 b32 = 22.
524        assert_eq!(from_ptx.kernel("dgemm").unwrap().registers_per_thread(), 22);
525    }
526
527    #[test]
528    fn predicates_do_not_consume_general_registers() {
529        let ptx = r#"
530.visible .entry only_preds() {
531    .reg .pred %p<16>;
532    ret;
533}
534"#;
535        let from_ptx = OccupancyFromPtx::parse(ptx).expect("valid");
536        let k = from_ptx.kernel("only_preds").unwrap();
537        assert_eq!(k.registers_per_thread(), 0);
538        assert_eq!(k.usage().predicate_registers(), 16);
539    }
540
541    #[test]
542    fn multiple_kernels_attributed_separately() {
543        let ptx = r#"
544.visible .entry ka() {
545    .reg .f32 %f<4>;
546    ret;
547}
548.visible .entry kb() {
549    .reg .f32 %f<16>;
550    .reg .b32 %r<8>;
551    ret;
552}
553"#;
554        let from_ptx = OccupancyFromPtx::parse(ptx).expect("valid");
555        assert_eq!(from_ptx.kernels().len(), 2);
556        assert_eq!(from_ptx.kernel("ka").unwrap().registers_per_thread(), 4);
557        assert_eq!(from_ptx.kernel("kb").unwrap().registers_per_thread(), 24);
558    }
559
560    #[test]
561    fn module_scope_reg_before_entry_ignored() {
562        // A `.reg` outside any kernel body must not be attributed to a kernel.
563        let ptx = r#"
564.reg .b32 %global_dummy<4>;
565.visible .entry k() {
566    .reg .f32 %f<2>;
567    ret;
568}
569"#;
570        let from_ptx = OccupancyFromPtx::parse(ptx).expect("valid");
571        assert_eq!(from_ptx.kernel("k").unwrap().registers_per_thread(), 2);
572    }
573
574    #[test]
575    fn parse_errors_when_no_entry() {
576        let ptx = ".version 8.0\n.target sm_70\n";
577        assert!(matches!(
578            OccupancyFromPtx::parse(ptx),
579            Err(CudaError::InvalidValue)
580        ));
581    }
582
583    #[test]
584    fn estimate_for_produces_nonzero_occupancy() {
585        let from_ptx = OccupancyFromPtx::parse(SAXPY).expect("valid");
586        let est = from_ptx
587            .estimate_for("saxpy", 9, 0, 256, 0)
588            .expect("kernel present");
589        assert!(est.occupancy_ratio > 0.0);
590        assert!(est.active_warps_per_sm <= est.max_warps_per_sm);
591    }
592
593    #[test]
594    fn estimate_for_unknown_kernel_errors() {
595        let from_ptx = OccupancyFromPtx::parse(SAXPY).expect("valid");
596        assert!(matches!(
597            from_ptx.estimate_for("nope", 9, 0, 256, 0),
598            Err(CudaError::NotFound)
599        ));
600    }
601
602    #[test]
603    fn high_register_pressure_lowers_occupancy() {
604        // A kernel hogging 128 registers/thread should be register-limited on
605        // an sm_86 device at a 256-thread block.
606        let heavy_ptx = r#"
607.visible .entry heavy() {
608    .reg .b32 %r<128>;
609    ret;
610}
611"#;
612        let heavy = OccupancyFromPtx::parse(heavy_ptx).expect("valid");
613        let est_heavy = heavy.estimate_for("heavy", 8, 6, 256, 0).unwrap();
614        assert_eq!(est_heavy.limiting_factor, LimitingFactor::Registers);
615
616        // A light kernel (a handful of registers) on the same device / block
617        // size must NOT be register-limited.
618        let light_ptx = r#"
619.visible .entry light() {
620    .reg .b32 %r<8>;
621    ret;
622}
623"#;
624        let light = OccupancyFromPtx::parse(light_ptx).expect("valid");
625        let est_light = light
626            .estimate_with_info(
627                "light",
628                DeviceOccupancyInfo::for_compute_capability(8, 6),
629                256,
630                0,
631            )
632            .unwrap();
633        assert_ne!(est_light.limiting_factor, LimitingFactor::Registers);
634        // And the heavy kernel achieves strictly lower occupancy than light.
635        assert!(est_heavy.occupancy_ratio < est_light.occupancy_ratio);
636    }
637
638    #[test]
639    fn visible_and_plain_entry_both_parsed() {
640        let ptx = r#"
641.entry plain() {
642    .reg .f32 %f<3>;
643    ret;
644}
645"#;
646        let from_ptx = OccupancyFromPtx::parse(ptx).expect("valid");
647        assert_eq!(from_ptx.kernel("plain").unwrap().registers_per_thread(), 3);
648    }
649}