oxicuda_driver/graph.rs
1//! CUDA Graph API for recording and replaying sequences of GPU operations.
2//!
3//! CUDA Graphs allow capturing a sequence of operations (kernel launches,
4//! memory copies, memsets) into a graph data structure that can be
5//! instantiated and launched repeatedly with minimal CPU overhead.
6//!
7//! # Architecture
8//!
9//! This module exposes a Rust-side graph representation that records
10//! operations as nodes with explicit dependency edges. [`Graph::instantiate`]
11//! translates that representation into the native CUDA Graph API
12//! (`cuGraphCreate` + `cuGraphInstantiate`) whenever a CUDA driver is
13//! available, and [`GraphExec::launch`] issues a real `cuGraphLaunch`. On macOS
14//! (or any host without a driver) the graph is still built and validated
15//! CPU-side, and launching reports [`CudaError::NotInitialized`].
16//!
17//! # Node lowering (important)
18//!
19//! A [`GraphNode`] stores only an operation *specification* — a kernel name,
20//! copy direction/size, or memset size/value — and carries **no** resolved
21//! `CUfunction` or device pointers. Kernel / memcpy / memset nodes therefore
22//! cannot yet be lowered to their real `cuGraphAddKernelNode` /
23//! `cuGraphAddMemcpyNode` / `cuGraphAddMemsetNode` form: they are added as
24//! `cuGraphAddEmptyNode` barriers. The instantiated graph faithfully
25//! reproduces the node count and dependency **topology**, but a `cuGraphLaunch`
26//! performs none of those operations' work (`instantiate` logs a
27//! `tracing::warn!` when any such node is lowered). Only genuine
28//! [`GraphNode::Empty`] barriers are represented exactly.
29//!
30//! # Example
31//!
32//! ```rust,no_run
33//! # use oxicuda_driver::graph::{Graph, GraphNode, MemcpyDirection};
34//! let mut graph = Graph::new();
35//!
36//! let n0 = graph.add_memcpy_node(MemcpyDirection::HostToDevice, 4096);
37//! let n1 = graph.add_kernel_node(
38//! "vector_add",
39//! (4, 1, 1),
40//! (256, 1, 1),
41//! 0,
42//! );
43//! let n2 = graph.add_memcpy_node(MemcpyDirection::DeviceToHost, 4096);
44//!
45//! graph.add_dependency(n0, n1).ok();
46//! graph.add_dependency(n1, n2).ok();
47//!
48//! assert_eq!(graph.node_count(), 3);
49//! assert_eq!(graph.dependency_count(), 2);
50//! ```
51
52use crate::error::{CudaError, CudaResult};
53use crate::stream::Stream;
54
55// ---------------------------------------------------------------------------
56// GraphNode — individual operation in a graph
57// ---------------------------------------------------------------------------
58
59/// Direction of a memory copy operation within a graph node.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub enum MemcpyDirection {
62 /// Host to device transfer.
63 HostToDevice,
64 /// Device to host transfer.
65 DeviceToHost,
66 /// Device to device transfer.
67 DeviceToDevice,
68}
69
70impl std::fmt::Display for MemcpyDirection {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 Self::HostToDevice => write!(f, "HtoD"),
74 Self::DeviceToHost => write!(f, "DtoH"),
75 Self::DeviceToDevice => write!(f, "DtoD"),
76 }
77 }
78}
79
80/// A single operation node within a [`Graph`].
81///
82/// Each variant represents a different type of GPU operation that can
83/// be recorded into a graph.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum GraphNode {
86 /// A kernel launch with grid/block configuration.
87 KernelLaunch {
88 /// Name of the kernel function.
89 function_name: String,
90 /// Grid dimensions `(x, y, z)`.
91 grid: (u32, u32, u32),
92 /// Block dimensions `(x, y, z)`.
93 block: (u32, u32, u32),
94 /// Dynamic shared memory in bytes.
95 shared_mem: u32,
96 },
97 /// A memory copy operation.
98 Memcpy {
99 /// Direction of the copy.
100 direction: MemcpyDirection,
101 /// Size of the transfer in bytes.
102 size: usize,
103 },
104 /// A memset operation (fill device memory with a byte value).
105 Memset {
106 /// Number of bytes to set.
107 size: usize,
108 /// Byte value to fill with.
109 value: u8,
110 },
111 /// An empty/no-op node used as a synchronisation barrier.
112 Empty,
113}
114
115impl std::fmt::Display for GraphNode {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 match self {
118 Self::KernelLaunch {
119 function_name,
120 grid,
121 block,
122 shared_mem,
123 } => write!(
124 f,
125 "Kernel({}, grid=({},{},{}), block=({},{},{}), smem={})",
126 function_name, grid.0, grid.1, grid.2, block.0, block.1, block.2, shared_mem,
127 ),
128 Self::Memcpy { direction, size } => {
129 write!(f, "Memcpy({direction}, {size} bytes)")
130 }
131 Self::Memset { size, value } => {
132 write!(f, "Memset({size} bytes, value=0x{value:02x})")
133 }
134 Self::Empty => write!(f, "Empty"),
135 }
136 }
137}
138
139// ---------------------------------------------------------------------------
140// Graph — collection of nodes with dependency edges
141// ---------------------------------------------------------------------------
142
143/// A CUDA graph representing a DAG of GPU operations.
144///
145/// Nodes represent individual operations (kernel launches, memory copies,
146/// memsets, or empty barriers). Dependencies are directed edges that
147/// enforce execution ordering between nodes.
148///
149/// The graph can be instantiated into a [`GraphExec`] for repeated
150/// low-overhead execution.
151#[derive(Debug, Clone)]
152pub struct Graph {
153 nodes: Vec<GraphNode>,
154 dependencies: Vec<(usize, usize)>,
155}
156
157impl Default for Graph {
158 fn default() -> Self {
159 Self::new()
160 }
161}
162
163impl Graph {
164 /// Creates a new empty graph with no nodes or dependencies.
165 pub fn new() -> Self {
166 Self {
167 nodes: Vec::new(),
168 dependencies: Vec::new(),
169 }
170 }
171
172 /// Adds a kernel launch node to the graph.
173 ///
174 /// Returns the index of the newly created node, which can be used
175 /// to establish dependencies via [`add_dependency`](Self::add_dependency).
176 ///
177 /// # Parameters
178 ///
179 /// * `function_name` - Name of the kernel function.
180 /// * `grid` - Grid dimensions `(x, y, z)`.
181 /// * `block` - Block dimensions `(x, y, z)`.
182 /// * `shared_mem` - Dynamic shared memory in bytes.
183 pub fn add_kernel_node(
184 &mut self,
185 function_name: &str,
186 grid: (u32, u32, u32),
187 block: (u32, u32, u32),
188 shared_mem: u32,
189 ) -> usize {
190 let idx = self.nodes.len();
191 self.nodes.push(GraphNode::KernelLaunch {
192 function_name: function_name.to_owned(),
193 grid,
194 block,
195 shared_mem,
196 });
197 idx
198 }
199
200 /// Adds a memory copy node to the graph.
201 ///
202 /// Returns the index of the newly created node.
203 ///
204 /// # Parameters
205 ///
206 /// * `direction` - Direction of the memory copy.
207 /// * `size` - Size of the transfer in bytes.
208 pub fn add_memcpy_node(&mut self, direction: MemcpyDirection, size: usize) -> usize {
209 let idx = self.nodes.len();
210 self.nodes.push(GraphNode::Memcpy { direction, size });
211 idx
212 }
213
214 /// Adds a memset node to the graph.
215 ///
216 /// Returns the index of the newly created node.
217 ///
218 /// # Parameters
219 ///
220 /// * `size` - Number of bytes to set.
221 /// * `value` - Byte value to fill with.
222 pub fn add_memset_node(&mut self, size: usize, value: u8) -> usize {
223 let idx = self.nodes.len();
224 self.nodes.push(GraphNode::Memset { size, value });
225 idx
226 }
227
228 /// Adds an empty (no-op) node to the graph.
229 ///
230 /// Empty nodes are useful as synchronisation barriers — they have
231 /// no work of their own but can serve as join points for multiple
232 /// dependency chains.
233 ///
234 /// Returns the index of the newly created node.
235 pub fn add_empty_node(&mut self) -> usize {
236 let idx = self.nodes.len();
237 self.nodes.push(GraphNode::Empty);
238 idx
239 }
240
241 /// Adds a dependency edge from node `from` to node `to`.
242 ///
243 /// This means `to` will not begin execution until `from` has
244 /// completed. Both indices must refer to existing nodes.
245 ///
246 /// # Errors
247 ///
248 /// Returns [`CudaError::InvalidValue`] if either index is out of bounds
249 /// or if `from == to` (self-dependency).
250 pub fn add_dependency(&mut self, from: usize, to: usize) -> CudaResult<()> {
251 if from >= self.nodes.len() || to >= self.nodes.len() {
252 return Err(CudaError::InvalidValue);
253 }
254 if from == to {
255 return Err(CudaError::InvalidValue);
256 }
257 self.dependencies.push((from, to));
258 Ok(())
259 }
260
261 /// Returns the total number of nodes in the graph.
262 #[inline]
263 pub fn node_count(&self) -> usize {
264 self.nodes.len()
265 }
266
267 /// Returns the total number of dependency edges in the graph.
268 #[inline]
269 pub fn dependency_count(&self) -> usize {
270 self.dependencies.len()
271 }
272
273 /// Returns a slice of all nodes in insertion order.
274 #[inline]
275 pub fn nodes(&self) -> &[GraphNode] {
276 &self.nodes
277 }
278
279 /// Returns a slice of all dependency edges as `(from, to)` pairs.
280 #[inline]
281 pub fn dependencies(&self) -> &[(usize, usize)] {
282 &self.dependencies
283 }
284
285 /// Returns the node at the given index, or `None` if out of bounds.
286 pub fn get_node(&self, index: usize) -> Option<&GraphNode> {
287 self.nodes.get(index)
288 }
289
290 /// Performs a topological sort of the graph nodes.
291 ///
292 /// Returns the node indices in an order that respects all
293 /// dependency edges, or an error if the graph contains a cycle.
294 ///
295 /// # Errors
296 ///
297 /// Returns [`CudaError::InvalidValue`] if the graph contains a
298 /// dependency cycle.
299 pub fn topological_sort(&self) -> CudaResult<Vec<usize>> {
300 let n = self.nodes.len();
301 let mut in_degree = vec![0u32; n];
302 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
303
304 for &(from, to) in &self.dependencies {
305 adj[from].push(to);
306 in_degree[to] = in_degree[to].saturating_add(1);
307 }
308
309 let mut queue: Vec<usize> = (0..n).filter(|&i| in_degree[i] == 0).collect();
310 let mut result = Vec::with_capacity(n);
311
312 while let Some(node) = queue.pop() {
313 result.push(node);
314 for &next in &adj[node] {
315 in_degree[next] = in_degree[next].saturating_sub(1);
316 if in_degree[next] == 0 {
317 queue.push(next);
318 }
319 }
320 }
321
322 if result.len() != n {
323 return Err(CudaError::InvalidValue);
324 }
325
326 Ok(result)
327 }
328
329 /// Instantiates the graph into an executable form.
330 ///
331 /// The returned [`GraphExec`] can be launched on a stream with minimal
332 /// CPU overhead. The graph is always validated (topological sort)
333 /// during instantiation.
334 ///
335 /// When a CUDA driver is available, a genuine `CUgraph` is built
336 /// (`cuGraphCreate` + per-node `cuGraphAdd*Node` with the dependency DAG
337 /// wired through real `CUgraphNode` edges) and finalised into a
338 /// `CUgraphExec` via `cuGraphInstantiate`; [`GraphExec::launch`] then
339 /// issues a real `cuGraphLaunch`. Without a driver (macOS, or a host
340 /// with no GPU) the `GraphExec` is CPU-side only and `launch` reports
341 /// [`CudaError::NotInitialized`].
342 ///
343 /// # Errors
344 ///
345 /// * [`CudaError::InvalidValue`] if the graph contains a dependency
346 /// cycle.
347 /// * Any [`CudaError`] mapped from a failing `cuGraph*` driver call
348 /// when a driver is present (e.g. [`CudaError::OutOfMemory`]).
349 pub fn instantiate(&self) -> CudaResult<GraphExec> {
350 // Validate the graph is a DAG by performing a topological sort.
351 // This must succeed regardless of driver availability.
352 let execution_order = self.topological_sort()?;
353
354 // Attempt a real driver-backed instantiation. Fall back to a
355 // CPU-side-only GraphExec for environmental reasons — no driver, no
356 // GPU, no current CUDA context, or a driver predating the graph API
357 // — since none of those indicate a malformed graph. A genuine
358 // graph-construction failure (e.g. OutOfMemory, InvalidValue) is a
359 // real error and propagates to the caller.
360 let (raw_graph, raw_exec) = match self.build_driver_graph() {
361 Ok(handles) => handles,
362 Err(
363 CudaError::NotInitialized
364 | CudaError::NotSupported
365 | CudaError::InvalidContext
366 | CudaError::NoDevice
367 | CudaError::InvalidDevice
368 | CudaError::Deinitialized,
369 ) => (None, None),
370 Err(other) => return Err(other),
371 };
372
373 Ok(GraphExec {
374 graph: self.clone(),
375 execution_order,
376 raw_graph,
377 raw_exec,
378 owner: crate::context::current_ctx_owner(),
379 })
380 }
381
382 /// Build a real CUDA driver graph from this in-memory representation.
383 ///
384 /// Returns `(Some(CUgraph), Some(CUgraphExec))` on success. Returns
385 /// [`CudaError::NotInitialized`] when no driver is loaded and
386 /// [`CudaError::NotSupported`] when the loaded driver predates the CUDA
387 /// Graph API; [`Graph::instantiate`] turns both (and other environmental
388 /// errors) into a CPU-side-only `GraphExec`. Any other error is a
389 /// genuine driver failure.
390 ///
391 /// Each in-memory [`GraphNode`] is translated to a real driver node and
392 /// the dependency edges are reproduced exactly. Nodes are created in
393 /// topological order so that, when `cuGraphAddEmptyNode` is given a
394 /// node's dependency list, every referenced `CUgraphNode` already
395 /// exists — regardless of the order edges were added to the in-memory
396 /// graph. Because [`GraphNode`] stores only an operation specification
397 /// (no resolved `CUfunction` or device pointers), every node is added
398 /// via `cuGraphAddEmptyNode`; the resulting driver graph preserves the
399 /// node count and dependency topology and executes as a DAG of
400 /// synchronisation barriers.
401 fn build_driver_graph(
402 &self,
403 ) -> CudaResult<(Option<crate::ffi::CUgraph>, Option<crate::ffi::CUgraphExec>)> {
404 use crate::ffi::{CUgraph, CUgraphExec, CUgraphNode};
405
406 let api = crate::loader::try_driver()?;
407
408 // Resolve every required graph entry point; a pre-10.0 driver lacks
409 // them and yields a clean NotSupported fallback.
410 let create = api.cu_graph_create.ok_or(CudaError::NotSupported)?;
411 let add_empty = api.cu_graph_add_empty_node.ok_or(CudaError::NotSupported)?;
412 let destroy = api.cu_graph_destroy.ok_or(CudaError::NotSupported)?;
413
414 // A topological order of the in-memory nodes — guaranteed acyclic
415 // because `instantiate` runs `topological_sort` first.
416 let order = self.topological_sort()?;
417
418 // HONESTY: `GraphNode` carries only an operation *specification*
419 // (kernel name, copy direction/size, memset size/value) — no resolved
420 // `CUfunction` or device pointers — so kernel / memcpy / memset nodes
421 // cannot yet be lowered to their real `cuGraphAdd*Node` form. They are
422 // added as `cuGraphAddEmptyNode` barriers, so a subsequent
423 // `cuGraphLaunch` reproduces the DAG *topology* but performs **none** of
424 // their work. Warn loudly so a successful `launch()` is never mistaken
425 // for the nodes' operations having executed.
426 let lowered_nodes = self
427 .nodes
428 .iter()
429 .filter(|n| !matches!(n, GraphNode::Empty))
430 .count();
431 if lowered_nodes > 0 {
432 tracing::warn!(
433 lowered_nodes,
434 "instantiating CUDA graph: {lowered_nodes} kernel/memcpy/memset node(s) \
435 are lowered to empty barriers (cuGraphAddEmptyNode) and will NOT perform \
436 their operation; only the dependency topology executes",
437 );
438 }
439
440 // 1. Create an empty CUgraph.
441 let mut raw_graph = CUgraph::default();
442 // SAFETY: `create` was just resolved from the driver; `raw_graph` is
443 // a valid out-pointer and flags=0 is the only documented value.
444 crate::error::check(unsafe { create(&mut raw_graph, 0) })?;
445
446 // From here on, any failure must destroy `raw_graph` before
447 // returning so the driver-side object does not leak.
448 let build = || -> CudaResult<CUgraphExec> {
449 // 2. Add one real driver node per in-memory node, in topological
450 // order, wiring the incoming dependency edges as we go.
451 // `driver_nodes[idx]` holds the driver handle for in-memory
452 // node `idx` once it has been created.
453 let mut driver_nodes: Vec<Option<CUgraphNode>> = vec![None; self.nodes.len()];
454 for &node_idx in &order {
455 // Collect the driver handles of every node this node depends
456 // on — edges `(from, to)` with `to == node_idx`. In a valid
457 // topological order every `from` precedes `node_idx`, so each
458 // handle is already present.
459 let mut deps: Vec<CUgraphNode> = Vec::new();
460 for &(from, to) in &self.dependencies {
461 if to == node_idx {
462 let handle = driver_nodes
463 .get(from)
464 .copied()
465 .flatten()
466 .ok_or(CudaError::InvalidValue)?;
467 deps.push(handle);
468 }
469 }
470
471 let dep_ptr = if deps.is_empty() {
472 std::ptr::null()
473 } else {
474 deps.as_ptr()
475 };
476
477 let mut driver_node = CUgraphNode::default();
478 // SAFETY: `add_empty` was resolved from the driver;
479 // `driver_node` is a valid out-pointer, `raw_graph` is the
480 // live graph created above, and `dep_ptr`/`deps.len()`
481 // describe a valid (possibly empty) dependency slice whose
482 // handles were all produced by earlier iterations.
483 crate::error::check(unsafe {
484 add_empty(&mut driver_node, raw_graph, dep_ptr, deps.len())
485 })?;
486 driver_nodes[node_idx] = Some(driver_node);
487 }
488
489 // 3. Instantiate the populated graph into an executable form.
490 self.instantiate_driver_graph(api, raw_graph)
491 };
492
493 match build() {
494 Ok(raw_exec) => Ok((Some(raw_graph), Some(raw_exec))),
495 Err(e) => {
496 // SAFETY: `destroy` was resolved from the driver and
497 // `raw_graph` is the live handle created above.
498 let rc = unsafe { destroy(raw_graph) };
499 if rc != 0 {
500 tracing::warn!(
501 cuda_error = rc,
502 "cuGraphDestroy failed while unwinding a failed instantiation"
503 );
504 }
505 Err(e)
506 }
507 }
508 }
509
510 /// Finalise a populated `CUgraph` into an executable `CUgraphExec`.
511 ///
512 /// Prefers `cuGraphInstantiateWithFlags` (CUDA 11.4+) and falls back to
513 /// the legacy `cuGraphInstantiate_v2` signature.
514 fn instantiate_driver_graph(
515 &self,
516 api: &crate::loader::DriverApi,
517 raw_graph: crate::ffi::CUgraph,
518 ) -> CudaResult<crate::ffi::CUgraphExec> {
519 use crate::ffi::CUgraphExec;
520
521 let mut raw_exec = CUgraphExec::default();
522
523 if let Some(instantiate_flags) = api.cu_graph_instantiate_with_flags {
524 // SAFETY: `instantiate_flags` was resolved from the driver;
525 // `raw_exec` is a valid out-pointer, `raw_graph` is a live
526 // populated graph, and flags=0 requests default instantiation.
527 crate::error::check(unsafe { instantiate_flags(&mut raw_exec, raw_graph, 0) })?;
528 return Ok(raw_exec);
529 }
530
531 let instantiate = api.cu_graph_instantiate.ok_or(CudaError::NotSupported)?;
532 // SAFETY: `instantiate` was resolved from the driver; `raw_exec` is a
533 // valid out-pointer, `raw_graph` is a live populated graph, and
534 // passing null error-node / log-buffer pointers with a zero buffer
535 // size is the documented "no diagnostics" configuration.
536 crate::error::check(unsafe {
537 instantiate(
538 &mut raw_exec,
539 raw_graph,
540 std::ptr::null_mut(),
541 std::ptr::null_mut(),
542 0,
543 )
544 })?;
545 Ok(raw_exec)
546 }
547}
548
549impl std::fmt::Display for Graph {
550 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
551 write!(
552 f,
553 "Graph({} nodes, {} deps)",
554 self.nodes.len(),
555 self.dependencies.len()
556 )
557 }
558}
559
560// ---------------------------------------------------------------------------
561// GraphExec — instantiated executable graph
562// ---------------------------------------------------------------------------
563
564/// An instantiated, executable graph.
565///
566/// Created by [`Graph::instantiate`], a `GraphExec` holds a snapshot of the
567/// graph and a pre-computed execution order.
568///
569/// # Driver backing
570///
571/// When a CUDA driver is available, `instantiate` builds a genuine
572/// `CUgraph` (`cuGraphCreate` + one `cuGraphAdd*Node` per in-memory node,
573/// with the dependency DAG wired through real `CUgraphNode` edges) and
574/// finalises it into a `CUgraphExec` via `cuGraphInstantiate`. In that
575/// case [`launch`](Self::launch) issues a real `cuGraphLaunch`.
576///
577/// The in-memory [`GraphNode`] representation stores only an operation
578/// *specification* (kernel name, copy direction/size, memset size/value) —
579/// it carries no resolved `CUfunction` or device pointers. Every node is
580/// therefore translated to a real `cuGraphAddEmptyNode`: the resulting
581/// driver graph reproduces the node count and dependency topology exactly
582/// and executes on the GPU as a DAG of synchronisation barriers. The
583/// per-node dispatch in `Graph::build_driver_graph` is structured so that
584/// kernel / memcpy / memset nodes that gain concrete device operands can be
585/// promoted to `cuGraphAddKernelNode` / `cuGraphAddMemcpyNode` /
586/// `cuGraphAddMemsetNode` without further restructuring.
587///
588/// On macOS (or any host without a CUDA driver), no driver handles are
589/// created; the graph is still validated (topological sort) and
590/// [`launch`](Self::launch) returns [`CudaError::NotInitialized`].
591pub struct GraphExec {
592 graph: Graph,
593 execution_order: Vec<usize>,
594 /// Real `CUgraph` handle, when a driver backed instantiation.
595 raw_graph: Option<crate::ffi::CUgraph>,
596 /// Real `CUgraphExec` handle, when a driver backed instantiation.
597 raw_exec: Option<crate::ffi::CUgraphExec>,
598 /// The context that owned the driver handles at instantiation, used to skip
599 /// the driver destroys if that context was torn down first (avoids a
600 /// use-after-free). `None` when no tracked context was current — see
601 /// [`crate::context::current_ctx_owner`].
602 owner: crate::context::CtxOwner,
603}
604
605impl GraphExec {
606 /// Launches the executable graph on the given stream.
607 ///
608 /// When this `GraphExec` is backed by a real `CUgraphExec`, this issues
609 /// `cuGraphLaunch(hGraphExec, hStream)`, submitting the entire graph to
610 /// the stream with minimal CPU overhead. Otherwise it surfaces the
611 /// driver-load error.
612 ///
613 /// # Errors
614 ///
615 /// * [`CudaError::NotInitialized`] if the CUDA driver is not available
616 /// (e.g. on macOS, or a host without an NVIDIA GPU).
617 /// * Any [`CudaError`] mapped from `cuGraphLaunch`.
618 pub fn launch(&self, stream: &Stream) -> CudaResult<()> {
619 let api = crate::loader::try_driver()?;
620
621 // A driver is present. If instantiation produced a real executable
622 // graph, submit it; otherwise the driver lacks the graph API.
623 let raw_exec = self.raw_exec.ok_or(CudaError::NotSupported)?;
624 let launch = api.cu_graph_launch.ok_or(CudaError::NotSupported)?;
625
626 // SAFETY: `launch` was just resolved from the driver; `raw_exec` is a
627 // live `CUgraphExec` produced by `cuGraphInstantiate` and kept alive
628 // by `self`, and `stream.raw()` is a valid `CUstream`.
629 crate::error::check(unsafe { launch(raw_exec, stream.raw()) })
630 }
631
632 /// Returns a reference to the underlying graph.
633 #[inline]
634 pub fn graph(&self) -> &Graph {
635 &self.graph
636 }
637
638 /// Returns the pre-computed execution order (topological sort).
639 #[inline]
640 pub fn execution_order(&self) -> &[usize] {
641 &self.execution_order
642 }
643
644 /// Returns the total number of nodes that would be executed.
645 #[inline]
646 pub fn node_count(&self) -> usize {
647 self.graph.node_count()
648 }
649
650 /// Returns `true` if this `GraphExec` is backed by a real, live
651 /// `CUgraphExec` driver handle (as opposed to a CPU-side-only graph).
652 #[inline]
653 pub fn is_driver_backed(&self) -> bool {
654 self.raw_exec.is_some()
655 }
656}
657
658impl std::fmt::Debug for GraphExec {
659 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
660 f.debug_struct("GraphExec")
661 .field("graph", &self.graph)
662 .field("execution_order", &self.execution_order)
663 .field("driver_backed", &self.is_driver_backed())
664 .finish()
665 }
666}
667
668impl Drop for GraphExec {
669 fn drop(&mut self) {
670 // Hold the registry lock across the destroys, and skip them entirely if
671 // the owning context was already torn down (its `cuCtxDestroy` already
672 // freed these graph objects — destroying them again would be a
673 // use-after-free).
674 let map = crate::context::lock_live_ctxs();
675 if !crate::context::owner_is_live(&map, self.owner) {
676 return;
677 }
678 // Release driver handles in reverse construction order: the
679 // executable graph first, then the source graph.
680 if let Ok(api) = crate::loader::try_driver() {
681 if let (Some(exec), Some(destroy)) = (self.raw_exec, api.cu_graph_exec_destroy) {
682 // SAFETY: `destroy` was resolved from the driver and `exec`
683 // is a live handle produced by `cuGraphInstantiate`.
684 let rc = unsafe { destroy(exec) };
685 if rc != 0 {
686 tracing::warn!(cuda_error = rc, "cuGraphExecDestroy failed during drop");
687 }
688 }
689 if let (Some(graph), Some(destroy)) = (self.raw_graph, api.cu_graph_destroy) {
690 // SAFETY: `destroy` was resolved from the driver and `graph`
691 // is a live handle produced by `cuGraphCreate`.
692 let rc = unsafe { destroy(graph) };
693 if rc != 0 {
694 tracing::warn!(cuda_error = rc, "cuGraphDestroy failed during drop");
695 }
696 }
697 }
698 }
699}
700
701// ---------------------------------------------------------------------------
702// StreamCapture — capture operations into a graph
703// ---------------------------------------------------------------------------
704
705/// Records GPU operations submitted to a stream into a [`Graph`].
706///
707/// Stream capture intercepts operations that would normally be submitted
708/// to a CUDA stream and instead records them as graph nodes. The captured
709/// operations can then be replayed efficiently via [`GraphExec`].
710///
711/// # Usage
712///
713/// ```rust,no_run
714/// # use oxicuda_driver::graph::{StreamCapture, MemcpyDirection};
715/// # use oxicuda_driver::stream::Stream;
716/// # use std::sync::Arc;
717/// # use oxicuda_driver::context::Context;
718/// # fn main() -> oxicuda_driver::CudaResult<()> {
719/// # let ctx: Arc<Context> = unimplemented!();
720/// # let stream = Stream::new(&ctx)?;
721/// let mut capture = StreamCapture::begin(&stream)?;
722///
723/// capture.record_kernel("my_kernel", (4, 1, 1), (256, 1, 1), 0);
724/// capture.record_memcpy(MemcpyDirection::DeviceToHost, 1024);
725///
726/// let graph = capture.end()?;
727/// assert_eq!(graph.node_count(), 2);
728/// # Ok(())
729/// # }
730/// ```
731pub struct StreamCapture {
732 nodes: Vec<GraphNode>,
733 /// Whether capture is still active (not yet ended).
734 active: bool,
735}
736
737impl StreamCapture {
738 /// Begins capturing operations on the given stream.
739 ///
740 /// On a real CUDA system, this would call
741 /// `cuStreamBeginCapture(stream, CU_STREAM_CAPTURE_MODE_GLOBAL)`.
742 ///
743 /// # Errors
744 ///
745 /// Returns [`CudaError::NotInitialized`] if the CUDA driver is not
746 /// available.
747 pub fn begin(_stream: &Stream) -> CudaResult<Self> {
748 // Validate that the driver is available.
749 let _api = crate::loader::try_driver()?;
750 Ok(Self {
751 nodes: Vec::new(),
752 active: true,
753 })
754 }
755
756 /// Records a kernel launch operation in the capture.
757 ///
758 /// # Parameters
759 ///
760 /// * `function_name` - Name of the kernel function.
761 /// * `grid` - Grid dimensions `(x, y, z)`.
762 /// * `block` - Block dimensions `(x, y, z)`.
763 /// * `shared_mem` - Dynamic shared memory in bytes.
764 pub fn record_kernel(
765 &mut self,
766 function_name: &str,
767 grid: (u32, u32, u32),
768 block: (u32, u32, u32),
769 shared_mem: u32,
770 ) {
771 if self.active {
772 self.nodes.push(GraphNode::KernelLaunch {
773 function_name: function_name.to_owned(),
774 grid,
775 block,
776 shared_mem,
777 });
778 }
779 }
780
781 /// Records a memory copy operation in the capture.
782 ///
783 /// # Parameters
784 ///
785 /// * `direction` - Direction of the memory copy.
786 /// * `size` - Size of the transfer in bytes.
787 pub fn record_memcpy(&mut self, direction: MemcpyDirection, size: usize) {
788 if self.active {
789 self.nodes.push(GraphNode::Memcpy { direction, size });
790 }
791 }
792
793 /// Records a memset operation in the capture.
794 ///
795 /// # Parameters
796 ///
797 /// * `size` - Number of bytes to set.
798 /// * `value` - Byte value to fill with.
799 pub fn record_memset(&mut self, size: usize, value: u8) {
800 if self.active {
801 self.nodes.push(GraphNode::Memset { size, value });
802 }
803 }
804
805 /// Returns the number of operations recorded so far.
806 #[inline]
807 pub fn recorded_count(&self) -> usize {
808 self.nodes.len()
809 }
810
811 /// Returns whether the capture is still active.
812 #[inline]
813 pub fn is_active(&self) -> bool {
814 self.active
815 }
816
817 /// Ends the capture and returns the resulting [`Graph`].
818 ///
819 /// On a real CUDA system, this would call `cuStreamEndCapture`
820 /// and return the captured graph handle.
821 ///
822 /// The captured nodes are connected in a linear chain (each node
823 /// depends on the previous one) to preserve the order in which
824 /// operations were recorded.
825 ///
826 /// # Errors
827 ///
828 /// Returns [`CudaError::StreamCaptureUnmatched`] if the capture
829 /// was already ended.
830 pub fn end(mut self) -> CudaResult<Graph> {
831 if !self.active {
832 return Err(CudaError::StreamCaptureUnmatched);
833 }
834 self.active = false;
835
836 let mut graph = Graph::new();
837 let mut prev_idx: Option<usize> = None;
838
839 for node in self.nodes.drain(..) {
840 let idx = graph.nodes.len();
841 graph.nodes.push(node);
842
843 // Chain each node after the previous to maintain order.
844 if let Some(prev) = prev_idx {
845 graph.dependencies.push((prev, idx));
846 }
847 prev_idx = Some(idx);
848 }
849
850 Ok(graph)
851 }
852}
853
854// ---------------------------------------------------------------------------
855// StreamGraphCapture — real driver-backed stream capture
856// ---------------------------------------------------------------------------
857
858/// Driver-backed CUDA stream capture.
859///
860/// Where [`StreamCapture`] is a CPU-side recorder (it only logs operation
861/// *specifications*), `StreamGraphCapture` drives the real
862/// `cuStreamBeginCapture_v2` / `cuStreamEndCapture` API. The caller begins
863/// capture on a live [`Stream`], submits ordinary GPU work to that stream
864/// (kernel launches, async memset/memcpy) which the driver records instead of
865/// executing, and [`end`](Self::end) finalises the captured `CUgraph` into a
866/// launchable [`GraphExec`]. Launching that exec replays the captured work.
867///
868/// Requires a driver with stream-capture support (CUDA 10.0+); otherwise
869/// [`begin`](Self::begin) returns [`CudaError::NotSupported`].
870pub struct StreamGraphCapture<'s> {
871 stream: &'s Stream,
872 active: bool,
873}
874
875impl<'s> StreamGraphCapture<'s> {
876 /// Begins driver-backed capture on `stream` with the given capture mode
877 /// (e.g. [`CU_STREAM_CAPTURE_MODE_GLOBAL`](crate::ffi::CU_STREAM_CAPTURE_MODE_GLOBAL)).
878 ///
879 /// # Errors
880 ///
881 /// * [`CudaError::NotInitialized`] when no driver is loaded.
882 /// * [`CudaError::NotSupported`] when the driver predates stream capture.
883 /// * Any [`CudaError`] mapped from `cuStreamBeginCapture_v2`.
884 pub fn begin(stream: &'s Stream, mode: crate::ffi::CUstreamCaptureMode) -> CudaResult<Self> {
885 let api = crate::loader::try_driver()?;
886 let begin = api.cu_stream_begin_capture.ok_or(CudaError::NotSupported)?;
887 // SAFETY: `begin` was resolved from the driver; `stream.raw()` is a
888 // live `CUstream` and `mode` is a documented capture-mode value.
889 crate::error::check(unsafe { begin(stream.raw(), mode) })?;
890 Ok(Self {
891 stream,
892 active: true,
893 })
894 }
895
896 /// Returns whether the capture is still active (not yet ended).
897 #[inline]
898 pub fn is_active(&self) -> bool {
899 self.active
900 }
901
902 /// Reports the driver's capture status for the stream
903 /// (`cuStreamIsCapturing`).
904 ///
905 /// # Errors
906 ///
907 /// Propagates driver-load failures and any error from
908 /// `cuStreamIsCapturing`.
909 pub fn capture_status(&self) -> CudaResult<crate::ffi::CUstreamCaptureStatus> {
910 let api = crate::loader::try_driver()?;
911 let is_capturing = api.cu_stream_is_capturing.ok_or(CudaError::NotSupported)?;
912 let mut status = crate::ffi::CU_STREAM_CAPTURE_STATUS_NONE;
913 // SAFETY: resolved from the driver; live stream, valid out-pointer.
914 crate::error::check(unsafe { is_capturing(self.stream.raw(), &mut status) })?;
915 Ok(status)
916 }
917
918 /// Ends capture and instantiates the captured `CUgraph` into a launchable
919 /// [`GraphExec`].
920 ///
921 /// The returned exec's [`node_count`](GraphExec::node_count) reflects the
922 /// number of nodes the driver actually captured (queried via
923 /// `cuGraphGetNodes`), and [`launch`](GraphExec::launch) replays the
924 /// captured work via `cuGraphLaunch`.
925 ///
926 /// # Errors
927 ///
928 /// Propagates driver-load failures and any error from `cuStreamEndCapture`
929 /// or graph instantiation. On instantiation failure the captured graph is
930 /// destroyed before returning so the driver object does not leak.
931 pub fn end(mut self) -> CudaResult<GraphExec> {
932 let api = crate::loader::try_driver()?;
933 let end = api.cu_stream_end_capture.ok_or(CudaError::NotSupported)?;
934 let mut raw_graph = crate::ffi::CUgraph::default();
935 // SAFETY: resolved from the driver; live stream, valid out-pointer.
936 crate::error::check(unsafe { end(self.stream.raw(), &mut raw_graph) })?;
937 self.active = false;
938
939 let build = || -> CudaResult<GraphExec> {
940 // Build a CPU-side model with the captured node count so
941 // `GraphExec::node_count()` is truthful. The driver-side topology
942 // is the source of truth for launch; the model is a linear chain
943 // of that many empty nodes (the captured DAG's exact edges are not
944 // re-queried — only its size).
945 let node_count = Self::query_node_count(api, raw_graph).unwrap_or(0);
946 let mut model = Graph::new();
947 let mut prev: Option<usize> = None;
948 for _ in 0..node_count {
949 let idx = model.add_empty_node();
950 if let Some(p) = prev {
951 model.add_dependency(p, idx)?;
952 }
953 prev = Some(idx);
954 }
955 let execution_order = model.topological_sort().unwrap_or_default();
956 // `instantiate_driver_graph` ignores `&self`; a throwaway graph
957 // gives access to the shared instantiation path.
958 let raw_exec = Graph::new().instantiate_driver_graph(api, raw_graph)?;
959 Ok(GraphExec {
960 graph: model,
961 execution_order,
962 raw_graph: Some(raw_graph),
963 raw_exec: Some(raw_exec),
964 owner: crate::context::current_ctx_owner(),
965 })
966 };
967
968 match build() {
969 Ok(exec) => Ok(exec),
970 Err(e) => {
971 if let Some(destroy) = api.cu_graph_destroy {
972 // SAFETY: `destroy` resolved from the driver; `raw_graph`
973 // is the live handle just produced by end-capture.
974 let rc = unsafe { destroy(raw_graph) };
975 if rc != 0 {
976 tracing::warn!(
977 cuda_error = rc,
978 "cuGraphDestroy failed while unwinding end_capture"
979 );
980 }
981 }
982 Err(e)
983 }
984 }
985 }
986
987 /// Queries the number of nodes in a captured graph via `cuGraphGetNodes`
988 /// (null node pointer returns just the count).
989 fn query_node_count(
990 api: &crate::loader::DriverApi,
991 graph: crate::ffi::CUgraph,
992 ) -> CudaResult<usize> {
993 let get_nodes = api.cu_graph_get_nodes.ok_or(CudaError::NotSupported)?;
994 let mut count: usize = 0;
995 // SAFETY: resolved from the driver; `graph` is live, a null nodes
996 // pointer with a valid count out-pointer is the documented
997 // count-query form.
998 crate::error::check(unsafe { get_nodes(graph, std::ptr::null_mut(), &mut count) })?;
999 Ok(count)
1000 }
1001}
1002
1003impl Drop for StreamGraphCapture<'_> {
1004 fn drop(&mut self) {
1005 // If the caller dropped the capture without ending it, terminate the
1006 // capture so the stream is not left in a capturing state, and destroy
1007 // any graph the driver hands back.
1008 if !self.active {
1009 return;
1010 }
1011 let Ok(api) = crate::loader::try_driver() else {
1012 return;
1013 };
1014 if let Some(end) = api.cu_stream_end_capture {
1015 let mut g = crate::ffi::CUgraph::default();
1016 // SAFETY: resolved from the driver; live stream, valid out-pointer.
1017 let _ = unsafe { end(self.stream.raw(), &mut g) };
1018 if !g.0.is_null() {
1019 if let Some(destroy) = api.cu_graph_destroy {
1020 // SAFETY: `g` is the live graph returned by end-capture.
1021 let _ = unsafe { destroy(g) };
1022 }
1023 }
1024 }
1025 }
1026}
1027
1028// ---------------------------------------------------------------------------
1029// Tests
1030// ---------------------------------------------------------------------------
1031
1032#[cfg(test)]
1033mod tests {
1034 use super::*;
1035
1036 #[test]
1037 fn graph_new_is_empty() {
1038 let g = Graph::new();
1039 assert_eq!(g.node_count(), 0);
1040 assert_eq!(g.dependency_count(), 0);
1041 assert!(g.nodes().is_empty());
1042 assert!(g.dependencies().is_empty());
1043 }
1044
1045 #[test]
1046 fn graph_default_is_empty() {
1047 let g = Graph::default();
1048 assert_eq!(g.node_count(), 0);
1049 }
1050
1051 #[test]
1052 fn add_kernel_node_returns_sequential_indices() {
1053 let mut g = Graph::new();
1054 let n0 = g.add_kernel_node("k0", (1, 1, 1), (32, 1, 1), 0);
1055 let n1 = g.add_kernel_node("k1", (2, 1, 1), (64, 1, 1), 128);
1056 assert_eq!(n0, 0);
1057 assert_eq!(n1, 1);
1058 assert_eq!(g.node_count(), 2);
1059 }
1060
1061 #[test]
1062 fn add_memcpy_node_records_direction_and_size() {
1063 let mut g = Graph::new();
1064 let idx = g.add_memcpy_node(MemcpyDirection::HostToDevice, 4096);
1065 assert_eq!(idx, 0);
1066 let node = g.get_node(0);
1067 assert!(node.is_some());
1068 if let Some(GraphNode::Memcpy { direction, size }) = node {
1069 assert_eq!(*direction, MemcpyDirection::HostToDevice);
1070 assert_eq!(*size, 4096);
1071 } else {
1072 panic!("expected Memcpy node");
1073 }
1074 }
1075
1076 #[test]
1077 fn add_memset_node_records_size_and_value() {
1078 let mut g = Graph::new();
1079 let idx = g.add_memset_node(8192, 0xAB);
1080 assert_eq!(idx, 0);
1081 if let Some(GraphNode::Memset { size, value }) = g.get_node(idx) {
1082 assert_eq!(*size, 8192);
1083 assert_eq!(*value, 0xAB);
1084 } else {
1085 panic!("expected Memset node");
1086 }
1087 }
1088
1089 #[test]
1090 fn add_empty_node_works() {
1091 let mut g = Graph::new();
1092 let idx = g.add_empty_node();
1093 assert_eq!(idx, 0);
1094 assert_eq!(g.get_node(idx), Some(&GraphNode::Empty));
1095 }
1096
1097 #[test]
1098 fn add_dependency_valid() {
1099 let mut g = Graph::new();
1100 let n0 = g.add_kernel_node("k0", (1, 1, 1), (32, 1, 1), 0);
1101 let n1 = g.add_kernel_node("k1", (1, 1, 1), (32, 1, 1), 0);
1102 assert!(g.add_dependency(n0, n1).is_ok());
1103 assert_eq!(g.dependency_count(), 1);
1104 assert_eq!(g.dependencies()[0], (0, 1));
1105 }
1106
1107 #[test]
1108 fn add_dependency_out_of_bounds() {
1109 let mut g = Graph::new();
1110 let _n0 = g.add_kernel_node("k0", (1, 1, 1), (32, 1, 1), 0);
1111 let result = g.add_dependency(0, 5);
1112 assert_eq!(result, Err(CudaError::InvalidValue));
1113 }
1114
1115 #[test]
1116 fn add_dependency_self_loop() {
1117 let mut g = Graph::new();
1118 let n0 = g.add_kernel_node("k0", (1, 1, 1), (32, 1, 1), 0);
1119 let result = g.add_dependency(n0, n0);
1120 assert_eq!(result, Err(CudaError::InvalidValue));
1121 }
1122
1123 #[test]
1124 fn topological_sort_linear_chain() {
1125 let mut g = Graph::new();
1126 let n0 = g.add_kernel_node("k0", (1, 1, 1), (32, 1, 1), 0);
1127 let n1 = g.add_kernel_node("k1", (1, 1, 1), (32, 1, 1), 0);
1128 let n2 = g.add_kernel_node("k2", (1, 1, 1), (32, 1, 1), 0);
1129 g.add_dependency(n0, n1).ok();
1130 g.add_dependency(n1, n2).ok();
1131
1132 let order = g.topological_sort();
1133 assert!(order.is_ok());
1134 let order = order.ok();
1135 assert!(order.is_some());
1136 let order = order.unwrap_or_default();
1137 // n0 must come before n1, n1 before n2
1138 let pos = |n: usize| -> usize { order.iter().position(|&x| x == n).unwrap_or(usize::MAX) };
1139 assert!(pos(n0) < pos(n1));
1140 assert!(pos(n1) < pos(n2));
1141 }
1142
1143 #[test]
1144 fn topological_sort_detects_cycle() {
1145 let mut g = Graph::new();
1146 let n0 = g.add_kernel_node("k0", (1, 1, 1), (32, 1, 1), 0);
1147 let n1 = g.add_kernel_node("k1", (1, 1, 1), (32, 1, 1), 0);
1148 g.add_dependency(n0, n1).ok();
1149 g.add_dependency(n1, n0).ok();
1150
1151 let result = g.topological_sort();
1152 assert_eq!(result, Err(CudaError::InvalidValue));
1153 }
1154
1155 #[test]
1156 fn topological_sort_no_deps() {
1157 let mut g = Graph::new();
1158 g.add_kernel_node("k0", (1, 1, 1), (32, 1, 1), 0);
1159 g.add_kernel_node("k1", (1, 1, 1), (32, 1, 1), 0);
1160 g.add_kernel_node("k2", (1, 1, 1), (32, 1, 1), 0);
1161
1162 let order = g.topological_sort();
1163 assert!(order.is_ok());
1164 let order = order.unwrap_or_default();
1165 assert_eq!(order.len(), 3);
1166 }
1167
1168 #[test]
1169 fn instantiate_valid_graph() {
1170 let mut g = Graph::new();
1171 let n0 = g.add_memcpy_node(MemcpyDirection::HostToDevice, 1024);
1172 let n1 = g.add_kernel_node("k0", (1, 1, 1), (32, 1, 1), 0);
1173 let n2 = g.add_memcpy_node(MemcpyDirection::DeviceToHost, 1024);
1174 g.add_dependency(n0, n1).ok();
1175 g.add_dependency(n1, n2).ok();
1176
1177 let exec = g.instantiate();
1178 assert!(exec.is_ok());
1179 let exec = exec.ok();
1180 assert!(exec.is_some());
1181 if let Some(exec) = exec {
1182 assert_eq!(exec.node_count(), 3);
1183 assert_eq!(exec.execution_order().len(), 3);
1184 }
1185 }
1186
1187 #[test]
1188 fn instantiate_cyclic_graph_fails() {
1189 let mut g = Graph::new();
1190 let n0 = g.add_kernel_node("k0", (1, 1, 1), (32, 1, 1), 0);
1191 let n1 = g.add_kernel_node("k1", (1, 1, 1), (32, 1, 1), 0);
1192 g.add_dependency(n0, n1).ok();
1193 g.add_dependency(n1, n0).ok();
1194
1195 let result = g.instantiate();
1196 assert!(result.is_err());
1197 }
1198
1199 #[test]
1200 fn graph_display() {
1201 let mut g = Graph::new();
1202 g.add_kernel_node("k0", (1, 1, 1), (32, 1, 1), 0);
1203 g.add_memcpy_node(MemcpyDirection::HostToDevice, 512);
1204 let disp = format!("{g}");
1205 assert!(disp.contains("2 nodes"));
1206 assert!(disp.contains("0 deps"));
1207 }
1208
1209 #[test]
1210 fn node_display() {
1211 let node = GraphNode::KernelLaunch {
1212 function_name: "foo".to_owned(),
1213 grid: (4, 1, 1),
1214 block: (256, 1, 1),
1215 shared_mem: 0,
1216 };
1217 let disp = format!("{node}");
1218 assert!(disp.contains("foo"));
1219
1220 let node = GraphNode::Memcpy {
1221 direction: MemcpyDirection::DeviceToHost,
1222 size: 1024,
1223 };
1224 let disp = format!("{node}");
1225 assert!(disp.contains("DtoH"));
1226
1227 let node = GraphNode::Memset {
1228 size: 256,
1229 value: 0xFF,
1230 };
1231 let disp = format!("{node}");
1232 assert!(disp.contains("0xff"));
1233
1234 let node = GraphNode::Empty;
1235 let disp = format!("{node}");
1236 assert!(disp.contains("Empty"));
1237 }
1238
1239 #[test]
1240 fn memcpy_direction_display() {
1241 assert_eq!(format!("{}", MemcpyDirection::HostToDevice), "HtoD");
1242 assert_eq!(format!("{}", MemcpyDirection::DeviceToHost), "DtoH");
1243 assert_eq!(format!("{}", MemcpyDirection::DeviceToDevice), "DtoD");
1244 }
1245
1246 #[test]
1247 fn graph_get_node_out_of_bounds() {
1248 let g = Graph::new();
1249 assert!(g.get_node(0).is_none());
1250 assert!(g.get_node(100).is_none());
1251 }
1252
1253 #[test]
1254 fn graph_diamond_dag() {
1255 // Diamond: n0 -> n1, n0 -> n2, n1 -> n3, n2 -> n3
1256 let mut g = Graph::new();
1257 let n0 = g.add_empty_node();
1258 let n1 = g.add_kernel_node("k1", (1, 1, 1), (32, 1, 1), 0);
1259 let n2 = g.add_kernel_node("k2", (1, 1, 1), (32, 1, 1), 0);
1260 let n3 = g.add_empty_node();
1261 g.add_dependency(n0, n1).ok();
1262 g.add_dependency(n0, n2).ok();
1263 g.add_dependency(n1, n3).ok();
1264 g.add_dependency(n2, n3).ok();
1265
1266 let order = g.topological_sort().unwrap_or_default();
1267 assert_eq!(order.len(), 4);
1268 let pos = |n: usize| -> usize { order.iter().position(|&x| x == n).unwrap_or(usize::MAX) };
1269 assert!(pos(n0) < pos(n1));
1270 assert!(pos(n0) < pos(n2));
1271 assert!(pos(n1) < pos(n3));
1272 assert!(pos(n2) < pos(n3));
1273
1274 let exec = g.instantiate();
1275 assert!(exec.is_ok());
1276 }
1277
1278 #[test]
1279 fn graph_exec_debug() {
1280 let mut g = Graph::new();
1281 g.add_empty_node();
1282 let exec = g.instantiate().ok();
1283 assert!(exec.is_some());
1284 if let Some(exec) = exec {
1285 let dbg = format!("{exec:?}");
1286 assert!(dbg.contains("GraphExec"));
1287 // The debug output advertises the driver-backed status.
1288 assert!(dbg.contains("driver_backed"));
1289 }
1290 }
1291
1292 // -- Driver-backed instantiation ---------------------------------------
1293 //
1294 // `instantiate` builds a real `CUgraph`/`CUgraphExec` when a driver is
1295 // present, and a CPU-side-only `GraphExec` otherwise. On a host with no
1296 // CUDA driver every path below must still produce a valid `GraphExec`
1297 // (clean fallback) — never a panic, never an error from the missing
1298 // driver alone.
1299
1300 /// Returns `true` when a real CUDA driver is loadable on this host.
1301 fn driver_present() -> bool {
1302 crate::loader::try_driver().is_ok()
1303 }
1304
1305 /// Instantiating an empty graph succeeds; without a driver the result
1306 /// is a CPU-side-only `GraphExec`.
1307 #[test]
1308 fn instantiate_empty_graph_driver_state() {
1309 let g = Graph::new();
1310 let exec = g.instantiate().expect("empty graph instantiates");
1311 assert_eq!(exec.node_count(), 0);
1312 if driver_present() {
1313 // A live driver either backs the graph or, on a graphless
1314 // driver, leaves it CPU-side — both are valid, typed outcomes.
1315 let _ = exec.is_driver_backed();
1316 } else {
1317 assert!(!exec.is_driver_backed());
1318 }
1319 }
1320
1321 /// A linear-chain graph instantiates and preserves topology; the
1322 /// `GraphExec` reports a consistent driver-backed flag.
1323 #[test]
1324 fn instantiate_chain_preserves_topology() {
1325 let mut g = Graph::new();
1326 let n0 = g.add_memset_node(256, 0);
1327 let n1 = g.add_kernel_node("k", (1, 1, 1), (32, 1, 1), 0);
1328 let n2 = g.add_memcpy_node(MemcpyDirection::DeviceToHost, 256);
1329 g.add_dependency(n0, n1).ok();
1330 g.add_dependency(n1, n2).ok();
1331
1332 let exec = g.instantiate().expect("chain instantiates");
1333 assert_eq!(exec.node_count(), 3);
1334 assert_eq!(exec.execution_order().len(), 3);
1335 if !driver_present() {
1336 assert!(!exec.is_driver_backed());
1337 }
1338 }
1339
1340 /// A diamond DAG instantiates without a driver to a CPU-side `GraphExec`.
1341 #[test]
1342 fn instantiate_diamond_without_driver_is_clean() {
1343 let mut g = Graph::new();
1344 let n0 = g.add_empty_node();
1345 let n1 = g.add_kernel_node("k1", (1, 1, 1), (32, 1, 1), 0);
1346 let n2 = g.add_kernel_node("k2", (1, 1, 1), (32, 1, 1), 0);
1347 let n3 = g.add_empty_node();
1348 g.add_dependency(n0, n1).ok();
1349 g.add_dependency(n0, n2).ok();
1350 g.add_dependency(n1, n3).ok();
1351 g.add_dependency(n2, n3).ok();
1352
1353 let exec = g.instantiate();
1354 assert!(exec.is_ok(), "diamond DAG must instantiate cleanly");
1355 if !driver_present() {
1356 if let Ok(exec) = exec {
1357 assert!(!exec.is_driver_backed());
1358 }
1359 }
1360 }
1361
1362 /// `build_driver_graph` surfaces a clean typed error on a host with no
1363 /// driver — `NotInitialized`, never a panic.
1364 #[test]
1365 fn build_driver_graph_absent_driver_is_clean() {
1366 let mut g = Graph::new();
1367 g.add_empty_node();
1368 let result = g.build_driver_graph();
1369 if driver_present() {
1370 // Live driver: either real handles, or a typed driver error.
1371 match result {
1372 Ok((raw_graph, raw_exec)) => {
1373 assert_eq!(raw_graph.is_some(), raw_exec.is_some());
1374 }
1375 Err(_) => { /* typed driver error is acceptable */ }
1376 }
1377 } else {
1378 assert_eq!(result.err(), Some(CudaError::NotInitialized));
1379 }
1380 }
1381
1382 /// Dropping a CPU-side-only `GraphExec` must not panic (the `Drop` impl
1383 /// only touches driver handles when both they and the driver exist).
1384 #[test]
1385 fn graph_exec_drop_without_driver_is_safe() {
1386 let mut g = Graph::new();
1387 g.add_empty_node();
1388 g.add_empty_node();
1389 let exec = g.instantiate().expect("instantiates");
1390 // Explicit drop — must complete without panicking.
1391 drop(exec);
1392 }
1393
1394 /// A cyclic graph fails instantiation at the topological-sort stage,
1395 /// before any driver call is attempted.
1396 #[test]
1397 fn instantiate_cycle_fails_before_driver() {
1398 let mut g = Graph::new();
1399 let n0 = g.add_empty_node();
1400 let n1 = g.add_empty_node();
1401 g.add_dependency(n0, n1).ok();
1402 g.add_dependency(n1, n0).ok();
1403 assert_eq!(g.instantiate().err(), Some(CudaError::InvalidValue));
1404 }
1405
1406 // -- End-to-end real-GPU graph execution -------------------------------
1407 //
1408 // When this host has a usable GPU, build a CUDA context (which makes it
1409 // current), instantiate a real driver-backed graph, and launch it via
1410 // `cuGraphLaunch`. On a host without a GPU the test is a clean no-op.
1411
1412 /// Instantiate and launch a real diamond-DAG graph on the GPU.
1413 #[test]
1414 fn real_graph_instantiate_and_launch() {
1415 use crate::context::Context;
1416 use crate::device::Device;
1417
1418 // No GPU on this host — nothing to exercise.
1419 let device = match Device::get(0) {
1420 Ok(d) => d,
1421 Err(_) => return,
1422 };
1423 // Creating the context makes it current on this thread, which the
1424 // CUDA Graph API requires.
1425 let ctx = match Context::new(&device) {
1426 Ok(c) => std::sync::Arc::new(c),
1427 Err(_) => return,
1428 };
1429 let stream = match Stream::new(&ctx) {
1430 Ok(s) => s,
1431 Err(_) => return,
1432 };
1433
1434 // Diamond DAG: n0 -> {n1, n2} -> n3.
1435 let mut g = Graph::new();
1436 let n0 = g.add_empty_node();
1437 let n1 = g.add_kernel_node("k1", (1, 1, 1), (32, 1, 1), 0);
1438 let n2 = g.add_kernel_node("k2", (1, 1, 1), (32, 1, 1), 0);
1439 let n3 = g.add_empty_node();
1440 g.add_dependency(n0, n1).ok();
1441 g.add_dependency(n0, n2).ok();
1442 g.add_dependency(n1, n3).ok();
1443 g.add_dependency(n2, n3).ok();
1444
1445 let exec = g.instantiate().expect("diamond DAG instantiates");
1446 assert_eq!(exec.node_count(), 4);
1447
1448 // A context is current and any driver from the CUDA 10.0+ era exposes
1449 // the Graph API (`cuGraphAddEmptyNode` / `cuGraphInstantiate` /
1450 // `cuGraphLaunch`). With a real device present the graph MUST therefore
1451 // be driver-backed — otherwise the launch below would be silently
1452 // skipped and the test would pass vacuously without ever exercising the
1453 // driver path.
1454 assert!(
1455 exec.is_driver_backed(),
1456 "a real CUDA device is present but the graph is not driver-backed; \
1457 the cuGraph* FFI entry points failed to load"
1458 );
1459 exec.launch(&stream)
1460 .expect("cuGraphLaunch on a real graph succeeds");
1461 stream
1462 .synchronize()
1463 .expect("stream synchronises after graph launch");
1464 }
1465
1466 /// A driver-backed graph can be relaunched repeatedly on the same stream.
1467 #[test]
1468 fn real_graph_repeated_launch() {
1469 use crate::context::Context;
1470 use crate::device::Device;
1471
1472 let device = match Device::get(0) {
1473 Ok(d) => d,
1474 Err(_) => return,
1475 };
1476 let ctx = match Context::new(&device) {
1477 Ok(c) => std::sync::Arc::new(c),
1478 Err(_) => return,
1479 };
1480 let stream = match Stream::new(&ctx) {
1481 Ok(s) => s,
1482 Err(_) => return,
1483 };
1484
1485 let mut g = Graph::new();
1486 let a = g.add_empty_node();
1487 let b = g.add_empty_node();
1488 g.add_dependency(a, b).ok();
1489
1490 let exec = g.instantiate().expect("chain instantiates");
1491 // A real device is present, so the Graph API must be driver-backed
1492 // (see `real_graph_instantiate_and_launch`); a CPU-only fallback here
1493 // would make the repeated-launch assertion vacuous.
1494 assert!(
1495 exec.is_driver_backed(),
1496 "a real CUDA device is present but the graph is not driver-backed"
1497 );
1498 // The whole point of a graph: cheap repeated submission.
1499 for _ in 0..8 {
1500 exec.launch(&stream)
1501 .expect("repeated cuGraphLaunch succeeds");
1502 }
1503 stream.synchronize().expect("stream synchronises");
1504 }
1505
1506 /// End-to-end **real stream capture** round-trip on the GPU: capture an
1507 /// async memset of a device buffer, instantiate the captured graph, launch
1508 /// it, and verify the replayed memset wrote the expected pattern to device
1509 /// memory — i.e. `cuGraphLaunch` output matches the CPU simulation of the
1510 /// captured op (the `oxicuda-graph` TODO:164 hardware check).
1511 #[test]
1512 fn real_stream_capture_memset_round_trip() {
1513 use crate::context::Context;
1514 use crate::device::Device;
1515 use crate::ffi::{
1516 CU_STREAM_CAPTURE_MODE_GLOBAL, CU_STREAM_CAPTURE_STATUS_ACTIVE, CUdeviceptr,
1517 };
1518
1519 let device = match Device::get(0) {
1520 Ok(d) => d,
1521 Err(_) => return,
1522 };
1523 let ctx = match Context::new(&device) {
1524 Ok(c) => std::sync::Arc::new(c),
1525 Err(_) => return,
1526 };
1527 let stream = match Stream::new(&ctx) {
1528 Ok(s) => s,
1529 Err(_) => return,
1530 };
1531
1532 let api = crate::loader::try_driver().expect("driver present");
1533 // Skip cleanly if this driver lacks stream capture or async memset.
1534 if api.cu_stream_begin_capture.is_none() || api.cu_memset_d32_async.is_none() {
1535 return;
1536 }
1537
1538 const N: usize = 256;
1539 const MAGIC: u32 = 0xABCD_1234;
1540 let bytes = N * std::mem::size_of::<u32>();
1541
1542 // Allocate and zero-initialise the device buffer.
1543 let mut dptr: CUdeviceptr = 0;
1544 crate::error::check(unsafe { (api.cu_mem_alloc_v2)(&mut dptr, bytes) }).expect("alloc");
1545 crate::error::check(unsafe { (api.cu_memset_d32_v2)(dptr, 0, N) }).expect("zero-init");
1546
1547 let run = || -> CudaResult<Vec<u32>> {
1548 // Begin capture and record an async memset to MAGIC on the stream.
1549 let cap = StreamGraphCapture::begin(&stream, CU_STREAM_CAPTURE_MODE_GLOBAL)?;
1550 if let Ok(status) = cap.capture_status() {
1551 assert_eq!(
1552 status, CU_STREAM_CAPTURE_STATUS_ACTIVE,
1553 "stream must report active capture after begin"
1554 );
1555 }
1556 let memset_async = api.cu_memset_d32_async.expect("async memset");
1557 // SAFETY: `dptr` is a live N-element u32 allocation; while capture
1558 // is active this call is recorded into the graph, not executed.
1559 crate::error::check(unsafe { memset_async(dptr, MAGIC, N, stream.raw()) })?;
1560 let exec = cap.end()?;
1561
1562 assert!(
1563 exec.is_driver_backed(),
1564 "captured graph must be driver-backed"
1565 );
1566 assert!(exec.node_count() >= 1, "capture recorded no nodes");
1567
1568 // Capture records but does not execute: the buffer is still zero.
1569 // (This synchronous copy runs after capture has ended, so it does
1570 // not invalidate the capture.)
1571 let mut pre = vec![0u32; N];
1572 crate::error::check(unsafe {
1573 (api.cu_memcpy_dtoh_v2)(pre.as_mut_ptr().cast(), dptr, bytes)
1574 })?;
1575 assert!(
1576 pre.iter().all(|&v| v == 0),
1577 "capture must not execute the recorded memset"
1578 );
1579
1580 // Replaying the graph performs the memset on the device.
1581 exec.launch(&stream)?;
1582 crate::error::check(unsafe { (api.cu_stream_synchronize)(stream.raw()) })?;
1583 let mut out = vec![0u32; N];
1584 crate::error::check(unsafe {
1585 (api.cu_memcpy_dtoh_v2)(out.as_mut_ptr().cast(), dptr, bytes)
1586 })?;
1587 Ok(out)
1588 };
1589
1590 let result = run();
1591 // Always release the device allocation, even on failure.
1592 let _ = unsafe { (api.cu_mem_free_v2)(dptr) };
1593
1594 let out = result.expect("stream-capture round-trip");
1595 // CPU simulation of the captured op: every element becomes MAGIC.
1596 assert!(
1597 out.iter().all(|&v| v == MAGIC),
1598 "replayed captured graph did not memset device memory to MAGIC"
1599 );
1600 }
1601}