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