Skip to main content

onnx_runtime_optimizer/fusion/
mod.rs

1//! Operator fusion: match a connected op-sequence and replace it with a single
2//! fused op (see `docs/architecture/ORT2.md` §18.2).
3//!
4//! ## Matching model
5//!
6//! A [`FusionPattern`] is an ordered op sequence plus a replacement op type.
7//! **Structural** patterns (MatMul+Add, MatMul+Add+Relu) use
8//! [`FusionPattern::try_match_from`], which walks the graph forward from each
9//! candidate start node following producer→consumer ("spine") edges: node `i+1`
10//! of the match must consume an output of node `i`. Extra data edges *back* to
11//! already-matched nodes are allowed.
12//!
13//! The **LayerNorm** rewrite instead uses a dedicated DAG-aware matcher
14//! ([`FusionPattern::try_match_layernorm`]): a real LayerNorm decomposition is a
15//! diamond whose `mean` feeds both a variance branch and a numerator branch, and
16//! some exporters emit two distinct `Sub(x, mean)` nodes rather than reusing one
17//! `diff`, so a single linear successor-walk can't express it. The matcher
18//! anchors on the `mean` `ReduceMean` and follows both branches to the final
19//! `Add`, accepting both the canonical 9-op (shared `Sub`) and the 10-op
20//! split-`Sub` shapes.
21//!
22//! ## Safety rule (never change numerics-visible semantics)
23//!
24//! A match is only fused when **every intermediate output is consumed solely
25//! within the matched set** — i.e. no matched node except the last has an
26//! output that escapes to an outside consumer or to a graph output. This is the
27//! generalization of "single-consumer chain": internal reuse is fine, external
28//! escape is not. It guarantees fusion cannot delete a value another part of
29//! the graph still observes.
30//!
31//! [`FusionPattern::apply_fusion`] removes the matched nodes and inserts the
32//! replacement, reusing the final output value id so external wiring and graph
33//! outputs are preserved automatically. External inputs are collected in
34//! first-seen order across the matched nodes.
35//!
36//! ## Kernel note
37//!
38//! The optimizer-produced fused op types (`LayerNormalization`,
39//! `FusedMatMulBias`, `FusedGemm`) are emitted in the private contrib domain
40//! [`CONTRIB_DOMAIN`] (`com.microsoft`), **not** the reserved default ONNX
41//! domain. `FusedMatMulBias`/`FusedGemm` are invented (non-standard) ops, so
42//! putting them in `ai.onnx` would collide with standard-op opset validation and
43//! make kernel dispatch ambiguous; a private contrib domain is the only
44//! unambiguous key. `com.microsoft` is the established ONNX-ecosystem contrib
45//! domain (where the `FusedMatMul`/`LayerNormalization` contrib variants live),
46//! so our IR stays interoperable with ORT-exported models and wider tooling.
47//!
48//! Kernel dispatch (`onnx-runtime-ep-cpu`) binds these by `(domain, op_type)`.
49//! `LayerNormalization`, `FusedMatMulBias` and `FusedGemm` all have CPU kernels
50//! (registered under the contrib domain). `FusedGemm` (MatMul+Add+Relu) is not
51//! exercised by the current model-level validation target (BERT uses GELU/Erf,
52//! not Relu), so it is instead validated by the synthetic end-to-end parity
53//! test in `crates/onnx-runtime-session/tests/fused_gemm_parity.rs`, which
54//! builds a MatMul→Add→Relu graph and checks the fused single-pass output
55//! against the unfused reference.
56//!
57//! ## Schema-aware rewrites
58//!
59//! Most patterns use a *structural* rewrite: the fused node's inputs are the
60//! matched region's external inputs in first-seen order, which happens to match
61//! the kernel signature for `FusedMatMulBias` (`[A, B, bias]`). The LayerNorm
62//! fusion is instead **schema-aware** (see [`RewriteKind::LayerNorm`]): it emits
63//! a node with inputs exactly `[X, Scale, B]` and synthesizes the `axis` /
64//! `epsilon` attributes the kernel reads, extracting them from the matched
65//! subgraph (the `ReduceMean` axes and the `var + eps` constant).
66
67use std::collections::{BTreeSet, HashMap, HashSet};
68
69use onnx_runtime_ir::{Attribute, DataType, Graph, Node, NodeId, TensorData, ValueId, WeightRef};
70
71use crate::error::Result;
72use crate::pass::{OptimizationPass, PassContext};
73
74/// The private contrib domain under which the optimizer emits every fused op.
75///
76/// `com.microsoft` is the established ONNX-ecosystem contrib domain; keeping our
77/// fused ops there (rather than the reserved `""`/`ai.onnx` domain) avoids
78/// colliding with standard-op opset validation, keeps kernel dispatch keyed
79/// unambiguously on `(domain, op_type)`, and stays interoperable with
80/// ORT-exported models. This is model-agnostic: it is a property of the op
81/// *domain*, independent of any particular model.
82pub const CONTRIB_DOMAIN: &str = "com.microsoft";
83
84/// `√2`, the exact-GELU inner divisor (`Erf(X / √2)`).
85const SQRT_2: f32 = std::f32::consts::SQRT_2;
86/// `1/√2`, the equivalent inner *multiplier* encoding (`Mul(X, 1/√2)`).
87const FRAC_1_SQRT_2: f32 = std::f32::consts::FRAC_1_SQRT_2;
88
89/// Whether `a` matches an expected exact-GELU structural constant. The GELU
90/// constants (`0.5`, `1.0`, `√2`, `1/√2`, `2.0`) are all small and exactly
91/// representable-ish in f32; the tolerance only absorbs f32 rounding of `√2`
92/// / `1/√2`, never a numerically different coefficient — an off constant
93/// **declines** rather than silently fuses a wrong decomposition.
94fn approx(a: f32, expected: f32) -> bool {
95    (a - expected).abs() <= 1e-6 * expected.abs().max(1.0)
96}
97
98/// The inputs and attributes of a fused node: `(inputs, attributes)`.
99type FusedNodeSpec = (Vec<Option<ValueId>>, HashMap<String, Attribute>);
100
101/// A matched occurrence of a [`FusionPattern`] in a graph.#[derive(Clone, Debug)]
102pub struct PatternMatch {
103    /// Matched node ids, in op-sequence order.
104    pub nodes: Vec<NodeId>,
105    /// Values consumed by the matched region but produced outside it
106    /// (graph inputs, initializers, or outputs of non-matched nodes), in
107    /// first-seen order.
108    pub external_inputs: Vec<ValueId>,
109    /// The single output of the last matched node — reused as the fused node's
110    /// output so downstream wiring is preserved.
111    pub output: ValueId,
112}
113
114/// How a matched pattern is rewritten into its fused node.
115#[derive(Clone, Copy, Debug, PartialEq, Eq)]
116pub enum RewriteKind {
117    /// The fused node's inputs are the matched region's external inputs in
118    /// first-seen order (e.g. `MatMul(A,B)+bias` → `FusedMatMulBias[A, B, bias]`).
119    Structural,
120    /// Schema-aware LayerNorm rewrite: emit `[X, Scale, B]` plus the `axis` and
121    /// `epsilon` attributes the kernel reads, extracted from the matched
122    /// 9-op decomposition (see [`FusionPattern::layernorm_spec`]).
123    LayerNorm,
124    /// Schema-aware SDPA rewrite: emit `[Q, K, V]` (+ optional `[mask]`) plus
125    /// the concrete `scale` and `k_transposed` attributes, extracted from the
126    /// matched `MatMul → (Mul|Div) → [Add] → Softmax → MatMul` core (see
127    /// [`FusionPattern::attention_spec`]).
128    Attention,
129    /// Schema-aware exact-GELU rewrite: emit `[X]` with no attributes, extracted
130    /// from the matched Erf decomposition
131    /// `0.5·X · (1 + Erf(X / √2))` — a diamond whose single external input `X`
132    /// feeds both the `Erf` branch and the outer half-scale (see
133    /// [`FusionPattern::gelu_spec`]). Only the exact (`Erf`) form is recognized;
134    /// the `tanh`-approximation FastGelu is out of scope.
135    Gelu,
136}
137
138/// A fusion rule: an op-type sequence rewritten to a single replacement op.
139#[derive(Clone, Debug)]
140pub struct FusionPattern {
141    name: String,
142    ops: Vec<String>,
143    replacement: String,
144    #[cfg(test)]
145    replacement_domain: String,
146    kind: RewriteKind,
147}
148
149impl FusionPattern {
150    /// A new *structural* pattern matching `ops` in sequence, replaced by
151    /// `replacement`. The fused node's inputs are the matched region's external
152    /// inputs in first-seen order.
153    pub fn new(name: &str, ops: &[&str], replacement: &str) -> Self {
154        assert!(!ops.is_empty(), "fusion pattern must have at least one op");
155        Self {
156            name: name.to_string(),
157            ops: ops.iter().map(|s| s.to_string()).collect(),
158            replacement: replacement.to_string(),
159            #[cfg(test)]
160            replacement_domain: CONTRIB_DOMAIN.to_string(),
161            kind: RewriteKind::Structural,
162        }
163    }
164
165    /// The schema-aware LayerNorm pattern: the canonical 9-op decomposition
166    /// (`ReduceMean, Sub, Pow, ReduceMean, Add, Sqrt, Div, Mul, Add`) rewritten
167    /// to a `com.microsoft::LayerNormalization` node with inputs `[X, Scale, B]`
168    /// and synthesized `axis`/`epsilon` attributes.
169    pub fn layernorm() -> Self {
170        Self {
171            name: "LayerNorm".to_string(),
172            ops: [
173                "ReduceMean",
174                "Sub",
175                "Pow",
176                "ReduceMean",
177                "Add",
178                "Sqrt",
179                "Div",
180                "Mul",
181                "Add",
182            ]
183            .iter()
184            .map(|s| s.to_string())
185            .collect(),
186            replacement: "LayerNormalization".to_string(),
187            #[cfg(test)]
188            replacement_domain: CONTRIB_DOMAIN.to_string(),
189            kind: RewriteKind::LayerNorm,
190        }
191    }
192
193    /// This pattern's rewrite kind.
194    pub fn kind(&self) -> RewriteKind {
195        self.kind
196    }
197
198    /// The schema-aware SDPA-core pattern, rewritten to a
199    /// `com.microsoft::FusedAttention` node with inputs `[Q, K, V]` (+ optional
200    /// `[mask]`) and synthesized `scale`/`k_transposed` attributes. Anchored on
201    /// the `Softmax` (see [`Self::try_match_attention`]).
202    pub fn attention() -> Self {
203        Self {
204            name: "Attention".to_string(),
205            // The op list is descriptive only; the DAG-aware matcher does the
206            // real recognition. Softmax is the anchor.
207            ops: ["Softmax"].iter().map(|s| s.to_string()).collect(),
208            replacement: "FusedAttention".to_string(),
209            #[cfg(test)]
210            replacement_domain: CONTRIB_DOMAIN.to_string(),
211            kind: RewriteKind::Attention,
212        }
213    }
214
215    /// The schema-aware exact-GELU pattern: the `Erf` decomposition
216    /// `0.5·X · (1 + Erf(X / √2))` rewritten to a `com.microsoft::Gelu` node
217    /// with the single input `[X]` and no attributes. Anchored on the `Erf`
218    /// (see [`Self::try_match_gelu`]).
219    pub fn gelu() -> Self {
220        Self {
221            name: "Gelu".to_string(),
222            // Descriptive only; the DAG-aware matcher does the real recognition.
223            // `Erf` is the anchor.
224            ops: ["Erf"].iter().map(|s| s.to_string()).collect(),
225            replacement: "Gelu".to_string(),
226            #[cfg(test)]
227            replacement_domain: CONTRIB_DOMAIN.to_string(),
228            kind: RewriteKind::Gelu,
229        }
230    }
231
232    #[cfg(test)]
233    fn with_replacement_domain(mut self, domain: &str) -> Self {
234        self.replacement_domain = domain.to_string();
235        self
236    }
237
238    /// This pattern's name.
239    pub fn pattern_name(&self) -> &str {
240        &self.name
241    }
242
243    /// Find the next occurrence of this pattern, scanning nodes in id order.
244    ///
245    /// [`RewriteKind::LayerNorm`] uses a dedicated DAG-aware matcher
246    /// ([`Self::try_match_layernorm`]) because a real LayerNorm decomposition is
247    /// a diamond DAG whose `mean` feeds two branches (variance + numerator) and
248    /// may even use two distinct `Sub(x, mean)` nodes; the linear successor-walk
249    /// used by the structural patterns can't express that. All structural
250    /// patterns (MatMul+Add, MatMul+Add+Relu) keep the linear-chain matcher.
251    pub fn find_match(&self, graph: &Graph) -> Option<PatternMatch> {
252        for start in graph.nodes.keys() {
253            if let Some(m) = self.try_match_at(graph, start) {
254                return Some(m);
255            }
256        }
257        None
258    }
259
260    fn try_match_at(&self, graph: &Graph, start: NodeId) -> Option<PatternMatch> {
261        match self.kind {
262            RewriteKind::LayerNorm => self.try_match_layernorm(graph, start),
263            RewriteKind::Attention => self.try_match_attention(graph, start),
264            RewriteKind::Gelu => self.try_match_gelu(graph, start),
265            RewriteKind::Structural => self.try_match_from(graph, start),
266        }
267    }
268
269    /// Candidate starts whose match result may be affected when `matched` is
270    /// replaced. The replacement is always a contrib-domain op, so it cannot
271    /// itself satisfy any standard-domain pattern step. Existing producers can
272    /// still observe changed consumer adjacency, so conservatively revisit them
273    /// and the bounded predecessor chains from which this pattern could reach
274    /// them.
275    fn affected_candidate_starts(&self, graph: &Graph, matched: &PatternMatch) -> Vec<NodeId> {
276        let max_depth = match self.kind {
277            RewriteKind::LayerNorm => 10,
278            RewriteKind::Attention => 6,
279            RewriteKind::Gelu => 5,
280            RewriteKind::Structural => self.ops.len(),
281        };
282        let mut affected = HashSet::new();
283        let mut frontier: Vec<(NodeId, usize)> = matched
284            .external_inputs
285            .iter()
286            .filter_map(|&value| graph.value(value).producer)
287            .map(|producer| (producer, 0))
288            .collect();
289
290        while let Some((node_id, depth)) = frontier.pop() {
291            if !affected.insert(node_id) || depth >= max_depth.saturating_sub(1) {
292                continue;
293            }
294            frontier.extend(
295                graph
296                    .node(node_id)
297                    .input_values()
298                    .filter_map(|value| graph.value(value).producer)
299                    .map(|producer| (producer, depth + 1)),
300            );
301        }
302        affected.into_iter().collect()
303    }
304
305    /// Whether `node` is a standard-domain op named `op`.
306    fn op_matches(node: &Node, op: &str) -> bool {
307        node.op_type == op && matches!(node.domain.as_str(), "" | "ai.onnx")
308    }
309
310    /// The first consumer of `value` whose op is `op` (standard domain).
311    fn find_consumer(graph: &Graph, value: ValueId, op: &str) -> Option<NodeId> {
312        graph
313            .consumers(value)
314            .into_iter()
315            .find(|&c| Self::op_matches(graph.node(c), op))
316    }
317
318    /// DAG-aware LayerNorm matcher anchored on the *mean* `ReduceMean` node.
319    ///
320    /// Real LayerNorm decompositions are a diamond, not a chain: the mean feeds
321    /// both the variance branch (`Sub → Pow → ReduceMean → Add(eps) → Sqrt`) and
322    /// the numerator branch (`Sub → Div`). Some exporters (e.g. the one that
323    /// produced `bert_toy`) emit **two distinct `Sub(x, mean)` nodes** — one per
324    /// branch — instead of reusing a single `diff`, so the region is 10 ops and
325    /// the shared `mean` value is consumed by two Subs. Both shapes are matched
326    /// here; the canonical single-`Sub` diamond is the 9-op special case where
327    /// the two branches share one `Sub`.
328    ///
329    /// The returned [`PatternMatch::nodes`] are in a fixed canonical order the
330    /// schema extractor relies on:
331    /// `[mean_rm, sub_pow, pow, var_rm, add_eps, sqrt, div, mul, final_add]`,
332    /// with `sub_div` appended as a 10th node only when the numerator uses a
333    /// distinct `Sub`. Fusion is declined (via [`Self::layernorm_spec`]) unless
334    /// every schema assumption (single concrete `axis`, constant f32 `epsilon`,
335    /// interior data-flow) is provable.
336    fn try_match_layernorm(&self, graph: &Graph, start: NodeId) -> Option<PatternMatch> {
337        let mean_rm = graph.try_node(start)?;
338        if !Self::op_matches(mean_rm, "ReduceMean") || mean_rm.outputs.len() != 1 {
339            return None;
340        }
341        let mean = mean_rm.outputs[0];
342
343        // Every `Sub` that consumes `mean` (i.e. computes `x - mean`). One in the
344        // canonical diamond, two in the split-diff variant.
345        let subs: Vec<NodeId> = graph
346            .consumers(mean)
347            .into_iter()
348            .filter(|&c| {
349                let n = graph.node(c);
350                Self::op_matches(n, "Sub") && n.input_values().any(|v| v == mean)
351            })
352            .collect();
353
354        // Try each Sub as the *variance* diff source (feeding `Pow`).
355        for &sub_pow in &subs {
356            let sp = graph.node(sub_pow);
357            if sp.outputs.len() != 1 {
358                continue;
359            }
360            let diff_pow = sp.outputs[0];
361            // Variance branch: Pow → ReduceMean → Add(eps) → Sqrt.
362            let Some(pow) = Self::find_consumer(graph, diff_pow, "Pow") else {
363                continue;
364            };
365            let sq = graph.node(pow).outputs[0];
366            let Some(var_rm) = Self::find_consumer(graph, sq, "ReduceMean") else {
367                continue;
368            };
369            let var = graph.node(var_rm).outputs[0];
370            let Some(add_eps) = Self::find_consumer(graph, var, "Add") else {
371                continue;
372            };
373            let vare = graph.node(add_eps).outputs[0];
374            let Some(sqrt) = Self::find_consumer(graph, vare, "Sqrt") else {
375                continue;
376            };
377            let std = graph.node(sqrt).outputs[0];
378            // Numerator branch: Div(diff, std) → Mul(scale) → Add(bias).
379            let Some(div) = Self::find_consumer(graph, std, "Div") else {
380                continue;
381            };
382            let dn = graph.node(div);
383            // The numerator is the Div operand that isn't `std`; it must be the
384            // output of a `Sub(x, mean)` (the same or a sibling of `sub_pow`).
385            let Some(num) = dn.input_values().find(|&v| v != std) else {
386                continue;
387            };
388            let Some(&sub_div) = subs.iter().find(|&&s| graph.node(s).outputs[0] == num) else {
389                continue;
390            };
391            let norm = dn.outputs[0];
392            let Some(mul) = Self::find_consumer(graph, norm, "Mul") else {
393                continue;
394            };
395            let scaled = graph.node(mul).outputs[0];
396            let Some(final_add) = Self::find_consumer(graph, scaled, "Add") else {
397                continue;
398            };
399
400            // Canonical node order (see doc). Append `sub_div` iff distinct.
401            let mut nodes = vec![
402                start, sub_pow, pow, var_rm, add_eps, sqrt, div, mul, final_add,
403            ];
404            if sub_div != sub_pow {
405                nodes.push(sub_div);
406            }
407            let matched_set: HashSet<NodeId> = nodes.iter().copied().collect();
408            // All matched nodes must be distinct (no accidental aliasing).
409            if matched_set.len() != nodes.len() {
410                continue;
411            }
412
413            // Safety rule: no matched node except `final_add` may have an output
414            // that escapes the matched set (external consumer or graph output).
415            let escapes = nodes.iter().any(|&nid| {
416                nid != final_add
417                    && graph.node(nid).outputs.iter().any(|&out| {
418                        graph.outputs.contains(&out)
419                            || graph
420                                .consumers(out)
421                                .into_iter()
422                                .any(|consumer| !matched_set.contains(&consumer))
423                    })
424            });
425            if escapes {
426                continue;
427            }
428
429            // The fused node reuses `final_add`'s single output; it must survive
430            // removal (graph output or an external consumer).
431            let fa = graph.node(final_add);
432            if fa.outputs.len() != 1 {
433                continue;
434            }
435            let output = fa.outputs[0];
436            let survives = graph.outputs.contains(&output)
437                || graph
438                    .consumers(output)
439                    .into_iter()
440                    .any(|consumer| !matched_set.contains(&consumer));
441            if !survives {
442                continue;
443            }
444
445            // External inputs in first-seen order (X, Scale, B, plus constants).
446            let produced: HashSet<ValueId> = nodes
447                .iter()
448                .flat_map(|&n| graph.node(n).outputs.iter().copied())
449                .collect();
450            let mut external = Vec::new();
451            let mut seen = HashSet::new();
452            for &nid in &nodes {
453                for iv in graph.node(nid).input_values() {
454                    if produced.contains(&iv) {
455                        continue;
456                    }
457                    if seen.insert(iv) {
458                        external.push(iv);
459                    }
460                }
461            }
462
463            let matched = PatternMatch {
464                nodes,
465                external_inputs: external,
466                output,
467            };
468
469            // Decline unless every schema assumption is provable.
470            if self.layernorm_spec(graph, &matched).is_none() {
471                continue;
472            }
473            return Some(matched);
474        }
475        None
476    }
477
478    /// DAG-aware SDPA-core matcher anchored on the `Softmax`.
479    ///
480    /// Recognizes the scaled-dot-product-attention core
481    /// `MatMul(Q, Kside) → (Mul|Div by scalar) → [Add(mask)] → Softmax(axis=-1)
482    /// → MatMul(probs, V)` and rewrites it to a single
483    /// `com.microsoft::FusedAttention[Q, K, V, (mask)]`. All recognition and
484    /// every decline guard live in [`Self::try_parse_attention`]; this wrapper
485    /// just packages the parsed pieces into a [`PatternMatch`].
486    fn try_match_attention(&self, graph: &Graph, start: NodeId) -> Option<PatternMatch> {
487        let p = self.try_parse_attention(graph, start)?;
488        Some(PatternMatch {
489            nodes: p.nodes,
490            external_inputs: p.external_inputs,
491            output: p.output,
492        })
493    }
494
495    /// DAG-aware exact-GELU matcher anchored on the `Erf` node. Packages the
496    /// parsed pieces from [`Self::try_parse_gelu`] into a [`PatternMatch`].
497    fn try_match_gelu(&self, graph: &Graph, start: NodeId) -> Option<PatternMatch> {
498        let p = self.try_parse_gelu(graph, start)?;
499        Some(PatternMatch {
500            nodes: p.nodes,
501            external_inputs: p.external_inputs,
502            output: p.output,
503        })
504    }
505
506    /// Parse (and fully validate) the SDPA core anchored on the `Softmax` node
507    /// `sm_start`, or `None` to **decline-to-fuse** when any structural or
508    /// numeric assumption cannot be proven from the graph. Model-agnostic:
509    /// purely structural / constant checks, no model-specific names.
510    ///
511    /// Decline guards (each returns `None`):
512    /// * anchor is not a single-in/single-out `Softmax`, or its `axis` is not
513    ///   provably the **last** axis (absent axis or non-last → decline; never
514    ///   guess the opset default);
515    /// * the softmax output is not the **left** operand of a following `MatMul`
516    ///   (the `probs · V` product);
517    /// * the score scaling is not a `Mul`/`Div` by a **concrete scalar f32
518    ///   constant** whose other operand is a `MatMul` output;
519    /// * an intervening `Add` (mask) whose scaled-scores branch can't be
520    ///   uniquely identified (both or neither operand parse as the score
521    ///   scaling);
522    /// * any interior value escapes the matched region (consumed outside it or
523    ///   is a graph output), or the matched nodes are not all distinct, or the
524    ///   fused output would not survive removal.
525    fn try_parse_attention(&self, graph: &Graph, sm_start: NodeId) -> Option<AttnParts> {
526        // Anchor: a Softmax normalizing over its LAST axis.
527        let sm = graph.try_node(sm_start)?;
528        if !Self::op_matches(sm, "Softmax") || sm.inputs.len() != 1 || sm.outputs.len() != 1 {
529            return None;
530        }
531        let sm_in = sm.inputs[0]?;
532        let sm_out = sm.outputs[0];
533        let rank = graph.value(sm_in).shape.len();
534        if rank == 0 {
535            return None;
536        }
537        // Require an explicit `axis` that resolves to the last dim. An absent
538        // axis is the opset default (1 for ≤12, -1 for ≥13) — not provably the
539        // last axis on a >2-D tensor — so we decline rather than guess.
540        let axis = sm.attr("axis").and_then(Attribute::as_int)?;
541        let axis = if axis < 0 { axis + rank as i64 } else { axis };
542        if axis != rank as i64 - 1 {
543            return None;
544        }
545
546        // Forward: out = probs · V. `sm_out` must be the LEFT operand of a
547        // following MatMul (matmul is not commutative; a right-operand softmax
548        // would be `V · probs`, a different op → decline).
549        let out_mm = graph.consumers(sm_out).into_iter().find(|&c| {
550            let n = graph.node(c);
551            Self::op_matches(n, "MatMul") && n.inputs.first() == Some(&Some(sm_out))
552        })?;
553        let out_mm_node = graph.node(out_mm);
554        if out_mm_node.inputs.len() != 2 || out_mm_node.outputs.len() != 1 {
555            return None;
556        }
557        let v = out_mm_node.inputs[1]?;
558        let output = out_mm_node.outputs[0];
559
560        // Backward: the Softmax input is produced either directly by the score
561        // scaling, or by a mask `Add` sitting between the scaling and Softmax.
562        let sm_in_prod = graph.value(sm_in).producer?;
563        let prod = graph.node(sm_in_prod);
564        let (scale_out, mask, mask_add) = if Self::op_matches(prod, "Add") && prod.inputs.len() == 2
565        {
566            let a = prod.inputs[0]?;
567            let b = prod.inputs[1]?;
568            // The scaled-scores operand is the one whose producer parses as
569            // the score scaling (`Mul`/`Div` scalar of a MatMul output);
570            // the other operand is the additive mask. Exactly one must
571            // qualify — otherwise the dataflow is ambiguous → decline.
572            let a_scale = graph
573                .value(a)
574                .producer
575                .is_some_and(|p| Self::parse_scale(graph, p).is_some());
576            let b_scale = graph
577                .value(b)
578                .producer
579                .is_some_and(|p| Self::parse_scale(graph, p).is_some());
580            match (a_scale, b_scale) {
581                (true, false) => (a, Some(b), Some(sm_in_prod)),
582                (false, true) => (b, Some(a), Some(sm_in_prod)),
583                _ => return None,
584            }
585        } else {
586            (sm_in, None, None)
587        };
588
589        // Score scaling: `scores * c` (Mul) or `scores / c` (Div), c a concrete
590        // scalar f32 constant, `scores` a MatMul output.
591        let scale_node_id = graph.value(scale_out).producer?;
592        let scale_node = graph.node(scale_node_id);
593        if scale_node.outputs.len() != 1 || scale_node.outputs[0] != scale_out {
594            return None;
595        }
596        let (scores_out, scale) = Self::parse_scale(graph, scale_node_id)?;
597
598        // Score MatMul: scores = Q · Kside. `parse_scale` already proved the
599        // producer is a MatMul; re-fetch it and read its operands.
600        let score_mm_id = graph.value(scores_out).producer?;
601        let score_mm = graph.node(score_mm_id);
602        if !Self::op_matches(score_mm, "MatMul")
603            || score_mm.inputs.len() != 2
604            || score_mm.outputs.len() != 1
605            || score_mm.outputs[0] != scores_out
606        {
607            return None;
608        }
609        let q = score_mm.inputs[0]?;
610        let k_side = score_mm.inputs[1]?;
611
612        // K handling: optionally absorb a clean single-consumer last-two-axis
613        // `Transpose` that produced Kᵀ; otherwise pass Kside through as an
614        // already-transposed K.
615        let (k, k_transposed, transpose_node) = Self::attention_k(graph, k_side, score_mm_id);
616
617        // Matched nodes, canonical order (anchor first): the four core ops then
618        // the optional mask `Add` and optional absorbed `Transpose`.
619        let mut nodes = vec![sm_start, score_mm_id, scale_node_id, out_mm];
620        if let Some(ma) = mask_add {
621            nodes.push(ma);
622        }
623        if let Some(t) = transpose_node {
624            nodes.push(t);
625        }
626        let matched_set: HashSet<NodeId> = nodes.iter().copied().collect();
627        if matched_set.len() != nodes.len() {
628            return None;
629        }
630
631        // Safety rule: every matched node except `out_mm` must have all outputs
632        // consumed solely within the matched set (no external consumer, no
633        // graph output) — fusion must not delete a value observed elsewhere.
634        let escapes = nodes.iter().any(|&nid| {
635            nid != out_mm
636                && graph.node(nid).outputs.iter().any(|&o| {
637                    graph.outputs.contains(&o)
638                        || graph
639                            .consumers(o)
640                            .into_iter()
641                            .any(|consumer| !matched_set.contains(&consumer))
642                })
643        });
644        if escapes {
645            return None;
646        }
647
648        // The fused output (out_mm's single output) must survive removal.
649        let survives = graph.outputs.contains(&output)
650            || graph
651                .consumers(output)
652                .into_iter()
653                .any(|consumer| !matched_set.contains(&consumer));
654        if !survives {
655            return None;
656        }
657
658        // Schema-order external inputs: [Q, K, V] (+ mask).
659        let mut external = vec![q, k, v];
660        if let Some(m) = mask {
661            external.push(m);
662        }
663
664        Some(AttnParts {
665            nodes,
666            q,
667            k,
668            v,
669            mask,
670            scale,
671            k_transposed,
672            output,
673            external_inputs: external,
674        })
675    }
676
677    /// Parse a score-scaling node into `(scores_value, scale_multiplier)`, or
678    /// `None` if it is not a `Mul`/`Div` by a **concrete scalar f32 constant**
679    /// whose other operand is produced by a `MatMul`. `Div(scores, c)` yields
680    /// `1/c` (declining `c == 0`); `Mul` yields `c`. The scores-must-be-a-MatMul
681    /// check is what disambiguates the scaled branch from the mask branch (a
682    /// mask precompute is often itself a `Mul`, but not of a MatMul output).
683    fn parse_scale(graph: &Graph, node_id: NodeId) -> Option<(ValueId, f32)> {
684        let n = graph.node(node_id);
685        if n.inputs.len() != 2 || n.outputs.len() != 1 {
686            return None;
687        }
688        let (scores_out, scale) = if Self::op_matches(n, "Div") {
689            let num = n.inputs[0]?;
690            let den = n.inputs[1]?;
691            let c = read_scalar_const_f32(graph, den)?;
692            if c == 0.0 {
693                return None;
694            }
695            (num, 1.0 / c)
696        } else if Self::op_matches(n, "Mul") {
697            let x = n.inputs[0]?;
698            let y = n.inputs[1]?;
699            match (
700                read_scalar_const_f32(graph, x),
701                read_scalar_const_f32(graph, y),
702            ) {
703                (None, Some(c)) => (x, c),
704                (Some(c), None) => (y, c),
705                // both const (fold elsewhere) or neither const → not a scale.
706                _ => return None,
707            }
708        } else {
709            return None;
710        };
711        // The scaled operand must be a MatMul output (the score product).
712        let prod = graph.value(scores_out).producer?;
713        if !Self::op_matches(graph.node(prod), "MatMul") {
714            return None;
715        }
716        Some((scores_out, scale))
717    }
718
719    /// Decide the fused node's `K` input and `k_transposed` flag. If `k_side`
720    /// (the score MatMul's second operand) is produced by a clean last-two-axis
721    /// `Transpose` consumed **only** by the score MatMul, absorb it: `K` becomes
722    /// the transpose's input in `[…, seq_k, head_dim]` layout and the kernel
723    /// transposes internally (`k_transposed = false`, transpose node removed).
724    /// Otherwise `K = k_side` is used as-is as an already-transposed Kᵀ
725    /// (`k_transposed = true`, nothing absorbed).
726    fn attention_k(
727        graph: &Graph,
728        k_side: ValueId,
729        score_mm_id: NodeId,
730    ) -> (ValueId, bool, Option<NodeId>) {
731        if let Some(t_id) = graph.value(k_side).producer {
732            let t = graph.node(t_id);
733            if Self::op_matches(t, "Transpose")
734                && t.inputs.len() == 1
735                && t.outputs.len() == 1
736                && t.outputs[0] == k_side
737                && graph.consumers(k_side) == [score_mm_id]
738                && let Some(perm) = t.attr("perm").and_then(Attribute::as_ints)
739                && is_last2_swap_perm(perm)
740                && let Some(kin) = t.inputs[0]
741            {
742                return (kin, false, Some(t_id));
743            }
744        }
745        (k_side, true, None)
746    }
747
748    /// Extract the `[Q, K, V]` (+ optional `[mask]`) inputs and the
749    /// `scale`/`k_transposed` attributes for a matched SDPA core, or `None` to
750    /// decline. Re-parses from the anchor (`m.nodes[0]`, the Softmax) so the
751    /// spec is single-sourced with the matcher, and confirms the re-parse
752    /// covers exactly the same node set.
753    fn attention_spec(&self, graph: &Graph, m: &PatternMatch) -> Option<FusedNodeSpec> {
754        let start = *m.nodes.first()?;
755        let p = self.try_parse_attention(graph, start)?;
756        if p.nodes != m.nodes {
757            return None;
758        }
759        let mut inputs: Vec<Option<ValueId>> = vec![Some(p.q), Some(p.k), Some(p.v)];
760        if let Some(mask) = p.mask {
761            inputs.push(Some(mask));
762        }
763        let mut attributes = HashMap::new();
764        attributes.insert("scale".to_string(), Attribute::Float(p.scale));
765        attributes.insert(
766            "k_transposed".to_string(),
767            Attribute::Int(if p.k_transposed { 1 } else { 0 }),
768        );
769        Some((inputs, attributes))
770    }
771
772    /// Parse (and fully validate) the exact-GELU `Erf` decomposition anchored on
773    /// the `Erf` node `erf_start`, or `None` to **decline-to-fuse** when any
774    /// structural or numeric assumption cannot be proven from the graph.
775    /// Model-agnostic: purely structural / constant checks.
776    ///
777    /// Recognizes the diamond `out = (0.5·X) · (1 + Erf(X / √2))`, i.e.
778    /// `X → Div(X, √2) → Erf → Add(·, 1) → Mul(0.5·X, ·)` where the SAME `X`
779    /// also feeds `0.5·X = Mul(X, 0.5)`. The equivalent constant encodings
780    /// (`Mul(X, 1/√2)` for the inner scale, `Div(X, 2)` for the half scale) are
781    /// accepted too, since they are numerically identical.
782    ///
783    /// Decline guards (each returns `None`):
784    /// * anchor is not a single-in/single-out `Erf`;
785    /// * the `Erf` input is not `X / √2` (`Div(X, √2)` or `Mul(X, 1/√2)` with a
786    ///   concrete scalar f32 constant);
787    /// * the `Erf` output is not consumed by an `Add(erf, 1.0)` (`1.0` a
788    ///   concrete scalar constant);
789    /// * that `Add`'s output is not consumed by a `Mul` whose other operand is
790    ///   `0.5·X` (`Mul(X, 0.5)` or `Div(X, 2.0)`);
791    /// * the `0.5·X` operand's `X` is **not the same value** that feeds the
792    ///   `Erf` branch (the diamond is not closed);
793    /// * any interior value escapes the matched region, the matched nodes are
794    ///   not all distinct, or the fused output would not survive removal.
795    fn try_parse_gelu(&self, graph: &Graph, erf_start: NodeId) -> Option<GeluParts> {
796        // Anchor: a single-in/single-out `Erf`.
797        let erf = graph.try_node(erf_start)?;
798        if !Self::op_matches(erf, "Erf") || erf.inputs.len() != 1 || erf.outputs.len() != 1 {
799            return None;
800        }
801        let erf_in = erf.inputs[0]?;
802        let erf_out = erf.outputs[0];
803
804        // Backward: `erf_in = X / √2`, via `Div(X, √2)` or `Mul(X, 1/√2)`.
805        let inner_id = graph.value(erf_in).producer?;
806        let inner = graph.node(inner_id);
807        if inner.outputs.first() != Some(&erf_in) {
808            return None;
809        }
810        let x = Self::parse_scaled(graph, inner, &[("Div", SQRT_2), ("Mul", FRAC_1_SQRT_2)])?;
811
812        // Forward: `erf_out` consumed by `Add(erf_out, 1.0)`.
813        let add1_id = Self::find_consumer(graph, erf_out, "Add")?;
814        let add1 = graph.node(add1_id);
815        if add1.inputs.len() != 2 || add1.outputs.len() != 1 {
816            return None;
817        }
818        let one = add1.input_values().find(|&v| v != erf_out)?;
819        if !approx(read_scalar_const_f32(graph, one)?, 1.0) {
820            return None;
821        }
822        let add1_out = add1.outputs[0];
823
824        // Forward: `add1_out` consumed by `Mul(0.5·X, add1_out)`.
825        let outer_id = Self::find_consumer(graph, add1_out, "Mul")?;
826        let outer = graph.node(outer_id);
827        if outer.inputs.len() != 2 || outer.outputs.len() != 1 {
828            return None;
829        }
830        let half = outer.input_values().find(|&v| v != add1_out)?;
831        let output = outer.outputs[0];
832
833        // The half-scale operand must be `0.5·X` (`Mul(X, 0.5)` or `Div(X, 2.0)`)
834        // over the SAME `X` that feeds the `Erf` branch — this closes the
835        // diamond and confirms a real GELU, not a coincidental op sequence.
836        let half_id = graph.value(half).producer?;
837        let half_node = graph.node(half_id);
838        if half_node.outputs.first() != Some(&half) {
839            return None;
840        }
841        let x2 = Self::parse_scaled(graph, half_node, &[("Mul", 0.5), ("Div", 2.0)])?;
842        if x2 != x {
843            return None;
844        }
845
846        // Canonical node order (anchor first): [Erf, inner, Add, outer, half].
847        let nodes = vec![erf_start, inner_id, add1_id, outer_id, half_id];
848        let matched_set: HashSet<NodeId> = nodes.iter().copied().collect();
849        if matched_set.len() != nodes.len() {
850            return None;
851        }
852
853        // Safety rule: every matched node except the final `outer` `Mul` must
854        // have all outputs consumed solely within the matched set (no external
855        // consumer, no graph output).
856        let escapes = nodes.iter().any(|&nid| {
857            nid != outer_id
858                && graph.node(nid).outputs.iter().any(|&o| {
859                    graph.outputs.contains(&o)
860                        || graph
861                            .consumers(o)
862                            .into_iter()
863                            .any(|consumer| !matched_set.contains(&consumer))
864                })
865        });
866        if escapes {
867            return None;
868        }
869
870        // The fused output (outer's single output) must survive removal.
871        let survives = graph.outputs.contains(&output)
872            || graph
873                .consumers(output)
874                .into_iter()
875                .any(|consumer| !matched_set.contains(&consumer));
876        if !survives {
877            return None;
878        }
879
880        Some(GeluParts {
881            nodes,
882            x,
883            output,
884            external_inputs: vec![x],
885        })
886    }
887
888    /// If `node` computes `x · k` (`Mul`) or `x / k` (`Div`) for one of the
889    /// allowed `(op_type, constant)` forms, return the data operand `x`. The
890    /// constant must be a **strict scalar** f32 initializer approximately equal
891    /// to the expected value. `Mul` is commutative (the constant may be either
892    /// operand); `Div` is not (the constant must be the divisor). Any other
893    /// shape → `None`.
894    fn parse_scaled(graph: &Graph, node: &Node, forms: &[(&str, f32)]) -> Option<ValueId> {
895        if node.inputs.len() != 2 || node.outputs.len() != 1 {
896            return None;
897        }
898        let a = node.inputs[0]?;
899        let b = node.inputs[1]?;
900        for &(op, k) in forms {
901            if !Self::op_matches(node, op) {
902                continue;
903            }
904            // The scalar constant is valid as the second operand for both forms
905            // (the `Div` divisor, or a `Mul` factor); `Mul` is commutative, so
906            // it may additionally be the first operand.
907            if read_scalar_const_f32(graph, b).is_some_and(|c| approx(c, k)) {
908                return Some(a);
909            }
910            if op == "Mul" && read_scalar_const_f32(graph, a).is_some_and(|c| approx(c, k)) {
911                return Some(b);
912            }
913        }
914        None
915    }
916
917    /// Extract the schema-conformant `[X]` input (no attributes) for a matched
918    /// exact-GELU decomposition, or `None` to decline. Re-parses from the anchor
919    /// (`m.nodes[0]`, the `Erf`) so the spec is single-sourced with the matcher,
920    /// and confirms the re-parse covers exactly the same node set.
921    fn gelu_spec(&self, graph: &Graph, m: &PatternMatch) -> Option<FusedNodeSpec> {
922        let start = *m.nodes.first()?;
923        let p = self.try_parse_gelu(graph, start)?;
924        if p.nodes != m.nodes {
925            return None;
926        }
927        Some((vec![Some(p.x)], HashMap::new()))
928    }
929
930    /// Attempt to grow a match whose first node is `start`.
931    fn try_match_from(&self, graph: &Graph, start: NodeId) -> Option<PatternMatch> {
932        let start_node = graph.try_node(start)?;
933        if !Self::op_matches(start_node, &self.ops[0]) {
934            return None;
935        }
936
937        let mut chain = vec![start];
938        let mut chain_set: HashSet<NodeId> = HashSet::from([start]);
939
940        for op in &self.ops[1..] {
941            let prev = *chain.last().unwrap();
942            // Deterministic: pick the lowest-id successor of `prev` that has the
943            // required op type and is not already in the chain.
944            let mut succ_ids = graph.successors(prev);
945            succ_ids.sort_by_key(|n| n.0);
946            let next = succ_ids
947                .into_iter()
948                .find(|&s| !chain_set.contains(&s) && Self::op_matches(graph.node(s), op))?;
949            chain.push(next);
950            chain_set.insert(next);
951        }
952
953        // Safety rule: no non-final matched node may have an output that escapes
954        // the matched set (external consumer or graph output).
955        for &nid in &chain[..chain.len() - 1] {
956            for &out in &graph.node(nid).outputs {
957                if graph.outputs.contains(&out) {
958                    return None;
959                }
960                if graph
961                    .consumers(out)
962                    .into_iter()
963                    .any(|consumer| !chain_set.contains(&consumer))
964                {
965                    return None;
966                }
967            }
968        }
969
970        // The fused node reuses the last node's single output.
971        let last = *chain.last().unwrap();
972        let last_node = graph.node(last);
973        if last_node.outputs.len() != 1 {
974            return None;
975        }
976        let output = last_node.outputs[0];
977
978        // The output must survive removal of the matched nodes: it is either a
979        // graph output or has a consumer outside the matched set.
980        let survives = graph.outputs.contains(&output)
981            || graph
982                .consumers(output)
983                .into_iter()
984                .any(|consumer| !chain_set.contains(&consumer));
985        if !survives {
986            return None;
987        }
988
989        // Collect external inputs in first-seen order.
990        let produced: HashSet<ValueId> = chain
991            .iter()
992            .flat_map(|&n| graph.node(n).outputs.iter().copied())
993            .collect();
994        let mut external = Vec::new();
995        let mut seen = HashSet::new();
996        for &nid in &chain {
997            for iv in graph.node(nid).input_values() {
998                if produced.contains(&iv) {
999                    continue;
1000                }
1001                if seen.insert(iv) {
1002                    external.push(iv);
1003                }
1004            }
1005        }
1006
1007        let matched = PatternMatch {
1008            nodes: chain,
1009            external_inputs: external,
1010            output,
1011        };
1012
1013        // Decline-to-fuse: never return a match whose rewrite assumptions can't
1014        // be *proven* from the graph. Declining here (rather than erroring later
1015        // in `apply_fusion`) leaves the original ops in place and lets the
1016        // fixpoint loop skip this occurrence instead of aborting the whole pass.
1017        if !self.match_is_fusable(graph, &matched) {
1018            return None;
1019        }
1020
1021        Some(matched)
1022    }
1023
1024    /// Whether a matched occurrence may be fused, or must **decline-to-fuse**
1025    /// because a rewrite assumption can't be proven from the graph. Model-
1026    /// agnostic: purely structural / shape checks, no model-specific logic.
1027    fn match_is_fusable(&self, graph: &Graph, m: &PatternMatch) -> bool {
1028        match self.kind {
1029            RewriteKind::LayerNorm => self.layernorm_spec(graph, m).is_some(),
1030            RewriteKind::Attention => self.attention_spec(graph, m).is_some(),
1031            RewriteKind::Gelu => self.gelu_spec(graph, m).is_some(),
1032            RewriteKind::Structural => {
1033                // The MatMul+Add → FusedMatMulBias and MatMul+Add+Relu →
1034                // FusedGemm rewrites both need a bias broadcast guard (the
1035                // trailing Relu is elementwise and shape-neutral); other
1036                // structural rewrites are unconstrained.
1037                if self.replacement == "FusedMatMulBias" || self.replacement == "FusedGemm" {
1038                    self.matmul_bias_broadcast_ok(graph, m)
1039                } else {
1040                    true
1041                }
1042            }
1043        }
1044    }
1045
1046    /// Decline the `MatMul + Add → FusedMatMulBias` (and
1047    /// `MatMul + Add + Relu → FusedGemm`) fusion unless the `Add`'s non-matmul
1048    /// (bias) operand broadcasts *into* the MatMul output shape **without
1049    /// expanding it** — i.e. the bias is a valid trailing broadcast of the
1050    /// matmul output (`[N]`, `[1, N]`, same-shape, scalar, …). The optional
1051    /// trailing `Relu` is elementwise and shape-neutral, so the same guard
1052    /// applies to both fusions.
1053    ///
1054    /// A standalone `Add` broadcasts *both* operands up to their joint shape, so
1055    /// a bias with extra leading dims, or a batch axis where the output is
1056    /// extent-1, would grow the semantic result. The fused kernel and shape rule
1057    /// instead assume the output equals the *matmul* shape and right-align the
1058    /// bias, silently truncating the excess — wrong values *and* a too-small
1059    /// output. We therefore only fuse when every overlapping axis is provably
1060    /// non-expanding (identical dim, or bias extent 1). Any unknown/symbolic dim
1061    /// that can't be proven equal makes us decline conservatively.
1062    fn matmul_bias_broadcast_ok(&self, graph: &Graph, m: &PatternMatch) -> bool {
1063        // The matched pattern starts with `[MatMul, Add, ...]` (an optional
1064        // trailing `Relu` for FusedGemm). The MatMul output is the intermediate
1065        // value the Add consumes, and the other Add operand is bias.
1066        let (Some(&matmul), Some(&add)) = (m.nodes.first(), m.nodes.get(1)) else {
1067            return false;
1068        };
1069        let mm_out = graph.node(matmul).outputs[0];
1070        let Some(bias) = graph.node(add).input_values().find(|&v| v != mm_out) else {
1071            return false;
1072        };
1073        let mm_shape = &graph.value(mm_out).shape;
1074        let bias_shape = &graph.value(bias).shape;
1075
1076        // More bias dims than the output → leading dims would expand the result.
1077        if bias_shape.len() > mm_shape.len() {
1078            return false;
1079        }
1080        // Right-align the bias against the output; every overlapping axis must be
1081        // provably non-expanding: identical extent, or bias extent 1 (which just
1082        // broadcasts up into the existing output dim).
1083        let offset = mm_shape.len() - bias_shape.len();
1084        for (i, &bdim) in bias_shape.iter().enumerate() {
1085            let mdim = mm_shape[offset + i];
1086            if bdim == mdim {
1087                continue;
1088            }
1089            if bdim.as_static() == Some(1) {
1090                continue;
1091            }
1092            return false;
1093        }
1094        true
1095    }
1096
1097    /// Apply a match: remove the matched nodes and insert the replacement,
1098    /// reusing `m.output` so downstream consumers and graph outputs stay wired.
1099    pub fn apply_fusion(&self, graph: &mut Graph, m: &PatternMatch) -> Result<()> {
1100        self.apply_fusion_returning_id(graph, m).map(|_| ())
1101    }
1102
1103    fn apply_fusion_returning_id(&self, graph: &mut Graph, m: &PatternMatch) -> Result<NodeId> {
1104        let output = m.output;
1105
1106        // For schema-aware rewrites, extract the kernel-signature inputs and
1107        // attributes *before* the matched nodes are removed.
1108        let (inputs, attributes) = match self.kind {
1109            RewriteKind::Structural => (
1110                m.external_inputs.iter().map(|&v| Some(v)).collect(),
1111                HashMap::new(),
1112            ),
1113            RewriteKind::LayerNorm => self
1114                .layernorm_spec(graph, m)
1115                .ok_or_else(|| crate::error::OptimizerError::Fusion(self.name.clone()))?,
1116            RewriteKind::Attention => self
1117                .attention_spec(graph, m)
1118                .ok_or_else(|| crate::error::OptimizerError::Fusion(self.name.clone()))?,
1119            RewriteKind::Gelu => self
1120                .gelu_spec(graph, m)
1121                .ok_or_else(|| crate::error::OptimizerError::Fusion(self.name.clone()))?,
1122        };
1123
1124        // Remove in reverse (last-first): a node's consumers are gone before it,
1125        // so intermediate values are cleanly garbage-collected. `output` itself
1126        // survives because it is a graph output or has an external consumer.
1127        for &nid in m.nodes.iter().rev() {
1128            graph.remove_node(nid);
1129        }
1130
1131        if graph.try_value(output).is_none() {
1132            return Err(crate::error::OptimizerError::Fusion(self.name.clone()));
1133        }
1134
1135        let mut fused = Node::new(NodeId(0), self.replacement.clone(), inputs, vec![output]);
1136        fused.attributes = attributes;
1137        // Production patterns emit in the private contrib domain. Unit tests
1138        // can override it to exercise a replacement that can match again.
1139        #[cfg(not(test))]
1140        {
1141            fused.domain = CONTRIB_DOMAIN.to_string();
1142        }
1143        #[cfg(test)]
1144        {
1145            fused.domain = self.replacement_domain.clone();
1146        }
1147        if !fused.domain.is_empty() {
1148            graph.opset_imports.entry(fused.domain.clone()).or_insert(1);
1149        }
1150        Ok(graph.insert_node(fused))
1151    }
1152
1153    /// Extract the schema-conformant `[X, Scale, B]` inputs and the
1154    /// `axis`/`epsilon` attributes for a matched LayerNorm decomposition, or
1155    /// `None` if any schema-aware assumption can't be proven — in which case the
1156    /// pattern **declines to fuse** and the original ops are kept intact.
1157    ///
1158    /// The matched nodes are in the canonical order produced by
1159    /// [`Self::try_match_layernorm`]:
1160    /// `0:ReduceMean(x) → mean`, `1:Sub(x, mean) → diff_pow`,
1161    /// `2:Pow(diff_pow, 2) → sq`, `3:ReduceMean(sq) → var`,
1162    /// `4:Add(var, eps) → vare`, `5:Sqrt → std`, `6:Div(diff_div, std) → norm`,
1163    /// `7:Mul(norm, Scale) → scaled`, `8:Add(scaled, B) → out`, and an optional
1164    /// `9:Sub(x, mean) → diff_div` — present only when the numerator uses a
1165    /// **second, distinct** `Sub` (the `bert_toy`-style split-diff variant). In
1166    /// the canonical 9-op diamond the single `Sub` feeds both branches, so
1167    /// `diff_div == diff_pow`.
1168    ///
1169    /// * **X** is the (shared) `Sub` operand that is not `mean`; **Scale** the
1170    ///   `Mul` operand that is not the `Div` output; **B** the final `Add`
1171    ///   operand that is not the `Mul` output. Order-independent disambiguation.
1172    /// * **axis** must resolve to the *same single concrete* axis for BOTH the
1173    ///   mean and the variance `ReduceMean`, read from each node's `axes`
1174    ///   **attribute** (opset < 18) or its axes **input** (opset-24 schema), with
1175    ///   `keepdims = 1` on both; multi-axis / absent / reduce-all / mismatched
1176    ///   axes / `keepdims = 0` → decline; never silently assume `-1`.
1177    /// * **epsilon** must be readable as a concrete f32 scalar constant (else
1178    ///   decline; never silently assume `1e-5`).
1179    fn layernorm_spec(&self, graph: &Graph, m: &PatternMatch) -> Option<FusedNodeSpec> {
1180        let nodes = &m.nodes;
1181        if nodes.len() != 9 && nodes.len() != 10 {
1182            return None;
1183        }
1184        let rm1 = graph.node(nodes[0]);
1185        let sub_pow = graph.node(nodes[1]);
1186        let pow = graph.node(nodes[2]);
1187        let rm2 = graph.node(nodes[3]);
1188        let add_eps = graph.node(nodes[4]);
1189        let div = graph.node(nodes[6]);
1190        let mul = graph.node(nodes[7]);
1191        let final_add = graph.node(nodes[8]);
1192        // The numerator `Sub` is a distinct 10th node in the split-diff variant,
1193        // otherwise it is the same `Sub` that feeds the variance branch.
1194        let sub_div = if nodes.len() == 10 {
1195            graph.node(nodes[9])
1196        } else {
1197            sub_pow
1198        };
1199
1200        let mean = rm1.outputs[0];
1201        let diff_pow = sub_pow.outputs[0];
1202        let diff_div = sub_div.outputs[0];
1203        let var = rm2.outputs[0];
1204        let norm = div.outputs[0];
1205        let scaled = mul.outputs[0];
1206
1207        // Positive structural guard: confirm the interior data-flow really is the
1208        // LayerNorm decomposition, not just a coincidental op-type sequence. Each
1209        // consumer must actually read the interior tensor it is meant to consume.
1210        if !sub_pow.input_values().any(|v| v == mean)
1211            || !sub_div.input_values().any(|v| v == mean)
1212            || !pow.input_values().any(|v| v == diff_pow)
1213            || !div.input_values().any(|v| v == diff_div)
1214            || !mul.input_values().any(|v| v == norm)
1215            || !final_add.input_values().any(|v| v == scaled)
1216        {
1217            return None;
1218        }
1219
1220        // Order-independent X/Scale/B disambiguation: each picks the operand that
1221        // is NOT the matched interior tensor. Both `Sub`s must subtract `mean`
1222        // from the *same* `X`.
1223        let x = sub_pow.input_values().find(|&v| v != mean)?;
1224        if !sub_div.input_values().any(|v| v == x) {
1225            return None;
1226        }
1227
1228        // Operand-ORDER guard: each centering `Sub` must compute `diff = x - mean`
1229        // (minuend `x` first, subtrahend `mean` second), NOT `mean - x`. Membership
1230        // alone (checked above) would accept a reversed `Sub(mean, x)` and silently
1231        // rewrite it to a sign-flipped LayerNormalization. `Sub` is exactly binary,
1232        // so require input[0] == X and input[1] == mean on BOTH the variance-branch
1233        // and numerator-branch Subs. Ambiguous arity (not exactly two inputs) → decline.
1234        let subtracts_x_minus_mean = |sub: &Node| -> bool {
1235            matches!(sub.inputs.as_slice(), [Some(a), Some(b)] if *a == x && *b == mean)
1236        };
1237        if !subtracts_x_minus_mean(sub_pow) || !subtracts_x_minus_mean(sub_div) {
1238            return None;
1239        }
1240        let scale = mul.input_values().find(|&v| v != norm)?;
1241        let bias = final_add.input_values().find(|&v| v != scaled)?;
1242
1243        // epsilon guard: must be a concrete f32 scalar constant (no 1e-5 default).
1244        let eps_val = add_eps.input_values().find(|&v| v != var)?;
1245        let epsilon = read_scalar_f32(graph, eps_val)?;
1246
1247        // axis guard: BOTH `ReduceMean` nodes must reduce a single concrete axis
1248        // read from the `axes` ATTRIBUTE (opset < 18) or, for the opset-24 schema,
1249        // the axes INPUT; both must keep the reduced dim (`keepdims = 1`, or its
1250        // default). The mean and variance reductions must be over the SAME axis,
1251        // otherwise this is not a LayerNorm — decline (never silently assume -1).
1252        let axis = reduce_single_axis(graph, rm1)?;
1253        if reduce_single_axis(graph, rm2)? != axis {
1254            return None;
1255        }
1256
1257        let mut attributes = HashMap::new();
1258        attributes.insert("axis".to_string(), Attribute::Int(axis));
1259        attributes.insert("epsilon".to_string(), Attribute::Float(epsilon));
1260
1261        Some((vec![Some(x), Some(scale), Some(bias)], attributes))
1262    }
1263}
1264
1265/// Resolve the single concrete reduction axis of a `ReduceMean`, requiring the
1266/// reduced dimension to be kept (`keepdims = 1`, or absent → the schema default
1267/// of 1). The axes come from the `axes` **attribute** (opset < 18) or the axes
1268/// **input** (opset-24 schema). Multi-axis / absent / reduce-all / `keepdims = 0`
1269/// → `None`.
1270fn reduce_single_axis(graph: &Graph, rm: &Node) -> Option<i64> {
1271    if let Some(keepdims) = rm.attr("keepdims").and_then(Attribute::as_int)
1272        && keepdims != 1
1273    {
1274        return None;
1275    }
1276    let axes: Vec<i64> = if let Some(axes) = rm.attr("axes").and_then(Attribute::as_ints) {
1277        axes.to_vec()
1278    } else {
1279        let axes_value = rm.inputs.get(1).copied().flatten()?;
1280        read_i64_vector(graph, axes_value)?
1281    };
1282    let [axis] = axes.as_slice() else {
1283        return None;
1284    };
1285    Some(*axis)
1286}
1287
1288/// Read a scalar (or leading) f32 element from an inline float initializer, if
1289/// `value` is backed by one. Used to fold a constant `epsilon` into an attribute.
1290fn read_scalar_f32(graph: &Graph, value: ValueId) -> Option<f32> {
1291    match graph.initializers.get(&value)? {
1292        WeightRef::Inline(t) if t.dtype == DataType::Float32 && t.data.len() >= 4 => {
1293            Some(f32::from_le_bytes(t.data[0..4].try_into().ok()?))
1294        }
1295        _ => None,
1296    }
1297}
1298
1299/// Resolve a 1-D int64 `axes` vector for the value `value`. Supports an inline
1300/// int64 initializer and a not-yet-folded standard-domain `Constant` producer
1301/// (the fusion pass may run before `ConstantFolding` materializes it — see the
1302/// module-level pass order), covering both the `value` tensor and the
1303/// `value_ints` attribute spellings. Any malformed encoding (non-int64 dtype,
1304/// non-whole int64 byte length, wrong rank, dims/data mismatch, or a
1305/// non-standard-domain `Constant`) yields `None` so the caller declines rather
1306/// than mis-reading the axes.
1307fn read_i64_vector(graph: &Graph, value: ValueId) -> Option<Vec<i64>> {
1308    if let Some(WeightRef::Inline(tensor)) = graph.initializers.get(&value) {
1309        return i64_axes_from_tensor(tensor);
1310    }
1311    // A not-yet-folded `Constant` producer must be a standard-domain op.
1312    let producer = graph.value(value).producer?;
1313    let node = graph.node(producer);
1314    if node.op_type != "Constant" || !(node.domain.is_empty() || node.domain == "ai.onnx") {
1315        return None;
1316    }
1317    if let Some(Attribute::Tensor(tensor)) = node.attr("value") {
1318        return i64_axes_from_tensor(tensor);
1319    }
1320    // `Constant(value_ints=...)` carries an inherently 1-D int64 list.
1321    if let Some(ints) = node.attr("value_ints").and_then(Attribute::as_ints) {
1322        return Some(ints.to_vec());
1323    }
1324    None
1325}
1326
1327/// Decode a strictly 1-D int64 `axes` tensor. Rejects a non-int64 dtype, a byte
1328/// length that is not a whole number of int64 elements, a rank other than 1, or
1329/// dims inconsistent with the element count. A rank-0 scalar is intentionally
1330/// declined: its raw-byte equivalence to a rank-1 `[axis]` is ambiguous, so the
1331/// fusion declines rather than guess.
1332fn i64_axes_from_tensor(tensor: &TensorData) -> Option<Vec<i64>> {
1333    if tensor.dtype != DataType::Int64 || !tensor.data.len().is_multiple_of(8) {
1334        return None;
1335    }
1336    let numel = tensor.data.len() / 8;
1337    if tensor.dims.len() != 1 || tensor.dims[0] != numel {
1338        return None;
1339    }
1340    tensor
1341        .data
1342        .chunks_exact(8)
1343        .map(|chunk| Some(i64::from_le_bytes(chunk.try_into().ok()?)))
1344        .collect()
1345}
1346
1347/// The parsed pieces of a matched SDPA core (see
1348/// [`FusionPattern::try_parse_attention`]).
1349#[derive(Clone, Debug)]
1350struct AttnParts {
1351    /// All matched node ids, canonical order (anchor first):
1352    /// `[softmax, score_mm, scale_node, out_mm]` then optional `mask_add` and
1353    /// optional absorbed `transpose`.
1354    nodes: Vec<NodeId>,
1355    q: ValueId,
1356    k: ValueId,
1357    v: ValueId,
1358    mask: Option<ValueId>,
1359    scale: f32,
1360    k_transposed: bool,
1361    output: ValueId,
1362    external_inputs: Vec<ValueId>,
1363}
1364
1365/// The parsed pieces of a matched exact-GELU decomposition (see
1366/// [`FusionPattern::try_parse_gelu`]).
1367#[derive(Clone, Debug)]
1368struct GeluParts {
1369    /// All matched node ids, canonical order (anchor first):
1370    /// `[erf, inner_scale, add_one, outer_mul, half_scale]`.
1371    nodes: Vec<NodeId>,
1372    /// The single external input `X` (feeds both the `Erf` branch and `0.5·X`).
1373    x: ValueId,
1374    /// The fused node's output (the outer `Mul`'s single output).
1375    output: ValueId,
1376    external_inputs: Vec<ValueId>,
1377}
1378/// `None`. Stricter than [`read_scalar_f32`]: the score scale must be a genuine
1379/// scalar, so a multi-element initializer (whose first element we'd otherwise
1380/// silently read) is declined.
1381fn read_scalar_const_f32(graph: &Graph, value: ValueId) -> Option<f32> {
1382    match graph.initializers.get(&value)? {
1383        WeightRef::Inline(t) if t.dtype == DataType::Float32 => {
1384            let numel: usize = t.dims.iter().product();
1385            if numel != 1 || t.data.len() < 4 {
1386                return None;
1387            }
1388            Some(f32::from_le_bytes(t.data[0..4].try_into().ok()?))
1389        }
1390        _ => None,
1391    }
1392}
1393
1394/// Whether `perm` is a clean "swap the last two axes" permutation
1395/// (`[0, 1, …, r-3, r-1, r-2]`) for a rank-`perm.len()` tensor. Any other
1396/// permutation (including one that also moves batch/head axes) is not a plain
1397/// Kᵀ and is left un-absorbed.
1398fn is_last2_swap_perm(perm: &[i64]) -> bool {
1399    let r = perm.len();
1400    if r < 2 {
1401        return false;
1402    }
1403    for (i, &p) in perm.iter().enumerate().take(r - 2) {
1404        if p != i as i64 {
1405            return false;
1406        }
1407    }
1408    perm[r - 2] == (r - 1) as i64 && perm[r - 1] == (r - 2) as i64
1409}
1410
1411/// The default device-independent fusion patterns.
1412///
1413/// Ordered most-specific-first so `MatMul+Add+Relu` is captured before the
1414/// shorter `MatMul+Add`. `Residual+LayerNorm` remains deferred to Phase 2b/3.
1415pub fn default_fusion_patterns() -> Vec<FusionPattern> {
1416    vec![
1417        // Attention first: the SDPA core consumes plain MatMul/Softmax nodes, so
1418        // recognize it before the MatMul+Add(+Relu) rewrites can claim any of
1419        // its MatMuls.
1420        FusionPattern::attention(),
1421        FusionPattern::new("MatMul+Bias+Relu", &["MatMul", "Add", "Relu"], "FusedGemm"),
1422        FusionPattern::layernorm(),
1423        FusionPattern::gelu(),
1424        FusionPattern::new("MatMul+Bias", &["MatMul", "Add"], "FusedMatMulBias"),
1425    ]
1426}
1427
1428/// The op-fusion pass: applies each [`FusionPattern`] to fixpoint.
1429#[derive(Clone, Debug)]
1430pub struct OpFusion {
1431    patterns: Vec<FusionPattern>,
1432}
1433
1434#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1435enum ScanCandidateSource {
1436    Initial,
1437    Revisit,
1438}
1439
1440impl Default for OpFusion {
1441    fn default() -> Self {
1442        Self::new()
1443    }
1444}
1445
1446impl OpFusion {
1447    /// The pass with the default pattern set.
1448    pub fn new() -> Self {
1449        Self {
1450            patterns: default_fusion_patterns(),
1451        }
1452    }
1453
1454    /// The pass with a custom pattern set (used by tests / future callers).
1455    pub fn with_patterns(patterns: Vec<FusionPattern>) -> Self {
1456        Self { patterns }
1457    }
1458
1459    fn run_resumable(
1460        &self,
1461        graph: &mut Graph,
1462        mut observe_fusion: impl FnMut(&str, ScanCandidateSource, NodeId, &[NodeId], &[NodeId], NodeId),
1463    ) -> Result<()> {
1464        for pattern in &self.patterns {
1465            let candidates: Vec<u32> = graph.nodes.keys().map(|id| id.0).collect();
1466            let mut cursor = 0;
1467            let mut revisits = BTreeSet::new();
1468            loop {
1469                let initial = candidates.get(cursor).copied();
1470                let revisit = revisits.first().copied();
1471                let (raw_id, source) = match (initial, revisit) {
1472                    (None, None) => break,
1473                    (Some(id), None) => {
1474                        cursor += 1;
1475                        (id, ScanCandidateSource::Initial)
1476                    }
1477                    (None, Some(_)) => {
1478                        (revisits.pop_first().unwrap(), ScanCandidateSource::Revisit)
1479                    }
1480                    (Some(id), Some(revisit)) if id <= revisit => {
1481                        cursor += 1;
1482                        if id == revisit {
1483                            revisits.pop_first();
1484                        }
1485                        (id, ScanCandidateSource::Initial)
1486                    }
1487                    (Some(_), Some(_)) => {
1488                        (revisits.pop_first().unwrap(), ScanCandidateSource::Revisit)
1489                    }
1490                };
1491                let start = NodeId(raw_id);
1492                let Some(matched) = pattern.try_match_at(graph, start) else {
1493                    continue;
1494                };
1495
1496                let affected = pattern.affected_candidate_starts(graph, &matched);
1497                let fused_id = pattern.apply_fusion_returning_id(graph, &matched)?;
1498                observe_fusion(
1499                    pattern.pattern_name(),
1500                    source,
1501                    start,
1502                    &matched.nodes,
1503                    &affected,
1504                    fused_id,
1505                );
1506
1507                // The ordered set is the source of truth for resolution order:
1508                // any lower affected start is reconsidered before an untouched
1509                // higher-id candidate, exactly like a restart from arena slot 0.
1510                revisits.insert(fused_id.0);
1511                for candidate in affected {
1512                    if graph.try_node(candidate).is_some() {
1513                        revisits.insert(candidate.0);
1514                    }
1515                }
1516            }
1517        }
1518        Ok(())
1519    }
1520
1521    #[cfg(test)]
1522    fn run_with_fusion_observer(
1523        &self,
1524        graph: &mut Graph,
1525        observe_fusion: impl FnMut(&str, ScanCandidateSource, NodeId, &[NodeId], &[NodeId], NodeId),
1526    ) -> Result<()> {
1527        self.run_resumable(graph, observe_fusion)
1528    }
1529}
1530
1531impl OptimizationPass for OpFusion {
1532    fn name(&self) -> &str {
1533        "OpFusion"
1534    }
1535
1536    fn run(&self, graph: &mut Graph, _ctx: &PassContext) -> Result<()> {
1537        self.run_resumable(graph, |_, _, _, _, _, _| {})
1538    }
1539}
1540
1541#[cfg(test)]
1542mod tests;