1use 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
74pub const CONTRIB_DOMAIN: &str = "com.microsoft";
83
84const SQRT_2: f32 = std::f32::consts::SQRT_2;
86const FRAC_1_SQRT_2: f32 = std::f32::consts::FRAC_1_SQRT_2;
88
89fn approx(a: f32, expected: f32) -> bool {
95 (a - expected).abs() <= 1e-6 * expected.abs().max(1.0)
96}
97
98type FusedNodeSpec = (Vec<Option<ValueId>>, HashMap<String, Attribute>);
100
101pub struct PatternMatch {
103 pub nodes: Vec<NodeId>,
105 pub external_inputs: Vec<ValueId>,
109 pub output: ValueId,
112}
113
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
116pub enum RewriteKind {
117 Structural,
120 LayerNorm,
124 Attention,
129 Gelu,
136}
137
138#[derive(Clone, Debug)]
140pub struct FusionPattern {
141 name: String,
142 ops: Vec<String>,
143 replacement: String,
144 kind: RewriteKind,
145}
146
147impl FusionPattern {
148 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 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 pub fn kind(&self) -> RewriteKind {
181 self.kind
182 }
183
184 pub fn attention() -> Self {
189 Self {
190 name: "Attention".to_string(),
191 ops: ["Softmax"].iter().map(|s| s.to_string()).collect(),
194 replacement: "FusedAttention".to_string(),
195 kind: RewriteKind::Attention,
196 }
197 }
198
199 pub fn gelu() -> Self {
204 Self {
205 name: "Gelu".to_string(),
206 ops: ["Erf"].iter().map(|s| s.to_string()).collect(),
209 replacement: "Gelu".to_string(),
210 kind: RewriteKind::Gelu,
211 }
212 }
213
214 pub fn pattern_name(&self) -> &str {
216 &self.name
217 }
218
219 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 fn op_matches(node: &Node, op: &str) -> bool {
244 node.op_type == op && matches!(node.domain.as_str(), "" | "ai.onnx")
245 }
246
247 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 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 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 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 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 let Some(div) = Self::find_consumer(graph, std, "Div") else {
321 continue;
322 };
323 let dn = graph.node(div);
324 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 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 if matched_set.len() != nodes.len() {
351 continue;
352 }
353
354 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 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 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 if self.layernorm_spec(graph, &matched).is_none() {
411 continue;
412 }
413 return Some(matched);
414 }
415 None
416 }
417
418 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 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 fn try_parse_attention(&self, graph: &Graph, sm_start: NodeId) -> Option<AttnParts> {
466 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 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 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 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 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 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 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 let (k, k_transposed, transpose_node) = Self::attention_k(graph, k_side, score_mm_id);
561
562 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 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 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 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 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 _ => return None,
651 }
652 } else {
653 return None;
654 };
655 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 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 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 fn try_parse_gelu(&self, graph: &Graph, erf_start: NodeId) -> Option<GeluParts> {
740 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if !self.match_is_fusable(graph, &matched) {
960 return None;
961 }
962
963 Some(matched)
964 }
965
966 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 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 fn matmul_bias_broadcast_ok(&self, graph: &Graph, m: &PatternMatch) -> bool {
1005 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 if bias_shape.len() > mm_shape.len() {
1020 return false;
1021 }
1022 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 pub fn apply_fusion(&self, graph: &mut Graph, m: &PatternMatch) -> Result<()> {
1042 let output = m.output;
1043
1044 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 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 fused.domain = CONTRIB_DOMAIN.to_string();
1078 graph.insert_node(fused);
1079 Ok(())
1080 }
1081
1082 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 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 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 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 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 let eps_val = add_eps.input_values().find(|&v| v != var)?;
1172 let epsilon = read_scalar_f32(graph, eps_val)?;
1173
1174 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
1190fn 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#[derive(Clone, Debug)]
1204struct AttnParts {
1205 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#[derive(Clone, Debug)]
1222struct GeluParts {
1223 nodes: Vec<NodeId>,
1226 x: ValueId,
1228 output: ValueId,
1230 external_inputs: Vec<ValueId>,
1231}
1232fn 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
1248fn 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
1265pub fn default_fusion_patterns() -> Vec<FusionPattern> {
1270 vec![
1271 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#[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 pub fn new() -> Self {
1297 Self {
1298 patterns: default_fusion_patterns(),
1299 }
1300 }
1301
1302 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 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 assert_eq!(fused.inputs.len(), 3);
1363 assert!(g.validate().is_ok());
1364 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 let mut g = matmul_add_graph();
1399 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 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 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 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 assert_eq!(fused.inputs, vec![Some(x), Some(scale), Some(bias)]);
1527 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 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 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 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 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 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 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 let mut g = layernorm_graph();
1726 let p = FusionPattern::layernorm();
1729 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 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 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 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 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 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 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 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 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 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 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 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 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]); 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 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 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 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 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]); 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 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 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 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", &[]); 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 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 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 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 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 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 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 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 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 let mut g = gelu_graph(true, true);
2321 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 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 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 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}