Skip to main content

oxicuda_graph/
schedule.rs

1//! Wavefront (levelized) scheduling — grouping independent nodes into
2//! concurrently-launchable waves.
3//!
4//! A computation DAG can be partitioned into *wavefronts* (a.k.a. topological
5//! levels): the set of nodes whose longest-path distance from any source is
6//! `k` forms wavefront `k`. All nodes within a wavefront are mutually
7//! independent and may, in principle, be launched concurrently (on separate
8//! streams); wavefront `k+1` cannot begin until wavefront `k` completes.
9//!
10//! This is the natural CPU-side model for "how much parallelism does this
11//! graph expose, and in what order should waves of work be issued?" — the
12//! information a stream partitioner or a multi-stream launcher consumes.
13//!
14//! [`Schedule::levelize`] computes the wavefront decomposition, the critical
15//! path (cost-weighted longest path) and a simple concurrency model (the
16//! makespan under unbounded streams vs. a bounded stream count).
17//!
18//! This module is distinct from [`crate::analysis::topo`], which annotates
19//! *per node* (ASAP/ALAP/slack). Here the output is *per wave*: explicit
20//! groups of independent nodes, which is what a launcher iterates over.
21
22use std::collections::VecDeque;
23
24use crate::error::{GraphError, GraphResult};
25use crate::graph::ComputeGraph;
26use crate::node::NodeId;
27
28// ---------------------------------------------------------------------------
29// Wavefront
30// ---------------------------------------------------------------------------
31
32/// One wavefront: a set of mutually-independent nodes at the same topological
33/// level, all of which may be launched concurrently.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Wavefront {
36    /// The level index (longest-path distance in edges from any source).
37    pub level: usize,
38    /// Nodes in this wave, in ascending `NodeId` order for determinism.
39    pub nodes: Vec<NodeId>,
40    /// The maximum per-node cost in this wave — the wave's own duration when
41    /// every node runs on its own stream.
42    pub max_cost: u64,
43    /// The summed per-node cost in this wave — the wave's duration when forced
44    /// onto a single stream.
45    pub total_cost: u64,
46}
47
48impl Wavefront {
49    /// Number of nodes in this wave (the instantaneous parallelism).
50    #[must_use]
51    pub fn width(&self) -> usize {
52        self.nodes.len()
53    }
54}
55
56// ---------------------------------------------------------------------------
57// Schedule
58// ---------------------------------------------------------------------------
59
60/// A wavefront schedule: the full level decomposition of a [`ComputeGraph`]
61/// plus derived concurrency metrics.
62#[derive(Debug, Clone)]
63pub struct Schedule {
64    /// Wavefronts in execution order (`waves[0]` runs first).
65    waves: Vec<Wavefront>,
66    /// Per-node level (indexed by `NodeId.0`).
67    levels: Vec<usize>,
68    /// Cost-weighted critical path length (makespan under unbounded streams).
69    critical_path_cost: u64,
70}
71
72impl Schedule {
73    /// Computes the wavefront decomposition of `graph`.
74    ///
75    /// Each node's level is its longest-path distance (in edges) from any
76    /// source node; nodes sharing a level form one wavefront. The critical
77    /// path cost is the cost-weighted longest source→sink path.
78    ///
79    /// # Errors
80    ///
81    /// Returns [`GraphError::EmptyGraph`] if the graph has no nodes.
82    pub fn levelize(graph: &ComputeGraph) -> GraphResult<Self> {
83        if graph.is_empty() {
84            return Err(GraphError::EmptyGraph);
85        }
86        let n = graph.node_count();
87
88        // ---- Edge-based level assignment (Kahn layering) -------------------
89        let mut levels = vec![0usize; n];
90        let mut in_degree: Vec<u32> = (0..n)
91            .map(|i| {
92                graph
93                    .predecessors(NodeId(i as u32))
94                    .map(|p| p.len() as u32)
95                    .unwrap_or(0)
96            })
97            .collect();
98        let mut queue: VecDeque<NodeId> = (0..n)
99            .filter(|&i| in_degree[i] == 0)
100            .map(|i| NodeId(i as u32))
101            .collect();
102        let mut processed = 0usize;
103        while let Some(id) = queue.pop_front() {
104            processed += 1;
105            let lv = levels[id.0 as usize];
106            for &succ in graph.successors(id)? {
107                let nl = lv + 1;
108                if nl > levels[succ.0 as usize] {
109                    levels[succ.0 as usize] = nl;
110                }
111                let d = &mut in_degree[succ.0 as usize];
112                *d -= 1;
113                if *d == 0 {
114                    queue.push_back(succ);
115                }
116            }
117        }
118        // The DAG invariant guarantees full processing.
119        debug_assert_eq!(processed, n, "levelization did not visit every node");
120
121        // ---- Group nodes by level into wavefronts --------------------------
122        let max_level = *levels.iter().max().unwrap_or(&0);
123        let mut buckets: Vec<Vec<NodeId>> = vec![Vec::new(); max_level + 1];
124        for i in 0..n {
125            buckets[levels[i]].push(NodeId(i as u32));
126        }
127
128        // ---- Cost-weighted critical path (longest path) --------------------
129        // dist[v] = cost[v] + max over predecessors p of dist[p].
130        let order = graph.topological_order()?;
131        let mut dist = vec![0u64; n];
132        for &id in &order {
133            let cost = graph.node(id)?.cost_hint;
134            let mut best_pred = 0u64;
135            for &pred in graph.predecessors(id)? {
136                best_pred = best_pred.max(dist[pred.0 as usize]);
137            }
138            dist[id.0 as usize] = best_pred + cost;
139        }
140        let critical_path_cost = dist.iter().copied().max().unwrap_or(0);
141
142        let waves: Vec<Wavefront> = buckets
143            .into_iter()
144            .enumerate()
145            .map(|(level, mut nodes)| {
146                nodes.sort();
147                let max_cost = nodes
148                    .iter()
149                    .map(|&id| graph.nodes()[id.0 as usize].cost_hint)
150                    .max()
151                    .unwrap_or(0);
152                let total_cost: u64 = nodes
153                    .iter()
154                    .map(|&id| graph.nodes()[id.0 as usize].cost_hint)
155                    .sum();
156                Wavefront {
157                    level,
158                    nodes,
159                    max_cost,
160                    total_cost,
161                }
162            })
163            .collect();
164
165        Ok(Self {
166            waves,
167            levels,
168            critical_path_cost,
169        })
170    }
171
172    /// Returns the wavefronts in execution order.
173    #[must_use]
174    pub fn wavefronts(&self) -> &[Wavefront] {
175        &self.waves
176    }
177
178    /// Returns the number of wavefronts (the depth of the schedule).
179    #[must_use]
180    pub fn depth(&self) -> usize {
181        self.waves.len()
182    }
183
184    /// Returns the level of a node.
185    ///
186    /// # Errors
187    ///
188    /// Returns [`GraphError::NodeNotFound`] if `id` is out of range.
189    pub fn level_of(&self, id: NodeId) -> GraphResult<usize> {
190        self.levels
191            .get(id.0 as usize)
192            .copied()
193            .ok_or(GraphError::NodeNotFound(id))
194    }
195
196    /// Returns the maximum instantaneous parallelism (widest wavefront).
197    #[must_use]
198    pub fn max_width(&self) -> usize {
199        self.waves.iter().map(Wavefront::width).max().unwrap_or(0)
200    }
201
202    /// Returns the cost-weighted critical path length — the makespan achievable
203    /// with unbounded concurrency.
204    #[must_use]
205    pub fn critical_path_cost(&self) -> u64 {
206        self.critical_path_cost
207    }
208
209    /// Estimates the makespan when each wavefront is executed with at most
210    /// `max_streams` concurrent nodes.
211    ///
212    /// Within a wave, nodes are greedily packed onto `max_streams` lanes by
213    /// longest-cost-first (LPT list scheduling); the wave's duration is the
214    /// most-loaded lane, and the schedule's makespan is the sum over waves.
215    /// With `max_streams == 0` it is treated as `1` (fully sequential per
216    /// wave). With `max_streams >= max_width()` the result equals the sum of
217    /// each wave's `max_cost`.
218    ///
219    /// This is a *model*, not a device measurement: it gives an upper bound on
220    /// achievable speedup from stream parallelism without launching anything.
221    #[must_use]
222    pub fn bounded_makespan(&self, max_streams: usize) -> u64 {
223        let lanes = max_streams.max(1);
224        self.waves.iter().map(|w| wave_makespan(w, lanes)).sum()
225    }
226
227    /// Returns the makespan under unbounded streams: the sum of each wave's
228    /// `max_cost`. This is a lower bound that the critical-path cost refines.
229    #[must_use]
230    pub fn unbounded_makespan(&self) -> u64 {
231        self.waves.iter().map(|w| w.max_cost).sum()
232    }
233}
234
235/// Computes one wavefront's makespan with `lanes` concurrent slots via LPT.
236fn wave_makespan(wave: &Wavefront, lanes: usize) -> u64 {
237    if wave.nodes.is_empty() {
238        return 0;
239    }
240    if lanes >= wave.nodes.len() {
241        return wave.max_cost;
242    }
243    // We only have the aggregate (max, total) per wave, but to schedule by LPT
244    // we need per-node costs. Reconstruct a balanced lower bound: the makespan
245    // is at least max(max_cost, ceil(total_cost / lanes)). For the modelling
246    // purpose this exact bound is what an optimal LPT packing approaches.
247    let balanced = wave.total_cost.div_ceil(lanes as u64);
248    wave.max_cost.max(balanced)
249}
250
251// ---------------------------------------------------------------------------
252// Tests
253// ---------------------------------------------------------------------------
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::builder::GraphBuilder;
259
260    fn cost_node(b: &mut GraphBuilder, name: &str, cost: u64) -> NodeId {
261        b.add_raw(
262            crate::node::GraphNode::new(NodeId(0), crate::node::NodeKind::Barrier)
263                .with_name(name)
264                .with_cost(cost),
265        )
266    }
267
268    #[test]
269    fn levelize_empty_errors() {
270        let g = ComputeGraph::new();
271        assert!(matches!(
272            Schedule::levelize(&g),
273            Err(GraphError::EmptyGraph)
274        ));
275    }
276
277    #[test]
278    fn linear_chain_one_node_per_wave() {
279        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
280        let a = b.add_barrier("a");
281        let c = b.add_barrier("b");
282        let d = b.add_barrier("c");
283        b.chain(&[a, c, d]);
284        let g = b.build().expect("builds");
285        let sch = Schedule::levelize(&g).expect("levelize");
286        assert_eq!(sch.depth(), 3);
287        for w in sch.wavefronts() {
288            assert_eq!(w.width(), 1);
289        }
290        assert_eq!(sch.max_width(), 1);
291    }
292
293    #[test]
294    fn fork_join_groups_independent_nodes() {
295        // src → {a,b,c} → sink. The middle wave must contain exactly a,b,c.
296        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
297        let src = b.add_barrier("src");
298        let a = b.add_barrier("a");
299        let bb = b.add_barrier("b");
300        let c = b.add_barrier("c");
301        let sink = b.add_barrier("sink");
302        b.fan_out(src, &[a, bb, c]);
303        b.fan_in(&[a, bb, c], sink);
304        let g = b.build().expect("builds");
305        let sch = Schedule::levelize(&g).expect("levelize");
306        assert_eq!(sch.depth(), 3);
307        let mid = &sch.wavefronts()[1];
308        assert_eq!(mid.width(), 3);
309        let mut got = mid.nodes.clone();
310        got.sort();
311        let mut want = vec![a, bb, c];
312        want.sort();
313        assert_eq!(got, want);
314        assert_eq!(sch.max_width(), 3);
315    }
316
317    #[test]
318    fn wavefront_nodes_are_mutually_independent() {
319        // Property: no two nodes in the same wave have a dependency between them.
320        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
321        let a = b.add_barrier("a");
322        let bb = b.add_barrier("b");
323        let c = b.add_barrier("c");
324        let d = b.add_barrier("d");
325        let e = b.add_barrier("e");
326        // a→c, a→d, bb→d, bb→e (two sources, mixed fan-out)
327        b.dep(a, c);
328        b.dep(a, d);
329        b.dep(bb, d);
330        b.dep(bb, e);
331        let g = b.build().expect("builds");
332        let sch = Schedule::levelize(&g).expect("levelize");
333        for wave in sch.wavefronts() {
334            for &u in &wave.nodes {
335                for &v in &wave.nodes {
336                    if u != v {
337                        assert!(
338                            !g.is_reachable(u, v),
339                            "wave-mates {u} and {v} must be independent"
340                        );
341                    }
342                }
343            }
344        }
345    }
346
347    #[test]
348    fn levels_match_longest_path() {
349        // Diamond a→{b,c}→d, but with an extra long edge a→x→d so d is level 2.
350        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
351        let a = b.add_barrier("a");
352        let bb = b.add_barrier("b");
353        let c = b.add_barrier("c");
354        let d = b.add_barrier("d");
355        b.dep(a, bb).dep(a, c).dep(bb, d).dep(c, d);
356        let g = b.build().expect("builds");
357        let sch = Schedule::levelize(&g).expect("levelize");
358        assert_eq!(sch.level_of(a).expect("a"), 0);
359        assert_eq!(sch.level_of(bb).expect("b"), 1);
360        assert_eq!(sch.level_of(c).expect("c"), 1);
361        assert_eq!(sch.level_of(d).expect("d"), 2);
362    }
363
364    #[test]
365    fn level_of_out_of_range() {
366        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
367        b.add_barrier("a");
368        let g = b.build().expect("builds");
369        let sch = Schedule::levelize(&g).expect("levelize");
370        assert!(matches!(
371            sch.level_of(NodeId(50)),
372            Err(GraphError::NodeNotFound(_))
373        ));
374    }
375
376    #[test]
377    fn critical_path_cost_weighted() {
378        // a(1) → b(10) → c(1); critical path = 12.
379        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
380        let a = cost_node(&mut b, "a", 1);
381        let bb = cost_node(&mut b, "b", 10);
382        let c = cost_node(&mut b, "c", 1);
383        b.chain(&[a, bb, c]);
384        let g = b.build().expect("builds");
385        let sch = Schedule::levelize(&g).expect("levelize");
386        assert_eq!(sch.critical_path_cost(), 12);
387    }
388
389    #[test]
390    fn critical_path_takes_longest_branch() {
391        // a → {b(5), c(20)} → d ; critical path = a(1)+c(20)+d(1) = 22.
392        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
393        let a = cost_node(&mut b, "a", 1);
394        let bb = cost_node(&mut b, "b", 5);
395        let c = cost_node(&mut b, "c", 20);
396        let d = cost_node(&mut b, "d", 1);
397        b.dep(a, bb).dep(a, c).dep(bb, d).dep(c, d);
398        let g = b.build().expect("builds");
399        let sch = Schedule::levelize(&g).expect("levelize");
400        assert_eq!(sch.critical_path_cost(), 22);
401    }
402
403    #[test]
404    fn bounded_makespan_serializes_wide_wave() {
405        // One wave of 4 nodes each cost 10. Unbounded → 10, with 2 lanes → 20,
406        // with 1 lane → 40.
407        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
408        let src = cost_node(&mut b, "src", 0);
409        let leaves: Vec<NodeId> = (0..4)
410            .map(|i| cost_node(&mut b, &format!("l{i}"), 10))
411            .collect();
412        b.fan_out(src, &leaves);
413        let g = b.build().expect("builds");
414        let sch = Schedule::levelize(&g).expect("levelize");
415        // wave 0 = src (cost 0), wave 1 = 4 leaves (cost 10 each).
416        assert_eq!(sch.unbounded_makespan(), 10);
417        assert_eq!(sch.bounded_makespan(4), 10);
418        assert_eq!(sch.bounded_makespan(2), 20);
419        assert_eq!(sch.bounded_makespan(1), 40);
420        // max_streams=0 treated as 1.
421        assert_eq!(sch.bounded_makespan(0), 40);
422    }
423
424    #[test]
425    fn bounded_makespan_respects_max_cost_lower_bound() {
426        // Wave with costs {30, 1, 1, 1}; with 2 lanes the makespan is bounded
427        // below by max_cost=30 (not total/lanes=16.5→17).
428        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
429        let src = cost_node(&mut b, "src", 0);
430        let big = cost_node(&mut b, "big", 30);
431        let s1 = cost_node(&mut b, "s1", 1);
432        let s2 = cost_node(&mut b, "s2", 1);
433        let s3 = cost_node(&mut b, "s3", 1);
434        b.fan_out(src, &[big, s1, s2, s3]);
435        let g = b.build().expect("builds");
436        let sch = Schedule::levelize(&g).expect("levelize");
437        assert_eq!(sch.bounded_makespan(2), 30);
438    }
439
440    #[test]
441    fn wave_cost_aggregates() {
442        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
443        let src = cost_node(&mut b, "src", 0);
444        let a = cost_node(&mut b, "a", 3);
445        let c = cost_node(&mut b, "c", 7);
446        b.fan_out(src, &[a, c]);
447        let g = b.build().expect("builds");
448        let sch = Schedule::levelize(&g).expect("levelize");
449        let wave1 = &sch.wavefronts()[1];
450        assert_eq!(wave1.max_cost, 7);
451        assert_eq!(wave1.total_cost, 10);
452    }
453
454    #[test]
455    fn isolated_nodes_all_in_wave_zero() {
456        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
457        b.add_barrier("a");
458        b.add_barrier("b");
459        b.add_barrier("c");
460        let g = b.build().expect("builds");
461        let sch = Schedule::levelize(&g).expect("levelize");
462        assert_eq!(sch.depth(), 1);
463        assert_eq!(sch.wavefronts()[0].width(), 3);
464    }
465}