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}