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}