Skip to main content

oxicuda_graph/
capture.rs

1//! Stream-capture modeling — a CPU-side model of CUDA stream capture.
2//!
3//! CUDA lets a program record a sequence of asynchronous operations issued
4//! into a stream and turn them into a graph via
5//! `cudaStreamBeginCapture` / `cudaStreamEndCapture`. During capture, every
6//! operation enqueued on the stream becomes a graph node, and inter-stream
7//! dependencies introduced through events (`cudaEventRecord` /
8//! `cudaStreamWaitEvent`) become graph edges — this is how a captured graph
9//! reconstructs the fork / join parallelism of multi-stream code.
10//!
11//! This module provides a *pure-CPU* model of that mechanism. No GPU is
12//! required: it is a state machine that tracks per-stream capture status,
13//! records the operations issued into each captured stream, threads
14//! dependencies through events, and finally emits a [`ComputeGraph`]. The
15//! resulting graph can then be analysed, optimised, and (on real hardware)
16//! lowered to a driver graph exactly like a hand-built graph.
17//!
18//! # State machine
19//!
20//! ```text
21//!                 begin_capture(s)
22//!     None  ───────────────────────────▶  Active
23//!      ▲                                     │
24//!      │  end_capture(s) -> ComputeGraph     │  record_*/wait_event
25//!      └─────────────────────────────────────┘
26//!                                            │
27//!               (illegal op, e.g. join cycle)│
28//!                                            ▼
29//!                                       Invalidated
30//! ```
31//!
32//! A captured stream that is forced into the `Invalidated` state (for
33//! example, because a join would have introduced a cycle, or because an
34//! operation was issued on a stream that was never begun) can no longer be
35//! ended successfully: [`StreamCapture::end_capture`] returns an error and
36//! the partially-built graph is discarded.
37
38use std::collections::HashMap;
39
40use crate::error::{GraphError, GraphResult};
41use crate::graph::ComputeGraph;
42use crate::node::{GraphNode, MemcpyDir, NodeId, NodeKind, StreamId};
43
44// ---------------------------------------------------------------------------
45// CaptureStatus
46// ---------------------------------------------------------------------------
47
48/// Capture status of a single stream, mirroring `cudaStreamCaptureStatus`.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum CaptureStatus {
51    /// The stream is not currently capturing.
52    None,
53    /// The stream is actively capturing operations into the graph.
54    Active,
55    /// Capture was invalidated by an illegal operation; the graph is unusable.
56    Invalidated,
57}
58
59impl std::fmt::Display for CaptureStatus {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            Self::None => write!(f, "none"),
63            Self::Active => write!(f, "active"),
64            Self::Invalidated => write!(f, "invalidated"),
65        }
66    }
67}
68
69// ---------------------------------------------------------------------------
70// CaptureEvent
71// ---------------------------------------------------------------------------
72
73/// Opaque identifier for an event used to thread cross-stream dependencies.
74///
75/// Mirrors a `cudaEvent_t` that is recorded on one stream and waited on by
76/// another to reconstruct fork / join parallelism in a captured graph.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
78pub struct CaptureEvent(pub u32);
79
80impl std::fmt::Display for CaptureEvent {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        write!(f, "E{}", self.0)
83    }
84}
85
86// ---------------------------------------------------------------------------
87// StreamCapture
88// ---------------------------------------------------------------------------
89
90/// A CPU-side model of one or more concurrently-capturing CUDA streams.
91///
92/// Operations are recorded into a single underlying [`ComputeGraph`]; the
93/// capture session tracks, per stream, the most recently recorded node so it
94/// can wire in the implicit sequential dependency between successive ops on
95/// the same stream. Events bridge dependencies across streams.
96///
97/// # Example
98///
99/// ```
100/// use oxicuda_graph::capture::{StreamCapture, CaptureEvent, CaptureStatus};
101/// use oxicuda_graph::node::{MemcpyDir, StreamId};
102///
103/// let mut cap = StreamCapture::new();
104/// let main = StreamId(0);
105/// let side = StreamId(1);
106///
107/// cap.begin_capture(main).unwrap();
108/// cap.begin_capture(side).unwrap();
109/// // origin op on the main stream
110/// let up = cap.record_memcpy(main, "upload", MemcpyDir::HostToDevice, 4096).unwrap();
111///
112/// // fork: side stream waits for an event recorded on the main stream
113/// let ev = CaptureEvent(0);
114/// cap.record_event(main, ev).unwrap();
115/// cap.wait_event(side, ev).unwrap();
116/// let k = cap.record_kernel(side, "relu", 4, 256, 0).unwrap();
117///
118/// // join back into the main stream
119/// let ev2 = CaptureEvent(1);
120/// cap.record_event(side, ev2).unwrap();
121/// cap.wait_event(main, ev2).unwrap();
122/// let dn = cap.record_memcpy(main, "download", MemcpyDir::DeviceToHost, 4096).unwrap();
123///
124/// assert_eq!(cap.status(main), CaptureStatus::Active);
125/// let graph = cap.end_capture(main).unwrap();
126/// // up → k (via fork) and k → dn (via join) must hold.
127/// assert!(graph.is_reachable(up, k));
128/// assert!(graph.is_reachable(k, dn));
129/// ```
130#[derive(Debug, Default)]
131pub struct StreamCapture {
132    /// The graph being assembled.
133    graph: ComputeGraph,
134    /// Per-stream capture status.
135    status: HashMap<StreamId, CaptureStatus>,
136    /// Per-stream most-recently-recorded node (for sequential ordering).
137    last_on_stream: HashMap<StreamId, NodeId>,
138    /// Event → node that last "recorded" it (the node a later wait depends on).
139    event_source: HashMap<CaptureEvent, NodeId>,
140    /// Cross-stream waits not yet attached to a node (stream → source nodes).
141    ///
142    /// When a stream waits on an event while it has no head node, the
143    /// dependency is parked here and attached to the next op recorded on that
144    /// stream.
145    pending_waits: HashMap<StreamId, Vec<NodeId>>,
146    /// Whether any stream entered the Invalidated state.
147    poisoned: bool,
148}
149
150impl StreamCapture {
151    /// Creates an empty capture session with no streams capturing.
152    #[must_use]
153    pub fn new() -> Self {
154        Self::default()
155    }
156
157    /// Returns the capture status of `stream`.
158    ///
159    /// A stream that was never begun reports [`CaptureStatus::None`].
160    #[must_use]
161    pub fn status(&self, stream: StreamId) -> CaptureStatus {
162        self.status
163            .get(&stream)
164            .copied()
165            .unwrap_or(CaptureStatus::None)
166    }
167
168    /// Returns `true` if any stream in this session is actively capturing.
169    #[must_use]
170    pub fn is_capturing(&self) -> bool {
171        self.status.values().any(|s| *s == CaptureStatus::Active)
172    }
173
174    /// Begins capture on `stream`.
175    ///
176    /// # Errors
177    ///
178    /// Returns [`GraphError::Internal`] if `stream` is already capturing.
179    pub fn begin_capture(&mut self, stream: StreamId) -> GraphResult<()> {
180        match self.status(stream) {
181            CaptureStatus::Active => Err(GraphError::Internal(format!(
182                "stream {stream} is already capturing"
183            ))),
184            _ => {
185                self.status.insert(stream, CaptureStatus::Active);
186                Ok(())
187            }
188        }
189    }
190
191    /// Records a kernel-launch operation on a capturing stream.
192    ///
193    /// Returns the [`NodeId`] of the created node.
194    ///
195    /// # Errors
196    ///
197    /// Returns [`GraphError::Internal`] if `stream` is not actively capturing.
198    pub fn record_kernel(
199        &mut self,
200        stream: StreamId,
201        function_name: &str,
202        num_blocks: u32,
203        threads_per_block: u32,
204        shared_mem: u32,
205    ) -> GraphResult<NodeId> {
206        let kind = NodeKind::KernelLaunch {
207            function_name: function_name.to_owned(),
208            config: crate::node::KernelConfig::linear(num_blocks, threads_per_block, shared_mem),
209            fusible: false,
210        };
211        self.record_op(stream, kind, function_name)
212    }
213
214    /// Records a memcpy operation on a capturing stream.
215    ///
216    /// # Errors
217    ///
218    /// Returns [`GraphError::Internal`] if `stream` is not actively capturing.
219    pub fn record_memcpy(
220        &mut self,
221        stream: StreamId,
222        name: &str,
223        dir: MemcpyDir,
224        size_bytes: usize,
225    ) -> GraphResult<NodeId> {
226        self.record_op(stream, NodeKind::Memcpy { dir, size_bytes }, name)
227    }
228
229    /// Records a memset operation on a capturing stream.
230    ///
231    /// # Errors
232    ///
233    /// Returns [`GraphError::Internal`] if `stream` is not actively capturing.
234    pub fn record_memset(
235        &mut self,
236        stream: StreamId,
237        name: &str,
238        size_bytes: usize,
239        value: u8,
240    ) -> GraphResult<NodeId> {
241        self.record_op(stream, NodeKind::Memset { size_bytes, value }, name)
242    }
243
244    /// Records a host-callback operation on a capturing stream.
245    ///
246    /// # Errors
247    ///
248    /// Returns [`GraphError::Internal`] if `stream` is not actively capturing.
249    pub fn record_host_callback(&mut self, stream: StreamId, label: &str) -> GraphResult<NodeId> {
250        self.record_op(
251            stream,
252            NodeKind::HostCallback {
253                label: label.to_owned(),
254            },
255            label,
256        )
257    }
258
259    /// Internal: appends a node, wiring the sequential edge from the previous
260    /// op on the same stream.
261    fn record_op(&mut self, stream: StreamId, kind: NodeKind, name: &str) -> GraphResult<NodeId> {
262        self.ensure_active(stream)?;
263        let node = GraphNode::new(NodeId(0), kind)
264            .with_name(name)
265            .with_stream(stream);
266        let id = self.graph.add_node(node);
267        // Cross-stream waits parked before this op was issued attach first, so
268        // the join dependency precedes the sequential predecessor.
269        self.drain_pending_waits(stream, id)?;
270        // Sequential ordering within the stream.
271        if let Some(&prev) = self.last_on_stream.get(&stream) {
272            // Within one stream the order is linear, so this never cycles.
273            self.graph.add_edge(prev, id)?;
274        }
275        self.last_on_stream.insert(stream, id);
276        Ok(id)
277    }
278
279    /// Records `event` on `stream`, capturing the current head node so that a
280    /// later [`wait_event`](Self::wait_event) can depend on it.
281    ///
282    /// This is the "fork" half of fork / join capture. Recording an event on
283    /// a stream that has issued no operations is a no-op source (the event
284    /// carries no dependency).
285    ///
286    /// # Errors
287    ///
288    /// Returns [`GraphError::Internal`] if `stream` is not actively capturing.
289    pub fn record_event(&mut self, stream: StreamId, event: CaptureEvent) -> GraphResult<()> {
290        self.ensure_active(stream)?;
291        if let Some(&head) = self.last_on_stream.get(&stream) {
292            self.event_source.insert(event, head);
293        }
294        Ok(())
295    }
296
297    /// Makes `stream` wait on `event`, threading the cross-stream dependency.
298    ///
299    /// This is the "join" half of fork / join capture: every operation
300    /// recorded on `stream` *after* this wait depends on the node that
301    /// recorded `event`. In CUDA semantics a stream-wait gates only subsequent
302    /// work, so the dependency is parked and attached to the next op recorded
303    /// on `stream` (it never re-orders operations already issued on the
304    /// stream).
305    ///
306    /// A cycle is only detectable once the dependency is materialised against
307    /// the next recorded op; see [`record_kernel`](Self::record_kernel) and
308    /// friends.
309    ///
310    /// # Errors
311    ///
312    /// Returns [`GraphError::Internal`] if `stream` is not actively capturing.
313    pub fn wait_event(&mut self, stream: StreamId, event: CaptureEvent) -> GraphResult<()> {
314        self.ensure_active(stream)?;
315        let Some(&source) = self.event_source.get(&event) else {
316            // Waiting on an event that was never recorded carries no
317            // dependency (mirrors CUDA's "completed event" semantics).
318            return Ok(());
319        };
320        // Park the dependency so the *next* op recorded on this stream inherits
321        // it. A stream-wait gates only subsequent work, never ops already
322        // issued, so we deliberately do not touch the current head.
323        self.pending_waits.entry(stream).or_default().push(source);
324        Ok(())
325    }
326
327    /// Ends capture on `stream`, returning the assembled [`ComputeGraph`].
328    ///
329    /// Consumes the entire capture session: a graph is captured once.
330    ///
331    /// # Errors
332    ///
333    /// * [`GraphError::Internal`] if `stream` was not actively capturing.
334    /// * [`GraphError::ValidationFailed`] (via [`GraphError::Internal`]) if any
335    ///   stream was invalidated during capture.
336    pub fn end_capture(mut self, stream: StreamId) -> GraphResult<ComputeGraph> {
337        match self.status(stream) {
338            CaptureStatus::Active => {}
339            CaptureStatus::Invalidated => {
340                return Err(GraphError::Internal(
341                    "capture was invalidated by an illegal operation".to_owned(),
342                ));
343            }
344            CaptureStatus::None => {
345                return Err(GraphError::Internal(format!(
346                    "stream {stream} is not capturing"
347                )));
348            }
349        }
350        if self.poisoned {
351            return Err(GraphError::Internal(
352                "capture was invalidated on another stream in the session".to_owned(),
353            ));
354        }
355        self.status.insert(stream, CaptureStatus::None);
356        Ok(std::mem::take(&mut self.graph))
357    }
358
359    /// Returns the number of nodes recorded so far.
360    #[must_use]
361    pub fn node_count(&self) -> usize {
362        self.graph.node_count()
363    }
364
365    /// Forcibly invalidates capture on `stream`, modelling an illegal
366    /// operation that CUDA would reject (e.g. a disallowed cross-stream
367    /// synchronisation, or unsafe memory operation issued during capture).
368    ///
369    /// An invalidated stream can no longer record ops or be ended successfully;
370    /// the whole session is poisoned to mirror CUDA's behaviour where one
371    /// stream's invalidation aborts the capture.
372    ///
373    /// # Errors
374    ///
375    /// Returns [`GraphError::Internal`] if `stream` is not actively capturing.
376    pub fn invalidate(&mut self, stream: StreamId) -> GraphResult<()> {
377        self.ensure_active(stream)?;
378        self.status.insert(stream, CaptureStatus::Invalidated);
379        self.poisoned = true;
380        Ok(())
381    }
382
383    /// Internal: validates the stream is actively capturing, also draining any
384    /// pending cross-stream waits into the next op.
385    fn ensure_active(&self, stream: StreamId) -> GraphResult<()> {
386        match self.status(stream) {
387            CaptureStatus::Active => Ok(()),
388            CaptureStatus::Invalidated => Err(GraphError::Internal(format!(
389                "stream {stream} capture is invalidated"
390            ))),
391            CaptureStatus::None => Err(GraphError::Internal(format!(
392                "stream {stream} is not capturing"
393            ))),
394        }
395    }
396
397    /// Drains the pending cross-stream waits for `stream`, wiring `source → id`
398    /// for the freshly created node `id`. Any wait that would form a cycle
399    /// invalidates the stream and poisons the session.
400    fn drain_pending_waits(&mut self, stream: StreamId, id: NodeId) -> GraphResult<()> {
401        if let Some(sources) = self.pending_waits.remove(&stream) {
402            for source in sources {
403                if source != id {
404                    if let Err(e) = self.graph.add_edge(source, id) {
405                        self.status.insert(stream, CaptureStatus::Invalidated);
406                        self.poisoned = true;
407                        return Err(e);
408                    }
409                }
410            }
411        }
412        Ok(())
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::node::{MemcpyDir, StreamId};
420
421    fn s(n: u32) -> StreamId {
422        StreamId(n)
423    }
424
425    #[test]
426    fn fresh_stream_is_not_capturing() {
427        let cap = StreamCapture::new();
428        assert_eq!(cap.status(s(0)), CaptureStatus::None);
429        assert!(!cap.is_capturing());
430    }
431
432    #[test]
433    fn begin_sets_active() {
434        let mut cap = StreamCapture::new();
435        cap.begin_capture(s(0)).expect("begin capture");
436        assert_eq!(cap.status(s(0)), CaptureStatus::Active);
437        assert!(cap.is_capturing());
438    }
439
440    #[test]
441    fn double_begin_rejected() {
442        let mut cap = StreamCapture::new();
443        cap.begin_capture(s(0)).expect("begin capture");
444        assert!(cap.begin_capture(s(0)).is_err());
445    }
446
447    #[test]
448    fn record_on_uncaptured_stream_rejected() {
449        let mut cap = StreamCapture::new();
450        assert!(cap.record_kernel(s(0), "k", 1, 32, 0).is_err());
451    }
452
453    #[test]
454    fn sequential_ops_are_chained() {
455        let mut cap = StreamCapture::new();
456        cap.begin_capture(s(0)).expect("begin");
457        let a = cap.record_kernel(s(0), "a", 1, 32, 0).expect("a");
458        let b = cap.record_kernel(s(0), "b", 1, 32, 0).expect("b");
459        let c = cap.record_kernel(s(0), "c", 1, 32, 0).expect("c");
460        let g = cap.end_capture(s(0)).expect("end");
461        // Linear chain a→b→c.
462        assert!(g.is_reachable(a, b));
463        assert!(g.is_reachable(b, c));
464        assert!(g.is_reachable(a, c));
465        assert!(!g.is_reachable(c, a));
466        assert_eq!(g.edge_count(), 2);
467    }
468
469    #[test]
470    fn fork_join_reconstructs_dependencies() {
471        let mut cap = StreamCapture::new();
472        let main = s(0);
473        let side = s(1);
474        cap.begin_capture(main).expect("begin main");
475        let up = cap
476            .record_memcpy(main, "up", MemcpyDir::HostToDevice, 1024)
477            .expect("up");
478        // fork main → side
479        cap.begin_capture(side).expect("begin side");
480        let ev = CaptureEvent(0);
481        cap.record_event(main, ev).expect("record ev");
482        cap.wait_event(side, ev).expect("wait ev");
483        let k = cap.record_kernel(side, "k", 1, 32, 0).expect("k");
484        // join side → main
485        let ev2 = CaptureEvent(1);
486        cap.record_event(side, ev2).expect("record ev2");
487        cap.wait_event(main, ev2).expect("wait ev2");
488        let dn = cap
489            .record_memcpy(main, "dn", MemcpyDir::DeviceToHost, 1024)
490            .expect("dn");
491        let g = cap.end_capture(main).expect("end");
492        assert!(g.is_reachable(up, k), "fork dependency up→k missing");
493        assert!(g.is_reachable(k, dn), "join dependency k→dn missing");
494    }
495
496    #[test]
497    fn wait_on_unrecorded_event_is_noop() {
498        let mut cap = StreamCapture::new();
499        cap.begin_capture(s(0)).expect("begin");
500        // No event recorded yet.
501        assert!(cap.wait_event(s(0), CaptureEvent(9)).is_ok());
502        let g = cap.end_capture(s(0)).expect("end");
503        assert_eq!(g.edge_count(), 0);
504    }
505
506    #[test]
507    fn end_without_begin_errs() {
508        let cap = StreamCapture::new();
509        assert!(cap.end_capture(s(0)).is_err());
510    }
511
512    #[test]
513    fn pending_wait_applies_to_next_op() {
514        // side waits before issuing any op; the dependency must attach to the
515        // first op recorded afterwards.
516        let mut cap = StreamCapture::new();
517        let main = s(0);
518        let side = s(1);
519        cap.begin_capture(main).expect("begin main");
520        cap.begin_capture(side).expect("begin side");
521        let a = cap.record_kernel(main, "a", 1, 32, 0).expect("a");
522        let ev = CaptureEvent(0);
523        cap.record_event(main, ev).expect("rec");
524        cap.wait_event(side, ev).expect("wait"); // side has no head yet
525        let b = cap.record_kernel(side, "b", 1, 32, 0).expect("b");
526        let g = cap.end_capture(main).expect("end");
527        assert!(g.is_reachable(a, b), "pending wait a→b not applied");
528    }
529
530    #[test]
531    fn status_display() {
532        assert_eq!(CaptureStatus::None.to_string(), "none");
533        assert_eq!(CaptureStatus::Active.to_string(), "active");
534        assert_eq!(CaptureStatus::Invalidated.to_string(), "invalidated");
535    }
536
537    #[test]
538    fn event_display() {
539        assert_eq!(CaptureEvent(3).to_string(), "E3");
540    }
541
542    #[test]
543    fn explicit_invalidation_aborts_capture() {
544        let mut cap = StreamCapture::new();
545        let main = s(0);
546        cap.begin_capture(main).expect("begin main");
547        cap.record_kernel(main, "a", 1, 32, 0).expect("a");
548        cap.invalidate(main).expect("invalidate");
549        assert_eq!(cap.status(main), CaptureStatus::Invalidated);
550        // Further ops are rejected.
551        assert!(cap.record_kernel(main, "b", 1, 32, 0).is_err());
552        // The capture cannot be ended successfully.
553        assert!(
554            cap.end_capture(main).is_err(),
555            "invalidated capture cannot end"
556        );
557    }
558
559    #[test]
560    fn invalidation_poisons_other_streams() {
561        // Invalidating one stream poisons the whole session: a second,
562        // still-active stream also fails to end.
563        let mut cap = StreamCapture::new();
564        let main = s(0);
565        let side = s(1);
566        cap.begin_capture(main).expect("begin main");
567        cap.begin_capture(side).expect("begin side");
568        cap.record_kernel(side, "x", 1, 32, 0).expect("x");
569        cap.invalidate(main).expect("invalidate main");
570        assert_eq!(cap.status(side), CaptureStatus::Active);
571        assert!(
572            cap.end_capture(side).is_err(),
573            "poisoned session cannot end"
574        );
575    }
576
577    #[test]
578    fn node_count_tracks_recorded_ops() {
579        let mut cap = StreamCapture::new();
580        cap.begin_capture(s(0)).expect("begin");
581        cap.record_kernel(s(0), "a", 1, 32, 0).expect("a");
582        cap.record_memset(s(0), "z", 64, 0).expect("z");
583        assert_eq!(cap.node_count(), 2);
584    }
585}