Skip to main content

onnx_runtime_optimizer/
fusion.rs

1//! Operator fusion: match a connected op-sequence and replace it with a single
2//! fused op (see `docs/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::{HashMap, HashSet};
68
69use onnx_runtime_ir::{Attribute, DataType, Graph, Node, NodeId, 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    kind: RewriteKind,
145}
146
147impl FusionPattern {
148    /// A new *structural* pattern matching `ops` in sequence, replaced by
149    /// `replacement`. The fused node's inputs are the matched region's external
150    /// inputs in first-seen order.
151    pub fn new(name: &str, ops: &[&str], replacement: &str) -> Self {
152        assert!(!ops.is_empty(), "fusion pattern must have at least one op");
153        Self {
154            name: name.to_string(),
155            ops: ops.iter().map(|s| s.to_string()).collect(),
156            replacement: replacement.to_string(),
157            kind: RewriteKind::Structural,
158        }
159    }
160
161    /// The schema-aware LayerNorm pattern: the canonical 9-op decomposition
162    /// (`ReduceMean, Sub, Pow, ReduceMean, Add, Sqrt, Div, Mul, Add`) rewritten
163    /// to a `com.microsoft::LayerNormalization` node with inputs `[X, Scale, B]`
164    /// and synthesized `axis`/`epsilon` attributes.
165    pub fn layernorm() -> Self {
166        Self {
167            name: "LayerNorm".to_string(),
168            ops: [
169                "ReduceMean", "Sub", "Pow", "ReduceMean", "Add", "Sqrt", "Div", "Mul", "Add",
170            ]
171            .iter()
172            .map(|s| s.to_string())
173            .collect(),
174            replacement: "LayerNormalization".to_string(),
175            kind: RewriteKind::LayerNorm,
176        }
177    }
178
179    /// This pattern's rewrite kind.
180    pub fn kind(&self) -> RewriteKind {
181        self.kind
182    }
183
184    /// The schema-aware SDPA-core pattern, rewritten to a
185    /// `com.microsoft::FusedAttention` node with inputs `[Q, K, V]` (+ optional
186    /// `[mask]`) and synthesized `scale`/`k_transposed` attributes. Anchored on
187    /// the `Softmax` (see [`Self::try_match_attention`]).
188    pub fn attention() -> Self {
189        Self {
190            name: "Attention".to_string(),
191            // The op list is descriptive only; the DAG-aware matcher does the
192            // real recognition. Softmax is the anchor.
193            ops: ["Softmax"].iter().map(|s| s.to_string()).collect(),
194            replacement: "FusedAttention".to_string(),
195            kind: RewriteKind::Attention,
196        }
197    }
198
199    /// The schema-aware exact-GELU pattern: the `Erf` decomposition
200    /// `0.5·X · (1 + Erf(X / √2))` rewritten to a `com.microsoft::Gelu` node
201    /// with the single input `[X]` and no attributes. Anchored on the `Erf`
202    /// (see [`Self::try_match_gelu`]).
203    pub fn gelu() -> Self {
204        Self {
205            name: "Gelu".to_string(),
206            // Descriptive only; the DAG-aware matcher does the real recognition.
207            // `Erf` is the anchor.
208            ops: ["Erf"].iter().map(|s| s.to_string()).collect(),
209            replacement: "Gelu".to_string(),
210            kind: RewriteKind::Gelu,
211        }
212    }
213
214    /// This pattern's name.
215    pub fn pattern_name(&self) -> &str {
216        &self.name
217    }
218
219    /// Find the next occurrence of this pattern, scanning nodes in id order.
220    ///
221    /// [`RewriteKind::LayerNorm`] uses a dedicated DAG-aware matcher
222    /// ([`Self::try_match_layernorm`]) because a real LayerNorm decomposition is
223    /// a diamond DAG whose `mean` feeds two branches (variance + numerator) and
224    /// may even use two distinct `Sub(x, mean)` nodes; the linear successor-walk
225    /// used by the structural patterns can't express that. All structural
226    /// patterns (MatMul+Add, MatMul+Add+Relu) keep the linear-chain matcher.
227    pub fn find_match(&self, graph: &Graph) -> Option<PatternMatch> {
228        for start in graph.nodes.keys() {
229            let m = match self.kind {
230                RewriteKind::LayerNorm => self.try_match_layernorm(graph, start),
231                RewriteKind::Attention => self.try_match_attention(graph, start),
232                RewriteKind::Gelu => self.try_match_gelu(graph, start),
233                RewriteKind::Structural => self.try_match_from(graph, start),
234            };
235            if let Some(m) = m {
236                return Some(m);
237            }
238        }
239        None
240    }
241
242    /// Whether `node` is a standard-domain op named `op`.
243    fn op_matches(node: &Node, op: &str) -> bool {
244        node.op_type == op && matches!(node.domain.as_str(), "" | "ai.onnx")
245    }
246
247    /// The first consumer of `value` whose op is `op` (standard domain).
248    fn find_consumer(graph: &Graph, value: ValueId, op: &str) -> Option<NodeId> {
249        graph
250            .value(value)
251            .consumers
252            .iter()
253            .copied()
254            .find(|&c| Self::op_matches(graph.node(c), op))
255    }
256
257    /// DAG-aware LayerNorm matcher anchored on the *mean* `ReduceMean` node.
258    ///
259    /// Real LayerNorm decompositions are a diamond, not a chain: the mean feeds
260    /// both the variance branch (`Sub → Pow → ReduceMean → Add(eps) → Sqrt`) and
261    /// the numerator branch (`Sub → Div`). Some exporters (e.g. the one that
262    /// produced `bert_toy`) emit **two distinct `Sub(x, mean)` nodes** — one per
263    /// branch — instead of reusing a single `diff`, so the region is 10 ops and
264    /// the shared `mean` value is consumed by two Subs. Both shapes are matched
265    /// here; the canonical single-`Sub` diamond is the 9-op special case where
266    /// the two branches share one `Sub`.
267    ///
268    /// The returned [`PatternMatch::nodes`] are in a fixed canonical order the
269    /// schema extractor relies on:
270    /// `[mean_rm, sub_pow, pow, var_rm, add_eps, sqrt, div, mul, final_add]`,
271    /// with `sub_div` appended as a 10th node only when the numerator uses a
272    /// distinct `Sub`. Fusion is declined (via [`Self::layernorm_spec`]) unless
273    /// every schema assumption (single concrete `axis`, constant f32 `epsilon`,
274    /// interior data-flow) is provable.
275    fn try_match_layernorm(&self, graph: &Graph, start: NodeId) -> Option<PatternMatch> {
276        let mean_rm = graph.try_node(start)?;
277        if !Self::op_matches(mean_rm, "ReduceMean") || mean_rm.outputs.len() != 1 {
278            return None;
279        }
280        let mean = mean_rm.outputs[0];
281
282        // Every `Sub` that consumes `mean` (i.e. computes `x - mean`). One in the
283        // canonical diamond, two in the split-diff variant.
284        let subs: Vec<NodeId> = graph
285            .value(mean)
286            .consumers
287            .iter()
288            .copied()
289            .filter(|&c| {
290                let n = graph.node(c);
291                Self::op_matches(n, "Sub") && n.input_values().any(|v| v == mean)
292            })
293            .collect();
294
295        // Try each Sub as the *variance* diff source (feeding `Pow`).
296        for &sub_pow in &subs {
297            let sp = graph.node(sub_pow);
298            if sp.outputs.len() != 1 {
299                continue;
300            }
301            let diff_pow = sp.outputs[0];
302            // Variance branch: Pow → ReduceMean → Add(eps) → Sqrt.
303            let Some(pow) = Self::find_consumer(graph, diff_pow, "Pow") else {
304                continue;
305            };
306            let sq = graph.node(pow).outputs[0];
307            let Some(var_rm) = Self::find_consumer(graph, sq, "ReduceMean") else {
308                continue;
309            };
310            let var = graph.node(var_rm).outputs[0];
311            let Some(add_eps) = Self::find_consumer(graph, var, "Add") else {
312                continue;
313            };
314            let vare = graph.node(add_eps).outputs[0];
315            let Some(sqrt) = Self::find_consumer(graph, vare, "Sqrt") else {
316                continue;
317            };
318            let std = graph.node(sqrt).outputs[0];
319            // Numerator branch: Div(diff, std) → Mul(scale) → Add(bias).
320            let Some(div) = Self::find_consumer(graph, std, "Div") else {
321                continue;
322            };
323            let dn = graph.node(div);
324            // The numerator is the Div operand that isn't `std`; it must be the
325            // output of a `Sub(x, mean)` (the same or a sibling of `sub_pow`).
326            let Some(num) = dn.input_values().find(|&v| v != std) else {
327                continue;
328            };
329            let Some(&sub_div) = subs.iter().find(|&&s| graph.node(s).outputs[0] == num) else {
330                continue;
331            };
332            let norm = dn.outputs[0];
333            let Some(mul) = Self::find_consumer(graph, norm, "Mul") else {
334                continue;
335            };
336            let scaled = graph.node(mul).outputs[0];
337            let Some(final_add) = Self::find_consumer(graph, scaled, "Add") else {
338                continue;
339            };
340
341            // Canonical node order (see doc). Append `sub_div` iff distinct.
342            let mut nodes = vec![
343                start, sub_pow, pow, var_rm, add_eps, sqrt, div, mul, final_add,
344            ];
345            if sub_div != sub_pow {
346                nodes.push(sub_div);
347            }
348            let matched_set: HashSet<NodeId> = nodes.iter().copied().collect();
349            // All matched nodes must be distinct (no accidental aliasing).
350            if matched_set.len() != nodes.len() {
351                continue;
352            }
353
354            // Safety rule: no matched node except `final_add` may have an output
355            // that escapes the matched set (external consumer or graph output).
356            let escapes = nodes.iter().any(|&nid| {
357                nid != final_add
358                    && graph.node(nid).outputs.iter().any(|&out| {
359                        graph.outputs.contains(&out)
360                            || graph
361                                .value(out)
362                                .consumers
363                                .iter()
364                                .any(|c| !matched_set.contains(c))
365                    })
366            });
367            if escapes {
368                continue;
369            }
370
371            // The fused node reuses `final_add`'s single output; it must survive
372            // removal (graph output or an external consumer).
373            let fa = graph.node(final_add);
374            if fa.outputs.len() != 1 {
375                continue;
376            }
377            let output = fa.outputs[0];
378            let out_val = graph.value(output);
379            let survives = graph.outputs.contains(&output)
380                || out_val.consumers.iter().any(|c| !matched_set.contains(c));
381            if !survives {
382                continue;
383            }
384
385            // External inputs in first-seen order (X, Scale, B, plus constants).
386            let produced: HashSet<ValueId> = nodes
387                .iter()
388                .flat_map(|&n| graph.node(n).outputs.iter().copied())
389                .collect();
390            let mut external = Vec::new();
391            let mut seen = HashSet::new();
392            for &nid in &nodes {
393                for iv in graph.node(nid).input_values() {
394                    if produced.contains(&iv) {
395                        continue;
396                    }
397                    if seen.insert(iv) {
398                        external.push(iv);
399                    }
400                }
401            }
402
403            let matched = PatternMatch {
404                nodes,
405                external_inputs: external,
406                output,
407            };
408
409            // Decline unless every schema assumption is provable.
410            if self.layernorm_spec(graph, &matched).is_none() {
411                continue;
412            }
413            return Some(matched);
414        }
415        None
416    }
417
418    /// DAG-aware SDPA-core matcher anchored on the `Softmax`.
419    ///
420    /// Recognizes the scaled-dot-product-attention core
421    /// `MatMul(Q, Kside) → (Mul|Div by scalar) → [Add(mask)] → Softmax(axis=-1)
422    /// → MatMul(probs, V)` and rewrites it to a single
423    /// `com.microsoft::FusedAttention[Q, K, V, (mask)]`. All recognition and
424    /// every decline guard live in [`Self::try_parse_attention`]; this wrapper
425    /// just packages the parsed pieces into a [`PatternMatch`].
426    fn try_match_attention(&self, graph: &Graph, start: NodeId) -> Option<PatternMatch> {
427        let p = self.try_parse_attention(graph, start)?;
428        Some(PatternMatch {
429            nodes: p.nodes,
430            external_inputs: p.external_inputs,
431            output: p.output,
432        })
433    }
434
435    /// DAG-aware exact-GELU matcher anchored on the `Erf` node. Packages the
436    /// parsed pieces from [`Self::try_parse_gelu`] into a [`PatternMatch`].
437    fn try_match_gelu(&self, graph: &Graph, start: NodeId) -> Option<PatternMatch> {
438        let p = self.try_parse_gelu(graph, start)?;
439        Some(PatternMatch {
440            nodes: p.nodes,
441            external_inputs: p.external_inputs,
442            output: p.output,
443        })
444    }
445
446    /// Parse (and fully validate) the SDPA core anchored on the `Softmax` node
447    /// `sm_start`, or `None` to **decline-to-fuse** when any structural or
448    /// numeric assumption cannot be proven from the graph. Model-agnostic:
449    /// purely structural / constant checks, no model-specific names.
450    ///
451    /// Decline guards (each returns `None`):
452    /// * anchor is not a single-in/single-out `Softmax`, or its `axis` is not
453    ///   provably the **last** axis (absent axis or non-last → decline; never
454    ///   guess the opset default);
455    /// * the softmax output is not the **left** operand of a following `MatMul`
456    ///   (the `probs · V` product);
457    /// * the score scaling is not a `Mul`/`Div` by a **concrete scalar f32
458    ///   constant** whose other operand is a `MatMul` output;
459    /// * an intervening `Add` (mask) whose scaled-scores branch can't be
460    ///   uniquely identified (both or neither operand parse as the score
461    ///   scaling);
462    /// * any interior value escapes the matched region (consumed outside it or
463    ///   is a graph output), or the matched nodes are not all distinct, or the
464    ///   fused output would not survive removal.
465    fn try_parse_attention(&self, graph: &Graph, sm_start: NodeId) -> Option<AttnParts> {
466        // Anchor: a Softmax normalizing over its LAST axis.
467        let sm = graph.try_node(sm_start)?;
468        if !Self::op_matches(sm, "Softmax") || sm.inputs.len() != 1 || sm.outputs.len() != 1 {
469            return None;
470        }
471        let sm_in = sm.inputs[0]?;
472        let sm_out = sm.outputs[0];
473        let rank = graph.value(sm_in).shape.len();
474        if rank == 0 {
475            return None;
476        }
477        // Require an explicit `axis` that resolves to the last dim. An absent
478        // axis is the opset default (1 for ≤12, -1 for ≥13) — not provably the
479        // last axis on a >2-D tensor — so we decline rather than guess.
480        let axis = sm.attr("axis").and_then(Attribute::as_int)?;
481        let axis = if axis < 0 { axis + rank as i64 } else { axis };
482        if axis != rank as i64 - 1 {
483            return None;
484        }
485
486        // Forward: out = probs · V. `sm_out` must be the LEFT operand of a
487        // following MatMul (matmul is not commutative; a right-operand softmax
488        // would be `V · probs`, a different op → decline).
489        let out_mm = graph
490            .value(sm_out)
491            .consumers
492            .iter()
493            .copied()
494            .find(|&c| {
495                let n = graph.node(c);
496                Self::op_matches(n, "MatMul") && n.inputs.first() == Some(&Some(sm_out))
497            })?;
498        let out_mm_node = graph.node(out_mm);
499        if out_mm_node.inputs.len() != 2 || out_mm_node.outputs.len() != 1 {
500            return None;
501        }
502        let v = out_mm_node.inputs[1]?;
503        let output = out_mm_node.outputs[0];
504
505        // Backward: the Softmax input is produced either directly by the score
506        // scaling, or by a mask `Add` sitting between the scaling and Softmax.
507        let sm_in_prod = graph.value(sm_in).producer?;
508        let prod = graph.node(sm_in_prod);
509        let (scale_out, mask, mask_add) =
510            if Self::op_matches(prod, "Add") && prod.inputs.len() == 2 {
511                let a = prod.inputs[0]?;
512                let b = prod.inputs[1]?;
513                // The scaled-scores operand is the one whose producer parses as
514                // the score scaling (`Mul`/`Div` scalar of a MatMul output);
515                // the other operand is the additive mask. Exactly one must
516                // qualify — otherwise the dataflow is ambiguous → decline.
517                let a_scale = graph
518                    .value(a)
519                    .producer
520                    .is_some_and(|p| Self::parse_scale(graph, p).is_some());
521                let b_scale = graph
522                    .value(b)
523                    .producer
524                    .is_some_and(|p| Self::parse_scale(graph, p).is_some());
525                match (a_scale, b_scale) {
526                    (true, false) => (a, Some(b), Some(sm_in_prod)),
527                    (false, true) => (b, Some(a), Some(sm_in_prod)),
528                    _ => return None,
529                }
530            } else {
531                (sm_in, None, None)
532            };
533
534        // Score scaling: `scores * c` (Mul) or `scores / c` (Div), c a concrete
535        // scalar f32 constant, `scores` a MatMul output.
536        let scale_node_id = graph.value(scale_out).producer?;
537        let scale_node = graph.node(scale_node_id);
538        if scale_node.outputs.len() != 1 || scale_node.outputs[0] != scale_out {
539            return None;
540        }
541        let (scores_out, scale) = Self::parse_scale(graph, scale_node_id)?;
542
543        // Score MatMul: scores = Q · Kside. `parse_scale` already proved the
544        // producer is a MatMul; re-fetch it and read its operands.
545        let score_mm_id = graph.value(scores_out).producer?;
546        let score_mm = graph.node(score_mm_id);
547        if !Self::op_matches(score_mm, "MatMul")
548            || score_mm.inputs.len() != 2
549            || score_mm.outputs.len() != 1
550            || score_mm.outputs[0] != scores_out
551        {
552            return None;
553        }
554        let q = score_mm.inputs[0]?;
555        let k_side = score_mm.inputs[1]?;
556
557        // K handling: optionally absorb a clean single-consumer last-two-axis
558        // `Transpose` that produced Kᵀ; otherwise pass Kside through as an
559        // already-transposed K.
560        let (k, k_transposed, transpose_node) = Self::attention_k(graph, k_side, score_mm_id);
561
562        // Matched nodes, canonical order (anchor first): the four core ops then
563        // the optional mask `Add` and optional absorbed `Transpose`.
564        let mut nodes = vec![sm_start, score_mm_id, scale_node_id, out_mm];
565        if let Some(ma) = mask_add {
566            nodes.push(ma);
567        }
568        if let Some(t) = transpose_node {
569            nodes.push(t);
570        }
571        let matched_set: HashSet<NodeId> = nodes.iter().copied().collect();
572        if matched_set.len() != nodes.len() {
573            return None;
574        }
575
576        // Safety rule: every matched node except `out_mm` must have all outputs
577        // consumed solely within the matched set (no external consumer, no
578        // graph output) — fusion must not delete a value observed elsewhere.
579        let escapes = nodes.iter().any(|&nid| {
580            nid != out_mm
581                && graph.node(nid).outputs.iter().any(|&o| {
582                    graph.outputs.contains(&o)
583                        || graph
584                            .value(o)
585                            .consumers
586                            .iter()
587                            .any(|c| !matched_set.contains(c))
588                })
589        });
590        if escapes {
591            return None;
592        }
593
594        // The fused output (out_mm's single output) must survive removal.
595        let out_val = graph.value(output);
596        let survives = graph.outputs.contains(&output)
597            || out_val.consumers.iter().any(|c| !matched_set.contains(c));
598        if !survives {
599            return None;
600        }
601
602        // Schema-order external inputs: [Q, K, V] (+ mask).
603        let mut external = vec![q, k, v];
604        if let Some(m) = mask {
605            external.push(m);
606        }
607
608        Some(AttnParts {
609            nodes,
610            q,
611            k,
612            v,
613            mask,
614            scale,
615            k_transposed,
616            output,
617            external_inputs: external,
618        })
619    }
620
621    /// Parse a score-scaling node into `(scores_value, scale_multiplier)`, or
622    /// `None` if it is not a `Mul`/`Div` by a **concrete scalar f32 constant**
623    /// whose other operand is produced by a `MatMul`. `Div(scores, c)` yields
624    /// `1/c` (declining `c == 0`); `Mul` yields `c`. The scores-must-be-a-MatMul
625    /// check is what disambiguates the scaled branch from the mask branch (a
626    /// mask precompute is often itself a `Mul`, but not of a MatMul output).
627    fn parse_scale(graph: &Graph, node_id: NodeId) -> Option<(ValueId, f32)> {
628        let n = graph.node(node_id);
629        if n.inputs.len() != 2 || n.outputs.len() != 1 {
630            return None;
631        }
632        let (scores_out, scale) = if Self::op_matches(n, "Div") {
633            let num = n.inputs[0]?;
634            let den = n.inputs[1]?;
635            let c = read_scalar_const_f32(graph, den)?;
636            if c == 0.0 {
637                return None;
638            }
639            (num, 1.0 / c)
640        } else if Self::op_matches(n, "Mul") {
641            let x = n.inputs[0]?;
642            let y = n.inputs[1]?;
643            match (
644                read_scalar_const_f32(graph, x),
645                read_scalar_const_f32(graph, y),
646            ) {
647                (None, Some(c)) => (x, c),
648                (Some(c), None) => (y, c),
649                // both const (fold elsewhere) or neither const → not a scale.
650                _ => return None,
651            }
652        } else {
653            return None;
654        };
655        // The scaled operand must be a MatMul output (the score product).
656        let prod = graph.value(scores_out).producer?;
657        if !Self::op_matches(graph.node(prod), "MatMul") {
658            return None;
659        }
660        Some((scores_out, scale))
661    }
662
663    /// Decide the fused node's `K` input and `k_transposed` flag. If `k_side`
664    /// (the score MatMul's second operand) is produced by a clean last-two-axis
665    /// `Transpose` consumed **only** by the score MatMul, absorb it: `K` becomes
666    /// the transpose's input in `[…, seq_k, head_dim]` layout and the kernel
667    /// transposes internally (`k_transposed = false`, transpose node removed).
668    /// Otherwise `K = k_side` is used as-is as an already-transposed Kᵀ
669    /// (`k_transposed = true`, nothing absorbed).
670    fn attention_k(
671        graph: &Graph,
672        k_side: ValueId,
673        score_mm_id: NodeId,
674    ) -> (ValueId, bool, Option<NodeId>) {
675        if let Some(t_id) = graph.value(k_side).producer {
676            let t = graph.node(t_id);
677            if Self::op_matches(t, "Transpose")
678                && t.inputs.len() == 1
679                && t.outputs.len() == 1
680                && t.outputs[0] == k_side
681                && graph.value(k_side).consumers.as_slice() == [score_mm_id]
682                && let Some(perm) = t.attr("perm").and_then(Attribute::as_ints)
683                && is_last2_swap_perm(perm)
684                && let Some(kin) = t.inputs[0]
685            {
686                return (kin, false, Some(t_id));
687            }
688        }
689        (k_side, true, None)
690    }
691
692    /// Extract the `[Q, K, V]` (+ optional `[mask]`) inputs and the
693    /// `scale`/`k_transposed` attributes for a matched SDPA core, or `None` to
694    /// decline. Re-parses from the anchor (`m.nodes[0]`, the Softmax) so the
695    /// spec is single-sourced with the matcher, and confirms the re-parse
696    /// covers exactly the same node set.
697    fn attention_spec(&self, graph: &Graph, m: &PatternMatch) -> Option<FusedNodeSpec> {
698        let start = *m.nodes.first()?;
699        let p = self.try_parse_attention(graph, start)?;
700        if p.nodes != m.nodes {
701            return None;
702        }
703        let mut inputs: Vec<Option<ValueId>> = vec![Some(p.q), Some(p.k), Some(p.v)];
704        if let Some(mask) = p.mask {
705            inputs.push(Some(mask));
706        }
707        let mut attributes = HashMap::new();
708        attributes.insert("scale".to_string(), Attribute::Float(p.scale));
709        attributes.insert(
710            "k_transposed".to_string(),
711            Attribute::Int(if p.k_transposed { 1 } else { 0 }),
712        );
713        Some((inputs, attributes))
714    }
715
716    /// Parse (and fully validate) the exact-GELU `Erf` decomposition anchored on
717    /// the `Erf` node `erf_start`, or `None` to **decline-to-fuse** when any
718    /// structural or numeric assumption cannot be proven from the graph.
719    /// Model-agnostic: purely structural / constant checks.
720    ///
721    /// Recognizes the diamond `out = (0.5·X) · (1 + Erf(X / √2))`, i.e.
722    /// `X → Div(X, √2) → Erf → Add(·, 1) → Mul(0.5·X, ·)` where the SAME `X`
723    /// also feeds `0.5·X = Mul(X, 0.5)`. The equivalent constant encodings
724    /// (`Mul(X, 1/√2)` for the inner scale, `Div(X, 2)` for the half scale) are
725    /// accepted too, since they are numerically identical.
726    ///
727    /// Decline guards (each returns `None`):
728    /// * anchor is not a single-in/single-out `Erf`;
729    /// * the `Erf` input is not `X / √2` (`Div(X, √2)` or `Mul(X, 1/√2)` with a
730    ///   concrete scalar f32 constant);
731    /// * the `Erf` output is not consumed by an `Add(erf, 1.0)` (`1.0` a
732    ///   concrete scalar constant);
733    /// * that `Add`'s output is not consumed by a `Mul` whose other operand is
734    ///   `0.5·X` (`Mul(X, 0.5)` or `Div(X, 2.0)`);
735    /// * the `0.5·X` operand's `X` is **not the same value** that feeds the
736    ///   `Erf` branch (the diamond is not closed);
737    /// * any interior value escapes the matched region, the matched nodes are
738    ///   not all distinct, or the fused output would not survive removal.
739    fn try_parse_gelu(&self, graph: &Graph, erf_start: NodeId) -> Option<GeluParts> {
740        // Anchor: a single-in/single-out `Erf`.
741        let erf = graph.try_node(erf_start)?;
742        if !Self::op_matches(erf, "Erf") || erf.inputs.len() != 1 || erf.outputs.len() != 1 {
743            return None;
744        }
745        let erf_in = erf.inputs[0]?;
746        let erf_out = erf.outputs[0];
747
748        // Backward: `erf_in = X / √2`, via `Div(X, √2)` or `Mul(X, 1/√2)`.
749        let inner_id = graph.value(erf_in).producer?;
750        let inner = graph.node(inner_id);
751        if inner.outputs.first() != Some(&erf_in) {
752            return None;
753        }
754        let x = Self::parse_scaled(graph, inner, &[("Div", SQRT_2), ("Mul", FRAC_1_SQRT_2)])?;
755
756        // Forward: `erf_out` consumed by `Add(erf_out, 1.0)`.
757        let add1_id = Self::find_consumer(graph, erf_out, "Add")?;
758        let add1 = graph.node(add1_id);
759        if add1.inputs.len() != 2 || add1.outputs.len() != 1 {
760            return None;
761        }
762        let one = add1.input_values().find(|&v| v != erf_out)?;
763        if !approx(read_scalar_const_f32(graph, one)?, 1.0) {
764            return None;
765        }
766        let add1_out = add1.outputs[0];
767
768        // Forward: `add1_out` consumed by `Mul(0.5·X, add1_out)`.
769        let outer_id = Self::find_consumer(graph, add1_out, "Mul")?;
770        let outer = graph.node(outer_id);
771        if outer.inputs.len() != 2 || outer.outputs.len() != 1 {
772            return None;
773        }
774        let half = outer.input_values().find(|&v| v != add1_out)?;
775        let output = outer.outputs[0];
776
777        // The half-scale operand must be `0.5·X` (`Mul(X, 0.5)` or `Div(X, 2.0)`)
778        // over the SAME `X` that feeds the `Erf` branch — this closes the
779        // diamond and confirms a real GELU, not a coincidental op sequence.
780        let half_id = graph.value(half).producer?;
781        let half_node = graph.node(half_id);
782        if half_node.outputs.first() != Some(&half) {
783            return None;
784        }
785        let x2 = Self::parse_scaled(graph, half_node, &[("Mul", 0.5), ("Div", 2.0)])?;
786        if x2 != x {
787            return None;
788        }
789
790        // Canonical node order (anchor first): [Erf, inner, Add, outer, half].
791        let nodes = vec![erf_start, inner_id, add1_id, outer_id, half_id];
792        let matched_set: HashSet<NodeId> = nodes.iter().copied().collect();
793        if matched_set.len() != nodes.len() {
794            return None;
795        }
796
797        // Safety rule: every matched node except the final `outer` `Mul` must
798        // have all outputs consumed solely within the matched set (no external
799        // consumer, no graph output).
800        let escapes = nodes.iter().any(|&nid| {
801            nid != outer_id
802                && graph.node(nid).outputs.iter().any(|&o| {
803                    graph.outputs.contains(&o)
804                        || graph
805                            .value(o)
806                            .consumers
807                            .iter()
808                            .any(|c| !matched_set.contains(c))
809                })
810        });
811        if escapes {
812            return None;
813        }
814
815        // The fused output (outer's single output) must survive removal.
816        let out_val = graph.value(output);
817        let survives = graph.outputs.contains(&output)
818            || out_val.consumers.iter().any(|c| !matched_set.contains(c));
819        if !survives {
820            return None;
821        }
822
823        Some(GeluParts {
824            nodes,
825            x,
826            output,
827            external_inputs: vec![x],
828        })
829    }
830
831    /// If `node` computes `x · k` (`Mul`) or `x / k` (`Div`) for one of the
832    /// allowed `(op_type, constant)` forms, return the data operand `x`. The
833    /// constant must be a **strict scalar** f32 initializer approximately equal
834    /// to the expected value. `Mul` is commutative (the constant may be either
835    /// operand); `Div` is not (the constant must be the divisor). Any other
836    /// shape → `None`.
837    fn parse_scaled(graph: &Graph, node: &Node, forms: &[(&str, f32)]) -> Option<ValueId> {
838        if node.inputs.len() != 2 || node.outputs.len() != 1 {
839            return None;
840        }
841        let a = node.inputs[0]?;
842        let b = node.inputs[1]?;
843        for &(op, k) in forms {
844            if !Self::op_matches(node, op) {
845                continue;
846            }
847            // The scalar constant is valid as the second operand for both forms
848            // (the `Div` divisor, or a `Mul` factor); `Mul` is commutative, so
849            // it may additionally be the first operand.
850            if read_scalar_const_f32(graph, b).is_some_and(|c| approx(c, k)) {
851                return Some(a);
852            }
853            if op == "Mul" && read_scalar_const_f32(graph, a).is_some_and(|c| approx(c, k)) {
854                return Some(b);
855            }
856        }
857        None
858    }
859
860    /// Extract the schema-conformant `[X]` input (no attributes) for a matched
861    /// exact-GELU decomposition, or `None` to decline. Re-parses from the anchor
862    /// (`m.nodes[0]`, the `Erf`) so the spec is single-sourced with the matcher,
863    /// and confirms the re-parse covers exactly the same node set.
864    fn gelu_spec(&self, graph: &Graph, m: &PatternMatch) -> Option<FusedNodeSpec> {
865        let start = *m.nodes.first()?;
866        let p = self.try_parse_gelu(graph, start)?;
867        if p.nodes != m.nodes {
868            return None;
869        }
870        Some((vec![Some(p.x)], HashMap::new()))
871    }
872
873    /// Attempt to grow a match whose first node is `start`.
874    fn try_match_from(&self, graph: &Graph, start: NodeId) -> Option<PatternMatch> {
875        let start_node = graph.try_node(start)?;
876        if !Self::op_matches(start_node, &self.ops[0]) {
877            return None;
878        }
879
880        let mut chain = vec![start];
881        let mut chain_set: HashSet<NodeId> = HashSet::from([start]);
882
883        for op in &self.ops[1..] {
884            let prev = *chain.last().unwrap();
885            // Deterministic: pick the lowest-id successor of `prev` that has the
886            // required op type and is not already in the chain.
887            let mut succ_ids = graph.successors(prev);
888            succ_ids.sort_by_key(|n| n.0);
889            let next = succ_ids.into_iter().find(|&s| {
890                !chain_set.contains(&s) && Self::op_matches(graph.node(s), op)
891            })?;
892            chain.push(next);
893            chain_set.insert(next);
894        }
895
896        // Safety rule: no non-final matched node may have an output that escapes
897        // the matched set (external consumer or graph output).
898        for &nid in &chain[..chain.len() - 1] {
899            for &out in &graph.node(nid).outputs {
900                if graph.outputs.contains(&out) {
901                    return None;
902                }
903                if graph
904                    .value(out)
905                    .consumers
906                    .iter()
907                    .any(|c| !chain_set.contains(c))
908                {
909                    return None;
910                }
911            }
912        }
913
914        // The fused node reuses the last node's single output.
915        let last = *chain.last().unwrap();
916        let last_node = graph.node(last);
917        if last_node.outputs.len() != 1 {
918            return None;
919        }
920        let output = last_node.outputs[0];
921
922        // The output must survive removal of the matched nodes: it is either a
923        // graph output or has a consumer outside the matched set.
924        let out_val = graph.value(output);
925        let survives = graph.outputs.contains(&output)
926            || out_val.consumers.iter().any(|c| !chain_set.contains(c));
927        if !survives {
928            return None;
929        }
930
931        // Collect external inputs in first-seen order.
932        let produced: HashSet<ValueId> = chain
933            .iter()
934            .flat_map(|&n| graph.node(n).outputs.iter().copied())
935            .collect();
936        let mut external = Vec::new();
937        let mut seen = HashSet::new();
938        for &nid in &chain {
939            for iv in graph.node(nid).input_values() {
940                if produced.contains(&iv) {
941                    continue;
942                }
943                if seen.insert(iv) {
944                    external.push(iv);
945                }
946            }
947        }
948
949        let matched = PatternMatch {
950            nodes: chain,
951            external_inputs: external,
952            output,
953        };
954
955        // Decline-to-fuse: never return a match whose rewrite assumptions can't
956        // be *proven* from the graph. Declining here (rather than erroring later
957        // in `apply_fusion`) leaves the original ops in place and lets the
958        // fixpoint loop skip this occurrence instead of aborting the whole pass.
959        if !self.match_is_fusable(graph, &matched) {
960            return None;
961        }
962
963        Some(matched)
964    }
965
966    /// Whether a matched occurrence may be fused, or must **decline-to-fuse**
967    /// because a rewrite assumption can't be proven from the graph. Model-
968    /// agnostic: purely structural / shape checks, no model-specific logic.
969    fn match_is_fusable(&self, graph: &Graph, m: &PatternMatch) -> bool {
970        match self.kind {
971            RewriteKind::LayerNorm => self.layernorm_spec(graph, m).is_some(),
972            RewriteKind::Attention => self.attention_spec(graph, m).is_some(),
973            RewriteKind::Gelu => self.gelu_spec(graph, m).is_some(),
974            RewriteKind::Structural => {
975                // The MatMul+Add → FusedMatMulBias and MatMul+Add+Relu →
976                // FusedGemm rewrites both need a bias broadcast guard (the
977                // trailing Relu is elementwise and shape-neutral); other
978                // structural rewrites are unconstrained.
979                if self.replacement == "FusedMatMulBias" || self.replacement == "FusedGemm" {
980                    self.matmul_bias_broadcast_ok(graph, m)
981                } else {
982                    true
983                }
984            }
985        }
986    }
987
988    /// Decline the `MatMul + Add → FusedMatMulBias` (and
989    /// `MatMul + Add + Relu → FusedGemm`) fusion unless the `Add`'s non-matmul
990    /// (bias) operand broadcasts *into* the MatMul output shape **without
991    /// expanding it** — i.e. the bias is a valid trailing broadcast of the
992    /// matmul output (`[N]`, `[1, N]`, same-shape, scalar, …). The optional
993    /// trailing `Relu` is elementwise and shape-neutral, so the same guard
994    /// applies to both fusions.
995    ///
996    /// A standalone `Add` broadcasts *both* operands up to their joint shape, so
997    /// a bias with extra leading dims, or a batch axis where the output is
998    /// extent-1, would grow the semantic result. The fused kernel and shape rule
999    /// instead assume the output equals the *matmul* shape and right-align the
1000    /// bias, silently truncating the excess — wrong values *and* a too-small
1001    /// output. We therefore only fuse when every overlapping axis is provably
1002    /// non-expanding (identical dim, or bias extent 1). Any unknown/symbolic dim
1003    /// that can't be proven equal makes us decline conservatively.
1004    fn matmul_bias_broadcast_ok(&self, graph: &Graph, m: &PatternMatch) -> bool {
1005        // The matched pattern starts with `[MatMul, Add, ...]` (an optional
1006        // trailing `Relu` for FusedGemm). The MatMul output is the intermediate
1007        // value the Add consumes, and the other Add operand is bias.
1008        let (Some(&matmul), Some(&add)) = (m.nodes.first(), m.nodes.get(1)) else {
1009            return false;
1010        };
1011        let mm_out = graph.node(matmul).outputs[0];
1012        let Some(bias) = graph.node(add).input_values().find(|&v| v != mm_out) else {
1013            return false;
1014        };
1015        let mm_shape = &graph.value(mm_out).shape;
1016        let bias_shape = &graph.value(bias).shape;
1017
1018        // More bias dims than the output → leading dims would expand the result.
1019        if bias_shape.len() > mm_shape.len() {
1020            return false;
1021        }
1022        // Right-align the bias against the output; every overlapping axis must be
1023        // provably non-expanding: identical extent, or bias extent 1 (which just
1024        // broadcasts up into the existing output dim).
1025        let offset = mm_shape.len() - bias_shape.len();
1026        for (i, &bdim) in bias_shape.iter().enumerate() {
1027            let mdim = mm_shape[offset + i];
1028            if bdim == mdim {
1029                continue;
1030            }
1031            if bdim.as_static() == Some(1) {
1032                continue;
1033            }
1034            return false;
1035        }
1036        true
1037    }
1038
1039    /// Apply a match: remove the matched nodes and insert the replacement,
1040    /// reusing `m.output` so downstream consumers and graph outputs stay wired.
1041    pub fn apply_fusion(&self, graph: &mut Graph, m: &PatternMatch) -> Result<()> {
1042        let output = m.output;
1043
1044        // For schema-aware rewrites, extract the kernel-signature inputs and
1045        // attributes *before* the matched nodes are removed.
1046        let (inputs, attributes) = match self.kind {
1047            RewriteKind::Structural => (
1048                m.external_inputs.iter().map(|&v| Some(v)).collect(),
1049                HashMap::new(),
1050            ),
1051            RewriteKind::LayerNorm => self
1052                .layernorm_spec(graph, m)
1053                .ok_or_else(|| crate::error::OptimizerError::Fusion(self.name.clone()))?,
1054            RewriteKind::Attention => self
1055                .attention_spec(graph, m)
1056                .ok_or_else(|| crate::error::OptimizerError::Fusion(self.name.clone()))?,
1057            RewriteKind::Gelu => self
1058                .gelu_spec(graph, m)
1059                .ok_or_else(|| crate::error::OptimizerError::Fusion(self.name.clone()))?,
1060        };
1061
1062        // Remove in reverse (last-first): a node's consumers are gone before it,
1063        // so intermediate values are cleanly garbage-collected. `output` itself
1064        // survives because it is a graph output or has an external consumer.
1065        for &nid in m.nodes.iter().rev() {
1066            graph.remove_node(nid);
1067        }
1068
1069        if graph.try_value(output).is_none() {
1070            return Err(crate::error::OptimizerError::Fusion(self.name.clone()));
1071        }
1072
1073        let mut fused = Node::new(NodeId(0), self.replacement.clone(), inputs, vec![output]);
1074        fused.attributes = attributes;
1075        // Emit the fused op in the private contrib domain so it never collides
1076        // with standard `ai.onnx` ops and dispatch stays keyed on (domain, op).
1077        fused.domain = CONTRIB_DOMAIN.to_string();
1078        graph.insert_node(fused);
1079        Ok(())
1080    }
1081
1082    /// Extract the schema-conformant `[X, Scale, B]` inputs and the
1083    /// `axis`/`epsilon` attributes for a matched LayerNorm decomposition, or
1084    /// `None` if any schema-aware assumption can't be proven — in which case the
1085    /// pattern **declines to fuse** and the original ops are kept intact.
1086    ///
1087    /// The matched nodes are in the canonical order produced by
1088    /// [`Self::try_match_layernorm`]:
1089    /// `0:ReduceMean(x) → mean`, `1:Sub(x, mean) → diff_pow`,
1090    /// `2:Pow(diff_pow, 2) → sq`, `3:ReduceMean(sq) → var`,
1091    /// `4:Add(var, eps) → vare`, `5:Sqrt → std`, `6:Div(diff_div, std) → norm`,
1092    /// `7:Mul(norm, Scale) → scaled`, `8:Add(scaled, B) → out`, and an optional
1093    /// `9:Sub(x, mean) → diff_div` — present only when the numerator uses a
1094    /// **second, distinct** `Sub` (the `bert_toy`-style split-diff variant). In
1095    /// the canonical 9-op diamond the single `Sub` feeds both branches, so
1096    /// `diff_div == diff_pow`.
1097    ///
1098    /// * **X** is the (shared) `Sub` operand that is not `mean`; **Scale** the
1099    ///   `Mul` operand that is not the `Div` output; **B** the final `Add`
1100    ///   operand that is not the `Mul` output. Order-independent disambiguation.
1101    /// * **axis** must resolve to a *single concrete* axis read from the first
1102    ///   `ReduceMean`'s `axes` **attribute** (axes-as-input / multi-axis / absent
1103    ///   → decline; never silently assume `-1`).
1104    /// * **epsilon** must be readable as a concrete f32 scalar constant (else
1105    ///   decline; never silently assume `1e-5`).
1106    fn layernorm_spec(&self, graph: &Graph, m: &PatternMatch) -> Option<FusedNodeSpec> {
1107        let nodes = &m.nodes;
1108        if nodes.len() != 9 && nodes.len() != 10 {
1109            return None;
1110        }
1111        let rm1 = graph.node(nodes[0]);
1112        let sub_pow = graph.node(nodes[1]);
1113        let pow = graph.node(nodes[2]);
1114        let rm2 = graph.node(nodes[3]);
1115        let add_eps = graph.node(nodes[4]);
1116        let div = graph.node(nodes[6]);
1117        let mul = graph.node(nodes[7]);
1118        let final_add = graph.node(nodes[8]);
1119        // The numerator `Sub` is a distinct 10th node in the split-diff variant,
1120        // otherwise it is the same `Sub` that feeds the variance branch.
1121        let sub_div = if nodes.len() == 10 {
1122            graph.node(nodes[9])
1123        } else {
1124            sub_pow
1125        };
1126
1127        let mean = rm1.outputs[0];
1128        let diff_pow = sub_pow.outputs[0];
1129        let diff_div = sub_div.outputs[0];
1130        let var = rm2.outputs[0];
1131        let norm = div.outputs[0];
1132        let scaled = mul.outputs[0];
1133
1134        // Positive structural guard: confirm the interior data-flow really is the
1135        // LayerNorm decomposition, not just a coincidental op-type sequence. Each
1136        // consumer must actually read the interior tensor it is meant to consume.
1137        if !sub_pow.input_values().any(|v| v == mean)
1138            || !sub_div.input_values().any(|v| v == mean)
1139            || !pow.input_values().any(|v| v == diff_pow)
1140            || !div.input_values().any(|v| v == diff_div)
1141            || !mul.input_values().any(|v| v == norm)
1142            || !final_add.input_values().any(|v| v == scaled)
1143        {
1144            return None;
1145        }
1146
1147        // Order-independent X/Scale/B disambiguation: each picks the operand that
1148        // is NOT the matched interior tensor. Both `Sub`s must subtract `mean`
1149        // from the *same* `X`.
1150        let x = sub_pow.input_values().find(|&v| v != mean)?;
1151        if !sub_div.input_values().any(|v| v == x) {
1152            return None;
1153        }
1154
1155        // Operand-ORDER guard: each centering `Sub` must compute `diff = x - mean`
1156        // (minuend `x` first, subtrahend `mean` second), NOT `mean - x`. Membership
1157        // alone (checked above) would accept a reversed `Sub(mean, x)` and silently
1158        // rewrite it to a sign-flipped LayerNormalization. `Sub` is exactly binary,
1159        // so require input[0] == X and input[1] == mean on BOTH the variance-branch
1160        // and numerator-branch Subs. Ambiguous arity (not exactly two inputs) → decline.
1161        let subtracts_x_minus_mean = |sub: &Node| -> bool {
1162            matches!(sub.inputs.as_slice(), [Some(a), Some(b)] if *a == x && *b == mean)
1163        };
1164        if !subtracts_x_minus_mean(sub_pow) || !subtracts_x_minus_mean(sub_div) {
1165            return None;
1166        }
1167        let scale = mul.input_values().find(|&v| v != norm)?;
1168        let bias = final_add.input_values().find(|&v| v != scaled)?;
1169
1170        // epsilon guard: must be a concrete f32 scalar constant (no 1e-5 default).
1171        let eps_val = add_eps.input_values().find(|&v| v != var)?;
1172        let epsilon = read_scalar_f32(graph, eps_val)?;
1173
1174        // axis guard: a single concrete axis from the ReduceMean `axes` ATTRIBUTE.
1175        // Absent (axes-as-input at opset ≥ 18, or reduce-all) or multi-axis →
1176        // decline rather than silently defaulting to `-1`.
1177        let axes = rm1.attr("axes").and_then(Attribute::as_ints)?;
1178        let [axis] = axes else {
1179            return None;
1180        };
1181
1182        let mut attributes = HashMap::new();
1183        attributes.insert("axis".to_string(), Attribute::Int(*axis));
1184        attributes.insert("epsilon".to_string(), Attribute::Float(epsilon));
1185
1186        Some((vec![Some(x), Some(scale), Some(bias)], attributes))
1187    }
1188}
1189
1190/// Read a scalar (or leading) f32 element from an inline float initializer, if
1191/// `value` is backed by one. Used to fold a constant `epsilon` into an attribute.
1192fn read_scalar_f32(graph: &Graph, value: ValueId) -> Option<f32> {
1193    match graph.initializers.get(&value)? {
1194        WeightRef::Inline(t) if t.dtype == DataType::Float32 && t.data.len() >= 4 => {
1195            Some(f32::from_le_bytes(t.data[0..4].try_into().ok()?))
1196        }
1197        _ => None,
1198    }
1199}
1200
1201/// The parsed pieces of a matched SDPA core (see
1202/// [`FusionPattern::try_parse_attention`]).
1203#[derive(Clone, Debug)]
1204struct AttnParts {
1205    /// All matched node ids, canonical order (anchor first):
1206    /// `[softmax, score_mm, scale_node, out_mm]` then optional `mask_add` and
1207    /// optional absorbed `transpose`.
1208    nodes: Vec<NodeId>,
1209    q: ValueId,
1210    k: ValueId,
1211    v: ValueId,
1212    mask: Option<ValueId>,
1213    scale: f32,
1214    k_transposed: bool,
1215    output: ValueId,
1216    external_inputs: Vec<ValueId>,
1217}
1218
1219/// The parsed pieces of a matched exact-GELU decomposition (see
1220/// [`FusionPattern::try_parse_gelu`]).
1221#[derive(Clone, Debug)]
1222struct GeluParts {
1223    /// All matched node ids, canonical order (anchor first):
1224    /// `[erf, inner_scale, add_one, outer_mul, half_scale]`.
1225    nodes: Vec<NodeId>,
1226    /// The single external input `X` (feeds both the `Erf` branch and `0.5·X`).
1227    x: ValueId,
1228    /// The fused node's output (the outer `Mul`'s single output).
1229    output: ValueId,
1230    external_inputs: Vec<ValueId>,
1231}
1232/// `None`. Stricter than [`read_scalar_f32`]: the score scale must be a genuine
1233/// scalar, so a multi-element initializer (whose first element we'd otherwise
1234/// silently read) is declined.
1235fn read_scalar_const_f32(graph: &Graph, value: ValueId) -> Option<f32> {
1236    match graph.initializers.get(&value)? {
1237        WeightRef::Inline(t) if t.dtype == DataType::Float32 => {
1238            let numel: usize = t.dims.iter().product();
1239            if numel != 1 || t.data.len() < 4 {
1240                return None;
1241            }
1242            Some(f32::from_le_bytes(t.data[0..4].try_into().ok()?))
1243        }
1244        _ => None,
1245    }
1246}
1247
1248/// Whether `perm` is a clean "swap the last two axes" permutation
1249/// (`[0, 1, …, r-3, r-1, r-2]`) for a rank-`perm.len()` tensor. Any other
1250/// permutation (including one that also moves batch/head axes) is not a plain
1251/// Kᵀ and is left un-absorbed.
1252fn is_last2_swap_perm(perm: &[i64]) -> bool {
1253    let r = perm.len();
1254    if r < 2 {
1255        return false;
1256    }
1257    for (i, &p) in perm.iter().enumerate().take(r - 2) {
1258        if p != i as i64 {
1259            return false;
1260        }
1261    }
1262    perm[r - 2] == (r - 1) as i64 && perm[r - 1] == (r - 2) as i64
1263}
1264
1265/// The default device-independent fusion patterns.
1266///
1267/// Ordered most-specific-first so `MatMul+Add+Relu` is captured before the
1268/// shorter `MatMul+Add`. `Residual+LayerNorm` remains deferred to Phase 2b/3.
1269pub fn default_fusion_patterns() -> Vec<FusionPattern> {
1270    vec![
1271        // Attention first: the SDPA core consumes plain MatMul/Softmax nodes, so
1272        // recognize it before the MatMul+Add(+Relu) rewrites can claim any of
1273        // its MatMuls.
1274        FusionPattern::attention(),
1275        FusionPattern::new("MatMul+Bias+Relu", &["MatMul", "Add", "Relu"], "FusedGemm"),
1276        FusionPattern::layernorm(),
1277        FusionPattern::gelu(),
1278        FusionPattern::new("MatMul+Bias", &["MatMul", "Add"], "FusedMatMulBias"),
1279    ]
1280}
1281
1282/// The op-fusion pass: applies each [`FusionPattern`] to fixpoint.
1283#[derive(Clone, Debug)]
1284pub struct OpFusion {
1285    patterns: Vec<FusionPattern>,
1286}
1287
1288impl Default for OpFusion {
1289    fn default() -> Self {
1290        Self::new()
1291    }
1292}
1293
1294impl OpFusion {
1295    /// The pass with the default pattern set.
1296    pub fn new() -> Self {
1297        Self {
1298            patterns: default_fusion_patterns(),
1299        }
1300    }
1301
1302    /// The pass with a custom pattern set (used by tests / future callers).
1303    pub fn with_patterns(patterns: Vec<FusionPattern>) -> Self {
1304        Self { patterns }
1305    }
1306}
1307
1308impl OptimizationPass for OpFusion {
1309    fn name(&self) -> &str {
1310        "OpFusion"
1311    }
1312
1313    fn run(&self, graph: &mut Graph, _ctx: &PassContext) -> Result<()> {
1314        for pattern in &self.patterns {
1315            while let Some(m) = pattern.find_match(graph) {
1316                pattern.apply_fusion(graph, &m)?;
1317            }
1318        }
1319        Ok(())
1320    }
1321}
1322
1323#[cfg(test)]
1324mod tests {
1325    use super::*;
1326    use onnx_runtime_ir::{DataType, Node, NodeId, TensorData, static_shape};
1327
1328    fn val(g: &mut Graph, name: &str) -> ValueId {
1329        g.create_named_value(name, DataType::Float32, static_shape([4]))
1330    }
1331
1332    /// Build a linear MatMul+Add ending in a graph output.
1333    /// Returns (graph, matmul_out_value).
1334    fn matmul_add_graph() -> Graph {
1335        let mut g = Graph::new();
1336        g.opset_imports.insert(String::new(), 17);
1337        let a = val(&mut g, "a");
1338        let w = val(&mut g, "w");
1339        let bias = val(&mut g, "bias");
1340        g.add_input(a);
1341        g.add_input(w);
1342        g.add_input(bias);
1343
1344        let m = val(&mut g, "m");
1345        g.insert_node(Node::new(NodeId(0), "MatMul", vec![Some(a), Some(w)], vec![m]));
1346        let out = val(&mut g, "out");
1347        g.insert_node(Node::new(NodeId(0), "Add", vec![Some(m), Some(bias)], vec![out]));
1348        g.add_output(out);
1349        g
1350    }
1351
1352    #[test]
1353    fn fuses_matmul_add() {
1354        let mut g = matmul_add_graph();
1355        assert_eq!(g.num_nodes(), 2);
1356        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1357        assert_eq!(g.num_nodes(), 1);
1358        let fused = g.nodes.values().next().unwrap();
1359        assert_eq!(fused.op_type, "FusedMatMulBias");
1360        assert_eq!(fused.domain, CONTRIB_DOMAIN);
1361        // Inputs are [a, w, bias].
1362        assert_eq!(fused.inputs.len(), 3);
1363        assert!(g.validate().is_ok());
1364        // Output still a graph output.
1365        assert_eq!(g.outputs.len(), 1);
1366        assert_eq!(fused.outputs, g.outputs);
1367    }
1368
1369    #[test]
1370    fn fuses_matmul_add_relu_before_matmul_add() {
1371        let mut g = Graph::new();
1372        g.opset_imports.insert(String::new(), 17);
1373        let a = val(&mut g, "a");
1374        let w = val(&mut g, "w");
1375        let bias = val(&mut g, "bias");
1376        g.add_input(a);
1377        g.add_input(w);
1378        g.add_input(bias);
1379        let m = val(&mut g, "m");
1380        g.insert_node(Node::new(NodeId(0), "MatMul", vec![Some(a), Some(w)], vec![m]));
1381        let s = val(&mut g, "s");
1382        g.insert_node(Node::new(NodeId(0), "Add", vec![Some(m), Some(bias)], vec![s]));
1383        let out = val(&mut g, "out");
1384        g.insert_node(Node::new(NodeId(0), "Relu", vec![Some(s)], vec![out]));
1385        g.add_output(out);
1386
1387        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1388        assert_eq!(g.num_nodes(), 1);
1389        let fused = g.nodes.values().next().unwrap();
1390        assert_eq!(fused.op_type, "FusedGemm");
1391        assert_eq!(fused.domain, CONTRIB_DOMAIN);
1392        assert!(g.validate().is_ok());
1393    }
1394
1395    #[test]
1396    fn does_not_fuse_when_intermediate_has_second_consumer() {
1397        // MatMul -> m ; Add(m, bias) -> out ; and m also feeds a second Relu.
1398        let mut g = matmul_add_graph();
1399        // Find `m` (produced by MatMul, consumed by Add).
1400        let m = g
1401            .values
1402            .iter()
1403            .find(|(_, v)| v.name.as_deref() == Some("m"))
1404            .map(|(id, _)| id)
1405            .unwrap();
1406        let side = val(&mut g, "side");
1407        g.insert_node(Node::new(NodeId(0), "Relu", vec![Some(m)], vec![side]));
1408        g.add_output(side);
1409
1410        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1411        // MatMul's output escapes to the side Relu, so no fusion.
1412        assert!(
1413            g.nodes.values().any(|n| n.op_type == "MatMul"),
1414            "MatMul must remain — its output has a second consumer"
1415        );
1416        assert!(g.nodes.values().all(|n| n.op_type != "FusedMatMulBias"));
1417        assert!(g.validate().is_ok());
1418    }
1419
1420    #[test]
1421    fn no_match_returns_none() {
1422        let mut g = Graph::new();
1423        g.opset_imports.insert(String::new(), 17);
1424        let a = val(&mut g, "a");
1425        g.add_input(a);
1426        let out = val(&mut g, "out");
1427        g.insert_node(Node::new(NodeId(0), "Relu", vec![Some(a)], vec![out]));
1428        g.add_output(out);
1429        let p = FusionPattern::new("MatMul+Bias", &["MatMul", "Add"], "FusedMatMulBias");
1430        assert!(p.find_match(&g).is_none());
1431    }
1432
1433    /// Build the canonical 9-op LayerNorm decomposition over `x`.
1434    ///
1435    /// `eps` is an inline f32 initializer (as it would be after `ConstantFolding`
1436    /// materializes the `var + eps` constant) so the schema-aware rewrite can
1437    /// fold it into the `epsilon` attribute; the `ReduceMean` nodes carry an
1438    /// `axes = [-1]` attribute so `axis` extraction is exercised too.
1439    fn layernorm_graph() -> Graph {
1440        const EPS: f32 = 1e-12;
1441        let mut g = Graph::new();
1442        g.opset_imports.insert(String::new(), 17);
1443        let x = val(&mut g, "x");
1444        let two = val(&mut g, "two");
1445        let eps = val(&mut g, "eps");
1446        let scale = val(&mut g, "scale");
1447        let bias = val(&mut g, "bias");
1448        g.add_input(x);
1449        g.add_input(two);
1450        g.set_initializer(
1451            eps,
1452            WeightRef::Inline(TensorData::from_raw(
1453                DataType::Float32,
1454                vec![],
1455                EPS.to_le_bytes().to_vec(),
1456            )),
1457        );
1458        g.add_input(scale);
1459        g.add_input(bias);
1460
1461        let reduce_mean = |g: &mut Graph, input: ValueId, out: ValueId| {
1462            let mut n = Node::new(NodeId(0), "ReduceMean", vec![Some(input)], vec![out]);
1463            n.attributes.insert("axes".into(), Attribute::Ints(vec![-1]));
1464            n.attributes.insert("keepdims".into(), Attribute::Int(1));
1465            g.insert_node(n);
1466        };
1467
1468        let mean = val(&mut g, "mean");
1469        reduce_mean(&mut g, x, mean);
1470        let diff = val(&mut g, "diff");
1471        g.insert_node(Node::new(NodeId(0), "Sub", vec![Some(x), Some(mean)], vec![diff]));
1472        let sq = val(&mut g, "sq");
1473        g.insert_node(Node::new(NodeId(0), "Pow", vec![Some(diff), Some(two)], vec![sq]));
1474        let var = val(&mut g, "var");
1475        reduce_mean(&mut g, sq, var);
1476        let vare = val(&mut g, "vare");
1477        g.insert_node(Node::new(NodeId(0), "Add", vec![Some(var), Some(eps)], vec![vare]));
1478        let std = val(&mut g, "std");
1479        g.insert_node(Node::new(NodeId(0), "Sqrt", vec![Some(vare)], vec![std]));
1480        let norm = val(&mut g, "norm");
1481        g.insert_node(Node::new(NodeId(0), "Div", vec![Some(diff), Some(std)], vec![norm]));
1482        let scaled = val(&mut g, "scaled");
1483        g.insert_node(Node::new(
1484            NodeId(0),
1485            "Mul",
1486            vec![Some(norm), Some(scale)],
1487            vec![scaled],
1488        ));
1489        let out = val(&mut g, "out");
1490        g.insert_node(Node::new(
1491            NodeId(0),
1492            "Add",
1493            vec![Some(scaled), Some(bias)],
1494            vec![out],
1495        ));
1496        g.add_output(out);
1497        g
1498    }
1499
1500    #[test]
1501    fn fuses_layernorm_chain() {
1502        let mut g = layernorm_graph();
1503        assert_eq!(g.num_nodes(), 9);
1504        assert!(g.validate().is_ok());
1505
1506        // Record the value ids the schema-aware rewrite must reference.
1507        let vid = |name: &str| {
1508            g.values
1509                .iter()
1510                .find(|(_, v)| v.name.as_deref() == Some(name))
1511                .map(|(id, _)| id)
1512                .unwrap()
1513        };
1514        let x = vid("x");
1515        let scale = vid("scale");
1516        let bias = vid("bias");
1517
1518        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1519
1520        assert_eq!(g.num_nodes(), 1, "9-op chain collapses to one node");
1521        let fused = g.nodes.values().next().unwrap();
1522        assert_eq!(fused.op_type, "LayerNormalization");
1523        assert_eq!(fused.domain, CONTRIB_DOMAIN);
1524        // Schema-conformant inputs: exactly [X, Scale, B] — NOT the intermediate
1525        // pow-exponent / epsilon tensors.
1526        assert_eq!(fused.inputs, vec![Some(x), Some(scale), Some(bias)]);
1527        // Synthesized attributes read by the kernel.
1528        assert_eq!(
1529            fused.attr("axis").and_then(Attribute::as_int),
1530            Some(-1),
1531            "axis extracted from ReduceMean axes"
1532        );
1533        let eps = fused
1534            .attr("epsilon")
1535            .and_then(Attribute::as_float)
1536            .expect("epsilon attribute present");
1537        assert!(
1538            (eps - 1e-12).abs() < 1e-18,
1539            "epsilon extracted from the var+eps constant, got {eps}"
1540        );
1541        assert_eq!(fused.outputs, g.outputs);
1542        assert!(g.validate().is_ok());
1543    }
1544
1545    #[test]
1546    fn layernorm_count_bookkeeping() {
1547        let mut g = layernorm_graph();
1548        let ln_before = g.nodes.values().filter(|n| n.op_type == "LayerNormalization").count();
1549        let rm_before = g.nodes.values().filter(|n| n.op_type == "ReduceMean").count();
1550        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1551        let ln_after = g.nodes.values().filter(|n| n.op_type == "LayerNormalization").count();
1552        let rm_after = g.nodes.values().filter(|n| n.op_type == "ReduceMean").count();
1553        assert_eq!(ln_before, 0);
1554        assert_eq!(ln_after, 1);
1555        assert_eq!(rm_before, 2);
1556        assert_eq!(rm_after, 0);
1557    }
1558
1559    /// Build the 10-op split-diff LayerNorm decomposition over `x` (the
1560    /// `bert_toy`-style variant): the variance branch and the numerator branch
1561    /// each get their **own** distinct `Sub` node instead of sharing one `diff`.
1562    /// `mean` therefore fans out to two Subs and `x` to two Subs. When
1563    /// `reverse_num_sub` is true the numerator `Sub` is emitted reversed as
1564    /// `Sub(mean, x)` (an adversarial sign-flip) to exercise the operand-order
1565    /// guard.
1566    fn layernorm_split_graph(reverse_num_sub: bool) -> Graph {
1567        const EPS: f32 = 1e-12;
1568        let mut g = Graph::new();
1569        g.opset_imports.insert(String::new(), 17);
1570        let x = val(&mut g, "x");
1571        let two = val(&mut g, "two");
1572        let eps = val(&mut g, "eps");
1573        let scale = val(&mut g, "scale");
1574        let bias = val(&mut g, "bias");
1575        g.add_input(x);
1576        g.add_input(two);
1577        g.set_initializer(
1578            eps,
1579            WeightRef::Inline(TensorData::from_raw(
1580                DataType::Float32,
1581                vec![],
1582                EPS.to_le_bytes().to_vec(),
1583            )),
1584        );
1585        g.add_input(scale);
1586        g.add_input(bias);
1587
1588        let reduce_mean = |g: &mut Graph, input: ValueId, out: ValueId| {
1589            let mut n = Node::new(NodeId(0), "ReduceMean", vec![Some(input)], vec![out]);
1590            n.attributes.insert("axes".into(), Attribute::Ints(vec![-1]));
1591            n.attributes.insert("keepdims".into(), Attribute::Int(1));
1592            g.insert_node(n);
1593        };
1594
1595        let mean = val(&mut g, "mean");
1596        reduce_mean(&mut g, x, mean);
1597        // Variance-branch Sub: always the canonical `x - mean`.
1598        let diff_pow = val(&mut g, "diff_pow");
1599        g.insert_node(Node::new(
1600            NodeId(0),
1601            "Sub",
1602            vec![Some(x), Some(mean)],
1603            vec![diff_pow],
1604        ));
1605        // Numerator-branch Sub: a SECOND, distinct node. Reversed operands when
1606        // `reverse_num_sub` (adversarial `mean - x`), else canonical `x - mean`.
1607        let diff_div = val(&mut g, "diff_div");
1608        let num_inputs = if reverse_num_sub {
1609            vec![Some(mean), Some(x)]
1610        } else {
1611            vec![Some(x), Some(mean)]
1612        };
1613        g.insert_node(Node::new(NodeId(0), "Sub", num_inputs, vec![diff_div]));
1614
1615        let sq = val(&mut g, "sq");
1616        g.insert_node(Node::new(
1617            NodeId(0),
1618            "Pow",
1619            vec![Some(diff_pow), Some(two)],
1620            vec![sq],
1621        ));
1622        let var = val(&mut g, "var");
1623        reduce_mean(&mut g, sq, var);
1624        let vare = val(&mut g, "vare");
1625        g.insert_node(Node::new(NodeId(0), "Add", vec![Some(var), Some(eps)], vec![vare]));
1626        let std = val(&mut g, "std");
1627        g.insert_node(Node::new(NodeId(0), "Sqrt", vec![Some(vare)], vec![std]));
1628        let norm = val(&mut g, "norm");
1629        g.insert_node(Node::new(
1630            NodeId(0),
1631            "Div",
1632            vec![Some(diff_div), Some(std)],
1633            vec![norm],
1634        ));
1635        let scaled = val(&mut g, "scaled");
1636        g.insert_node(Node::new(
1637            NodeId(0),
1638            "Mul",
1639            vec![Some(norm), Some(scale)],
1640            vec![scaled],
1641        ));
1642        let out = val(&mut g, "out");
1643        g.insert_node(Node::new(
1644            NodeId(0),
1645            "Add",
1646            vec![Some(scaled), Some(bias)],
1647            vec![out],
1648        ));
1649        g.add_output(out);
1650        g
1651    }
1652
1653    #[test]
1654    fn fuses_layernorm_split_chain() {
1655        // Isolated optimizer-layer coverage for the 10-op split-diff shape
1656        // (previously only exercised end-to-end via the bert_toy model).
1657        let mut g = layernorm_split_graph(false);
1658        assert_eq!(g.num_nodes(), 10, "split-diff shape has two distinct Subs");
1659        assert!(g.validate().is_ok());
1660
1661        let vid = |name: &str| {
1662            g.values
1663                .iter()
1664                .find(|(_, v)| v.name.as_deref() == Some(name))
1665                .map(|(id, _)| id)
1666                .unwrap()
1667        };
1668        let x = vid("x");
1669        let scale = vid("scale");
1670        let bias = vid("bias");
1671
1672        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1673
1674        assert_eq!(g.num_nodes(), 1, "10-op split chain collapses to one node");
1675        let fused = g.nodes.values().next().unwrap();
1676        assert_eq!(fused.op_type, "LayerNormalization");
1677        assert_eq!(fused.domain, CONTRIB_DOMAIN);
1678        // Schema-conformant inputs: exactly [X, Scale, B].
1679        assert_eq!(fused.inputs, vec![Some(x), Some(scale), Some(bias)]);
1680        assert_eq!(
1681            fused.attr("axis").and_then(Attribute::as_int),
1682            Some(-1),
1683            "axis extracted from ReduceMean axes"
1684        );
1685        let eps = fused
1686            .attr("epsilon")
1687            .and_then(Attribute::as_float)
1688            .expect("epsilon attribute present");
1689        assert!(
1690            (eps - 1e-12).abs() < 1e-18,
1691            "epsilon extracted from the var+eps constant, got {eps}"
1692        );
1693        assert_eq!(fused.outputs, g.outputs);
1694        assert!(g.validate().is_ok());
1695    }
1696
1697    #[test]
1698    fn declines_layernorm_when_numerator_sub_reversed() {
1699        // A-CHEW-1 adversarial: the numerator diamond centers with a REVERSED
1700        // `Sub(mean, x)` = -(x - mean). Membership of {x, mean} still holds, but
1701        // the operand-order guard must DECLINE (else the rewrite silently produces
1702        // a sign-flipped LayerNormalization). Ops must be left untouched.
1703        let mut g = layernorm_split_graph(true);
1704        assert_eq!(g.num_nodes(), 10);
1705        assert!(g.validate().is_ok());
1706
1707        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1708
1709        assert!(
1710            g.nodes.values().all(|n| n.op_type != "LayerNormalization"),
1711            "reversed Sub(mean, x) must NOT fuse — sign-flip over-match"
1712        );
1713        assert_eq!(g.num_nodes(), 10, "all 10 ops remain (declined)");
1714        assert_eq!(
1715            g.nodes.values().filter(|n| n.op_type == "Sub").count(),
1716            2,
1717            "both centering Subs preserved"
1718        );
1719        assert!(g.validate().is_ok());
1720    }
1721
1722    #[test]
1723    fn does_not_fuse_partial_layernorm() {
1724        // A LayerNorm chain missing its final Add must not fuse.
1725        let mut g = layernorm_graph();
1726        // Remove the last Add by rebuilding: easier to just check a shorter
1727        // pattern doesn't accidentally match — assert Sub alone isn't fused.
1728        let p = FusionPattern::layernorm();
1729        // Break the chain: give `diff` an external consumer so the safety rule
1730        // trips (Sub is a non-final matched node).
1731        let diff = g
1732            .values
1733            .iter()
1734            .find(|(_, v)| v.name.as_deref() == Some("diff"))
1735            .map(|(id, _)| id)
1736            .unwrap();
1737        let side = val(&mut g, "side");
1738        g.insert_node(Node::new(NodeId(0), "Neg", vec![Some(diff)], vec![side]));
1739        g.add_output(side);
1740        assert!(
1741            p.find_match(&g).is_none(),
1742            "external consumer on `diff` blocks the fusion"
1743        );
1744    }
1745
1746    #[test]
1747    fn fuses_two_independent_matmul_adds() {
1748        let mut g = Graph::new();
1749        g.opset_imports.insert(String::new(), 17);
1750        for i in 0..2 {
1751            let a = val(&mut g, &format!("a{i}"));
1752            let w = val(&mut g, &format!("w{i}"));
1753            let bias = val(&mut g, &format!("bias{i}"));
1754            g.add_input(a);
1755            g.add_input(w);
1756            g.add_input(bias);
1757            let m = val(&mut g, &format!("m{i}"));
1758            g.insert_node(Node::new(NodeId(0), "MatMul", vec![Some(a), Some(w)], vec![m]));
1759            let out = val(&mut g, &format!("out{i}"));
1760            g.insert_node(Node::new(NodeId(0), "Add", vec![Some(m), Some(bias)], vec![out]));
1761            g.add_output(out);
1762        }
1763        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1764        assert_eq!(g.num_nodes(), 2);
1765        assert!(g.nodes.values().all(|n| n.op_type == "FusedMatMulBias"));
1766        assert!(g.validate().is_ok());
1767    }
1768
1769    #[test]
1770    fn find_match_reports_correct_shape() {
1771        let g = matmul_add_graph();
1772        let p = FusionPattern::new("MatMul+Bias", &["MatMul", "Add"], "FusedMatMulBias");
1773        let m = p.find_match(&g).expect("should match");
1774        assert_eq!(m.nodes.len(), 2);
1775        assert_eq!(m.external_inputs.len(), 3);
1776        assert_eq!(p.pattern_name(), "MatMul+Bias");
1777    }
1778
1779    #[test]
1780    fn declines_layernorm_when_axes_is_input() {
1781        // Opset-18 style: `ReduceMean` takes `axes` as an INPUT, not an
1782        // attribute. The axis can't be pinned to a single concrete value from an
1783        // attribute, so the fusion must DECLINE and leave all 9 ops intact
1784        // (never silently assume axis = -1).
1785        let mut g = layernorm_graph();
1786        let mean = g
1787            .values
1788            .iter()
1789            .find(|(_, v)| v.name.as_deref() == Some("mean"))
1790            .map(|(id, _)| id)
1791            .unwrap();
1792        let rm1 = g.value(mean).producer.unwrap();
1793        // Drop the `axes` attribute and feed axes in as an initializer INPUT.
1794        g.node_mut(rm1).attributes.remove("axes");
1795        let axes_in = g.create_named_value("axes_in", DataType::Int64, static_shape([1]));
1796        g.set_initializer(
1797            axes_in,
1798            WeightRef::Inline(TensorData::from_raw(
1799                DataType::Int64,
1800                vec![1],
1801                (-1i64).to_le_bytes().to_vec(),
1802            )),
1803        );
1804        g.node_mut(rm1).inputs.push(Some(axes_in));
1805        g.value_mut(axes_in).consumers.push(rm1);
1806        assert!(g.validate().is_ok());
1807
1808        assert_eq!(g.num_nodes(), 9);
1809        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1810        assert_eq!(
1811            g.num_nodes(),
1812            9,
1813            "axes-as-input LayerNorm must NOT fuse — all original ops kept"
1814        );
1815        assert!(
1816            g.nodes.values().all(|n| n.op_type != "LayerNormalization"),
1817            "no fused LayerNormalization must be emitted"
1818        );
1819        assert_eq!(
1820            g.nodes.values().filter(|n| n.op_type == "ReduceMean").count(),
1821            2,
1822            "both ReduceMean ops remain"
1823        );
1824        assert!(g.validate().is_ok());
1825    }
1826
1827    #[test]
1828    fn declines_layernorm_when_epsilon_not_constant() {
1829        // If epsilon is a runtime graph INPUT (not a folded f32 initializer) it
1830        // can't be read as a concrete scalar → DECLINE rather than silently
1831        // substituting the ONNX default 1e-5.
1832        let mut g = layernorm_graph();
1833        let eps = g
1834            .values
1835            .iter()
1836            .find(|(_, v)| v.name.as_deref() == Some("eps"))
1837            .map(|(id, _)| id)
1838            .unwrap();
1839        // Turn the eps initializer into a plain runtime graph input.
1840        g.initializers.remove(&eps);
1841        g.add_input(eps);
1842        assert!(g.validate().is_ok());
1843
1844        assert_eq!(g.num_nodes(), 9);
1845        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1846        assert_eq!(
1847            g.num_nodes(),
1848            9,
1849            "non-constant epsilon LayerNorm must NOT fuse"
1850        );
1851        assert!(g.nodes.values().all(|n| n.op_type != "LayerNormalization"));
1852        assert!(g.validate().is_ok());
1853    }
1854
1855    #[test]
1856    fn declines_matmul_add_when_bias_expands() {
1857        // MatMul output is [4]; the Add's bias is [2, 4], whose extra leading dim
1858        // would broadcast the result UP to [2, 4]. The fused kernel/shape rule
1859        // assume the output equals the matmul shape and would silently truncate,
1860        // so the fusion must DECLINE and keep the original MatMul + Add.
1861        let mut g = Graph::new();
1862        g.opset_imports.insert(String::new(), 17);
1863        let a = g.create_named_value("a", DataType::Float32, static_shape([4, 4]));
1864        let w = g.create_named_value("w", DataType::Float32, static_shape([4]));
1865        let bias = g.create_named_value("bias", DataType::Float32, static_shape([2, 4]));
1866        g.add_input(a);
1867        g.add_input(w);
1868        g.add_input(bias);
1869        let m = g.create_named_value("m", DataType::Float32, static_shape([4]));
1870        g.insert_node(Node::new(NodeId(0), "MatMul", vec![Some(a), Some(w)], vec![m]));
1871        let out = g.create_named_value("out", DataType::Float32, static_shape([2, 4]));
1872        g.insert_node(Node::new(NodeId(0), "Add", vec![Some(m), Some(bias)], vec![out]));
1873        g.add_output(out);
1874
1875        assert_eq!(g.num_nodes(), 2);
1876        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1877        assert_eq!(g.num_nodes(), 2, "expanding bias must NOT fuse");
1878        assert!(g.nodes.values().any(|n| n.op_type == "MatMul"));
1879        assert!(g.nodes.values().any(|n| n.op_type == "Add"));
1880        assert!(g.nodes.values().all(|n| n.op_type != "FusedMatMulBias"));
1881        assert!(g.validate().is_ok());
1882    }
1883
1884    #[test]
1885    fn fuses_matmul_add_with_trailing_broadcast_bias() {
1886        // A `[1, 4]` bias broadcasts INTO a `[3, 4]` matmul output without
1887        // expanding it, so the guard must still allow this common case to fuse.
1888        let mut g = Graph::new();
1889        g.opset_imports.insert(String::new(), 17);
1890        let a = g.create_named_value("a", DataType::Float32, static_shape([3, 4]));
1891        let w = g.create_named_value("w", DataType::Float32, static_shape([4, 4]));
1892        let bias = g.create_named_value("bias", DataType::Float32, static_shape([1, 4]));
1893        g.add_input(a);
1894        g.add_input(w);
1895        g.add_input(bias);
1896        let m = g.create_named_value("m", DataType::Float32, static_shape([3, 4]));
1897        g.insert_node(Node::new(NodeId(0), "MatMul", vec![Some(a), Some(w)], vec![m]));
1898        let out = g.create_named_value("out", DataType::Float32, static_shape([3, 4]));
1899        g.insert_node(Node::new(NodeId(0), "Add", vec![Some(m), Some(bias)], vec![out]));
1900        g.add_output(out);
1901
1902        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1903        assert_eq!(g.num_nodes(), 1, "trailing-broadcast bias must fuse");
1904        assert_eq!(
1905            g.nodes.values().next().unwrap().op_type,
1906            "FusedMatMulBias"
1907        );
1908        assert!(g.validate().is_ok());
1909    }
1910
1911    #[test]
1912    fn declines_matmul_add_when_shape_unknown() {
1913        // If the matmul output shape can't be resolved (empty/unknown), the guard
1914        // can't prove the bias is non-expanding → DECLINE conservatively.
1915        let mut g = Graph::new();
1916        g.opset_imports.insert(String::new(), 17);
1917        let a = g.create_named_value("a", DataType::Float32, Vec::new());
1918        let w = g.create_named_value("w", DataType::Float32, Vec::new());
1919        let bias = g.create_named_value("bias", DataType::Float32, static_shape([4]));
1920        g.add_input(a);
1921        g.add_input(w);
1922        g.add_input(bias);
1923        // `m` has an unknown (empty) shape.
1924        let m = g.create_named_value("m", DataType::Float32, Vec::new());
1925        g.insert_node(Node::new(NodeId(0), "MatMul", vec![Some(a), Some(w)], vec![m]));
1926        let out = g.create_named_value("out", DataType::Float32, static_shape([4]));
1927        g.insert_node(Node::new(NodeId(0), "Add", vec![Some(m), Some(bias)], vec![out]));
1928        g.add_output(out);
1929
1930        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1931        assert_eq!(g.num_nodes(), 2, "unknown matmul shape must NOT fuse");
1932        assert!(g.nodes.values().all(|n| n.op_type != "FusedMatMulBias"));
1933        assert!(g.validate().is_ok());
1934    }
1935
1936    #[test]
1937    fn declines_fused_gemm_when_bias_expands() {
1938        // Roy's FusedGemm review advisory, locked in: a MatMul+Add+Relu whose
1939        // bias EXPANDS the matmul output (extra leading/batch dim) must DECLINE
1940        // to FusedGemm exactly like the FusedMatMulBias case — the trailing Relu
1941        // is shape-neutral, so the same non-expanding-bias guard applies. MatMul
1942        // output is [4]; bias [2, 4] would broadcast the result up to [2, 4].
1943        let mut g = Graph::new();
1944        g.opset_imports.insert(String::new(), 17);
1945        let a = g.create_named_value("a", DataType::Float32, static_shape([4, 4]));
1946        let w = g.create_named_value("w", DataType::Float32, static_shape([4]));
1947        let bias = g.create_named_value("bias", DataType::Float32, static_shape([2, 4]));
1948        g.add_input(a);
1949        g.add_input(w);
1950        g.add_input(bias);
1951        let m = g.create_named_value("m", DataType::Float32, static_shape([4]));
1952        g.insert_node(Node::new(NodeId(0), "MatMul", vec![Some(a), Some(w)], vec![m]));
1953        let biased = g.create_named_value("biased", DataType::Float32, static_shape([2, 4]));
1954        g.insert_node(Node::new(NodeId(0), "Add", vec![Some(m), Some(bias)], vec![biased]));
1955        let out = g.create_named_value("out", DataType::Float32, static_shape([2, 4]));
1956        g.insert_node(Node::new(NodeId(0), "Relu", vec![Some(biased)], vec![out]));
1957        g.add_output(out);
1958
1959        assert_eq!(g.num_nodes(), 3);
1960        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1961        assert_eq!(g.num_nodes(), 3, "expanding bias must NOT fuse to FusedGemm");
1962        assert!(g.nodes.values().any(|n| n.op_type == "MatMul"));
1963        assert!(g.nodes.values().any(|n| n.op_type == "Add"));
1964        assert!(g.nodes.values().any(|n| n.op_type == "Relu"));
1965        assert!(g.nodes.values().all(|n| n.op_type != "FusedGemm"));
1966        assert!(g.validate().is_ok());
1967    }
1968
1969    // --- AttentionFusion (SDPA core) ------------------------------------------
1970
1971    /// Add a strict-scalar f32 initializer, returning its value id.
1972    fn scalar_init(g: &mut Graph, name: &str, v: f32) -> ValueId {
1973        let vid = g.create_named_value(name, DataType::Float32, Vec::new());
1974        g.set_initializer(
1975            vid,
1976            WeightRef::Inline(TensorData::from_raw(
1977                DataType::Float32,
1978                vec![],
1979                v.to_le_bytes().to_vec(),
1980            )),
1981        );
1982        vid
1983    }
1984
1985    fn fval(g: &mut Graph, name: &str, dims: &[usize]) -> ValueId {
1986        g.create_named_value(name, DataType::Float32, static_shape(dims.iter().copied()))
1987    }
1988
1989    /// Look up a value id by name (test convenience).
1990    fn value_id(g: &Graph, name: &str) -> ValueId {
1991        g.values
1992            .iter()
1993            .find(|(_, v)| v.name.as_deref() == Some(name))
1994            .map(|(id, _)| id)
1995            .unwrap_or_else(|| panic!("no value named {name}"))
1996    }
1997
1998    /// Build an SDPA core graph `Softmax((Q·Kᵀ)/c [+ mask], axis=-1) · V` with
1999    /// rank-4 `[1, 2, seq, dim]` tensors, K supplied pre-transposed as
2000    /// `[1, 2, d, sk]` (so `k_transposed` should resolve to 1). `masked` adds an
2001    /// additive mask; `axis` is the Softmax reduction axis.
2002    fn sdpa_graph(masked: bool, axis: i64) -> Graph {
2003        let mut g = Graph::new();
2004        g.opset_imports.insert(String::new(), 12);
2005        let q = fval(&mut g, "Q", &[1, 2, 3, 4]);
2006        let kt = fval(&mut g, "K", &[1, 2, 4, 3]); // pre-transposed [d=4, sk=3]
2007        let v = fval(&mut g, "V", &[1, 2, 3, 4]);
2008        g.add_input(q);
2009        g.add_input(kt);
2010        g.add_input(v);
2011        let c = scalar_init(&mut g, "scale_c", 2.0);
2012
2013        let scores = fval(&mut g, "scores", &[1, 2, 3, 3]);
2014        g.insert_node(Node::new(NodeId(0), "MatMul", vec![Some(q), Some(kt)], vec![scores]));
2015        let scaled = fval(&mut g, "scaled", &[1, 2, 3, 3]);
2016        g.insert_node(Node::new(NodeId(0), "Div", vec![Some(scores), Some(c)], vec![scaled]));
2017
2018        let sm_in = if masked {
2019            let mask = fval(&mut g, "mask", &[1, 1, 3, 3]);
2020            g.add_input(mask);
2021            let masked_v = fval(&mut g, "masked", &[1, 2, 3, 3]);
2022            g.insert_node(Node::new(
2023                NodeId(0),
2024                "Add",
2025                vec![Some(scaled), Some(mask)],
2026                vec![masked_v],
2027            ));
2028            masked_v
2029        } else {
2030            scaled
2031        };
2032
2033        let probs = fval(&mut g, "probs", &[1, 2, 3, 3]);
2034        let mut sm = Node::new(NodeId(0), "Softmax", vec![Some(sm_in)], vec![probs]);
2035        sm.attributes.insert("axis".into(), Attribute::Int(axis));
2036        g.insert_node(sm);
2037        let out = fval(&mut g, "out", &[1, 2, 3, 4]);
2038        g.insert_node(Node::new(NodeId(0), "MatMul", vec![Some(probs), Some(v)], vec![out]));
2039        g.add_output(out);
2040        g
2041    }
2042
2043    fn fused_attention_node(g: &Graph) -> Option<&Node> {
2044        g.nodes.values().find(|n| n.op_type == "FusedAttention")
2045    }
2046
2047    #[test]
2048    fn fuses_sdpa_unmasked_pretransposed_k() {
2049        let mut g = sdpa_graph(false, 3);
2050        let q = value_id(&g, "Q");
2051        let k = value_id(&g, "K");
2052        let v = value_id(&g, "V");
2053        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2054
2055        // Exactly one FusedAttention; no surviving Softmax/MatMul/Div.
2056        assert_eq!(
2057            g.nodes.values().filter(|n| n.op_type == "FusedAttention").count(),
2058            1
2059        );
2060        assert!(g.nodes.values().all(|n| n.op_type != "Softmax"));
2061        assert!(g.nodes.values().all(|n| n.op_type != "MatMul"));
2062        assert!(g.nodes.values().all(|n| n.op_type != "Div"));
2063
2064        let fa = fused_attention_node(&g).unwrap();
2065        assert_eq!(fa.domain, CONTRIB_DOMAIN);
2066        assert_eq!(fa.inputs, vec![Some(q), Some(k), Some(v)]);
2067        // scale = 1/c = 1/2 = 0.5; K used as-is → k_transposed = 1.
2068        assert_eq!(fa.attr("scale").and_then(Attribute::as_float), Some(0.5));
2069        assert_eq!(fa.attr("k_transposed").and_then(Attribute::as_int), Some(1));
2070        assert!(g.validate().is_ok());
2071    }
2072
2073    #[test]
2074    fn fuses_sdpa_masked() {
2075        let mut g = sdpa_graph(true, 3);
2076        let (q, k, v, mask) = (
2077            value_id(&g, "Q"),
2078            value_id(&g, "K"),
2079            value_id(&g, "V"),
2080            value_id(&g, "mask"),
2081        );
2082        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2083
2084        assert_eq!(
2085            g.nodes.values().filter(|n| n.op_type == "FusedAttention").count(),
2086            1
2087        );
2088        assert!(g.nodes.values().all(|n| n.op_type != "Softmax"));
2089        assert!(g.nodes.values().all(|n| n.op_type != "Add"));
2090        let fa = fused_attention_node(&g).unwrap();
2091        // Mask appended as the 4th input.
2092        assert_eq!(fa.inputs, vec![Some(q), Some(k), Some(v), Some(mask)]);
2093        assert_eq!(fa.attr("k_transposed").and_then(Attribute::as_int), Some(1));
2094        assert!(g.validate().is_ok());
2095    }
2096
2097    #[test]
2098    fn fuses_sdpa_absorbing_clean_transpose_sets_k_transposed_0() {
2099        // K is supplied in natural [1,2,3,4] layout and transposed to Kᵀ by a
2100        // clean last-two-axis Transpose (perm [0,1,3,2]) consumed only by the
2101        // score MatMul. The matcher absorbs it: K input becomes the natural K
2102        // and k_transposed = 0 (kernel transposes internally).
2103        let mut g = Graph::new();
2104        g.opset_imports.insert(String::new(), 12);
2105        let q = fval(&mut g, "Q", &[1, 2, 3, 4]);
2106        let k = fval(&mut g, "K", &[1, 2, 3, 4]); // natural [sk=3, d=4]
2107        let v = fval(&mut g, "V", &[1, 2, 3, 4]);
2108        g.add_input(q);
2109        g.add_input(k);
2110        g.add_input(v);
2111        let c = scalar_init(&mut g, "scale_c", 4.0);
2112
2113        let kt = fval(&mut g, "Kt", &[1, 2, 4, 3]);
2114        let mut tr = Node::new(NodeId(0), "Transpose", vec![Some(k)], vec![kt]);
2115        tr.attributes.insert("perm".into(), Attribute::Ints(vec![0, 1, 3, 2]));
2116        g.insert_node(tr);
2117        let scores = fval(&mut g, "scores", &[1, 2, 3, 3]);
2118        g.insert_node(Node::new(NodeId(0), "MatMul", vec![Some(q), Some(kt)], vec![scores]));
2119        let scaled = fval(&mut g, "scaled", &[1, 2, 3, 3]);
2120        g.insert_node(Node::new(NodeId(0), "Div", vec![Some(scores), Some(c)], vec![scaled]));
2121        let probs = fval(&mut g, "probs", &[1, 2, 3, 3]);
2122        let mut sm = Node::new(NodeId(0), "Softmax", vec![Some(scaled)], vec![probs]);
2123        sm.attributes.insert("axis".into(), Attribute::Int(-1));
2124        g.insert_node(sm);
2125        let out = fval(&mut g, "out", &[1, 2, 3, 4]);
2126        g.insert_node(Node::new(NodeId(0), "MatMul", vec![Some(probs), Some(v)], vec![out]));
2127        g.add_output(out);
2128
2129        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2130        assert!(g.nodes.values().all(|n| n.op_type != "Transpose"), "clean Kᵀ Transpose absorbed");
2131        let fa = fused_attention_node(&g).unwrap();
2132        assert_eq!(fa.inputs, vec![Some(q), Some(k), Some(v)], "K input is the natural (un-transposed) K");
2133        assert_eq!(fa.attr("k_transposed").and_then(Attribute::as_int), Some(0));
2134        // scale = 1/4 = 0.25.
2135        assert_eq!(fa.attr("scale").and_then(Attribute::as_float), Some(0.25));
2136        assert!(g.validate().is_ok());
2137    }
2138
2139    #[test]
2140    fn declines_sdpa_when_softmax_axis_not_last() {
2141        // axis 1 on a rank-4 score tensor is not the last axis → decline.
2142        let mut g = sdpa_graph(false, 1);
2143        let before = g.num_nodes();
2144        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2145        assert!(g.nodes.values().all(|n| n.op_type != "FusedAttention"));
2146        assert!(g.nodes.values().any(|n| n.op_type == "Softmax"));
2147        assert_eq!(g.num_nodes(), before, "no fusion when axis is not last");
2148    }
2149
2150    #[test]
2151    fn declines_sdpa_when_scale_is_not_scalar_constant() {
2152        // The score-scaling divisor is a runtime graph input (not a constant),
2153        // so the scale can't be folded to a concrete f32 → decline.
2154        let mut g = Graph::new();
2155        g.opset_imports.insert(String::new(), 12);
2156        let q = fval(&mut g, "Q", &[1, 2, 3, 4]);
2157        let kt = fval(&mut g, "K", &[1, 2, 4, 3]);
2158        let v = fval(&mut g, "V", &[1, 2, 3, 4]);
2159        let c = fval(&mut g, "scale_c", &[]); // runtime input, NOT an initializer
2160        g.add_input(q);
2161        g.add_input(kt);
2162        g.add_input(v);
2163        g.add_input(c);
2164        let scores = fval(&mut g, "scores", &[1, 2, 3, 3]);
2165        g.insert_node(Node::new(NodeId(0), "MatMul", vec![Some(q), Some(kt)], vec![scores]));
2166        let scaled = fval(&mut g, "scaled", &[1, 2, 3, 3]);
2167        g.insert_node(Node::new(NodeId(0), "Div", vec![Some(scores), Some(c)], vec![scaled]));
2168        let probs = fval(&mut g, "probs", &[1, 2, 3, 3]);
2169        let mut sm = Node::new(NodeId(0), "Softmax", vec![Some(scaled)], vec![probs]);
2170        sm.attributes.insert("axis".into(), Attribute::Int(3));
2171        g.insert_node(sm);
2172        let out = fval(&mut g, "out", &[1, 2, 3, 4]);
2173        g.insert_node(Node::new(NodeId(0), "MatMul", vec![Some(probs), Some(v)], vec![out]));
2174        g.add_output(out);
2175
2176        let before = g.num_nodes();
2177        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2178        assert!(g.nodes.values().all(|n| n.op_type != "FusedAttention"));
2179        assert_eq!(g.num_nodes(), before, "non-constant scale must NOT fuse");
2180    }
2181
2182    #[test]
2183    fn declines_sdpa_when_softmax_is_right_operand_of_output_matmul() {
2184        // out = V · probs (softmax output is the RIGHT operand) is not `probs·V`
2185        // SDPA — the matcher requires the softmax output be the LEFT operand.
2186        let mut g = Graph::new();
2187        g.opset_imports.insert(String::new(), 12);
2188        let q = fval(&mut g, "Q", &[1, 2, 3, 4]);
2189        let kt = fval(&mut g, "K", &[1, 2, 4, 3]);
2190        let v = fval(&mut g, "V", &[1, 2, 3, 3]);
2191        g.add_input(q);
2192        g.add_input(kt);
2193        g.add_input(v);
2194        let c = scalar_init(&mut g, "scale_c", 2.0);
2195        let scores = fval(&mut g, "scores", &[1, 2, 3, 3]);
2196        g.insert_node(Node::new(NodeId(0), "MatMul", vec![Some(q), Some(kt)], vec![scores]));
2197        let scaled = fval(&mut g, "scaled", &[1, 2, 3, 3]);
2198        g.insert_node(Node::new(NodeId(0), "Div", vec![Some(scores), Some(c)], vec![scaled]));
2199        let probs = fval(&mut g, "probs", &[1, 2, 3, 3]);
2200        let mut sm = Node::new(NodeId(0), "Softmax", vec![Some(scaled)], vec![probs]);
2201        sm.attributes.insert("axis".into(), Attribute::Int(3));
2202        g.insert_node(sm);
2203        let out = fval(&mut g, "out", &[1, 2, 3, 3]);
2204        // Reversed operand order: V · probs.
2205        g.insert_node(Node::new(NodeId(0), "MatMul", vec![Some(v), Some(probs)], vec![out]));
2206        g.add_output(out);
2207
2208        let before = g.num_nodes();
2209        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2210        assert!(g.nodes.values().all(|n| n.op_type != "FusedAttention"));
2211        assert!(g.nodes.values().any(|n| n.op_type == "Softmax"));
2212        assert_eq!(g.num_nodes(), before);
2213    }
2214
2215    /// Build the exact-GELU `Erf` decomposition `0.5·x·(1 + erf(x / √2))` over a
2216    /// single graph input `x`, with the constants materialized as scalar
2217    /// initializers. `inner`/`half` select the constant encoding to emit so the
2218    /// equivalent forms can be exercised.
2219    fn gelu_graph(inner_div_sqrt2: bool, half_mul: bool) -> Graph {
2220        let mut g = Graph::new();
2221        g.opset_imports.insert(String::new(), 17);
2222        let x = val(&mut g, "x");
2223        g.add_input(x);
2224
2225        // half = 0.5 * x  (via Mul(x, 0.5) or Div(x, 2.0)).
2226        let half = val(&mut g, "half");
2227        if half_mul {
2228            let c = scalar_init(&mut g, "c_half", 0.5);
2229            g.insert_node(Node::new(NodeId(0), "Mul", vec![Some(x), Some(c)], vec![half]));
2230        } else {
2231            let c = scalar_init(&mut g, "c_two", 2.0);
2232            g.insert_node(Node::new(NodeId(0), "Div", vec![Some(x), Some(c)], vec![half]));
2233        }
2234
2235        // scaled = x / √2  (via Div(x, √2) or Mul(x, 1/√2)).
2236        let scaled = val(&mut g, "scaled");
2237        if inner_div_sqrt2 {
2238            let c = scalar_init(&mut g, "c_sqrt2", std::f32::consts::SQRT_2);
2239            g.insert_node(Node::new(NodeId(0), "Div", vec![Some(x), Some(c)], vec![scaled]));
2240        } else {
2241            let c = scalar_init(&mut g, "c_isqrt2", std::f32::consts::FRAC_1_SQRT_2);
2242            g.insert_node(Node::new(NodeId(0), "Mul", vec![Some(x), Some(c)], vec![scaled]));
2243        }
2244
2245        let e = val(&mut g, "e");
2246        g.insert_node(Node::new(NodeId(0), "Erf", vec![Some(scaled)], vec![e]));
2247        let one = scalar_init(&mut g, "c_one", 1.0);
2248        let a = val(&mut g, "a");
2249        g.insert_node(Node::new(NodeId(0), "Add", vec![Some(e), Some(one)], vec![a]));
2250        let out = val(&mut g, "out");
2251        g.insert_node(Node::new(NodeId(0), "Mul", vec![Some(half), Some(a)], vec![out]));
2252        g.add_output(out);
2253        g
2254    }
2255
2256    #[test]
2257    fn fuses_gelu_div_sqrt2() {
2258        let mut g = gelu_graph(true, true);
2259        assert_eq!(g.num_nodes(), 5);
2260        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2261        let gelu: Vec<_> = g.nodes.values().filter(|n| n.op_type == "Gelu").collect();
2262        assert_eq!(gelu.len(), 1, "the Erf decomposition must fuse to one Gelu");
2263        let fused = gelu[0];
2264        assert_eq!(fused.domain, CONTRIB_DOMAIN);
2265        assert_eq!(fused.inputs.len(), 1, "Gelu takes the single input x");
2266        assert!(fused.attributes.is_empty(), "exact Gelu has no attributes");
2267        // Single input is the graph input `x`.
2268        let x = g.values.iter().find(|(_, v)| v.name.as_deref() == Some("x")).map(|(id, _)| id).unwrap();
2269        assert_eq!(fused.inputs[0], Some(x));
2270        assert_eq!(fused.outputs, g.outputs);
2271        assert!(g.nodes.values().all(|n| n.op_type != "Erf"));
2272        assert!(g.validate().is_ok());
2273    }
2274
2275    #[test]
2276    fn fuses_gelu_mul_reciprocal_and_div_two() {
2277        // Equivalent encodings: inner Mul(x, 1/√2), half Div(x, 2.0).
2278        let mut g = gelu_graph(false, false);
2279        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2280        assert_eq!(
2281            g.nodes.values().filter(|n| n.op_type == "Gelu").count(),
2282            1,
2283            "the reciprocal/half-divisor encoding must also fuse"
2284        );
2285        assert!(g.validate().is_ok());
2286    }
2287
2288    #[test]
2289    fn declines_gelu_wrong_inner_constant() {
2290        // Div by 2.0 instead of √2 is not x/√2 → decline.
2291        let mut g = Graph::new();
2292        g.opset_imports.insert(String::new(), 17);
2293        let x = val(&mut g, "x");
2294        g.add_input(x);
2295        let half = val(&mut g, "half");
2296        let ch = scalar_init(&mut g, "c_half", 0.5);
2297        g.insert_node(Node::new(NodeId(0), "Mul", vec![Some(x), Some(ch)], vec![half]));
2298        let scaled = val(&mut g, "scaled");
2299        let cbad = scalar_init(&mut g, "c_bad", 2.0);
2300        g.insert_node(Node::new(NodeId(0), "Div", vec![Some(x), Some(cbad)], vec![scaled]));
2301        let e = val(&mut g, "e");
2302        g.insert_node(Node::new(NodeId(0), "Erf", vec![Some(scaled)], vec![e]));
2303        let one = scalar_init(&mut g, "c_one", 1.0);
2304        let a = val(&mut g, "a");
2305        g.insert_node(Node::new(NodeId(0), "Add", vec![Some(e), Some(one)], vec![a]));
2306        let out = val(&mut g, "out");
2307        g.insert_node(Node::new(NodeId(0), "Mul", vec![Some(half), Some(a)], vec![out]));
2308        g.add_output(out);
2309
2310        let before = g.num_nodes();
2311        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2312        assert!(g.nodes.values().all(|n| n.op_type != "Gelu"));
2313        assert!(g.nodes.values().any(|n| n.op_type == "Erf"));
2314        assert_eq!(g.num_nodes(), before);
2315    }
2316
2317    #[test]
2318    fn declines_gelu_wrong_half_constant() {
2319        // Mul(x, 0.4) instead of 0.5 → decline.
2320        let mut g = gelu_graph(true, true);
2321        // Rewrite the half Mul's constant initializer to 0.4.
2322        let ch = g.values.iter().find(|(_, v)| v.name.as_deref() == Some("c_half")).map(|(id, _)| id).unwrap();
2323        g.set_initializer(
2324            ch,
2325            WeightRef::Inline(TensorData::from_raw(DataType::Float32, vec![], 0.4f32.to_le_bytes().to_vec())),
2326        );
2327        let before = g.num_nodes();
2328        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2329        assert!(g.nodes.values().all(|n| n.op_type != "Gelu"));
2330        assert_eq!(g.num_nodes(), before);
2331    }
2332
2333    #[test]
2334    fn declines_gelu_when_half_uses_different_x() {
2335        // The `0.5··` operand uses a DIFFERENT value than the Erf branch, so the
2336        // diamond is not closed → decline.
2337        let mut g = Graph::new();
2338        g.opset_imports.insert(String::new(), 17);
2339        let x = val(&mut g, "x");
2340        let y = val(&mut g, "y");
2341        g.add_input(x);
2342        g.add_input(y);
2343        let half = val(&mut g, "half");
2344        let ch = scalar_init(&mut g, "c_half", 0.5);
2345        // half = 0.5 * y   (NOT x)
2346        g.insert_node(Node::new(NodeId(0), "Mul", vec![Some(y), Some(ch)], vec![half]));
2347        let scaled = val(&mut g, "scaled");
2348        let cs = scalar_init(&mut g, "c_sqrt2", std::f32::consts::SQRT_2);
2349        g.insert_node(Node::new(NodeId(0), "Div", vec![Some(x), Some(cs)], vec![scaled]));
2350        let e = val(&mut g, "e");
2351        g.insert_node(Node::new(NodeId(0), "Erf", vec![Some(scaled)], vec![e]));
2352        let one = scalar_init(&mut g, "c_one", 1.0);
2353        let a = val(&mut g, "a");
2354        g.insert_node(Node::new(NodeId(0), "Add", vec![Some(e), Some(one)], vec![a]));
2355        let out = val(&mut g, "out");
2356        g.insert_node(Node::new(NodeId(0), "Mul", vec![Some(half), Some(a)], vec![out]));
2357        g.add_output(out);
2358
2359        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2360        assert!(g.nodes.values().all(|n| n.op_type != "Gelu"));
2361        assert!(g.nodes.values().any(|n| n.op_type == "Erf"));
2362    }
2363
2364    #[test]
2365    fn declines_gelu_when_interior_escapes() {
2366        // The Erf output feeds an extra external consumer, so fusing would
2367        // delete an observed value → decline.
2368        let mut g = gelu_graph(true, true);
2369        let e = g.values.iter().find(|(_, v)| v.name.as_deref() == Some("e")).map(|(id, _)| id).unwrap();
2370        let side = val(&mut g, "side");
2371        g.insert_node(Node::new(NodeId(0), "Erf", vec![Some(e)], vec![side]));
2372        g.add_output(side);
2373
2374        OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2375        assert!(
2376            g.nodes.values().all(|n| n.op_type != "Gelu"),
2377            "must not fuse when an interior value escapes"
2378        );
2379    }
2380}