Skip to main content

oxicuda_graph/optimizer/
stream.rs

1//! Stream partitioning — assigns nodes to independent CUDA streams.
2//!
3//! Concurrent GPU execution requires independent work to be submitted on
4//! different CUDA streams. This pass analyses the dependency graph and
5//! assigns each node a `StreamId` such that:
6//!
7//! * Dependent nodes (connected by an edge) are on the **same** stream
8//!   *or* the dependency is enforced by an event record/wait pair.
9//! * Independent subgraphs (no dependency path between them) are placed
10//!   on **different** streams.
11//! * The number of streams is minimised (up to a user-specified cap).
12//!
13//! # Algorithm
14//!
15//! We use a **list-scheduling** heuristic on the topological order, guided
16//! by the ASAP/ALAP slack computed in the topo analysis pass:
17//!
18//! 1. Obtain the topological priority order (zero-slack nodes first).
19//! 2. Maintain a set of "available" streams (initially empty).
20//! 3. For each node in priority order: if it has predecessors, assign it to
21//!    the stream whose last node is a direct predecessor; otherwise open a
22//!    new stream (up to `max_streams`). Source nodes always get a new stream.
23//! 4. Record cross-stream sync points (pairs of nodes on different streams
24//!    where one depends on the other).
25//!
26//! # Limitations
27//!
28//! This is a heuristic, not an optimal ILP formulation. For production use,
29//! the result can be refined by a second pass that inserts event nodes.
30
31use std::collections::HashMap;
32
33use crate::analysis::topo_analyse;
34use crate::error::{GraphError, GraphResult};
35use crate::graph::ComputeGraph;
36use crate::node::{NodeId, StreamId};
37
38// ---------------------------------------------------------------------------
39// StreamAssignment
40// ---------------------------------------------------------------------------
41
42/// The stream assignment for a single node.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct StreamAssignment {
45    pub node: NodeId,
46    pub stream: StreamId,
47}
48
49// ---------------------------------------------------------------------------
50// SyncPoint — cross-stream synchronisation requirement
51// ---------------------------------------------------------------------------
52
53/// A pair of nodes on different streams where `from` must complete before `to` begins.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct SyncPoint {
56    /// The producing node (on `stream_from`).
57    pub from: NodeId,
58    pub stream_from: StreamId,
59    /// The consuming node (on `stream_to`).
60    pub to: NodeId,
61    pub stream_to: StreamId,
62}
63
64// ---------------------------------------------------------------------------
65// StreamPlan
66// ---------------------------------------------------------------------------
67
68/// The result of stream partitioning.
69#[derive(Debug, Clone)]
70pub struct StreamPlan {
71    /// Per-node stream assignments.
72    pub assignments: Vec<StreamAssignment>,
73    /// Cross-stream sync points (event record/wait pairs to insert).
74    pub sync_points: Vec<SyncPoint>,
75    /// Number of distinct streams used.
76    pub num_streams: usize,
77    /// Maximum number of streams the pass was allowed to create.
78    pub max_streams: usize,
79}
80
81impl StreamPlan {
82    /// Returns the stream assigned to `node`, or `StreamId(0)` if not found.
83    pub fn stream_of(&self, node: NodeId) -> StreamId {
84        self.assignments
85            .iter()
86            .find(|a| a.node == node)
87            .map(|a| a.stream)
88            .unwrap_or(StreamId::DEFAULT)
89    }
90
91    /// Returns all nodes assigned to `stream`.
92    pub fn nodes_on(&self, stream: StreamId) -> Vec<NodeId> {
93        self.assignments
94            .iter()
95            .filter(|a| a.stream == stream)
96            .map(|a| a.node)
97            .collect()
98    }
99
100    /// Returns `true` if any two nodes have been assigned to different streams.
101    pub fn has_concurrency(&self) -> bool {
102        let first = self.assignments.first().map(|a| a.stream);
103        self.assignments.iter().any(|a| Some(a.stream) != first)
104    }
105
106    /// Returns the number of cross-stream synchronisation points.
107    pub fn sync_count(&self) -> usize {
108        self.sync_points.len()
109    }
110}
111
112// ---------------------------------------------------------------------------
113// analyse — entry point
114// ---------------------------------------------------------------------------
115
116/// Runs the stream partitioning pass.
117///
118/// * `max_streams` — upper bound on the number of streams to create.
119///   Passing `1` disables parallelism (all nodes on stream 0).
120///   Passing `0` is treated as `1`.
121///
122/// # Errors
123///
124/// Returns [`GraphError::EmptyGraph`] if there are no nodes.
125/// Returns [`GraphError::StreamPartitioningFailed`] on internal errors.
126pub fn analyse(graph: &ComputeGraph, max_streams: usize) -> GraphResult<StreamPlan> {
127    if graph.is_empty() {
128        return Err(GraphError::EmptyGraph);
129    }
130
131    let max_streams = max_streams.max(1);
132    let topo = topo_analyse(graph)?;
133
134    // Priority order: zero-slack first (most critical first).
135    let priority = topo.priority_order();
136
137    // Stream state: stream_id → last node assigned and its ALAP.
138    // We use a simple vec of (last_alap, last_node, stream_id).
139    let mut stream_last: Vec<(u64, NodeId, StreamId)> = Vec::new();
140
141    // Per-node stream assignment.
142    let mut node_stream: HashMap<NodeId, StreamId> = HashMap::new();
143
144    // Assign streams.
145    for &node_id in &priority {
146        let preds = graph.predecessors(node_id)?;
147        let node_alap = topo.node_info(node_id).map(|i| i.alap).unwrap_or(0);
148
149        if preds.is_empty() {
150            // Source node: open a new stream if capacity allows.
151            let stream = if stream_last.len() < max_streams {
152                let sid = StreamId(stream_last.len() as u32);
153                stream_last.push((node_alap, node_id, sid));
154                sid
155            } else {
156                // All stream slots taken: assign to the stream with the
157                // lowest current ALAP (most urgent, least loaded stream).
158                let best = stream_last
159                    .iter()
160                    .enumerate()
161                    .min_by_key(|(_, (alap, _, _))| *alap)
162                    .map(|(i, (_, _, sid))| (i, *sid))
163                    .unwrap();
164                stream_last[best.0] = (node_alap, node_id, best.1);
165                best.1
166            };
167            node_stream.insert(node_id, stream);
168        } else {
169            // Node with predecessors: find the stream whose last committed
170            // node is a predecessor of `node_id` (or an ancestor via graph
171            // reachability). This avoids forcing independent nodes onto the
172            // same stream just because they share a common predecessor.
173            //
174            // Strategy: iterate over streams in order. Pick the first stream
175            // whose last node is a predecessor of `node_id`. If none qualify
176            // (e.g., all streams have moved on to non-predecessors), open a
177            // new stream if capacity allows; otherwise fall back to the
178            // stream whose predecessor has the highest ALAP.
179
180            // Collect streams that actually carry a predecessor of node_id.
181            let pred_set: std::collections::HashSet<NodeId> = preds.iter().copied().collect();
182            let compatible_stream = stream_last
183                .iter()
184                .find(|(_, last_node, _)| pred_set.contains(last_node))
185                .map(|(_, _, sid)| *sid);
186
187            let stream = if let Some(sid) = compatible_stream {
188                sid
189            } else {
190                // No stream has the predecessor as its last node.
191                // Check if we can open a new stream.
192                if stream_last.len() < max_streams {
193                    // Find any predecessor's stream to base ALAP on.
194                    let base_stream = preds
195                        .iter()
196                        .filter_map(|&p| node_stream.get(&p).copied())
197                        .next()
198                        .unwrap_or(StreamId::DEFAULT);
199                    // Only open a new stream if the base stream is actually
200                    // occupied (its last node is not our predecessor).
201                    let base_last_is_pred = stream_last
202                        .iter()
203                        .find(|(_, _, sid)| *sid == base_stream)
204                        .map(|(_, ln, _)| pred_set.contains(ln))
205                        .unwrap_or(false);
206                    if !base_last_is_pred {
207                        let sid = StreamId(stream_last.len() as u32);
208                        stream_last.push((node_alap, node_id, sid));
209                        node_stream.insert(node_id, sid);
210                        continue;
211                    }
212                    base_stream
213                } else {
214                    // All streams taken; use the one carrying our most urgent predecessor.
215                    preds
216                        .iter()
217                        .filter_map(|&p| node_stream.get(&p).copied())
218                        .max_by_key(|&s| {
219                            stream_last
220                                .iter()
221                                .find(|(_, _, sid)| *sid == s)
222                                .map(|(alap, _, _)| *alap)
223                                .unwrap_or(0)
224                        })
225                        .unwrap_or(StreamId::DEFAULT)
226                }
227            };
228
229            // Update stream's last ALAP.
230            if let Some(entry) = stream_last.iter_mut().find(|(_, _, sid)| *sid == stream) {
231                if node_alap > entry.0 {
232                    entry.0 = node_alap;
233                }
234                entry.1 = node_id;
235            }
236            node_stream.insert(node_id, stream);
237        }
238    }
239
240    // Collect assignments in topological order.
241    let assignments: Vec<StreamAssignment> = topo
242        .order
243        .iter()
244        .map(|&nid| StreamAssignment {
245            node: nid,
246            stream: node_stream.get(&nid).copied().unwrap_or(StreamId::DEFAULT),
247        })
248        .collect();
249
250    // Identify cross-stream sync points.
251    let mut sync_points: Vec<SyncPoint> = Vec::new();
252    for edge in graph.edges() {
253        let (from, to) = edge;
254        let sf = node_stream.get(&from).copied().unwrap_or(StreamId::DEFAULT);
255        let st = node_stream.get(&to).copied().unwrap_or(StreamId::DEFAULT);
256        if sf != st {
257            sync_points.push(SyncPoint {
258                from,
259                stream_from: sf,
260                to,
261                stream_to: st,
262            });
263        }
264    }
265    sync_points.sort_by_key(|sp| (sp.from.0, sp.to.0));
266    sync_points.dedup_by_key(|sp| (sp.from, sp.to));
267
268    let num_streams = stream_last.len().max(1);
269
270    Ok(StreamPlan {
271        assignments,
272        sync_points,
273        num_streams,
274        max_streams,
275    })
276}
277
278// ---------------------------------------------------------------------------
279// Tests
280// ---------------------------------------------------------------------------
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::builder::GraphBuilder;
286
287    fn barrier(b: &mut GraphBuilder, name: &str) -> NodeId {
288        b.add_barrier(name)
289    }
290
291    #[test]
292    fn stream_empty_graph() {
293        let g = ComputeGraph::new();
294        assert!(matches!(analyse(&g, 4), Err(GraphError::EmptyGraph)));
295    }
296
297    #[test]
298    fn stream_single_node_default_stream() {
299        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
300        let n = barrier(&mut b, "n");
301        let g = b.build().unwrap();
302        let plan = analyse(&g, 4).unwrap();
303        assert_eq!(plan.stream_of(n), StreamId(0));
304    }
305
306    #[test]
307    fn stream_linear_chain_single_stream() {
308        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
309        let a = barrier(&mut b, "a");
310        let bnode = barrier(&mut b, "b");
311        let c = barrier(&mut b, "c");
312        b.chain(&[a, bnode, c]);
313        let g = b.build().unwrap();
314        let plan = analyse(&g, 4).unwrap();
315        // All in a chain → same stream (no concurrency).
316        assert!(!plan.has_concurrency());
317        assert_eq!(plan.sync_count(), 0);
318    }
319
320    #[test]
321    fn stream_fork_parallel_branches() {
322        // src → a, src → b (a and b are independent)
323        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
324        let src = barrier(&mut b, "src");
325        let a = barrier(&mut b, "a");
326        let bnode = barrier(&mut b, "b");
327        b.fan_out(src, &[a, bnode]);
328        let g = b.build().unwrap();
329        let plan = analyse(&g, 4).unwrap();
330        // a and b should be on different streams.
331        let sa = plan.stream_of(a);
332        let sb = plan.stream_of(bnode);
333        assert_ne!(
334            sa, sb,
335            "independent branches should be on different streams"
336        );
337    }
338
339    #[test]
340    fn stream_max_streams_one_disables_parallelism() {
341        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
342        let a = barrier(&mut b, "a");
343        let bnode = barrier(&mut b, "b");
344        let c = barrier(&mut b, "c");
345        // No deps — all independent sources.
346        let g = b.build().unwrap();
347        let plan = analyse(&g, 1).unwrap();
348        // All on stream 0.
349        assert_eq!(plan.stream_of(a), StreamId(0));
350        assert_eq!(plan.stream_of(bnode), StreamId(0));
351        assert_eq!(plan.stream_of(c), StreamId(0));
352        assert!(!plan.has_concurrency());
353    }
354
355    #[test]
356    fn stream_cross_stream_sync_detected() {
357        // a → b on stream 0, c (independent of a) → b; if a and c are on
358        // different streams, there must be a sync point before b.
359        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
360        let a = barrier(&mut b, "a");
361        let c = barrier(&mut b, "c");
362        let bnode = barrier(&mut b, "b");
363        b.dep(a, bnode).dep(c, bnode);
364        let g = b.build().unwrap();
365        let plan = analyse(&g, 4).unwrap();
366        let sa = plan.stream_of(a);
367        let sc = plan.stream_of(c);
368        if sa != sc {
369            // There should be sync points for the cross-stream deps.
370            assert!(plan.sync_count() > 0);
371        }
372    }
373
374    #[test]
375    fn stream_nodes_on_stream_zero() {
376        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
377        let a = barrier(&mut b, "a");
378        let bnode = barrier(&mut b, "b");
379        b.dep(a, bnode);
380        let g = b.build().unwrap();
381        let plan = analyse(&g, 4).unwrap();
382        let on_s0 = plan.nodes_on(StreamId(0));
383        assert!(!on_s0.is_empty());
384    }
385
386    #[test]
387    fn stream_assignment_covers_all_nodes() {
388        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
389        let a = barrier(&mut b, "a");
390        let bnode = barrier(&mut b, "b");
391        let c = barrier(&mut b, "c");
392        b.chain(&[a, bnode, c]);
393        let g = b.build().unwrap();
394        let plan = analyse(&g, 2).unwrap();
395        assert_eq!(plan.assignments.len(), 3);
396    }
397
398    #[test]
399    fn stream_plan_respects_max_streams_cap() {
400        // 5 independent nodes with max_streams=3 → at most 3 streams used.
401        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
402        for i in 0..5 {
403            barrier(&mut b, &format!("n{i}"));
404        }
405        let g = b.build().unwrap();
406        let plan = analyse(&g, 3).unwrap();
407        assert!(plan.num_streams <= 3);
408    }
409
410    #[test]
411    fn stream_diamond_join_handled() {
412        // a → b, a → c, b → d, c → d
413        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
414        let a = barrier(&mut b, "a");
415        let bnode = barrier(&mut b, "b");
416        let c = barrier(&mut b, "c");
417        let d = barrier(&mut b, "d");
418        b.dep(a, bnode).dep(a, c).dep(bnode, d).dep(c, d);
419        let g = b.build().unwrap();
420        let plan = analyse(&g, 4).unwrap();
421        // d depends on both b and c; all preds must complete before d.
422        // No assertion about stream assignment (heuristic), but must not crash.
423        assert_eq!(plan.assignments.len(), 4);
424    }
425
426    #[test]
427    fn stream_zero_max_treated_as_one() {
428        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
429        barrier(&mut b, "n");
430        let g = b.build().unwrap();
431        let plan = analyse(&g, 0).unwrap(); // 0 → treated as 1
432        assert_eq!(plan.num_streams, 1);
433    }
434}