Skip to main content

oxicuda_graph/optimizer/
fusion.rs

1//! Operator fusion pass — merges chains of fusible element-wise kernels.
2//!
3//! # What is fused
4//!
5//! Two consecutive kernel nodes `a → b` are **fusion candidates** when:
6//!
7//! 1. Both are marked `fusible` in their `NodeKind::KernelLaunch`.
8//! 2. `a` dominates `b` in the dominator tree (no side path can bypass `b`).
9//! 3. There is no non-fusible node on any path from `a` to `b`.
10//! 4. `a` has exactly one output buffer shared with `b`'s inputs (single
11//!    producer–consumer chain — avoids creating broadcast copies).
12//! 5. Both have the same launch configuration (same total thread count),
13//!    or `b` uses a configuration that is a submultiple of `a`'s grid.
14//!
15//! When a group is fusible, the pass produces a `FusionGroup` describing
16//! which original nodes should be merged. The graph itself is not modified
17//! by this pass — the `Executor` is responsible for lowering the fusion
18//! groups into combined PTX.
19//!
20//! # Algorithm
21//!
22//! 1. Run topological analysis to get ASAP order and level info.
23//! 2. Run dominance analysis.
24//! 3. Traverse nodes in topological order; greedily extend chains of
25//!    fusible nodes that satisfy the rules above.
26//! 4. Return the list of `FusionGroup`s.
27
28use std::collections::{HashMap, HashSet};
29
30use crate::analysis::{dominance_analyse, topo_analyse};
31use crate::error::{GraphError, GraphResult};
32use crate::graph::ComputeGraph;
33use crate::node::{KernelConfig, NodeId, NodeKind};
34
35// ---------------------------------------------------------------------------
36// FusionGroup
37// ---------------------------------------------------------------------------
38
39/// A group of nodes that can be merged into a single fused kernel.
40///
41/// The nodes are listed in topological execution order. The fused kernel
42/// will be generated by the PTX codegen layer.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct FusionGroup {
45    /// Group identifier (sequential, 0-based).
46    pub id: usize,
47    /// Original node IDs in topological order (oldest first).
48    pub members: Vec<NodeId>,
49    /// Combined launch configuration (uses the first member's config).
50    pub config: KernelConfig,
51    /// Human-readable tag for debugging.
52    pub tag: String,
53}
54
55impl FusionGroup {
56    /// Returns the number of nodes in this group.
57    #[must_use]
58    pub fn size(&self) -> usize {
59        self.members.len()
60    }
61
62    /// Returns `true` if this group contains just one node (trivial — no fusion).
63    #[must_use]
64    pub fn is_trivial(&self) -> bool {
65        self.members.len() == 1
66    }
67}
68
69// ---------------------------------------------------------------------------
70// FusionPlan
71// ---------------------------------------------------------------------------
72
73/// The complete fusion plan produced by the fusion pass.
74#[derive(Debug, Clone)]
75pub struct FusionPlan {
76    /// All fusion groups (including trivial single-node groups).
77    pub groups: Vec<FusionGroup>,
78    /// Map from NodeId to the FusionGroup index it belongs to.
79    pub node_to_group: HashMap<NodeId, usize>,
80}
81
82impl FusionPlan {
83    /// Returns the number of non-trivial fusion groups (size ≥ 2).
84    pub fn fusion_count(&self) -> usize {
85        self.groups.iter().filter(|g| !g.is_trivial()).count()
86    }
87
88    /// Returns the total number of nodes saved by fusion.
89    ///
90    /// Each non-trivial group of size `k` saves `k-1` kernel launches.
91    pub fn nodes_saved(&self) -> usize {
92        self.groups
93            .iter()
94            .filter(|g| !g.is_trivial())
95            .map(|g| g.size() - 1)
96            .sum()
97    }
98
99    /// Returns the fusion group that owns `node`.
100    pub fn group_of(&self, node: NodeId) -> Option<&FusionGroup> {
101        self.node_to_group
102            .get(&node)
103            .and_then(|&idx| self.groups.get(idx))
104    }
105}
106
107// ---------------------------------------------------------------------------
108// Fusion eligibility checks
109// ---------------------------------------------------------------------------
110
111/// Returns `true` if two kernel configs are compatible for fusion.
112///
113/// Two configs are compatible when they have the same total number of threads
114/// (same grid×block volume), so the fused kernel can use an identical launch.
115fn configs_compatible(a: &KernelConfig, b: &KernelConfig) -> bool {
116    a.total_threads() == b.total_threads()
117}
118
119/// Returns `true` if node `a` is immediately before `b` in the topological
120/// order AND there are no intervening non-fusible nodes on the single path.
121fn only_fusible_between(
122    graph: &ComputeGraph,
123    a: NodeId,
124    b: NodeId,
125    topo_pos: &HashMap<NodeId, usize>,
126) -> bool {
127    let pos_a = topo_pos[&a];
128    let pos_b = topo_pos[&b];
129    if pos_b <= pos_a + 1 {
130        return true; // adjacent
131    }
132    // BFS from a; if we can reach b without going through a non-fusible
133    // compute node, the path is clean.
134    let mut visited = HashSet::new();
135    let mut stack = vec![a];
136    while let Some(cur) = stack.pop() {
137        if cur == b {
138            continue;
139        }
140        for &s in graph.successors(cur).unwrap_or(&[]) {
141            if visited.insert(s) {
142                if s == b {
143                    continue;
144                }
145                let node = graph.node(s).ok();
146                let is_fusible = node.map(|n| n.kind.is_fusible()).unwrap_or(false);
147                let is_barrier = node
148                    .map(|n| matches!(n.kind, NodeKind::Barrier))
149                    .unwrap_or(false);
150                let spos = topo_pos.get(&s).copied().unwrap_or(usize::MAX);
151                if spos < pos_b && (is_fusible || is_barrier) {
152                    stack.push(s);
153                } else if spos < pos_b && !is_fusible && !is_barrier {
154                    return false; // non-fusible node between a and b
155                }
156            }
157        }
158    }
159    true
160}
161
162// ---------------------------------------------------------------------------
163// analyse — entry point
164// ---------------------------------------------------------------------------
165
166/// Runs the fusion analysis pass on `graph`.
167///
168/// Returns a [`FusionPlan`] describing which nodes can be merged.
169///
170/// # Errors
171///
172/// Returns [`GraphError::EmptyGraph`] if the graph has no nodes.
173pub fn analyse(graph: &ComputeGraph) -> GraphResult<FusionPlan> {
174    if graph.is_empty() {
175        return Err(GraphError::EmptyGraph);
176    }
177
178    let topo = topo_analyse(graph)?;
179    let dt = dominance_analyse(graph)?;
180
181    let topo_pos: HashMap<NodeId, usize> = topo
182        .order
183        .iter()
184        .enumerate()
185        .map(|(p, &id)| (id, p))
186        .collect();
187
188    // Assigned group index for each node (None = unassigned).
189    let mut assigned: HashMap<NodeId, usize> = HashMap::new();
190    let mut groups: Vec<FusionGroup> = Vec::new();
191
192    // Traverse in topological order.
193    for &node_id in &topo.order {
194        if assigned.contains_key(&node_id) {
195            continue;
196        }
197
198        let node = graph.node(node_id)?;
199
200        // Only kernel nodes participate in fusion.
201        let (is_fusible, base_config) = match &node.kind {
202            NodeKind::KernelLaunch {
203                fusible, config, ..
204            } => (*fusible, *config),
205            _ => {
206                // Non-kernel node: assign to a trivial group.
207                let gid = groups.len();
208                groups.push(FusionGroup {
209                    id: gid,
210                    members: vec![node_id],
211                    config: KernelConfig::linear(1, 1, 0),
212                    tag: format!("non_kernel_{}", node.kind.tag()),
213                });
214                assigned.insert(node_id, gid);
215                continue;
216            }
217        };
218
219        if !is_fusible {
220            let gid = groups.len();
221            groups.push(FusionGroup {
222                id: gid,
223                members: vec![node_id],
224                config: base_config,
225                tag: format!("non_fusible_{}", node.display_name()),
226            });
227            assigned.insert(node_id, gid);
228            continue;
229        }
230
231        // Start a new fusion group with this node.
232        let gid = groups.len();
233        let mut members = vec![node_id];
234        assigned.insert(node_id, gid);
235
236        // Greedily extend: try to add direct successors that are fusible.
237        let mut frontier = graph.successors(node_id)?.to_vec();
238        while let Some(succ_id) = frontier.first().copied() {
239            frontier.remove(0);
240            if assigned.contains_key(&succ_id) {
241                continue;
242            }
243            let succ = graph.node(succ_id)?;
244            let (succ_fusible, succ_config) = match &succ.kind {
245                NodeKind::KernelLaunch {
246                    fusible, config, ..
247                } => (*fusible, *config),
248                _ => continue,
249            };
250            if !succ_fusible {
251                continue;
252            }
253            // Check fusion rules:
254            // 1. Compatible launch config.
255            if !configs_compatible(&base_config, &succ_config) {
256                continue;
257            }
258            // 2. Dominator: the last member must dominate succ.
259            let last_member = *members.last().ok_or_else(|| {
260                GraphError::Internal("fusion group members unexpectedly empty".into())
261            })?;
262            if !dt.dominates(last_member, succ_id) {
263                continue;
264            }
265            // 3. No non-fusible nodes between them.
266            if !only_fusible_between(graph, last_member, succ_id, &topo_pos) {
267                continue;
268            }
269            // Accept this node into the group.
270            members.push(succ_id);
271            assigned.insert(succ_id, gid);
272            // Continue extending from succ.
273            for &next in graph.successors(succ_id)? {
274                if !assigned.contains_key(&next) {
275                    frontier.push(next);
276                }
277            }
278        }
279
280        let tag = if members.len() > 1 {
281            format!(
282                "fused_{}..{}",
283                graph.node(members[0])?.display_name(),
284                graph
285                    .node(*members.last().ok_or_else(|| {
286                        GraphError::Internal("fusion group members unexpectedly empty".into())
287                    })?)?
288                    .display_name()
289            )
290        } else {
291            format!("solo_{}", graph.node(node_id)?.display_name())
292        };
293
294        groups.push(FusionGroup {
295            id: gid,
296            members,
297            config: base_config,
298            tag,
299        });
300    }
301
302    // Build node_to_group map.
303    let node_to_group: HashMap<NodeId, usize> = assigned;
304
305    Ok(FusionPlan {
306        groups,
307        node_to_group,
308    })
309}
310
311// ---------------------------------------------------------------------------
312// Tests
313// ---------------------------------------------------------------------------
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::builder::GraphBuilder;
319    use crate::node::MemcpyDir;
320
321    fn fusible_kernel(b: &mut GraphBuilder, name: &str) -> NodeId {
322        b.add_kernel(name, 4, 256, 0).fusible(true).finish()
323    }
324
325    fn non_fusible_kernel(b: &mut GraphBuilder, name: &str) -> NodeId {
326        b.add_kernel(name, 4, 256, 0).fusible(false).finish()
327    }
328
329    #[test]
330    fn fusion_empty_graph() {
331        let g = ComputeGraph::new();
332        assert!(matches!(analyse(&g), Err(GraphError::EmptyGraph)));
333    }
334
335    #[test]
336    fn fusion_single_fusible_kernel_trivial_group() {
337        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
338        let k = fusible_kernel(&mut b, "add");
339        let g = b.build().expect("test graph builds successfully");
340        let plan = analyse(&g).expect("kernel fusion analysis succeeds on valid graph");
341        assert_eq!(plan.groups.len(), 1);
342        assert!(plan.groups[0].is_trivial());
343        assert_eq!(
344            plan.group_of(k)
345                .expect("kernel node has fusion group")
346                .members,
347            vec![k]
348        );
349    }
350
351    #[test]
352    fn fusion_chain_of_fusible_kernels_merged() {
353        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
354        let k0 = fusible_kernel(&mut b, "k0");
355        let k1 = fusible_kernel(&mut b, "k1");
356        let k2 = fusible_kernel(&mut b, "k2");
357        b.chain(&[k0, k1, k2]);
358        let g = b.build().expect("test graph builds successfully");
359        let plan = analyse(&g).expect("kernel fusion analysis succeeds on valid graph");
360        // All three are fusible and in a chain → one non-trivial group.
361        assert_eq!(plan.fusion_count(), 1);
362        let group = plan.group_of(k0).expect("k0 has fusion group");
363        assert_eq!(group.size(), 3);
364        assert!(group.members.contains(&k0));
365        assert!(group.members.contains(&k1));
366        assert!(group.members.contains(&k2));
367    }
368
369    #[test]
370    fn fusion_non_fusible_breaks_chain() {
371        // k0 (fusible) → k1 (non-fusible) → k2 (fusible)
372        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
373        let k0 = fusible_kernel(&mut b, "k0");
374        let k1 = non_fusible_kernel(&mut b, "k1");
375        let k2 = fusible_kernel(&mut b, "k2");
376        b.chain(&[k0, k1, k2]);
377        let g = b.build().expect("test graph builds successfully");
378        let plan = analyse(&g).expect("kernel fusion analysis succeeds on valid graph");
379        // k0 and k2 should be in different groups.
380        let g0 = plan.group_of(k0).expect("k0 has fusion group").id;
381        let g2 = plan.group_of(k2).expect("k2 has fusion group").id;
382        assert_ne!(g0, g2);
383    }
384
385    #[test]
386    fn fusion_memcpy_not_fused() {
387        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
388        let upload = b.add_memcpy("up", MemcpyDir::HostToDevice, 1024);
389        let k = fusible_kernel(&mut b, "k");
390        let download = b.add_memcpy("dn", MemcpyDir::DeviceToHost, 1024);
391        b.chain(&[upload, k, download]);
392        let g = b.build().expect("test graph builds successfully");
393        let plan = analyse(&g).expect("kernel fusion analysis succeeds on valid graph");
394        // Upload and download are non-kernel nodes → trivial groups.
395        let gup = plan.group_of(upload).expect("upload node has fusion group");
396        let gdn = plan
397            .group_of(download)
398            .expect("download node has fusion group");
399        assert!(gup.is_trivial());
400        assert!(gdn.is_trivial());
401    }
402
403    #[test]
404    fn fusion_incompatible_configs_not_fused() {
405        // k0: 4 blocks × 256 threads = 1024 threads
406        // k1: 8 blocks × 256 threads = 2048 threads (incompatible)
407        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
408        let k0 = b.add_kernel("k0", 4, 256, 0).fusible(true).finish();
409        let k1 = b.add_kernel("k1", 8, 256, 0).fusible(true).finish();
410        b.dep(k0, k1);
411        let g = b.build().expect("test graph builds successfully");
412        let plan = analyse(&g).expect("kernel fusion analysis succeeds on valid graph");
413        let gk0 = plan.group_of(k0).expect("k0 has fusion group").id;
414        let gk1 = plan.group_of(k1).expect("k1 has fusion group").id;
415        assert_ne!(gk0, gk1);
416    }
417
418    #[test]
419    fn fusion_nodes_saved_count() {
420        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
421        let k0 = fusible_kernel(&mut b, "k0");
422        let k1 = fusible_kernel(&mut b, "k1");
423        let k2 = fusible_kernel(&mut b, "k2");
424        b.chain(&[k0, k1, k2]);
425        let g = b.build().expect("test graph builds successfully");
426        let plan = analyse(&g).expect("kernel fusion analysis succeeds on valid graph");
427        // Group of 3 saves 2 kernel launches.
428        assert_eq!(plan.nodes_saved(), 2);
429    }
430
431    #[test]
432    fn fusion_plan_covers_all_nodes() {
433        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
434        let k0 = fusible_kernel(&mut b, "k0");
435        let k1 = non_fusible_kernel(&mut b, "k1");
436        let upload = b.add_memcpy("up", MemcpyDir::HostToDevice, 512);
437        b.chain(&[upload, k0, k1]);
438        let g = b.build().expect("test graph builds successfully");
439        let plan = analyse(&g).expect("kernel fusion analysis succeeds on valid graph");
440        // Every node must appear in exactly one group.
441        let total: usize = plan.groups.iter().map(|g| g.size()).sum();
442        assert_eq!(total, 3);
443        // Every node must be in node_to_group.
444        assert!(plan.node_to_group.contains_key(&k0));
445        assert!(plan.node_to_group.contains_key(&k1));
446        assert!(plan.node_to_group.contains_key(&upload));
447    }
448
449    #[test]
450    fn fusion_parallel_branches_not_fused() {
451        // src → k0 and src → k1 (independent branches, cannot fuse across them).
452        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
453        let src = b.add_barrier("src");
454        let k0 = fusible_kernel(&mut b, "k0");
455        let k1 = fusible_kernel(&mut b, "k1");
456        b.fan_out(src, &[k0, k1]);
457        let g = b.build().expect("test graph builds successfully");
458        let plan = analyse(&g).expect("kernel fusion analysis succeeds on valid graph");
459        // k0 and k1 are in separate groups (not dominator-related to each other).
460        let gk0 = plan.group_of(k0).expect("k0 has fusion group").id;
461        let gk1 = plan.group_of(k1).expect("k1 has fusion group").id;
462        assert_ne!(gk0, gk1);
463    }
464
465    #[test]
466    fn fusion_group_tag_contains_names() {
467        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
468        let k0 = fusible_kernel(&mut b, "relu");
469        let k1 = fusible_kernel(&mut b, "scale");
470        b.dep(k0, k1);
471        let g = b.build().expect("test graph builds successfully");
472        let plan = analyse(&g).expect("kernel fusion analysis succeeds on valid graph");
473        let group = plan.group_of(k0).expect("k0 has fusion group");
474        assert!(!group.tag.is_empty());
475    }
476
477    #[test]
478    fn fusion_empty_fusible_graph_one_group() {
479        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
480        let k = fusible_kernel(&mut b, "solo");
481        let g = b.build().expect("test graph builds successfully");
482        let plan = analyse(&g).expect("kernel fusion analysis succeeds on valid graph");
483        assert_eq!(plan.fusion_count(), 0); // trivial, not fused
484        assert_eq!(plan.nodes_saved(), 0);
485        assert_eq!(
486            plan.group_of(k)
487                .expect("kernel node has fusion group")
488                .size(),
489            1
490        );
491    }
492}