Skip to main content

oxicuda_graph/analysis/
topo.rs

1//! Topological-level analysis and critical-path scheduling.
2//!
3//! Beyond a simple topological order (provided by [`ComputeGraph::topological_order`]),
4//! this module computes rich scheduling information:
5//!
6//! * **Level assignment**: Each node gets a "level" equal to the length of the
7//!   longest path (in edges) from any source node to that node.
8//! * **Cost-weighted ASAP/ALAP**: As-Soon-As-Possible and As-Late-As-Possible
9//!   schedules using per-node cost hints.
10//! * **Slack**: The difference `ALAP - ASAP` for each node; zero-slack nodes
11//!   lie on the critical path.
12//! * **Priority lists**: Used by the stream partitioner to order ready nodes.
13
14use std::collections::VecDeque;
15
16use crate::error::{GraphError, GraphResult};
17use crate::graph::ComputeGraph;
18use crate::node::NodeId;
19
20// ---------------------------------------------------------------------------
21// TopoInfo — per-node scheduling data
22// ---------------------------------------------------------------------------
23
24/// Scheduling metadata for a single node, produced by [`TopoAnalysis`].
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct NodeInfo {
27    /// Topological level (longest path in edges from any source).
28    pub level: usize,
29    /// As-Soon-As-Possible time (cost-weighted earliest start).
30    pub asap: u64,
31    /// As-Late-As-Possible time (cost-weighted latest start, critical-path constrained).
32    pub alap: u64,
33    /// Slack = `alap - asap`. Zero means the node is on the critical path.
34    pub slack: u64,
35    /// Whether this node lies on the critical path.
36    pub on_critical_path: bool,
37}
38
39impl NodeInfo {
40    /// Returns the "mobility" of the node — how many cost units it can be
41    /// delayed without extending the overall schedule.
42    #[must_use]
43    pub fn mobility(&self) -> u64 {
44        self.slack
45    }
46}
47
48// ---------------------------------------------------------------------------
49// TopoAnalysis — result of the full analysis pass
50// ---------------------------------------------------------------------------
51
52/// Result of running topological-level analysis on a [`ComputeGraph`].
53#[derive(Debug, Clone)]
54pub struct TopoAnalysis {
55    /// Topological order of all node IDs.
56    pub order: Vec<NodeId>,
57    /// Per-node scheduling info (indexed by `NodeId.0`).
58    info: Vec<NodeInfo>,
59    /// Length of the critical path in cost units.
60    pub critical_path_cost: u64,
61    /// Length of the critical path in edges (number of edges).
62    pub critical_path_edges: usize,
63}
64
65impl TopoAnalysis {
66    /// Returns the scheduling info for a specific node.
67    ///
68    /// Returns `None` if the `NodeId` is out of range.
69    #[must_use]
70    pub fn node_info(&self, id: NodeId) -> Option<&NodeInfo> {
71        self.info.get(id.0 as usize)
72    }
73
74    /// Returns all nodes on the critical path, in topological order.
75    pub fn critical_path_nodes(&self) -> Vec<NodeId> {
76        self.order
77            .iter()
78            .filter(|&&id| {
79                self.info
80                    .get(id.0 as usize)
81                    .map(|i| i.on_critical_path)
82                    .unwrap_or(false)
83            })
84            .copied()
85            .collect()
86    }
87
88    /// Returns nodes sorted by ascending slack (most-critical first).
89    pub fn priority_order(&self) -> Vec<NodeId> {
90        let mut nodes = self.order.clone();
91        nodes.sort_by_key(|&id| {
92            self.info
93                .get(id.0 as usize)
94                .map(|i| i.slack)
95                .unwrap_or(u64::MAX)
96        });
97        nodes
98    }
99
100    /// Returns the maximum topological level (depth of the graph in edges).
101    #[must_use]
102    pub fn max_level(&self) -> usize {
103        self.info.iter().map(|i| i.level).max().unwrap_or(0)
104    }
105
106    /// Returns the number of nodes at each level (the "width profile").
107    pub fn level_widths(&self) -> Vec<usize> {
108        let max = self.max_level();
109        let mut widths = vec![0usize; max + 1];
110        for info in &self.info {
111            widths[info.level] += 1;
112        }
113        widths
114    }
115}
116
117// ---------------------------------------------------------------------------
118// analyse — entry point
119// ---------------------------------------------------------------------------
120
121/// Performs full topological-level analysis on `graph`.
122///
123/// Computes ASAP, ALAP, slack, and critical path annotations for every node.
124///
125/// # Errors
126///
127/// Returns [`GraphError::EmptyGraph`] if the graph has no nodes.
128pub fn analyse(graph: &ComputeGraph) -> GraphResult<TopoAnalysis> {
129    if graph.is_empty() {
130        return Err(GraphError::EmptyGraph);
131    }
132
133    let order = graph.topological_order()?;
134    let n = graph.node_count();
135
136    // ---- ASAP pass (forward, cost-weighted) --------------------------------
137    // asap[v] = max over all predecessors p: asap[p] + cost[p]
138    let mut asap = vec![0u64; n];
139    for &id in &order {
140        let cost_before = asap[id.0 as usize];
141        for &succ in graph.successors(id)? {
142            let new_asap = cost_before + graph.node(id)?.cost_hint;
143            if new_asap > asap[succ.0 as usize] {
144                asap[succ.0 as usize] = new_asap;
145            }
146        }
147    }
148
149    // Total schedule length = max(asap[v] + cost[v]) over all nodes.
150    let schedule_len: u64 = (0..n)
151        .map(|i| asap[i] + graph.nodes()[i].cost_hint)
152        .max()
153        .unwrap_or(0);
154
155    // ---- ALAP pass (backward, cost-weighted) --------------------------------
156    // alap[v] = schedule_len - cost[v] - max over successors s: (schedule_len - alap[s])
157    let mut alap = vec![schedule_len; n];
158    // For sinks: alap[v] = schedule_len - cost[v].
159    for (i, node) in graph.nodes().iter().enumerate() {
160        if graph.successors(node.id)?.is_empty() {
161            alap[i] = schedule_len - node.cost_hint;
162        }
163    }
164    // Backward pass over reversed topological order.
165    for &id in order.iter().rev() {
166        let succs = graph.successors(id)?;
167        if succs.is_empty() {
168            continue;
169        }
170        // alap[v] = min over successors s: alap[s] - cost[v]
171        let cost_v = graph.node(id)?.cost_hint;
172        let min_succ_alap = succs
173            .iter()
174            .map(|&s| alap[s.0 as usize])
175            .min()
176            .unwrap_or(schedule_len);
177        let v_alap = min_succ_alap.saturating_sub(cost_v);
178        if v_alap < alap[id.0 as usize] {
179            alap[id.0 as usize] = v_alap;
180        }
181    }
182
183    // ---- Slack and critical path --------------------------------------------
184    let mut info = Vec::with_capacity(n);
185    for i in 0..n {
186        let slack = alap[i].saturating_sub(asap[i]);
187        info.push(NodeInfo {
188            level: 0, // filled below
189            asap: asap[i],
190            alap: alap[i],
191            slack,
192            on_critical_path: slack == 0,
193        });
194    }
195
196    // ---- Level assignment (edge-based, forward) ----------------------------
197    let mut level = vec![0usize; n];
198    let mut in_degree: Vec<u32> = graph
199        .nodes()
200        .iter()
201        .map(|nd| {
202            graph
203                .predecessors(nd.id)
204                .map(|p| p.len() as u32)
205                .unwrap_or(0)
206        })
207        .collect();
208    let mut queue: VecDeque<NodeId> = (0..n)
209        .filter(|&i| in_degree[i] == 0)
210        .map(|i| NodeId(i as u32))
211        .collect();
212    while let Some(id) = queue.pop_front() {
213        let lv = level[id.0 as usize];
214        for &succ in graph.successors(id)? {
215            let nl = lv + 1;
216            if nl > level[succ.0 as usize] {
217                level[succ.0 as usize] = nl;
218            }
219            let d = &mut in_degree[succ.0 as usize];
220            *d -= 1;
221            if *d == 0 {
222                queue.push_back(succ);
223            }
224        }
225    }
226    for (i, inf) in info.iter_mut().enumerate() {
227        inf.level = level[i];
228    }
229
230    let critical_path_edges = *level.iter().max().unwrap_or(&0);
231    let critical_path_cost = schedule_len;
232
233    Ok(TopoAnalysis {
234        order,
235        info,
236        critical_path_cost,
237        critical_path_edges,
238    })
239}
240
241// ---------------------------------------------------------------------------
242// Tests
243// ---------------------------------------------------------------------------
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::builder::GraphBuilder;
249    use crate::node::MemcpyDir;
250
251    fn make_linear(n: usize) -> (ComputeGraph, Vec<NodeId>) {
252        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
253        let ids: Vec<NodeId> = (0..n).map(|_| b.add_barrier("x")).collect();
254        for w in ids.windows(2) {
255            b.dep(w[0], w[1]);
256        }
257        let g = b.build().unwrap();
258        (g, ids)
259    }
260
261    #[test]
262    fn analyse_empty_returns_error() {
263        let g = ComputeGraph::new();
264        assert!(matches!(analyse(&g), Err(GraphError::EmptyGraph)));
265    }
266
267    #[test]
268    fn analyse_single_node() {
269        let (g, ids) = make_linear(1);
270        let ta = analyse(&g).unwrap();
271        let info = ta.node_info(ids[0]).unwrap();
272        assert_eq!(info.level, 0);
273        assert_eq!(info.asap, 0);
274        assert_eq!(info.slack, 0);
275        assert!(info.on_critical_path);
276    }
277
278    #[test]
279    fn analyse_linear_levels() {
280        let (g, ids) = make_linear(5);
281        let ta = analyse(&g).unwrap();
282        for (i, &id) in ids.iter().enumerate() {
283            assert_eq!(ta.node_info(id).unwrap().level, i, "level mismatch at {i}");
284        }
285    }
286
287    #[test]
288    fn analyse_linear_critical_path_edges() {
289        let (g, _) = make_linear(4);
290        let ta = analyse(&g).unwrap();
291        assert_eq!(ta.critical_path_edges, 3);
292    }
293
294    #[test]
295    fn analyse_diamond_all_on_critical_path() {
296        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
297        let a = b.add_barrier("a");
298        let b1 = b.add_barrier("b");
299        let c = b.add_barrier("c");
300        let d = b.add_barrier("d");
301        b.dep(a, b1).dep(a, c).dep(b1, d).dep(c, d);
302        let g = b.build().unwrap();
303        let ta = analyse(&g).unwrap();
304        // All nodes have the same depth in the diamond → all on critical path.
305        assert_eq!(ta.critical_path_edges, 2);
306        // a and d always on critical path.
307        assert!(ta.node_info(a).unwrap().on_critical_path);
308        assert!(ta.node_info(d).unwrap().on_critical_path);
309    }
310
311    #[test]
312    fn analyse_level_widths() {
313        let (g, _) = make_linear(3);
314        let ta = analyse(&g).unwrap();
315        // Linear: width at each level = 1.
316        let widths = ta.level_widths();
317        assert_eq!(widths, vec![1, 1, 1]);
318    }
319
320    #[test]
321    fn analyse_fork_join_widths() {
322        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
323        let src = b.add_barrier("src");
324        let a = b.add_barrier("a");
325        let c = b.add_barrier("c");
326        let d = b.add_barrier("d");
327        let sink = b.add_barrier("sink");
328        b.fan_out(src, &[a, c, d]);
329        b.fan_in(&[a, c, d], sink);
330        let g = b.build().unwrap();
331        let ta = analyse(&g).unwrap();
332        let widths = ta.level_widths();
333        // Level 0: src (1), Level 1: a,c,d (3), Level 2: sink (1).
334        assert_eq!(widths[0], 1);
335        assert_eq!(widths[1], 3);
336        assert_eq!(widths[2], 1);
337    }
338
339    #[test]
340    fn analyse_priority_order_zero_slack_first() {
341        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
342        // Chain a→b and isolated node c (high slack).
343        let a = b.add_barrier("a");
344        let bnode = b.add_barrier("b");
345        let c = b.add_barrier("c");
346        b.dep(a, bnode);
347        let g = b.build().unwrap();
348        let ta = analyse(&g).unwrap();
349        let prio = ta.priority_order();
350        // Nodes with slack=0 must come before nodes with slack>0.
351        let pos_a = prio.iter().position(|&x| x == a).unwrap();
352        let pos_c = prio.iter().position(|&x| x == c).unwrap();
353        let slack_a = ta.node_info(a).unwrap().slack;
354        let slack_c = ta.node_info(c).unwrap().slack;
355        if slack_a < slack_c {
356            assert!(
357                pos_a < pos_c,
358                "zero-slack node must precede high-slack node"
359            );
360        }
361        let _ = bnode;
362    }
363
364    #[test]
365    fn analyse_max_level() {
366        let (g, _) = make_linear(7);
367        let ta = analyse(&g).unwrap();
368        assert_eq!(ta.max_level(), 6);
369    }
370
371    #[test]
372    fn analyse_cost_weighted_asap() {
373        // a (cost=1) → b (cost=10) → c (cost=1)
374        // asap[a]=0, asap[b]=1, asap[c]=11
375        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
376        let a = b.add_raw(
377            crate::node::GraphNode::new(crate::node::NodeId(0), crate::node::NodeKind::Barrier)
378                .with_cost(1)
379                .with_name("a"),
380        );
381        let bnode = b.add_raw(
382            crate::node::GraphNode::new(crate::node::NodeId(0), crate::node::NodeKind::Barrier)
383                .with_cost(10)
384                .with_name("b"),
385        );
386        let c = b.add_raw(
387            crate::node::GraphNode::new(crate::node::NodeId(0), crate::node::NodeKind::Barrier)
388                .with_cost(1)
389                .with_name("c"),
390        );
391        b.dep(a, bnode).dep(bnode, c);
392        let g = b.build().unwrap();
393        let ta = analyse(&g).unwrap();
394        assert_eq!(ta.node_info(a).unwrap().asap, 0);
395        assert_eq!(ta.node_info(bnode).unwrap().asap, 1);
396        assert_eq!(ta.node_info(c).unwrap().asap, 11);
397    }
398
399    #[test]
400    fn analyse_critical_path_nodes_nonempty() {
401        let (g, _) = make_linear(4);
402        let ta = analyse(&g).unwrap();
403        let cp = ta.critical_path_nodes();
404        assert!(!cp.is_empty());
405    }
406
407    #[test]
408    fn analyse_memcpy_node_in_graph() {
409        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
410        let upload = b.add_memcpy("up", MemcpyDir::HostToDevice, 1024);
411        let compute = b.add_kernel("k", 4, 128, 0).cost(50).finish();
412        let download = b.add_memcpy("dn", MemcpyDir::DeviceToHost, 1024);
413        b.chain(&[upload, compute, download]);
414        let g = b.build().unwrap();
415        let ta = analyse(&g).unwrap();
416        assert_eq!(ta.critical_path_edges, 2);
417        assert!(ta.node_info(compute).unwrap().asap >= 1);
418    }
419}