oxicuda_driver/cupti_stubs.rs
1//! CUPTI-lite — runtime-loaded CUDA Profiling Tools Interface stubs.
2//!
3//! The CUDA Profiling Tools Interface (`libcupti.so`) ships alongside the
4//! driver and exposes the *Activity API* — an asynchronous, buffer-based
5//! firehose of timestamped records (kernel launches, memcpys, API calls) that
6//! tools like Nsight Systems consume. Just like [`crate::loader`] loads the
7//! driver itself, this module loads CUPTI **at runtime** via
8//! [`libloading`](https://crates.io/crates/libloading) — no `cupti.h`, no
9//! link-time dependency — and resolves the three pillars of the Activity API:
10//!
11//! | Symbol | Role |
12//! |------------------------------------|-----------------------------------------|
13//! | `cuptiActivityEnable` | turn a record kind on/off |
14//! | `cuptiActivityRegisterCallbacks` | hand CUPTI buffer-alloc / drain hooks |
15//! | `cuptiActivityFlushAll` | force completion of pending buffers |
16//!
17//! # Two layers
18//!
19//! 1. **`CuptiLibrary`** — the libloading layer. Searches the platform's
20//! CUPTI library names and resolves the activity entry points. On a host
21//! without CUPTI installed (every CI box here) `CuptiLibrary::load`
22//! returns a [`DriverLoadError`](crate::error::DriverLoadError), which is the honest, testable behaviour —
23//! we never fabricate a "profiler attached" success.
24//!
25//! 2. **[`ActivitySession`]** — a host-side behavioural model of the Activity
26//! API's buffer protocol. CUPTI's double-buffer ownership transfer (it asks
27//! the tool for a buffer, fills it, hands it back on flush) is a precise
28//! state machine that is fully deterministic and therefore CPU-testable
29//! without any GPU or CUPTI present. This is what lets the rest of OxiCUDA
30//! unit-test profiling logic offline.
31
32#[cfg(not(target_os = "macos"))]
33use crate::error::DriverLoadError;
34#[cfg(not(target_os = "macos"))]
35use libloading::Library;
36
37// ---------------------------------------------------------------------------
38// CuptiActivityKind
39// ---------------------------------------------------------------------------
40
41/// The category of activity record requested via `cuptiActivityEnable`.
42///
43/// Mirrors the most commonly used members of the C enum `CUpti_ActivityKind`.
44/// The discriminants match the CUPTI header so a future hardware-backed path
45/// can pass them straight through.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47#[repr(u32)]
48pub enum CuptiActivityKind {
49 /// Memory copies between host and device (`CUPTI_ACTIVITY_KIND_MEMCPY`).
50 Memcpy = 1,
51 /// Device-side memory sets (`CUPTI_ACTIVITY_KIND_MEMSET`).
52 Memset = 2,
53 /// Kernel executions (`CUPTI_ACTIVITY_KIND_KERNEL`).
54 Kernel = 3,
55 /// Driver API call durations (`CUPTI_ACTIVITY_KIND_DRIVER`).
56 Driver = 4,
57 /// Runtime API call durations (`CUPTI_ACTIVITY_KIND_RUNTIME`).
58 Runtime = 5,
59 /// Concurrent kernel executions (`CUPTI_ACTIVITY_KIND_CONCURRENT_KERNEL`).
60 ConcurrentKernel = 6,
61 /// CUDA stream / device / context names (`CUPTI_ACTIVITY_KIND_NAME`).
62 Name = 7,
63 /// User / driver markers (`CUPTI_ACTIVITY_KIND_MARKER`).
64 Marker = 8,
65 /// Stream-ordered memory pool operations
66 /// (`CUPTI_ACTIVITY_KIND_MEMORY_POOL`).
67 MemoryPool = 9,
68}
69
70impl CuptiActivityKind {
71 /// The raw `CUpti_ActivityKind` discriminant.
72 #[must_use]
73 pub fn raw(self) -> u32 {
74 self as u32
75 }
76}
77
78// ---------------------------------------------------------------------------
79// CuptiLibrary — the libloading layer
80// ---------------------------------------------------------------------------
81
82/// Candidate file names for the CUPTI shared library, in search order.
83///
84/// CUPTI is versioned with the CUDA major release; we try the unversioned
85/// `SONAME` first, then a spread of recent CUDA majors.
86#[cfg(not(target_os = "macos"))]
87const CUPTI_LIBRARY_NAMES: &[&str] = &[
88 "libcupti.so",
89 "libcupti.so.12",
90 "libcupti.so.11",
91 "cupti64_2025.1.0.dll",
92 "cupti64_2024.1.0.dll",
93];
94
95/// A runtime-loaded handle to `libcupti.so` exposing the Activity API.
96///
97/// Loading is entirely lazy and fallible: there is no CUPTI on a machine
98/// without the CUDA toolkit profiling components, in which case
99/// [`CuptiLibrary::load`] returns an `Err`. The handle keeps the [`Library`]
100/// alive for the lifetime of the resolved function pointers.
101#[cfg(not(target_os = "macos"))]
102pub struct CuptiLibrary {
103 /// Keeps the dynamic library mapped; the resolved fn pointers borrow it.
104 _lib: Library,
105 has_activity_enable: bool,
106 has_register_callbacks: bool,
107 has_flush_all: bool,
108}
109
110#[cfg(not(target_os = "macos"))]
111impl CuptiLibrary {
112 /// Attempt to load CUPTI and confirm the Activity API symbols are present.
113 ///
114 /// # Errors
115 ///
116 /// * [`DriverLoadError::LibraryNotFound`] if no CUPTI library can be
117 /// opened (the common case on a host without the profiling components).
118 /// * [`DriverLoadError::SymbolNotFound`] if the library opens but is
119 /// missing a required Activity API entry point.
120 pub fn load() -> Result<Self, DriverLoadError> {
121 let mut last_error = String::new();
122 let lib = 'open: {
123 for name in CUPTI_LIBRARY_NAMES {
124 // SAFETY: opening a shared library runs its initialisers; CUPTI
125 // is designed to be dlopen'd by profiling tools.
126 match unsafe { Library::new(*name) } {
127 Ok(lib) => break 'open lib,
128 Err(e) => last_error = e.to_string(),
129 }
130 }
131 return Err(DriverLoadError::LibraryNotFound {
132 candidates: CUPTI_LIBRARY_NAMES
133 .iter()
134 .map(|s| (*s).to_string())
135 .collect(),
136 last_error,
137 });
138 };
139
140 // Probe the three Activity API symbols. We only record presence here;
141 // calling them requires the matching C ABI, which is out of scope for
142 // the host model and gated behind real hardware.
143 let has_activity_enable = Self::has_symbol(&lib, b"cuptiActivityEnable");
144 let has_register_callbacks = Self::has_symbol(&lib, b"cuptiActivityRegisterCallbacks");
145 let has_flush_all = Self::has_symbol(&lib, b"cuptiActivityFlushAll");
146
147 if !has_activity_enable {
148 return Err(DriverLoadError::SymbolNotFound {
149 symbol: "cuptiActivityEnable",
150 reason: "symbol absent from loaded CUPTI library".to_string(),
151 });
152 }
153
154 Ok(Self {
155 _lib: lib,
156 has_activity_enable,
157 has_register_callbacks,
158 has_flush_all,
159 })
160 }
161
162 /// Whether a named symbol resolves in the given library.
163 fn has_symbol(lib: &Library, name: &[u8]) -> bool {
164 // SAFETY: we never call the resolved pointer here; we only test for
165 // its existence. The fn signature is a placeholder of the right shape.
166 unsafe { lib.get::<unsafe extern "C" fn()>(name) }.is_ok()
167 }
168
169 /// Whether `cuptiActivityEnable` resolved.
170 #[must_use]
171 pub fn supports_activity_enable(&self) -> bool {
172 self.has_activity_enable
173 }
174
175 /// Whether `cuptiActivityRegisterCallbacks` resolved.
176 #[must_use]
177 pub fn supports_register_callbacks(&self) -> bool {
178 self.has_register_callbacks
179 }
180
181 /// Whether `cuptiActivityFlushAll` resolved.
182 #[must_use]
183 pub fn supports_flush_all(&self) -> bool {
184 self.has_flush_all
185 }
186}
187
188/// Convenience: attempt to load CUPTI, returning whether it is available.
189///
190/// Never panics; on any platform without CUPTI it simply returns `false`.
191#[must_use]
192pub fn cupti_available() -> bool {
193 #[cfg(not(target_os = "macos"))]
194 {
195 CuptiLibrary::load().is_ok()
196 }
197 #[cfg(target_os = "macos")]
198 {
199 false
200 }
201}
202
203// ---------------------------------------------------------------------------
204// ActivityRecord — the modelled record payload
205// ---------------------------------------------------------------------------
206
207/// A single timestamped activity record, the host-model analogue of one
208/// `CUpti_Activity*` struct drained from a completed buffer.
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub struct ActivityRecord {
211 /// The record category.
212 pub kind: CuptiActivityKind,
213 /// Start timestamp in nanoseconds (device clock domain).
214 pub start_ns: u64,
215 /// End timestamp in nanoseconds.
216 pub end_ns: u64,
217 /// Correlation id linking this record to its launching API call.
218 pub correlation_id: u32,
219}
220
221impl ActivityRecord {
222 /// Duration of the activity in nanoseconds (`end - start`, saturating).
223 #[must_use]
224 pub fn duration_ns(&self) -> u64 {
225 self.end_ns.saturating_sub(self.start_ns)
226 }
227}
228
229// ---------------------------------------------------------------------------
230// ActivitySession — host-side Activity-buffer state machine
231// ---------------------------------------------------------------------------
232
233/// Errors specific to the modelled Activity buffer protocol.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
235pub enum CuptiActivityError {
236 /// An operation requires callbacks that were never registered.
237 #[error("activity buffer callbacks not registered")]
238 CallbacksNotRegistered,
239 /// A record kind was emitted while disabled (CUPTI would silently drop it).
240 #[error("activity kind {kind:?} is not enabled")]
241 KindNotEnabled {
242 /// The kind that was rejected.
243 kind: CuptiActivityKind,
244 },
245 /// The pending buffer is full and must be flushed before more records fit.
246 #[error("activity buffer full ({capacity} records); flush required")]
247 BufferFull {
248 /// The fixed record capacity of one buffer.
249 capacity: usize,
250 },
251}
252
253/// A host-side model of CUPTI's asynchronous Activity buffer protocol.
254///
255/// The real flow is: the tool registers a *requested* callback (CUPTI calls it
256/// to obtain an empty buffer) and a *completed* callback (CUPTI calls it to
257/// hand back a full buffer for draining), enables one or more
258/// [`CuptiActivityKind`]s, runs GPU work (which fills buffers), then calls
259/// `cuptiActivityFlushAll` to force any partial buffer to be completed.
260///
261/// This struct reproduces that ownership dance deterministically: `record`
262/// fills the pending buffer, `flush` (and a full buffer) moves records into the
263/// *completed* queue which [`ActivitySession::drain`] empties — modelling the
264/// completed-buffer callback delivering records to the tool.
265#[derive(Debug)]
266pub struct ActivitySession {
267 enabled: Vec<CuptiActivityKind>,
268 callbacks_registered: bool,
269 buffer_capacity: usize,
270 pending: Vec<ActivityRecord>,
271 completed: Vec<ActivityRecord>,
272 dropped: u64,
273}
274
275impl ActivitySession {
276 /// Default per-buffer record capacity, echoing CUPTI's habit of using a
277 /// few-MB buffer that holds many fixed-size records.
278 pub const DEFAULT_BUFFER_CAPACITY: usize = 256;
279
280 /// Create an idle session with no kinds enabled and no callbacks set.
281 #[must_use]
282 pub fn new() -> Self {
283 Self::with_buffer_capacity(Self::DEFAULT_BUFFER_CAPACITY)
284 }
285
286 /// Create a session with an explicit per-buffer record capacity.
287 #[must_use]
288 pub fn with_buffer_capacity(buffer_capacity: usize) -> Self {
289 Self {
290 enabled: Vec::new(),
291 callbacks_registered: false,
292 buffer_capacity: buffer_capacity.max(1),
293 pending: Vec::new(),
294 completed: Vec::new(),
295 dropped: 0,
296 }
297 }
298
299 /// Model `cuptiActivityRegisterCallbacks`: install the buffer request /
300 /// complete hooks. Idempotent.
301 pub fn register_callbacks(&mut self) {
302 self.callbacks_registered = true;
303 }
304
305 /// Whether buffer callbacks have been registered.
306 #[must_use]
307 pub fn callbacks_registered(&self) -> bool {
308 self.callbacks_registered
309 }
310
311 /// Model `cuptiActivityEnable(kind)`: turn a record kind on.
312 ///
313 /// # Errors
314 ///
315 /// [`CuptiActivityError::CallbacksNotRegistered`] — CUPTI rejects enabling
316 /// activity before buffer callbacks exist (there would be nowhere to put
317 /// the records).
318 pub fn enable(&mut self, kind: CuptiActivityKind) -> Result<(), CuptiActivityError> {
319 if !self.callbacks_registered {
320 return Err(CuptiActivityError::CallbacksNotRegistered);
321 }
322 if !self.enabled.contains(&kind) {
323 self.enabled.push(kind);
324 }
325 Ok(())
326 }
327
328 /// Model `cuptiActivityDisable(kind)`: turn a record kind off.
329 pub fn disable(&mut self, kind: CuptiActivityKind) {
330 self.enabled.retain(|k| *k != kind);
331 }
332
333 /// Whether the given kind is currently enabled.
334 #[must_use]
335 pub fn is_enabled(&self, kind: CuptiActivityKind) -> bool {
336 self.enabled.contains(&kind)
337 }
338
339 /// The set of currently enabled kinds.
340 #[must_use]
341 pub fn enabled_kinds(&self) -> &[CuptiActivityKind] {
342 &self.enabled
343 }
344
345 /// Emit one activity record into the pending buffer.
346 ///
347 /// Models the GPU filling a buffer obtained from the requested callback.
348 /// If the record's kind is not enabled it is silently dropped (CUPTI does
349 /// the same) and counted in [`ActivitySession::dropped_records`].
350 ///
351 /// # Errors
352 ///
353 /// * [`CuptiActivityError::CallbacksNotRegistered`] if no callbacks exist.
354 /// * [`CuptiActivityError::BufferFull`] if the pending buffer is already at
355 /// capacity — the caller must [`ActivitySession::flush`] first.
356 pub fn record(&mut self, record: ActivityRecord) -> Result<(), CuptiActivityError> {
357 if !self.callbacks_registered {
358 return Err(CuptiActivityError::CallbacksNotRegistered);
359 }
360 if !self.enabled.contains(&record.kind) {
361 self.dropped = self.dropped.saturating_add(1);
362 return Ok(());
363 }
364 if self.pending.len() >= self.buffer_capacity {
365 return Err(CuptiActivityError::BufferFull {
366 capacity: self.buffer_capacity,
367 });
368 }
369 self.pending.push(record);
370 Ok(())
371 }
372
373 /// Model `cuptiActivityFlushAll`: move every pending record into the
374 /// completed queue, as CUPTI does when it hands a full / forced buffer to
375 /// the completed callback. Returns the number of records flushed.
376 ///
377 /// # Errors
378 ///
379 /// [`CuptiActivityError::CallbacksNotRegistered`] if no callbacks exist —
380 /// `cuptiActivityFlushAll` is meaningless without a completed callback to
381 /// deliver buffers to.
382 pub fn flush(&mut self) -> Result<usize, CuptiActivityError> {
383 if !self.callbacks_registered {
384 return Err(CuptiActivityError::CallbacksNotRegistered);
385 }
386 let n = self.pending.len();
387 self.completed.append(&mut self.pending);
388 Ok(n)
389 }
390
391 /// Drain the completed queue, returning all records delivered to the tool
392 /// since the last drain. Models the tool consuming the completed buffer.
393 pub fn drain(&mut self) -> Vec<ActivityRecord> {
394 std::mem::take(&mut self.completed)
395 }
396
397 /// Number of records currently waiting in the (un-flushed) pending buffer.
398 #[must_use]
399 pub fn pending_len(&self) -> usize {
400 self.pending.len()
401 }
402
403 /// Number of records flushed and waiting to be drained.
404 #[must_use]
405 pub fn completed_len(&self) -> usize {
406 self.completed.len()
407 }
408
409 /// Count of records dropped because their kind was disabled.
410 #[must_use]
411 pub fn dropped_records(&self) -> u64 {
412 self.dropped
413 }
414}
415
416impl Default for ActivitySession {
417 fn default() -> Self {
418 Self::new()
419 }
420}
421
422// ---------------------------------------------------------------------------
423// Tests
424// ---------------------------------------------------------------------------
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 fn rec(kind: CuptiActivityKind, start: u64, end: u64, cid: u32) -> ActivityRecord {
431 ActivityRecord {
432 kind,
433 start_ns: start,
434 end_ns: end,
435 correlation_id: cid,
436 }
437 }
438
439 #[test]
440 fn activity_kind_raw_discriminants() {
441 assert_eq!(CuptiActivityKind::Memcpy.raw(), 1);
442 assert_eq!(CuptiActivityKind::Kernel.raw(), 3);
443 assert_eq!(CuptiActivityKind::ConcurrentKernel.raw(), 6);
444 assert_eq!(CuptiActivityKind::MemoryPool.raw(), 9);
445 }
446
447 #[test]
448 fn cupti_available_does_not_panic() {
449 // On CI there is no libcupti; this must return false (or true on a box
450 // that happens to have it) without panicking.
451 let _ = cupti_available();
452 }
453
454 #[cfg(not(target_os = "macos"))]
455 #[test]
456 fn load_without_cupti_yields_library_not_found() {
457 // The CI host has no CUPTI; the load path must fail honestly with
458 // LibraryNotFound rather than fabricating a session.
459 match CuptiLibrary::load() {
460 Err(DriverLoadError::LibraryNotFound { candidates, .. }) => {
461 assert!(candidates.iter().any(|c| c.contains("cupti")));
462 }
463 Err(DriverLoadError::SymbolNotFound { .. }) => {
464 // Acceptable if a stub libcupti exists but lacks symbols.
465 }
466 Ok(lib) => {
467 // If CUPTI genuinely exists, enable must have resolved.
468 assert!(lib.supports_activity_enable());
469 }
470 Err(other) => panic!("unexpected error variant: {other:?}"),
471 }
472 }
473
474 #[test]
475 fn record_requires_registered_callbacks() {
476 let mut s = ActivitySession::new();
477 let r = rec(CuptiActivityKind::Kernel, 0, 100, 1);
478 assert_eq!(s.record(r), Err(CuptiActivityError::CallbacksNotRegistered));
479 }
480
481 #[test]
482 fn enable_requires_registered_callbacks() {
483 let mut s = ActivitySession::new();
484 assert_eq!(
485 s.enable(CuptiActivityKind::Kernel),
486 Err(CuptiActivityError::CallbacksNotRegistered)
487 );
488 }
489
490 #[test]
491 fn flush_requires_registered_callbacks() {
492 let mut s = ActivitySession::new();
493 assert_eq!(s.flush(), Err(CuptiActivityError::CallbacksNotRegistered));
494 }
495
496 #[test]
497 fn full_enable_record_flush_drain_cycle() {
498 let mut s = ActivitySession::new();
499 s.register_callbacks();
500 s.enable(CuptiActivityKind::Kernel).unwrap();
501 s.enable(CuptiActivityKind::Memcpy).unwrap();
502
503 s.record(rec(CuptiActivityKind::Kernel, 0, 500, 1)).unwrap();
504 s.record(rec(CuptiActivityKind::Memcpy, 500, 700, 2))
505 .unwrap();
506 assert_eq!(s.pending_len(), 2);
507 assert_eq!(s.completed_len(), 0);
508
509 let flushed = s.flush().unwrap();
510 assert_eq!(flushed, 2);
511 assert_eq!(s.pending_len(), 0);
512 assert_eq!(s.completed_len(), 2);
513
514 let records = s.drain();
515 assert_eq!(records.len(), 2);
516 assert_eq!(records[0].duration_ns(), 500);
517 assert_eq!(records[1].duration_ns(), 200);
518 assert_eq!(s.completed_len(), 0);
519 }
520
521 #[test]
522 fn disabled_kind_is_dropped_and_counted() {
523 let mut s = ActivitySession::new();
524 s.register_callbacks();
525 s.enable(CuptiActivityKind::Kernel).unwrap();
526 // Memcpy is NOT enabled → dropped.
527 s.record(rec(CuptiActivityKind::Memcpy, 0, 10, 1)).unwrap();
528 s.record(rec(CuptiActivityKind::Kernel, 0, 10, 2)).unwrap();
529 assert_eq!(s.pending_len(), 1);
530 assert_eq!(s.dropped_records(), 1);
531 }
532
533 #[test]
534 fn disable_stops_recording() {
535 let mut s = ActivitySession::new();
536 s.register_callbacks();
537 s.enable(CuptiActivityKind::Kernel).unwrap();
538 assert!(s.is_enabled(CuptiActivityKind::Kernel));
539 s.disable(CuptiActivityKind::Kernel);
540 assert!(!s.is_enabled(CuptiActivityKind::Kernel));
541 s.record(rec(CuptiActivityKind::Kernel, 0, 10, 1)).unwrap();
542 assert_eq!(s.pending_len(), 0);
543 assert_eq!(s.dropped_records(), 1);
544 }
545
546 #[test]
547 fn buffer_full_requires_flush() {
548 let mut s = ActivitySession::with_buffer_capacity(2);
549 s.register_callbacks();
550 s.enable(CuptiActivityKind::Kernel).unwrap();
551 s.record(rec(CuptiActivityKind::Kernel, 0, 1, 1)).unwrap();
552 s.record(rec(CuptiActivityKind::Kernel, 1, 2, 2)).unwrap();
553 // Third record overflows the 2-slot buffer.
554 assert_eq!(
555 s.record(rec(CuptiActivityKind::Kernel, 2, 3, 3)),
556 Err(CuptiActivityError::BufferFull { capacity: 2 })
557 );
558 // After flushing, there is room again.
559 s.flush().unwrap();
560 s.record(rec(CuptiActivityKind::Kernel, 2, 3, 3)).unwrap();
561 assert_eq!(s.pending_len(), 1);
562 }
563
564 #[test]
565 fn enable_is_idempotent() {
566 let mut s = ActivitySession::new();
567 s.register_callbacks();
568 s.enable(CuptiActivityKind::Kernel).unwrap();
569 s.enable(CuptiActivityKind::Kernel).unwrap();
570 assert_eq!(s.enabled_kinds().len(), 1);
571 }
572
573 #[test]
574 fn register_callbacks_is_idempotent() {
575 let mut s = ActivitySession::new();
576 s.register_callbacks();
577 s.register_callbacks();
578 assert!(s.callbacks_registered());
579 }
580
581 #[test]
582 fn buffer_capacity_floors_at_one() {
583 let s = ActivitySession::with_buffer_capacity(0);
584 // capacity 0 would be unusable; it must floor to 1.
585 assert_eq!(s.pending_len(), 0);
586 let mut s = s;
587 s.register_callbacks();
588 s.enable(CuptiActivityKind::Kernel).unwrap();
589 s.record(rec(CuptiActivityKind::Kernel, 0, 1, 1)).unwrap();
590 assert_eq!(
591 s.record(rec(CuptiActivityKind::Kernel, 1, 2, 2)),
592 Err(CuptiActivityError::BufferFull { capacity: 1 })
593 );
594 }
595
596 #[test]
597 fn duration_saturates_on_inverted_timestamps() {
598 let r = rec(CuptiActivityKind::Kernel, 100, 50, 1);
599 assert_eq!(r.duration_ns(), 0);
600 }
601}