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().unwrap();
260            if !dt.dominates(last_member, succ_id) {
261                continue;
262            }
263            // 3. No non-fusible nodes between them.
264            if !only_fusible_between(graph, last_member, succ_id, &topo_pos) {
265                continue;
266            }
267            // Accept this node into the group.
268            members.push(succ_id);
269            assigned.insert(succ_id, gid);
270            // Continue extending from succ.
271            for &next in graph.successors(succ_id)? {
272                if !assigned.contains_key(&next) {
273                    frontier.push(next);
274                }
275            }
276        }
277
278        let tag = if members.len() > 1 {
279            format!(
280                "fused_{}..{}",
281                graph.node(members[0])?.display_name(),
282                graph.node(*members.last().unwrap())?.display_name()
283            )
284        } else {
285            format!("solo_{}", graph.node(node_id)?.display_name())
286        };
287
288        groups.push(FusionGroup {
289            id: gid,
290            members,
291            config: base_config,
292            tag,
293        });
294    }
295
296    // Build node_to_group map.
297    let node_to_group: HashMap<NodeId, usize> = assigned;
298
299    Ok(FusionPlan {
300        groups,
301        node_to_group,
302    })
303}
304
305// ---------------------------------------------------------------------------
306// Tests
307// ---------------------------------------------------------------------------
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::builder::GraphBuilder;
313    use crate::node::MemcpyDir;
314
315    fn fusible_kernel(b: &mut GraphBuilder, name: &str) -> NodeId {
316        b.add_kernel(name, 4, 256, 0).fusible(true).finish()
317    }
318
319    fn non_fusible_kernel(b: &mut GraphBuilder, name: &str) -> NodeId {
320        b.add_kernel(name, 4, 256, 0).fusible(false).finish()
321    }
322
323    #[test]
324    fn fusion_empty_graph() {
325        let g = ComputeGraph::new();
326        assert!(matches!(analyse(&g), Err(GraphError::EmptyGraph)));
327    }
328
329    #[test]
330    fn fusion_single_fusible_kernel_trivial_group() {
331        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
332        let k = fusible_kernel(&mut b, "add");
333        let g = b.build().unwrap();
334        let plan = analyse(&g).unwrap();
335        assert_eq!(plan.groups.len(), 1);
336        assert!(plan.groups[0].is_trivial());
337        assert_eq!(plan.group_of(k).unwrap().members, vec![k]);
338    }
339
340    #[test]
341    fn fusion_chain_of_fusible_kernels_merged() {
342        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
343        let k0 = fusible_kernel(&mut b, "k0");
344        let k1 = fusible_kernel(&mut b, "k1");
345        let k2 = fusible_kernel(&mut b, "k2");
346        b.chain(&[k0, k1, k2]);
347        let g = b.build().unwrap();
348        let plan = analyse(&g).unwrap();
349        // All three are fusible and in a chain → one non-trivial group.
350        assert_eq!(plan.fusion_count(), 1);
351        let group = plan.group_of(k0).unwrap();
352        assert_eq!(group.size(), 3);
353        assert!(group.members.contains(&k0));
354        assert!(group.members.contains(&k1));
355        assert!(group.members.contains(&k2));
356    }
357
358    #[test]
359    fn fusion_non_fusible_breaks_chain() {
360        // k0 (fusible) → k1 (non-fusible) → k2 (fusible)
361        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
362        let k0 = fusible_kernel(&mut b, "k0");
363        let k1 = non_fusible_kernel(&mut b, "k1");
364        let k2 = fusible_kernel(&mut b, "k2");
365        b.chain(&[k0, k1, k2]);
366        let g = b.build().unwrap();
367        let plan = analyse(&g).unwrap();
368        // k0 and k2 should be in different groups.
369        let g0 = plan.group_of(k0).unwrap().id;
370        let g2 = plan.group_of(k2).unwrap().id;
371        assert_ne!(g0, g2);
372    }
373
374    #[test]
375    fn fusion_memcpy_not_fused() {
376        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
377        let upload = b.add_memcpy("up", MemcpyDir::HostToDevice, 1024);
378        let k = fusible_kernel(&mut b, "k");
379        let download = b.add_memcpy("dn", MemcpyDir::DeviceToHost, 1024);
380        b.chain(&[upload, k, download]);
381        let g = b.build().unwrap();
382        let plan = analyse(&g).unwrap();
383        // Upload and download are non-kernel nodes → trivial groups.
384        let gup = plan.group_of(upload).unwrap();
385        let gdn = plan.group_of(download).unwrap();
386        assert!(gup.is_trivial());
387        assert!(gdn.is_trivial());
388    }
389
390    #[test]
391    fn fusion_incompatible_configs_not_fused() {
392        // k0: 4 blocks × 256 threads = 1024 threads
393        // k1: 8 blocks × 256 threads = 2048 threads (incompatible)
394        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
395        let k0 = b.add_kernel("k0", 4, 256, 0).fusible(true).finish();
396        let k1 = b.add_kernel("k1", 8, 256, 0).fusible(true).finish();
397        b.dep(k0, k1);
398        let g = b.build().unwrap();
399        let plan = analyse(&g).unwrap();
400        let gk0 = plan.group_of(k0).unwrap().id;
401        let gk1 = plan.group_of(k1).unwrap().id;
402        assert_ne!(gk0, gk1);
403    }
404
405    #[test]
406    fn fusion_nodes_saved_count() {
407        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
408        let k0 = fusible_kernel(&mut b, "k0");
409        let k1 = fusible_kernel(&mut b, "k1");
410        let k2 = fusible_kernel(&mut b, "k2");
411        b.chain(&[k0, k1, k2]);
412        let g = b.build().unwrap();
413        let plan = analyse(&g).unwrap();
414        // Group of 3 saves 2 kernel launches.
415        assert_eq!(plan.nodes_saved(), 2);
416    }
417
418    #[test]
419    fn fusion_plan_covers_all_nodes() {
420        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
421        let k0 = fusible_kernel(&mut b, "k0");
422        let k1 = non_fusible_kernel(&mut b, "k1");
423        let upload = b.add_memcpy("up", MemcpyDir::HostToDevice, 512);
424        b.chain(&[upload, k0, k1]);
425        let g = b.build().unwrap();
426        let plan = analyse(&g).unwrap();
427        // Every node must appear in exactly one group.
428        let total: usize = plan.groups.iter().map(|g| g.size()).sum();
429        assert_eq!(total, 3);
430        // Every node must be in node_to_group.
431        assert!(plan.node_to_group.contains_key(&k0));
432        assert!(plan.node_to_group.contains_key(&k1));
433        assert!(plan.node_to_group.contains_key(&upload));
434    }
435
436    #[test]
437    fn fusion_parallel_branches_not_fused() {
438        // src → k0 and src → k1 (independent branches, cannot fuse across them).
439        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
440        let src = b.add_barrier("src");
441        let k0 = fusible_kernel(&mut b, "k0");
442        let k1 = fusible_kernel(&mut b, "k1");
443        b.fan_out(src, &[k0, k1]);
444        let g = b.build().unwrap();
445        let plan = analyse(&g).unwrap();
446        // k0 and k1 are in separate groups (not dominator-related to each other).
447        let gk0 = plan.group_of(k0).unwrap().id;
448        let gk1 = plan.group_of(k1).unwrap().id;
449        assert_ne!(gk0, gk1);
450    }
451
452    #[test]
453    fn fusion_group_tag_contains_names() {
454        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
455        let k0 = fusible_kernel(&mut b, "relu");
456        let k1 = fusible_kernel(&mut b, "scale");
457        b.dep(k0, k1);
458        let g = b.build().unwrap();
459        let plan = analyse(&g).unwrap();
460        let group = plan.group_of(k0).unwrap();
461        assert!(!group.tag.is_empty());
462    }
463
464    #[test]
465    fn fusion_empty_fusible_graph_one_group() {
466        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
467        let k = fusible_kernel(&mut b, "solo");
468        let g = b.build().unwrap();
469        let plan = analyse(&g).unwrap();
470        assert_eq!(plan.fusion_count(), 0); // trivial, not fused
471        assert_eq!(plan.nodes_saved(), 0);
472        assert_eq!(plan.group_of(k).unwrap().size(), 1);
473    }
474}