Skip to main content

oxicuda_graph/optimizer/
fusion_reduction.rs

1//! Reduction-pattern fusion pass — fuses reduction-chained element-wise
2//! regions (`LayerNorm`, `softmax`, …) that the linear element-wise fuser in
3//! [`crate::optimizer::fusion`] cannot capture.
4//!
5//! # Why a separate pass
6//!
7//! The element-wise chain fuser merges only *linear* producer → consumer
8//! chains in which every fused node is the **sole** consumer of its
9//! predecessor. Reduction patterns break that rule on purpose:
10//!
11//! ```text
12//!   LayerNorm:                       Softmax:
13//!
14//!        x                               x
15//!        │                               │
16//!     ┌──┴── mean (reduction)        ┌───┴── max (reduction)
17//!     │       │                      │        │
18//!     ▼       ▼                      ▼        ▼
19//!     sub ◄───┘  (broadcast)         sub ◄────┘  (broadcast)
20//!     │                               │
21//!     ▼                               ▼
22//!     var (reduction)                exp
23//!     │                               │
24//!     ▼                               ▼
25//!     normalize ◄─ (broadcast)       sum (reduction)
26//!     │                               │
27//!     ▼                               ▼
28//!     scale_shift                    divide ◄─ (broadcast)
29//! ```
30//!
31//! A reduction node produces a **small** statistic that is *broadcast* back to
32//! one or more element-wise consumers, so the reduction output fans out to
33//! several nodes (out-degree ≥ 2) and the region re-converges at a single
34//! sink. That diamond shape is precisely what the chain fuser rejects (it
35//! treats any fan-out as "parallel branches, do not fuse"). Fusing these
36//! regions into one kernel removes the intermediate broadcast round-trips to
37//! device memory — the dominant cost of an un-fused `LayerNorm`/`softmax`.
38//!
39//! # What is fused
40//!
41//! A set of nodes is a **reduction-fusion region** rooted at a fusible kernel
42//! `r` when:
43//!
44//! 1. `r` is a fusible [`NodeKind::KernelLaunch`].
45//! 2. The region `R = {r} ∪ members` forms a **single-entry, single-exit**
46//!    (SESE) subgraph: `r` dominates every member, and a unique member `sink`
47//!    is reachable from every member and is the only node in `R` with a
48//!    successor outside `R` (post-dominates the region).
49//! 3. Every member is a fusible kernel with a launch configuration compatible
50//!    with `r` (same total thread count).
51//! 4. The region is **closed**: no edge leaves a non-`sink` member to a node
52//!    outside `R`. This is what makes the rewrite semantics-preserving — every
53//!    intermediate buffer is consumed entirely inside the fused kernel and is
54//!    never observed by the rest of the graph.
55//! 5. The region contains genuine **broadcast fan-out**: at least one member
56//!    has out-degree ≥ 2 *inside* `R` (the reduction → broadcast shape). This
57//!    is what distinguishes a reduction region from a plain linear chain
58//!    (handled by the element-wise fuser) and forces region size ≥ 3.
59//!
60//! When a region matches, the pass emits a [`ReductionFusionGroup`] describing
61//! the merge. As with [`crate::optimizer::fusion`], the graph itself is **not**
62//! mutated — the descriptor is consumed downstream by the PTX codegen layer,
63//! and the [`rewrite`] helper materialises the fused [`ComputeGraph`] when a
64//! concrete rewritten DAG is required (e.g. for the CPU simulator).
65//!
66//! # Algorithm
67//!
68//! 1. Topological + dominance analysis.
69//! 2. For each fusible kernel `r` in topological order that is not yet claimed,
70//!    grow the dominated, fusible, closed region below `r` and test the SESE +
71//!    fan-out conditions.
72//! 3. Accept the largest such region; mark its members claimed so they are not
73//!    re-used by a later (smaller) region.
74
75use std::collections::{HashMap, HashSet, VecDeque};
76
77use crate::analysis::{dominance_analyse, topo_analyse};
78use crate::error::{GraphError, GraphResult};
79use crate::graph::ComputeGraph;
80use crate::node::{BufferId, GraphNode, KernelConfig, NodeId, NodeKind};
81
82// ---------------------------------------------------------------------------
83// ReductionPattern
84// ---------------------------------------------------------------------------
85
86/// The classified shape of a fused reduction region.
87///
88/// Classification is a *descriptive* label derived from the member kernel
89/// function names; it never affects whether a region is fusible (that decision
90/// is purely structural). It is used to tag the fused kernel and to let the
91/// codegen layer pick a specialised template.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum ReductionPattern {
94    /// `mean → subtract → variance → normalize → scale/shift` (two reductions).
95    LayerNorm,
96    /// `max → subtract → exp → sum → divide` (two reductions, one exp).
97    Softmax,
98    /// A reduction-broadcast region that does not match a known template.
99    Generic,
100}
101
102impl ReductionPattern {
103    /// Returns a short, stable identifier for the pattern.
104    #[must_use]
105    pub fn name(self) -> &'static str {
106        match self {
107            Self::LayerNorm => "layernorm",
108            Self::Softmax => "softmax",
109            Self::Generic => "reduction",
110        }
111    }
112}
113
114impl std::fmt::Display for ReductionPattern {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.write_str(self.name())
117    }
118}
119
120// ---------------------------------------------------------------------------
121// ReductionFusionGroup
122// ---------------------------------------------------------------------------
123
124/// A reduction-broadcast region that the pass merges into a single kernel.
125///
126/// Members are listed in topological order (the rooting reduction first, the
127/// post-dominating sink last).
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct ReductionFusionGroup {
130    /// Group identifier (sequential, 0-based).
131    pub id: usize,
132    /// The rooting reduction node (single entry of the region).
133    pub root: NodeId,
134    /// The post-dominating exit node (single exit of the region).
135    pub sink: NodeId,
136    /// All region members in topological order (`root` first, `sink` last).
137    pub members: Vec<NodeId>,
138    /// Classified pattern shape.
139    pub pattern: ReductionPattern,
140    /// Combined launch configuration (uses the root's config).
141    pub config: KernelConfig,
142    /// Human-readable tag for debugging.
143    pub tag: String,
144}
145
146impl ReductionFusionGroup {
147    /// Returns the number of nodes merged by this region.
148    #[must_use]
149    pub fn size(&self) -> usize {
150        self.members.len()
151    }
152
153    /// Returns the number of kernel launches this region eliminates
154    /// (`size - 1`, since the whole region collapses to one launch).
155    #[must_use]
156    pub fn launches_saved(&self) -> usize {
157        self.members.len().saturating_sub(1)
158    }
159}
160
161// ---------------------------------------------------------------------------
162// ReductionFusionPlan
163// ---------------------------------------------------------------------------
164
165/// The complete reduction-fusion plan produced by [`analyse`].
166#[derive(Debug, Clone, Default)]
167pub struct ReductionFusionPlan {
168    /// All matched reduction-fusion regions.
169    pub groups: Vec<ReductionFusionGroup>,
170    /// Map from a region member [`NodeId`] to its group index.
171    pub node_to_group: HashMap<NodeId, usize>,
172}
173
174impl ReductionFusionPlan {
175    /// Returns the number of matched reduction regions.
176    #[must_use]
177    pub fn fusion_count(&self) -> usize {
178        self.groups.len()
179    }
180
181    /// Returns the total number of kernel launches eliminated across all
182    /// regions.
183    #[must_use]
184    pub fn nodes_saved(&self) -> usize {
185        self.groups
186            .iter()
187            .map(ReductionFusionGroup::launches_saved)
188            .sum()
189    }
190
191    /// Returns the region that owns `node`, if any.
192    #[must_use]
193    pub fn group_of(&self, node: NodeId) -> Option<&ReductionFusionGroup> {
194        self.node_to_group
195            .get(&node)
196            .and_then(|&idx| self.groups.get(idx))
197    }
198
199    /// Returns `true` if `node` was absorbed into a fused region but is not
200    /// that region's root (i.e. it disappears as an independent launch).
201    #[must_use]
202    pub fn is_absorbed(&self, node: NodeId) -> bool {
203        match self.group_of(node) {
204            Some(g) => g.root != node,
205            None => false,
206        }
207    }
208}
209
210// ---------------------------------------------------------------------------
211// Helpers
212// ---------------------------------------------------------------------------
213
214/// Returns `(fusible, config)` if `node` is a kernel launch, else `None`.
215fn kernel_meta(graph: &ComputeGraph, node: NodeId) -> Option<(bool, KernelConfig)> {
216    match &graph.node(node).ok()?.kind {
217        NodeKind::KernelLaunch {
218            fusible, config, ..
219        } => Some((*fusible, *config)),
220        _ => None,
221    }
222}
223
224/// Returns the lowercased function name of a kernel node (empty if not a kernel).
225fn fn_name_lower(graph: &ComputeGraph, node: NodeId) -> String {
226    graph
227        .node(node)
228        .ok()
229        .and_then(|n| n.kind.function_name())
230        .unwrap_or("")
231        .to_ascii_lowercase()
232}
233
234/// Two configs fuse iff they launch the same total number of threads.
235fn configs_compatible(a: &KernelConfig, b: &KernelConfig) -> bool {
236    a.total_threads() == b.total_threads()
237}
238
239/// Classifies a region's pattern from the member function names.
240///
241/// Recognises the canonical `LayerNorm` and `softmax` op vocabularies; any
242/// other reduction-broadcast region is [`ReductionPattern::Generic`].
243fn classify(graph: &ComputeGraph, members: &[NodeId]) -> ReductionPattern {
244    let names: Vec<String> = members.iter().map(|&m| fn_name_lower(graph, m)).collect();
245    let has = |needle: &str| names.iter().any(|n| n.contains(needle));
246
247    // Softmax fingerprint: an exponential plus a normalising divide/sum.
248    let softmax_like = has("exp") && (has("softmax") || has("div") || has("sum") || has("norm"));
249    // LayerNorm fingerprint: mean/variance statistics feeding a normalise.
250    let layernorm_like = (has("mean") || has("avg"))
251        && (has("var") || has("std") || has("rms") || has("norm") || has("layernorm"));
252
253    if has("softmax") || softmax_like {
254        ReductionPattern::Softmax
255    } else if has("layernorm") || layernorm_like {
256        ReductionPattern::LayerNorm
257    } else {
258        ReductionPattern::Generic
259    }
260}
261
262// ---------------------------------------------------------------------------
263// Region growth
264// ---------------------------------------------------------------------------
265
266/// Attempts to grow a maximal single-entry/single-exit, closed, fusible
267/// reduction region rooted at `root`.
268///
269/// Returns the region's members (topologically sorted, `root` first, `sink`
270/// last) on success, or `None` if no valid region exists.
271fn grow_region(
272    graph: &ComputeGraph,
273    root: NodeId,
274    dt: &crate::analysis::DomTree,
275    topo_pos: &HashMap<NodeId, usize>,
276    claimed: &HashSet<NodeId>,
277) -> GraphResult<Option<Vec<NodeId>>> {
278    let root_config = match kernel_meta(graph, root) {
279        Some((true, cfg)) => cfg,
280        _ => return Ok(None),
281    };
282
283    // Candidate members: nodes dominated by `root` that are fusible kernels
284    // with a compatible config, reachable from `root`, and not already claimed.
285    // We grow the region by forward BFS from `root`, only descending through
286    // nodes that satisfy the membership predicate, and bound the frontier so a
287    // single rogue non-fusible successor terminates that branch.
288    let member_ok = |n: NodeId| -> bool {
289        if n == root {
290            return true;
291        }
292        if claimed.contains(&n) {
293            return false;
294        }
295        if !dt.dominates(root, n) {
296            return false;
297        }
298        match kernel_meta(graph, n) {
299            Some((fusible, cfg)) => fusible && configs_compatible(&root_config, &cfg),
300            None => false,
301        }
302    };
303
304    // Collect the dominated fusible cone reachable from `root` through members.
305    let mut region: HashSet<NodeId> = HashSet::new();
306    region.insert(root);
307    let mut queue: VecDeque<NodeId> = VecDeque::new();
308    queue.push_back(root);
309    while let Some(cur) = queue.pop_front() {
310        for &succ in graph.successors(cur)? {
311            if region.contains(&succ) {
312                continue;
313            }
314            if member_ok(succ) {
315                region.insert(succ);
316                queue.push_back(succ);
317            }
318        }
319    }
320
321    if region.len() < 3 {
322        // Too small to be a reduction pattern (a 2-node chain is the
323        // element-wise fuser's job).
324        return Ok(None);
325    }
326
327    // The region's exits are members with at least one successor outside the
328    // region. For a SESE region there must be exactly one such exit, and it
329    // must be reachable from every member (post-dominator of the region).
330    let mut exits: Vec<NodeId> = Vec::new();
331    for &m in &region {
332        let leaves = graph.successors(m)?.iter().any(|s| !region.contains(s));
333        let is_graph_sink = graph.successors(m)?.is_empty();
334        if leaves || is_graph_sink {
335            exits.push(m);
336        }
337    }
338
339    // A closed SESE region has a unique exit. If several members leak out of
340    // the region, shrink the region to the sub-cone that re-converges: drop
341    // every member that is not on a path to a *single* common sink.
342    //
343    // Strategy: pick the topologically-last member as the candidate sink; then
344    // verify (a) it is reachable from every member, and (b) every member's
345    // out-of-region successors are absent (closedness). If verification fails,
346    // the region is not a clean reduction pattern and we reject it. This keeps
347    // the rewrite provably semantics-preserving rather than guessing.
348    let sink = *region
349        .iter()
350        .max_by_key(|&&m| topo_pos.get(&m).copied().unwrap_or(0))
351        .ok_or_else(|| GraphError::Internal("reduction region unexpectedly empty".into()))?;
352
353    // (a) Every member must reach the sink (sink post-dominates the region
354    //     *within the region's own edges*).
355    if !region_reaches_all(graph, &region, sink)? {
356        return Ok(None);
357    }
358
359    // (b) Closedness: the only member allowed to have successors outside the
360    //     region is the sink. Any other leak would expose an intermediate
361    //     buffer and break semantics preservation if fused.
362    for &m in &region {
363        if m == sink {
364            continue;
365        }
366        let leaks = graph.successors(m)?.iter().any(|s| !region.contains(s));
367        if leaks {
368            return Ok(None);
369        }
370    }
371
372    // (c) Genuine broadcast fan-out: some member must fan out to ≥ 2 members
373    //     inside the region. Without this the region is a plain linear chain.
374    let has_fanout = region
375        .iter()
376        .try_fold(false, |acc, &m| -> GraphResult<bool> {
377            if acc {
378                return Ok(true);
379            }
380            let in_region_succ = graph
381                .successors(m)?
382                .iter()
383                .filter(|s| region.contains(s))
384                .count();
385            Ok(in_region_succ >= 2)
386        })?;
387    if !has_fanout {
388        return Ok(None);
389    }
390
391    // Emit members in topological order.
392    let mut members: Vec<NodeId> = region.into_iter().collect();
393    members.sort_by_key(|m| topo_pos.get(m).copied().unwrap_or(usize::MAX));
394    Ok(Some(members))
395}
396
397/// Returns `true` if every node in `region` can reach `sink` using only edges
398/// that stay inside `region`.
399fn region_reaches_all(
400    graph: &ComputeGraph,
401    region: &HashSet<NodeId>,
402    sink: NodeId,
403) -> GraphResult<bool> {
404    // Reverse BFS from `sink` over in-region edges; everything in `region`
405    // must be visited.
406    let mut reached: HashSet<NodeId> = HashSet::new();
407    reached.insert(sink);
408    let mut queue: VecDeque<NodeId> = VecDeque::new();
409    queue.push_back(sink);
410    while let Some(cur) = queue.pop_front() {
411        for &pred in graph.predecessors(cur)? {
412            if region.contains(&pred) && reached.insert(pred) {
413                queue.push_back(pred);
414            }
415        }
416    }
417    Ok(region.iter().all(|m| reached.contains(m)))
418}
419
420// ---------------------------------------------------------------------------
421// analyse — entry point
422// ---------------------------------------------------------------------------
423
424/// Runs the reduction-fusion analysis pass on `graph`.
425///
426/// Returns a [`ReductionFusionPlan`] describing every reduction-broadcast
427/// region that can be merged into a single fused kernel. The input graph is
428/// left unchanged.
429///
430/// # Errors
431///
432/// Returns [`GraphError::EmptyGraph`] if the graph has no nodes.
433pub fn analyse(graph: &ComputeGraph) -> GraphResult<ReductionFusionPlan> {
434    if graph.is_empty() {
435        return Err(GraphError::EmptyGraph);
436    }
437
438    let topo = topo_analyse(graph)?;
439    let dt = dominance_analyse(graph)?;
440    let topo_pos: HashMap<NodeId, usize> = topo
441        .order
442        .iter()
443        .enumerate()
444        .map(|(p, &id)| (id, p))
445        .collect();
446
447    let mut claimed: HashSet<NodeId> = HashSet::new();
448    let mut groups: Vec<ReductionFusionGroup> = Vec::new();
449    let mut node_to_group: HashMap<NodeId, usize> = HashMap::new();
450
451    for &root in &topo.order {
452        if claimed.contains(&root) {
453            continue;
454        }
455        // Only fusible kernels can root a region.
456        match kernel_meta(graph, root) {
457            Some((true, _)) => {}
458            _ => continue,
459        }
460
461        let members = match grow_region(graph, root, &dt, &topo_pos, &claimed)? {
462            Some(m) => m,
463            None => continue,
464        };
465
466        let sink = *members.last().ok_or_else(|| {
467            GraphError::Internal("reduction region members unexpectedly empty".into())
468        })?;
469        let pattern = classify(graph, &members);
470        let config = kernel_meta(graph, root)
471            .map(|(_, c)| c)
472            .unwrap_or_else(|| KernelConfig::linear(1, 1, 0));
473
474        let gid = groups.len();
475        let tag = format!(
476            "fused_{}_{}..{}",
477            pattern.name(),
478            graph.node(root)?.display_name(),
479            graph.node(sink)?.display_name()
480        );
481
482        for &m in &members {
483            claimed.insert(m);
484            node_to_group.insert(m, gid);
485        }
486
487        groups.push(ReductionFusionGroup {
488            id: gid,
489            root,
490            sink,
491            members,
492            pattern,
493            config,
494            tag,
495        });
496    }
497
498    Ok(ReductionFusionPlan {
499        groups,
500        node_to_group,
501    })
502}
503
504// ---------------------------------------------------------------------------
505// rewrite — materialise the fused graph
506// ---------------------------------------------------------------------------
507
508/// Rewrites `graph` by collapsing every region in `plan` into a single fused
509/// kernel node, producing a new [`ComputeGraph`].
510///
511/// The rewrite is **semantics-preserving**:
512///
513/// * Each region `R` is replaced by one fused [`NodeKind::KernelLaunch`] whose
514///   `inputs` are the buffers read from *outside* `R` and whose `outputs` are
515///   the buffers the sink writes (the only buffers observed downstream — the
516///   region's closedness guarantees no other member output escapes).
517/// * Every edge `p → m` from outside into a member is rerouted to the fused
518///   node; every edge `sink → s` out of the region is rerouted from the fused
519///   node. Intra-region edges vanish (they are now internal to one kernel).
520///
521/// Non-region nodes are copied verbatim. Buffer descriptors are preserved.
522///
523/// # Errors
524///
525/// Propagates [`GraphError`] from node/edge reconstruction (e.g. a cycle, which
526/// cannot occur for a valid SESE collapse but is checked defensively).
527pub fn rewrite(graph: &ComputeGraph, plan: &ReductionFusionPlan) -> GraphResult<ComputeGraph> {
528    let mut out = ComputeGraph::new();
529
530    // Copy buffer descriptors verbatim (ids are dense and stable).
531    for buf in graph.buffers() {
532        out.add_buffer(buf.clone());
533    }
534
535    // Map every old node to the new node that represents it. Region members
536    // all map to their region's single fused node.
537    let mut old_to_new: HashMap<NodeId, NodeId> = HashMap::new();
538
539    // 1. Emit non-region nodes and region roots (as fused nodes), in the
540    //    original insertion order so ids stay deterministic.
541    for old in graph.nodes() {
542        let oid = old.id;
543        if let Some(group) = plan.group_of(oid) {
544            if group.root != oid {
545                // Absorbed member: skip; it maps to the fused node later.
546                continue;
547            }
548            // Build the fused node for this region.
549            let region: HashSet<NodeId> = group.members.iter().copied().collect();
550
551            // Inputs: buffers read by any member that are produced outside the
552            // region (or are graph inputs). Outputs: buffers written by the
553            // sink (the externally-visible result).
554            let mut region_outputs: HashSet<BufferId> = HashSet::new();
555            for &m in &group.members {
556                for &b in &graph.node(m)?.outputs {
557                    region_outputs.insert(b);
558                }
559            }
560            let mut fused_inputs: Vec<BufferId> = Vec::new();
561            let mut seen_in: HashSet<BufferId> = HashSet::new();
562            for &m in &group.members {
563                for &b in &graph.node(m)?.inputs {
564                    // An input is external if no member writes it.
565                    if !region_outputs.contains(&b) && seen_in.insert(b) {
566                        fused_inputs.push(b);
567                    }
568                }
569            }
570            // Outputs visible downstream = the sink's outputs.
571            let fused_outputs: Vec<BufferId> = graph.node(group.sink)?.outputs.clone();
572
573            let fn_name = format!(
574                "{}_{}",
575                group.pattern.name(),
576                group
577                    .members
578                    .iter()
579                    .filter_map(|&m| graph.node(m).ok().and_then(|n| n.kind.function_name()))
580                    .collect::<Vec<_>>()
581                    .join("_")
582            );
583            let cost: u64 = group
584                .members
585                .iter()
586                .filter_map(|&m| graph.node(m).ok().map(|n| n.cost_hint))
587                .sum();
588            let kind = NodeKind::KernelLaunch {
589                function_name: fn_name,
590                config: group.config,
591                fusible: true,
592            };
593            let node = GraphNode::new(NodeId(0), kind)
594                .with_inputs(fused_inputs)
595                .with_outputs(fused_outputs)
596                .with_cost(cost.max(1))
597                .with_name(group.tag.clone());
598            let nid = out.add_node(node);
599            for &m in &region {
600                old_to_new.insert(m, nid);
601            }
602        } else {
603            // Plain node: clone its kind/buffers; id is reassigned by add_node.
604            let mut node = GraphNode::new(NodeId(0), old.kind.clone())
605                .with_inputs(old.inputs.iter().copied())
606                .with_outputs(old.outputs.iter().copied())
607                .with_cost(old.cost_hint);
608            if let Some(s) = old.stream_hint {
609                node = node.with_stream(s);
610            }
611            if let Some(name) = &old.name {
612                node = node.with_name(name.clone());
613            }
614            let nid = out.add_node(node);
615            old_to_new.insert(oid, nid);
616        }
617    }
618
619    // 2. Recreate edges, skipping intra-region edges and de-duplicating.
620    let mut added: HashSet<(NodeId, NodeId)> = HashSet::new();
621    for (from_old, to_old) in graph.edges() {
622        let from_new = *old_to_new
623            .get(&from_old)
624            .ok_or_else(|| GraphError::Internal("missing node mapping (from)".into()))?;
625        let to_new = *old_to_new
626            .get(&to_old)
627            .ok_or_else(|| GraphError::Internal("missing node mapping (to)".into()))?;
628        if from_new == to_new {
629            // Intra-region edge collapsed into the fused node.
630            continue;
631        }
632        if added.insert((from_new, to_new)) {
633            out.add_edge(from_new, to_new)?;
634        }
635    }
636
637    Ok(out)
638}
639
640// ---------------------------------------------------------------------------
641// Tests
642// ---------------------------------------------------------------------------
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647    use crate::builder::GraphBuilder;
648    use crate::executor::{ExecutionPlan, SequentialExecutor};
649    use crate::node::MemcpyDir;
650
651    // -- Builders for canonical reduction patterns -------------------------
652
653    /// Builds a LayerNorm-style region:
654    /// `x → mean`, then `{x, mean} → sub`, `sub → var`, `{sub, var} → norm`,
655    /// `norm → scale_shift`. The mean and variance reductions each broadcast
656    /// to an element-wise consumer; the region re-converges at `scale_shift`.
657    ///
658    /// Returns `(graph, [mean, sub, var, norm, scale_shift])`.
659    fn build_layernorm() -> (ComputeGraph, Vec<NodeId>) {
660        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
661        let mean = b.add_kernel("mean", 4, 256, 0).fusible(true).finish();
662        let sub = b.add_kernel("subtract", 4, 256, 0).fusible(true).finish();
663        let var = b.add_kernel("variance", 4, 256, 0).fusible(true).finish();
664        let norm = b.add_kernel("normalize", 4, 256, 0).fusible(true).finish();
665        let scale = b
666            .add_kernel("scale_shift", 4, 256, 0)
667            .fusible(true)
668            .finish();
669
670        // mean broadcasts to sub; sub feeds var; var broadcasts to norm.
671        b.dep(mean, sub);
672        b.dep(sub, var);
673        b.dep(sub, norm); // mean's centred value also flows forward (fan-out of sub)
674        b.dep(var, norm);
675        b.dep(norm, scale);
676        let g = b.build().expect("layernorm graph builds");
677        (g, vec![mean, sub, var, norm, scale])
678    }
679
680    /// Builds a softmax-style region:
681    /// `max → sub`, `sub → exp`, `exp → sum`, `{exp, sum} → div`. The `max`
682    /// reduction fans out (to `sub` and onward) and `sum` broadcasts to `div`.
683    /// Region re-converges at `div`.
684    ///
685    /// Returns `(graph, [mx, sub, exp, sum, div])`.
686    fn build_softmax() -> (ComputeGraph, Vec<NodeId>) {
687        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
688        let mx = b.add_kernel("max", 4, 256, 0).fusible(true).finish();
689        let sub = b.add_kernel("subtract", 4, 256, 0).fusible(true).finish();
690        let exp = b.add_kernel("exp", 4, 256, 0).fusible(true).finish();
691        let sum = b.add_kernel("sum", 4, 256, 0).fusible(true).finish();
692        let div = b.add_kernel("divide", 4, 256, 0).fusible(true).finish();
693
694        b.dep(mx, sub);
695        b.dep(sub, exp);
696        b.dep(exp, sum);
697        b.dep(exp, div); // exp fans out: feeds both sum and the final divide
698        b.dep(sum, div);
699        let g = b.build().expect("softmax graph builds");
700        (g, vec![mx, sub, exp, sum, div])
701    }
702
703    // -- Detection ---------------------------------------------------------
704
705    #[test]
706    fn reduction_empty_graph_errors() {
707        let g = ComputeGraph::new();
708        assert!(matches!(analyse(&g), Err(GraphError::EmptyGraph)));
709    }
710
711    #[test]
712    fn layernorm_region_detected() {
713        let (g, ids) = build_layernorm();
714        let plan = analyse(&g).expect("reduction fusion analysis succeeds");
715        assert_eq!(plan.fusion_count(), 1);
716        let group = plan
717            .group_of(ids[0])
718            .expect("mean belongs to a fused region");
719        // All five nodes are merged.
720        assert_eq!(group.size(), 5);
721        for id in &ids {
722            assert!(group.members.contains(id), "member {id} missing");
723        }
724        assert_eq!(group.root, ids[0]); // mean is the entry
725        assert_eq!(group.sink, ids[4]); // scale_shift is the exit
726        assert_eq!(group.pattern, ReductionPattern::LayerNorm);
727        // 5 nodes → one launch, saving 4.
728        assert_eq!(plan.nodes_saved(), 4);
729    }
730
731    #[test]
732    fn softmax_region_detected() {
733        let (g, ids) = build_softmax();
734        let plan = analyse(&g).expect("reduction fusion analysis succeeds");
735        assert_eq!(plan.fusion_count(), 1);
736        let group = plan
737            .group_of(ids[2])
738            .expect("exp belongs to a fused region");
739        assert_eq!(group.size(), 5);
740        assert_eq!(group.root, ids[0]); // max
741        assert_eq!(group.sink, ids[4]); // divide
742        assert_eq!(group.pattern, ReductionPattern::Softmax);
743        assert_eq!(plan.nodes_saved(), 4);
744    }
745
746    #[test]
747    fn absorbed_members_flagged() {
748        let (g, ids) = build_softmax();
749        let plan = analyse(&g).expect("reduction fusion analysis succeeds");
750        // The root is not "absorbed"; every other member is.
751        assert!(!plan.is_absorbed(ids[0]));
752        for id in &ids[1..] {
753            assert!(plan.is_absorbed(*id), "member {id} should be absorbed");
754        }
755    }
756
757    // -- Negative cases ----------------------------------------------------
758
759    #[test]
760    fn linear_chain_not_a_reduction_region() {
761        // A pure linear chain has no broadcast fan-out → left to the
762        // element-wise fuser, not matched here.
763        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
764        let k0 = b.add_kernel("a", 4, 256, 0).fusible(true).finish();
765        let k1 = b.add_kernel("b", 4, 256, 0).fusible(true).finish();
766        let k2 = b.add_kernel("c", 4, 256, 0).fusible(true).finish();
767        b.chain(&[k0, k1, k2]);
768        let g = b.build().expect("chain graph builds");
769        let plan = analyse(&g).expect("reduction fusion analysis succeeds");
770        assert_eq!(plan.fusion_count(), 0);
771        assert_eq!(plan.nodes_saved(), 0);
772    }
773
774    #[test]
775    fn non_fusible_member_breaks_region() {
776        // Same softmax shape, but `exp` is non-fusible: the closed region can
777        // no longer span the reduction, so nothing is fused.
778        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
779        let mx = b.add_kernel("max", 4, 256, 0).fusible(true).finish();
780        let sub = b.add_kernel("subtract", 4, 256, 0).fusible(true).finish();
781        let exp = b.add_kernel("exp", 4, 256, 0).fusible(false).finish();
782        let sum = b.add_kernel("sum", 4, 256, 0).fusible(true).finish();
783        let div = b.add_kernel("divide", 4, 256, 0).fusible(true).finish();
784        b.dep(mx, sub);
785        b.dep(sub, exp);
786        b.dep(exp, sum);
787        b.dep(exp, div);
788        b.dep(sum, div);
789        let g = b.build().expect("graph builds");
790        let plan = analyse(&g).expect("reduction fusion analysis succeeds");
791        assert_eq!(plan.fusion_count(), 0);
792    }
793
794    #[test]
795    fn open_region_leaking_intermediate_not_fused() {
796        // The fan-out node `exp` ALSO feeds an external sink, so every closed
797        // reduction diamond would have to expose `exp`'s intermediate buffer.
798        // No closed SESE region survives → nothing is fused. (Leaking a
799        // *non-fan-out* node such as `sub` would still permit the independent
800        // `exp → {sum, div} → div` tail diamond to fuse; closedness is checked
801        // per candidate region, so the pass only refuses the regions that would
802        // actually hide an observed buffer.)
803        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
804        let mx = b.add_kernel("max", 4, 256, 0).fusible(true).finish();
805        let sub = b.add_kernel("subtract", 4, 256, 0).fusible(true).finish();
806        let exp = b.add_kernel("exp", 4, 256, 0).fusible(true).finish();
807        let sum = b.add_kernel("sum", 4, 256, 0).fusible(true).finish();
808        let div = b.add_kernel("divide", 4, 256, 0).fusible(true).finish();
809        // External consumer of the fan-out node's intermediate result.
810        let leak = b.add_memcpy("leak", MemcpyDir::DeviceToHost, 1024);
811        b.dep(mx, sub);
812        b.dep(sub, exp);
813        b.dep(exp, sum);
814        b.dep(exp, div);
815        b.dep(sum, div);
816        b.dep(exp, leak); // exp (the fan-out) leaks out of every candidate region
817        let g = b.build().expect("graph builds");
818        let plan = analyse(&g).expect("reduction fusion analysis succeeds");
819        assert_eq!(plan.fusion_count(), 0);
820    }
821
822    #[test]
823    fn incompatible_config_member_excluded() {
824        // `div` has a different total-thread count → cannot join the region;
825        // the remaining nodes lose their single exit, so nothing fuses.
826        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
827        let mx = b.add_kernel("max", 4, 256, 0).fusible(true).finish();
828        let sub = b.add_kernel("subtract", 4, 256, 0).fusible(true).finish();
829        let exp = b.add_kernel("exp", 4, 256, 0).fusible(true).finish();
830        let sum = b.add_kernel("sum", 4, 256, 0).fusible(true).finish();
831        let div = b.add_kernel("divide", 8, 256, 0).fusible(true).finish(); // 2048 threads
832        b.dep(mx, sub);
833        b.dep(sub, exp);
834        b.dep(exp, sum);
835        b.dep(exp, div);
836        b.dep(sum, div);
837        let g = b.build().expect("graph builds");
838        let plan = analyse(&g).expect("reduction fusion analysis succeeds");
839        // div is excluded; without it exp fans out to sum only (no re-converge
840        // sink inside the region) so the region is rejected.
841        assert_eq!(plan.fusion_count(), 0);
842    }
843
844    // -- Rewrite + semantics preservation ----------------------------------
845
846    #[test]
847    fn rewrite_collapses_region_to_one_node() {
848        let (g, _ids) = build_layernorm();
849        let plan = analyse(&g).expect("analysis succeeds");
850        let fused = rewrite(&g, &plan).expect("rewrite succeeds");
851        // 5 nodes collapse to 1.
852        assert_eq!(g.node_count(), 5);
853        assert_eq!(fused.node_count(), 1);
854        // The single node is a fused kernel.
855        let only = fused.node(NodeId(0)).expect("fused node exists");
856        assert!(only.kind.is_compute());
857        assert!(
858            only.kind
859                .function_name()
860                .unwrap_or("")
861                .contains("layernorm")
862        );
863    }
864
865    #[test]
866    fn rewrite_preserves_external_topology() {
867        // upload → [softmax region] → download. After rewrite the fused node
868        // sits between upload and download with the edges preserved.
869        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
870        let up = b.add_memcpy("up", MemcpyDir::HostToDevice, 1024);
871        let mx = b.add_kernel("max", 4, 256, 0).fusible(true).finish();
872        let sub = b.add_kernel("subtract", 4, 256, 0).fusible(true).finish();
873        let exp = b.add_kernel("exp", 4, 256, 0).fusible(true).finish();
874        let sum = b.add_kernel("sum", 4, 256, 0).fusible(true).finish();
875        let div = b.add_kernel("divide", 4, 256, 0).fusible(true).finish();
876        let dn = b.add_memcpy("dn", MemcpyDir::DeviceToHost, 1024);
877        b.dep(up, mx);
878        b.dep(mx, sub);
879        b.dep(sub, exp);
880        b.dep(exp, sum);
881        b.dep(exp, div);
882        b.dep(sum, div);
883        b.dep(div, dn);
884        let g = b.build().expect("graph builds");
885        let plan = analyse(&g).expect("analysis succeeds");
886        assert_eq!(plan.fusion_count(), 1);
887        let fused = rewrite(&g, &plan).expect("rewrite succeeds");
888        // up + fused + dn = 3 nodes.
889        assert_eq!(fused.node_count(), 3);
890        // upload still reaches download.
891        let up_new = fused.sources();
892        assert_eq!(up_new.len(), 1);
893        let dn_new = fused.sinks();
894        assert_eq!(dn_new.len(), 1);
895        assert!(fused.is_reachable(up_new[0], dn_new[0]));
896        // Exactly one compute node remains.
897        assert_eq!(fused.kernel_nodes().len(), 1);
898    }
899
900    #[test]
901    fn rewrite_no_match_is_identity() {
902        // A graph with no reduction region is rewritten unchanged.
903        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
904        let k0 = b.add_kernel("a", 4, 256, 0).fusible(true).finish();
905        let k1 = b.add_kernel("b", 4, 256, 0).fusible(true).finish();
906        b.chain(&[k0, k1]);
907        let g = b.build().expect("graph builds");
908        let plan = analyse(&g).expect("analysis succeeds");
909        let fused = rewrite(&g, &plan).expect("rewrite succeeds");
910        assert_eq!(fused.node_count(), g.node_count());
911        assert_eq!(fused.edge_count(), g.edge_count());
912    }
913
914    #[test]
915    fn simulator_agrees_before_and_after_fusion() {
916        // Build a full pipeline: upload → softmax region → download, then check
917        // the CPU simulator produces a *semantically equivalent* execution:
918        // identical bytes moved, identical reductions of kernel work, and the
919        // fused graph never launches more kernels than the original.
920        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
921        let up = b.add_memcpy("up", MemcpyDir::HostToDevice, 4096);
922        let mx = b.add_kernel("max", 4, 256, 0).fusible(true).finish();
923        let sub = b.add_kernel("subtract", 4, 256, 0).fusible(true).finish();
924        let exp = b.add_kernel("exp", 4, 256, 0).fusible(true).finish();
925        let sum = b.add_kernel("sum", 4, 256, 0).fusible(true).finish();
926        let div = b.add_kernel("divide", 4, 256, 0).fusible(true).finish();
927        let dn = b.add_memcpy("dn", MemcpyDir::DeviceToHost, 4096);
928        b.dep(up, mx);
929        b.dep(mx, sub);
930        b.dep(sub, exp);
931        b.dep(exp, sum);
932        b.dep(exp, div);
933        b.dep(sum, div);
934        b.dep(div, dn);
935        let g = b.build().expect("graph builds");
936
937        let plan = analyse(&g).expect("analysis succeeds");
938        assert_eq!(plan.fusion_count(), 1);
939        let fused = rewrite(&g, &plan).expect("rewrite succeeds");
940
941        let before =
942            SequentialExecutor::new(&ExecutionPlan::build(&g, 4).expect("plan(before) builds"))
943                .run()
944                .expect("before runs");
945        let after =
946            SequentialExecutor::new(&ExecutionPlan::build(&fused, 4).expect("plan(after) builds"))
947                .run()
948                .expect("after runs");
949
950        // Memory traffic is identical (the data semantics are unchanged):
951        // the same bytes are uploaded and downloaded before and after fusion.
952        assert_eq!(before.bytes_copied, after.bytes_copied);
953        assert_eq!(before.bytes_copied, 4096 * 2);
954        assert_eq!(before.bytes_set, after.bytes_set);
955        // The whole reduction region collapses to a single launch.
956        assert_eq!(after.kernels_launched, 1);
957        // Reduction fusion never *increases* the launch count over the
958        // baseline plan (which has already run the element-wise chain fuser).
959        assert!(after.kernels_launched <= before.kernels_launched);
960    }
961
962    #[test]
963    fn pattern_display_and_name() {
964        assert_eq!(ReductionPattern::LayerNorm.name(), "layernorm");
965        assert_eq!(ReductionPattern::Softmax.to_string(), "softmax");
966        assert_eq!(ReductionPattern::Generic.name(), "reduction");
967    }
968
969    #[test]
970    fn generic_reduction_region_classified() {
971        // A reduction-broadcast diamond whose names match no known template.
972        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
973        let r = b.add_kernel("reduce_op", 4, 256, 0).fusible(true).finish();
974        let a = b.add_kernel("elemwise_a", 4, 256, 0).fusible(true).finish();
975        let c = b.add_kernel("elemwise_c", 4, 256, 0).fusible(true).finish();
976        let join = b.add_kernel("combine", 4, 256, 0).fusible(true).finish();
977        // r fans out to a and c; both feed join (diamond).
978        b.dep(r, a);
979        b.dep(r, c);
980        b.dep(a, join);
981        b.dep(c, join);
982        let g = b.build().expect("graph builds");
983        let plan = analyse(&g).expect("analysis succeeds");
984        assert_eq!(plan.fusion_count(), 1);
985        let group = plan.group_of(r).expect("r in a region");
986        assert_eq!(group.pattern, ReductionPattern::Generic);
987        assert_eq!(group.size(), 4);
988    }
989}