Skip to main content

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}