Skip to main content

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}