Skip to main content

oxicuda_graph/executor/
plan.rs

1//! Execution plan — the linearised, stream-aware schedule produced by the
2//! compilation pipeline and consumed by the executor backends.
3//!
4//! An [`ExecutionPlan`] is an ordered sequence of [`PlanStep`]s, each of
5//! which describes one concrete GPU or host-side action. Steps are already
6//! assigned to streams and include the event synchronisation pairs needed
7//! to enforce cross-stream dependencies.
8//!
9//! The plan is produced by running all analysis and optimisation passes in
10//! sequence; see [`ExecutionPlan::build`].
11
12use crate::analysis::{liveness_analyse, topo_analyse};
13use crate::error::{GraphError, GraphResult};
14use crate::graph::ComputeGraph;
15use crate::node::{KernelConfig, MemcpyDir, NodeId, NodeKind, StreamId};
16use crate::optimizer::{fusion_analyse, memory_analyse, stream_analyse};
17
18// ---------------------------------------------------------------------------
19// PlanStep
20// ---------------------------------------------------------------------------
21
22/// A single step in the execution plan.
23#[derive(Debug, Clone, PartialEq)]
24pub enum PlanStep {
25    /// Launch a (possibly fused) kernel.
26    KernelLaunch {
27        /// Node(s) contributing to this launch (singleton if not fused).
28        nodes: Vec<NodeId>,
29        /// Function name to look up in the loaded PTX module.
30        function_name: String,
31        /// Launch configuration.
32        config: KernelConfig,
33        /// Stream to submit on.
34        stream: StreamId,
35    },
36
37    /// Transfer memory between host and device (or device to device).
38    Memcpy {
39        node: NodeId,
40        dir: MemcpyDir,
41        size_bytes: usize,
42        stream: StreamId,
43    },
44
45    /// Fill a device buffer with a byte pattern.
46    Memset {
47        node: NodeId,
48        size_bytes: usize,
49        value: u8,
50        stream: StreamId,
51    },
52
53    /// Record a CUDA event on `stream` to signal completion of preceding work.
54    EventRecord {
55        /// Synthetic event ID (for matching with `EventWait`).
56        event_id: usize,
57        stream: StreamId,
58    },
59
60    /// Wait for a previously recorded event before continuing on `stream`.
61    EventWait { event_id: usize, stream: StreamId },
62
63    /// Host-side callback inserted into the stream.
64    HostCallback {
65        node: NodeId,
66        label: String,
67        stream: StreamId,
68    },
69
70    /// No-op barrier (no device work, used for explicit synchronisation).
71    Barrier { node: NodeId, stream: StreamId },
72}
73
74impl PlanStep {
75    /// Returns the stream this step executes on.
76    pub fn stream(&self) -> StreamId {
77        match self {
78            Self::KernelLaunch { stream, .. } => *stream,
79            Self::Memcpy { stream, .. } => *stream,
80            Self::Memset { stream, .. } => *stream,
81            Self::EventRecord { stream, .. } => *stream,
82            Self::EventWait { stream, .. } => *stream,
83            Self::HostCallback { stream, .. } => *stream,
84            Self::Barrier { stream, .. } => *stream,
85        }
86    }
87
88    /// Returns a short tag string for display.
89    pub fn tag(&self) -> &'static str {
90        match self {
91            Self::KernelLaunch { .. } => "kernel",
92            Self::Memcpy { .. } => "memcpy",
93            Self::Memset { .. } => "memset",
94            Self::EventRecord { .. } => "event_record",
95            Self::EventWait { .. } => "event_wait",
96            Self::HostCallback { .. } => "host_cb",
97            Self::Barrier { .. } => "barrier",
98        }
99    }
100
101    /// Returns `true` if this step represents GPU compute work.
102    pub fn is_compute(&self) -> bool {
103        matches!(self, Self::KernelLaunch { .. })
104    }
105}
106
107impl std::fmt::Display for PlanStep {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        match self {
110            Self::KernelLaunch {
111                function_name,
112                config,
113                stream,
114                nodes,
115            } => {
116                write!(
117                    f,
118                    "{}  kernel {function_name} [{config}] @{stream} (nodes: {})",
119                    self.tag(),
120                    nodes
121                        .iter()
122                        .map(|n| n.to_string())
123                        .collect::<Vec<_>>()
124                        .join(",")
125                )
126            }
127            Self::Memcpy {
128                dir,
129                size_bytes,
130                stream,
131                ..
132            } => {
133                write!(f, "  memcpy {dir} {size_bytes}B @{stream}")
134            }
135            Self::Memset {
136                size_bytes,
137                value,
138                stream,
139                ..
140            } => {
141                write!(f, "  memset {size_bytes}B=0x{value:02x} @{stream}")
142            }
143            Self::EventRecord { event_id, stream } => {
144                write!(f, "  event_record ev{event_id} @{stream}")
145            }
146            Self::EventWait { event_id, stream } => {
147                write!(f, "  event_wait ev{event_id} @{stream}")
148            }
149            Self::HostCallback { label, stream, .. } => {
150                write!(f, "  host_cb '{label}' @{stream}")
151            }
152            Self::Barrier { stream, .. } => {
153                write!(f, "  barrier @{stream}")
154            }
155        }
156    }
157}
158
159// ---------------------------------------------------------------------------
160// ExecutionPlan
161// ---------------------------------------------------------------------------
162
163/// The compiled, optimised execution plan for a `ComputeGraph`.
164///
165/// Contains an ordered list of `PlanStep`s that can be submitted to an
166/// executor backend (sequential simulator or real CUDA graph capture).
167#[derive(Debug, Clone)]
168pub struct ExecutionPlan {
169    /// Steps in submission order.
170    pub steps: Vec<PlanStep>,
171    /// Number of distinct CUDA streams used.
172    pub num_streams: usize,
173    /// Total device memory pool size in bytes.
174    pub pool_bytes: usize,
175    /// Number of kernel launches (before fusion).
176    pub kernel_count_original: usize,
177    /// Number of kernel launches (after fusion).
178    pub kernel_count_fused: usize,
179    /// Number of cross-stream event sync pairs.
180    pub event_count: usize,
181}
182
183impl ExecutionPlan {
184    /// Compiles a `ComputeGraph` into an `ExecutionPlan`.
185    ///
186    /// Runs all analysis and optimisation passes in order:
187    /// topo → liveness → fusion → memory → stream → linearise.
188    ///
189    /// # Parameters
190    ///
191    /// * `graph` — the computation graph to compile.
192    /// * `max_streams` — upper bound on concurrent CUDA streams.
193    ///
194    /// # Errors
195    ///
196    /// Propagates errors from any analysis or optimisation pass.
197    pub fn build(graph: &ComputeGraph, max_streams: usize) -> GraphResult<Self> {
198        if graph.is_empty() {
199            return Err(GraphError::EmptyGraph);
200        }
201
202        // --- Analysis passes ---
203        let topo = topo_analyse(graph)?;
204        let _liveness = liveness_analyse(graph)?;
205        let fusion_plan = fusion_analyse(graph)?;
206        let memory_plan = memory_analyse(graph)?;
207        let stream_plan = stream_analyse(graph, max_streams)?;
208
209        let pool_bytes = memory_plan.total_bytes;
210        let num_streams = stream_plan.num_streams;
211        let kernel_count_original = graph.kernel_nodes().len();
212
213        // --- Build event map for cross-stream sync points ---
214        let mut event_counter = 0usize;
215        // Map: (from_node, to_node) → event_id
216        let mut edge_events: std::collections::HashMap<(NodeId, NodeId), usize> =
217            std::collections::HashMap::new();
218        for sp in &stream_plan.sync_points {
219            let key = (sp.from, sp.to);
220            edge_events.entry(key).or_insert_with(|| {
221                let eid = event_counter;
222                event_counter += 1;
223                eid
224            });
225        }
226
227        // For each node, collect the set of events it must wait for.
228        // event_waits[node] = list of event_ids to wait before executing node.
229        let mut event_waits: std::collections::HashMap<NodeId, Vec<usize>> =
230            std::collections::HashMap::new();
231        // event_records[node] = list of event_ids to record after executing node.
232        let mut event_records: std::collections::HashMap<NodeId, Vec<usize>> =
233            std::collections::HashMap::new();
234
235        for sp in &stream_plan.sync_points {
236            let eid = edge_events[&(sp.from, sp.to)];
237            event_records.entry(sp.from).or_default().push(eid);
238            event_waits.entry(sp.to).or_default().push(eid);
239        }
240
241        // --- Build fused kernel names ---
242        // For each node, determine what function name its group exposes.
243        let fused_name_of = |node_id: NodeId| -> String {
244            let group = fusion_plan.group_of(node_id);
245            match group {
246                Some(g) if !g.is_trivial() => {
247                    // Build name from all member function names joined.
248                    let member_names: Vec<String> = g
249                        .members
250                        .iter()
251                        .filter_map(|&m| {
252                            graph
253                                .node(m)
254                                .ok()
255                                .and_then(|n| n.kind.function_name().map(|s| s.to_owned()))
256                        })
257                        .collect();
258                    format!("fused_{}", member_names.join("_"))
259                }
260                _ => graph
261                    .node(node_id)
262                    .ok()
263                    .and_then(|n| n.kind.function_name())
264                    .unwrap_or("unknown")
265                    .to_owned(),
266            }
267        };
268
269        // --- Linearise: one step per node in topological order ---
270        // Track which fusion groups have already been emitted.
271        let mut emitted_groups: std::collections::HashSet<usize> = std::collections::HashSet::new();
272
273        let mut steps: Vec<PlanStep> = Vec::new();
274        let mut kernel_count_fused = 0;
275
276        for &node_id in &topo.order {
277            let node = graph.node(node_id)?;
278            let stream = stream_plan.stream_of(node_id);
279
280            // Emit event waits before this node.
281            if let Some(waits) = event_waits.get(&node_id) {
282                for &eid in waits {
283                    steps.push(PlanStep::EventWait {
284                        event_id: eid,
285                        stream,
286                    });
287                }
288            }
289
290            // Emit the operation itself.
291            match &node.kind {
292                NodeKind::KernelLaunch {
293                    function_name,
294                    config,
295                    ..
296                } => {
297                    // Check if this node is part of a non-trivial fusion group.
298                    let group_id = fusion_plan.node_to_group.get(&node_id).copied();
299                    let group = group_id.and_then(|gid| fusion_plan.groups.get(gid));
300
301                    match group {
302                        Some(g) if !g.is_trivial() => {
303                            // Only emit once (for the first member of the group).
304                            if !emitted_groups.contains(&g.id) {
305                                emitted_groups.insert(g.id);
306                                let fused_name = fused_name_of(node_id);
307                                steps.push(PlanStep::KernelLaunch {
308                                    nodes: g.members.clone(),
309                                    function_name: fused_name,
310                                    config: g.config,
311                                    stream,
312                                });
313                                kernel_count_fused += 1;
314                            }
315                            // Skip non-first members (they're folded into the fused launch).
316                        }
317                        _ => {
318                            // Trivial group or non-fusible: emit as-is.
319                            steps.push(PlanStep::KernelLaunch {
320                                nodes: vec![node_id],
321                                function_name: function_name.clone(),
322                                config: *config,
323                                stream,
324                            });
325                            kernel_count_fused += 1;
326                        }
327                    }
328                }
329                NodeKind::Memcpy { dir, size_bytes } => {
330                    steps.push(PlanStep::Memcpy {
331                        node: node_id,
332                        dir: *dir,
333                        size_bytes: *size_bytes,
334                        stream,
335                    });
336                }
337                NodeKind::Memset { size_bytes, value } => {
338                    steps.push(PlanStep::Memset {
339                        node: node_id,
340                        size_bytes: *size_bytes,
341                        value: *value,
342                        stream,
343                    });
344                }
345                NodeKind::HostCallback { label } => {
346                    steps.push(PlanStep::HostCallback {
347                        node: node_id,
348                        label: label.clone(),
349                        stream,
350                    });
351                }
352                NodeKind::EventRecord => {
353                    // User-inserted event record — keep as-is.
354                    steps.push(PlanStep::EventRecord {
355                        event_id: event_counter,
356                        stream,
357                    });
358                    event_counter += 1;
359                }
360                NodeKind::EventWait => {
361                    steps.push(PlanStep::EventWait {
362                        event_id: event_counter,
363                        stream,
364                    });
365                    event_counter += 1;
366                }
367                NodeKind::Barrier | NodeKind::Conditional { .. } => {
368                    steps.push(PlanStep::Barrier {
369                        node: node_id,
370                        stream,
371                    });
372                }
373            }
374
375            // Emit event records after this node.
376            if let Some(records) = event_records.get(&node_id) {
377                for &eid in records {
378                    steps.push(PlanStep::EventRecord {
379                        event_id: eid,
380                        stream,
381                    });
382                }
383            }
384        }
385
386        Ok(ExecutionPlan {
387            steps,
388            num_streams,
389            pool_bytes,
390            kernel_count_original,
391            kernel_count_fused,
392            event_count: event_counter,
393        })
394    }
395
396    /// Returns all steps assigned to a specific stream, in order.
397    pub fn steps_on(&self, stream: StreamId) -> Vec<&PlanStep> {
398        self.steps.iter().filter(|s| s.stream() == stream).collect()
399    }
400
401    /// Returns the total number of steps.
402    pub fn total_steps(&self) -> usize {
403        self.steps.len()
404    }
405
406    /// Returns the number of kernel-launch steps (after fusion).
407    pub fn compute_steps(&self) -> usize {
408        self.steps.iter().filter(|s| s.is_compute()).count()
409    }
410}
411
412impl std::fmt::Display for ExecutionPlan {
413    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
414        writeln!(
415            f,
416            "ExecutionPlan: {} steps, {} streams, {} bytes pool, {} kernels (→{} fused), {} events",
417            self.steps.len(),
418            self.num_streams,
419            self.pool_bytes,
420            self.kernel_count_original,
421            self.kernel_count_fused,
422            self.event_count
423        )?;
424        for (i, step) in self.steps.iter().enumerate() {
425            writeln!(f, "  [{i:3}] {step}")?;
426        }
427        Ok(())
428    }
429}
430
431// ---------------------------------------------------------------------------
432// Tests
433// ---------------------------------------------------------------------------
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use crate::builder::GraphBuilder;
439    use crate::node::MemcpyDir;
440
441    fn build_simple_inference_graph() -> ComputeGraph {
442        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
443        let inp = b.alloc_buffer("input", 4096);
444        let out = b.alloc_buffer("output", 4096);
445        let upload = b.add_memcpy("upload", MemcpyDir::HostToDevice, 4096);
446        let k0 = b
447            .add_kernel("relu", 4, 256, 0)
448            .fusible(true)
449            .inputs([inp])
450            .outputs([out])
451            .finish();
452        let k1 = b
453            .add_kernel("scale", 4, 256, 0)
454            .fusible(true)
455            .inputs([out])
456            .outputs([inp])
457            .finish();
458        let download = b.add_memcpy("download", MemcpyDir::DeviceToHost, 4096);
459        b.chain(&[upload, k0, k1, download]);
460        b.set_outputs(upload, [inp]);
461        b.set_inputs(download, [inp]);
462        b.build().unwrap()
463    }
464
465    #[test]
466    fn plan_empty_graph_error() {
467        let g = ComputeGraph::new();
468        assert!(matches!(
469            ExecutionPlan::build(&g, 4),
470            Err(GraphError::EmptyGraph)
471        ));
472    }
473
474    #[test]
475    fn plan_builds_without_error() {
476        let g = build_simple_inference_graph();
477        let plan = ExecutionPlan::build(&g, 4).unwrap();
478        assert!(plan.total_steps() > 0);
479    }
480
481    #[test]
482    fn plan_covers_all_nodes() {
483        let g = build_simple_inference_graph();
484        let plan = ExecutionPlan::build(&g, 4).unwrap();
485        // 4 original nodes; steps may include event records/waits too.
486        // At minimum, each non-fused node must appear in at least one step.
487        assert!(plan.total_steps() >= 2); // at least upload and download
488    }
489
490    #[test]
491    fn plan_fused_kernels_reduce_compute_steps() {
492        let g = build_simple_inference_graph();
493        let plan = ExecutionPlan::build(&g, 4).unwrap();
494        // relu and scale are fusible and in a chain → fused to 1 step.
495        assert!(plan.compute_steps() <= 2);
496        // kernel_count_fused ≤ kernel_count_original.
497        assert!(plan.kernel_count_fused <= plan.kernel_count_original);
498    }
499
500    #[test]
501    fn plan_single_stream_no_events() {
502        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
503        let a = b.add_barrier("a");
504        let bnode = b.add_barrier("b");
505        b.dep(a, bnode);
506        let g = b.build().unwrap();
507        let plan = ExecutionPlan::build(&g, 1).unwrap();
508        // With max_streams=1, no cross-stream syncs.
509        let event_steps: Vec<_> = plan
510            .steps
511            .iter()
512            .filter(|s| matches!(s, PlanStep::EventRecord { .. } | PlanStep::EventWait { .. }))
513            .collect();
514        // Events from user-inserted nodes: zero (no EventRecord/Wait nodes added).
515        // Cross-stream events: zero (single stream).
516        assert_eq!(event_steps.len(), 0);
517    }
518
519    #[test]
520    fn plan_steps_on_stream() {
521        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
522        b.add_barrier("n");
523        let g = b.build().unwrap();
524        let plan = ExecutionPlan::build(&g, 1).unwrap();
525        let on_s0 = plan.steps_on(StreamId(0));
526        assert!(!on_s0.is_empty());
527    }
528
529    #[test]
530    fn plan_display_output() {
531        let g = build_simple_inference_graph();
532        let plan = ExecutionPlan::build(&g, 4).unwrap();
533        let s = plan.to_string();
534        assert!(s.contains("ExecutionPlan"));
535        assert!(s.contains("streams"));
536    }
537
538    #[test]
539    fn plan_memcpy_and_memset_steps() {
540        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
541        let up = b.add_memcpy("up", MemcpyDir::HostToDevice, 1024);
542        let ms = b.add_memset("zero", 4096, 0x00);
543        b.dep(up, ms);
544        let g = b.build().unwrap();
545        let plan = ExecutionPlan::build(&g, 1).unwrap();
546        let has_memcpy = plan
547            .steps
548            .iter()
549            .any(|s| matches!(s, PlanStep::Memcpy { .. }));
550        let has_memset = plan
551            .steps
552            .iter()
553            .any(|s| matches!(s, PlanStep::Memset { .. }));
554        assert!(has_memcpy);
555        assert!(has_memset);
556    }
557
558    #[test]
559    fn plan_pool_bytes_reported() {
560        let g = build_simple_inference_graph();
561        let plan = ExecutionPlan::build(&g, 4).unwrap();
562        // Pool bytes ≥ size of at least one internal buffer.
563        // Two internal buffers of 4096 bytes; pool may be shared.
564        // pool_bytes is usize, always non-negative; just verify the field is accessible (non-crash)
565        let _ = plan.pool_bytes;
566    }
567
568    #[test]
569    fn plan_kernel_count_fields_valid() {
570        let g = build_simple_inference_graph();
571        let plan = ExecutionPlan::build(&g, 4).unwrap();
572        assert_eq!(plan.kernel_count_original, 2); // relu, scale
573        assert!(plan.kernel_count_fused <= plan.kernel_count_original);
574    }
575}