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                    .ok_or_else(|| {
164                        GraphError::StreamPartitioningFailed(
165                            "no streams available for assignment".into(),
166                        )
167                    })?;
168                stream_last[best.0] = (node_alap, node_id, best.1);
169                best.1
170            };
171            node_stream.insert(node_id, stream);
172        } else {
173            // Node with predecessors: find the stream whose last committed
174            // node is a predecessor of `node_id` (or an ancestor via graph
175            // reachability). This avoids forcing independent nodes onto the
176            // same stream just because they share a common predecessor.
177            //
178            // Strategy: iterate over streams in order. Pick the first stream
179            // whose last node is a predecessor of `node_id`. If none qualify
180            // (e.g., all streams have moved on to non-predecessors), open a
181            // new stream if capacity allows; otherwise fall back to the
182            // stream whose predecessor has the highest ALAP.
183
184            // Collect streams that actually carry a predecessor of node_id.
185            let pred_set: std::collections::HashSet<NodeId> = preds.iter().copied().collect();
186            let compatible_stream = stream_last
187                .iter()
188                .find(|(_, last_node, _)| pred_set.contains(last_node))
189                .map(|(_, _, sid)| *sid);
190
191            let stream = if let Some(sid) = compatible_stream {
192                sid
193            } else {
194                // No stream has the predecessor as its last node.
195                // Check if we can open a new stream.
196                if stream_last.len() < max_streams {
197                    // Find any predecessor's stream to base ALAP on.
198                    let base_stream = preds
199                        .iter()
200                        .filter_map(|&p| node_stream.get(&p).copied())
201                        .next()
202                        .unwrap_or(StreamId::DEFAULT);
203                    // Only open a new stream if the base stream is actually
204                    // occupied (its last node is not our predecessor).
205                    let base_last_is_pred = stream_last
206                        .iter()
207                        .find(|(_, _, sid)| *sid == base_stream)
208                        .map(|(_, ln, _)| pred_set.contains(ln))
209                        .unwrap_or(false);
210                    if !base_last_is_pred {
211                        let sid = StreamId(stream_last.len() as u32);
212                        stream_last.push((node_alap, node_id, sid));
213                        node_stream.insert(node_id, sid);
214                        continue;
215                    }
216                    base_stream
217                } else {
218                    // All streams taken; use the one carrying our most urgent predecessor.
219                    preds
220                        .iter()
221                        .filter_map(|&p| node_stream.get(&p).copied())
222                        .max_by_key(|&s| {
223                            stream_last
224                                .iter()
225                                .find(|(_, _, sid)| *sid == s)
226                                .map(|(alap, _, _)| *alap)
227                                .unwrap_or(0)
228                        })
229                        .unwrap_or(StreamId::DEFAULT)
230                }
231            };
232
233            // Update stream's last ALAP.
234            if let Some(entry) = stream_last.iter_mut().find(|(_, _, sid)| *sid == stream) {
235                if node_alap > entry.0 {
236                    entry.0 = node_alap;
237                }
238                entry.1 = node_id;
239            }
240            node_stream.insert(node_id, stream);
241        }
242    }
243
244    // Collect assignments in topological order.
245    let assignments: Vec<StreamAssignment> = topo
246        .order
247        .iter()
248        .map(|&nid| StreamAssignment {
249            node: nid,
250            stream: node_stream.get(&nid).copied().unwrap_or(StreamId::DEFAULT),
251        })
252        .collect();
253
254    // Identify cross-stream sync points.
255    let mut sync_points: Vec<SyncPoint> = Vec::new();
256    for edge in graph.edges() {
257        let (from, to) = edge;
258        let sf = node_stream.get(&from).copied().unwrap_or(StreamId::DEFAULT);
259        let st = node_stream.get(&to).copied().unwrap_or(StreamId::DEFAULT);
260        if sf != st {
261            sync_points.push(SyncPoint {
262                from,
263                stream_from: sf,
264                to,
265                stream_to: st,
266            });
267        }
268    }
269    sync_points.sort_by_key(|sp| (sp.from.0, sp.to.0));
270    sync_points.dedup_by_key(|sp| (sp.from, sp.to));
271
272    let num_streams = stream_last.len().max(1);
273
274    Ok(StreamPlan {
275        assignments,
276        sync_points,
277        num_streams,
278        max_streams,
279    })
280}
281
282// ---------------------------------------------------------------------------
283// Tests
284// ---------------------------------------------------------------------------
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::builder::GraphBuilder;
290
291    fn barrier(b: &mut GraphBuilder, name: &str) -> NodeId {
292        b.add_barrier(name)
293    }
294
295    #[test]
296    fn stream_empty_graph() {
297        let g = ComputeGraph::new();
298        assert!(matches!(analyse(&g, 4), Err(GraphError::EmptyGraph)));
299    }
300
301    #[test]
302    fn stream_single_node_default_stream() {
303        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
304        let n = barrier(&mut b, "n");
305        let g = b.build().unwrap();
306        let plan = analyse(&g, 4).unwrap();
307        assert_eq!(plan.stream_of(n), StreamId(0));
308    }
309
310    #[test]
311    fn stream_linear_chain_single_stream() {
312        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
313        let a = barrier(&mut b, "a");
314        let bnode = barrier(&mut b, "b");
315        let c = barrier(&mut b, "c");
316        b.chain(&[a, bnode, c]);
317        let g = b.build().unwrap();
318        let plan = analyse(&g, 4).unwrap();
319        // All in a chain → same stream (no concurrency).
320        assert!(!plan.has_concurrency());
321        assert_eq!(plan.sync_count(), 0);
322    }
323
324    #[test]
325    fn stream_fork_parallel_branches() {
326        // src → a, src → b (a and b are independent)
327        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
328        let src = barrier(&mut b, "src");
329        let a = barrier(&mut b, "a");
330        let bnode = barrier(&mut b, "b");
331        b.fan_out(src, &[a, bnode]);
332        let g = b.build().unwrap();
333        let plan = analyse(&g, 4).unwrap();
334        // a and b should be on different streams.
335        let sa = plan.stream_of(a);
336        let sb = plan.stream_of(bnode);
337        assert_ne!(
338            sa, sb,
339            "independent branches should be on different streams"
340        );
341    }
342
343    #[test]
344    fn stream_max_streams_one_disables_parallelism() {
345        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
346        let a = barrier(&mut b, "a");
347        let bnode = barrier(&mut b, "b");
348        let c = barrier(&mut b, "c");
349        // No deps — all independent sources.
350        let g = b.build().unwrap();
351        let plan = analyse(&g, 1).unwrap();
352        // All on stream 0.
353        assert_eq!(plan.stream_of(a), StreamId(0));
354        assert_eq!(plan.stream_of(bnode), StreamId(0));
355        assert_eq!(plan.stream_of(c), StreamId(0));
356        assert!(!plan.has_concurrency());
357    }
358
359    #[test]
360    fn stream_cross_stream_sync_detected() {
361        // a → b on stream 0, c (independent of a) → b; if a and c are on
362        // different streams, there must be a sync point before b.
363        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
364        let a = barrier(&mut b, "a");
365        let c = barrier(&mut b, "c");
366        let bnode = barrier(&mut b, "b");
367        b.dep(a, bnode).dep(c, bnode);
368        let g = b.build().unwrap();
369        let plan = analyse(&g, 4).unwrap();
370        let sa = plan.stream_of(a);
371        let sc = plan.stream_of(c);
372        if sa != sc {
373            // There should be sync points for the cross-stream deps.
374            assert!(plan.sync_count() > 0);
375        }
376    }
377
378    #[test]
379    fn stream_nodes_on_stream_zero() {
380        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
381        let a = barrier(&mut b, "a");
382        let bnode = barrier(&mut b, "b");
383        b.dep(a, bnode);
384        let g = b.build().unwrap();
385        let plan = analyse(&g, 4).unwrap();
386        let on_s0 = plan.nodes_on(StreamId(0));
387        assert!(!on_s0.is_empty());
388    }
389
390    #[test]
391    fn stream_assignment_covers_all_nodes() {
392        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
393        let a = barrier(&mut b, "a");
394        let bnode = barrier(&mut b, "b");
395        let c = barrier(&mut b, "c");
396        b.chain(&[a, bnode, c]);
397        let g = b.build().unwrap();
398        let plan = analyse(&g, 2).unwrap();
399        assert_eq!(plan.assignments.len(), 3);
400    }
401
402    #[test]
403    fn stream_plan_respects_max_streams_cap() {
404        // 5 independent nodes with max_streams=3 → at most 3 streams used.
405        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
406        for i in 0..5 {
407            barrier(&mut b, &format!("n{i}"));
408        }
409        let g = b.build().unwrap();
410        let plan = analyse(&g, 3).unwrap();
411        assert!(plan.num_streams <= 3);
412    }
413
414    #[test]
415    fn stream_diamond_join_handled() {
416        // a → b, a → c, b → d, c → d
417        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
418        let a = barrier(&mut b, "a");
419        let bnode = barrier(&mut b, "b");
420        let c = barrier(&mut b, "c");
421        let d = barrier(&mut b, "d");
422        b.dep(a, bnode).dep(a, c).dep(bnode, d).dep(c, d);
423        let g = b.build().unwrap();
424        let plan = analyse(&g, 4).unwrap();
425        // d depends on both b and c; all preds must complete before d.
426        // No assertion about stream assignment (heuristic), but must not crash.
427        assert_eq!(plan.assignments.len(), 4);
428    }
429
430    #[test]
431    fn stream_zero_max_treated_as_one() {
432        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
433        barrier(&mut b, "n");
434        let g = b.build().unwrap();
435        let plan = analyse(&g, 0).unwrap(); // 0 → treated as 1
436        assert_eq!(plan.num_streams, 1);
437    }
438}