1use std::collections::{BTreeSet, 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 #[cfg(test)]
145 replacement_domain: String,
146 kind: RewriteKind,
147}
148
149impl FusionPattern {
150 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 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 pub fn kind(&self) -> RewriteKind {
195 self.kind
196 }
197
198 pub fn attention() -> Self {
203 Self {
204 name: "Attention".to_string(),
205 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 pub fn gelu() -> Self {
220 Self {
221 name: "Gelu".to_string(),
222 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 pub fn pattern_name(&self) -> &str {
240 &self.name
241 }
242
243 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 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 fn op_matches(node: &Node, op: &str) -> bool {
307 node.op_type == op && matches!(node.domain.as_str(), "" | "ai.onnx")
308 }
309
310 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 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 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 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 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 let Some(div) = Self::find_consumer(graph, std, "Div") else {
380 continue;
381 };
382 let dn = graph.node(div);
383 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 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 if matched_set.len() != nodes.len() {
410 continue;
411 }
412
413 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 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 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 if self.layernorm_spec(graph, &matched).is_none() {
471 continue;
472 }
473 return Some(matched);
474 }
475 None
476 }
477
478 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 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 fn try_parse_attention(&self, graph: &Graph, sm_start: NodeId) -> Option<AttnParts> {
526 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 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 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 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 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 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 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 let (k, k_transposed, transpose_node) = Self::attention_k(graph, k_side, score_mm_id);
616
617 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 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 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 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 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 _ => return None,
707 }
708 } else {
709 return None;
710 };
711 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 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 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 fn try_parse_gelu(&self, graph: &Graph, erf_start: NodeId) -> Option<GeluParts> {
796 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if !self.match_is_fusable(graph, &matched) {
1018 return None;
1019 }
1020
1021 Some(matched)
1022 }
1023
1024 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 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 fn matmul_bias_broadcast_ok(&self, graph: &Graph, m: &PatternMatch) -> bool {
1063 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 if bias_shape.len() > mm_shape.len() {
1078 return false;
1079 }
1080 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 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 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 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 #[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 Ok(graph.insert_node(fused))
1148 }
1149
1150 fn layernorm_spec(&self, graph: &Graph, m: &PatternMatch) -> Option<FusedNodeSpec> {
1175 let nodes = &m.nodes;
1176 if nodes.len() != 9 && nodes.len() != 10 {
1177 return None;
1178 }
1179 let rm1 = graph.node(nodes[0]);
1180 let sub_pow = graph.node(nodes[1]);
1181 let pow = graph.node(nodes[2]);
1182 let rm2 = graph.node(nodes[3]);
1183 let add_eps = graph.node(nodes[4]);
1184 let div = graph.node(nodes[6]);
1185 let mul = graph.node(nodes[7]);
1186 let final_add = graph.node(nodes[8]);
1187 let sub_div = if nodes.len() == 10 {
1190 graph.node(nodes[9])
1191 } else {
1192 sub_pow
1193 };
1194
1195 let mean = rm1.outputs[0];
1196 let diff_pow = sub_pow.outputs[0];
1197 let diff_div = sub_div.outputs[0];
1198 let var = rm2.outputs[0];
1199 let norm = div.outputs[0];
1200 let scaled = mul.outputs[0];
1201
1202 if !sub_pow.input_values().any(|v| v == mean)
1206 || !sub_div.input_values().any(|v| v == mean)
1207 || !pow.input_values().any(|v| v == diff_pow)
1208 || !div.input_values().any(|v| v == diff_div)
1209 || !mul.input_values().any(|v| v == norm)
1210 || !final_add.input_values().any(|v| v == scaled)
1211 {
1212 return None;
1213 }
1214
1215 let x = sub_pow.input_values().find(|&v| v != mean)?;
1219 if !sub_div.input_values().any(|v| v == x) {
1220 return None;
1221 }
1222
1223 let subtracts_x_minus_mean = |sub: &Node| -> bool {
1230 matches!(sub.inputs.as_slice(), [Some(a), Some(b)] if *a == x && *b == mean)
1231 };
1232 if !subtracts_x_minus_mean(sub_pow) || !subtracts_x_minus_mean(sub_div) {
1233 return None;
1234 }
1235 let scale = mul.input_values().find(|&v| v != norm)?;
1236 let bias = final_add.input_values().find(|&v| v != scaled)?;
1237
1238 let eps_val = add_eps.input_values().find(|&v| v != var)?;
1240 let epsilon = read_scalar_f32(graph, eps_val)?;
1241
1242 let axes = rm1.attr("axes").and_then(Attribute::as_ints)?;
1246 let [axis] = axes else {
1247 return None;
1248 };
1249
1250 let mut attributes = HashMap::new();
1251 attributes.insert("axis".to_string(), Attribute::Int(*axis));
1252 attributes.insert("epsilon".to_string(), Attribute::Float(epsilon));
1253
1254 Some((vec![Some(x), Some(scale), Some(bias)], attributes))
1255 }
1256}
1257
1258fn read_scalar_f32(graph: &Graph, value: ValueId) -> Option<f32> {
1261 match graph.initializers.get(&value)? {
1262 WeightRef::Inline(t) if t.dtype == DataType::Float32 && t.data.len() >= 4 => {
1263 Some(f32::from_le_bytes(t.data[0..4].try_into().ok()?))
1264 }
1265 _ => None,
1266 }
1267}
1268
1269#[derive(Clone, Debug)]
1272struct AttnParts {
1273 nodes: Vec<NodeId>,
1277 q: ValueId,
1278 k: ValueId,
1279 v: ValueId,
1280 mask: Option<ValueId>,
1281 scale: f32,
1282 k_transposed: bool,
1283 output: ValueId,
1284 external_inputs: Vec<ValueId>,
1285}
1286
1287#[derive(Clone, Debug)]
1290struct GeluParts {
1291 nodes: Vec<NodeId>,
1294 x: ValueId,
1296 output: ValueId,
1298 external_inputs: Vec<ValueId>,
1299}
1300fn read_scalar_const_f32(graph: &Graph, value: ValueId) -> Option<f32> {
1304 match graph.initializers.get(&value)? {
1305 WeightRef::Inline(t) if t.dtype == DataType::Float32 => {
1306 let numel: usize = t.dims.iter().product();
1307 if numel != 1 || t.data.len() < 4 {
1308 return None;
1309 }
1310 Some(f32::from_le_bytes(t.data[0..4].try_into().ok()?))
1311 }
1312 _ => None,
1313 }
1314}
1315
1316fn is_last2_swap_perm(perm: &[i64]) -> bool {
1321 let r = perm.len();
1322 if r < 2 {
1323 return false;
1324 }
1325 for (i, &p) in perm.iter().enumerate().take(r - 2) {
1326 if p != i as i64 {
1327 return false;
1328 }
1329 }
1330 perm[r - 2] == (r - 1) as i64 && perm[r - 1] == (r - 2) as i64
1331}
1332
1333pub fn default_fusion_patterns() -> Vec<FusionPattern> {
1338 vec![
1339 FusionPattern::attention(),
1343 FusionPattern::new("MatMul+Bias+Relu", &["MatMul", "Add", "Relu"], "FusedGemm"),
1344 FusionPattern::layernorm(),
1345 FusionPattern::gelu(),
1346 FusionPattern::new("MatMul+Bias", &["MatMul", "Add"], "FusedMatMulBias"),
1347 ]
1348}
1349
1350#[derive(Clone, Debug)]
1352pub struct OpFusion {
1353 patterns: Vec<FusionPattern>,
1354}
1355
1356#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1357enum ScanCandidateSource {
1358 Initial,
1359 Revisit,
1360}
1361
1362impl Default for OpFusion {
1363 fn default() -> Self {
1364 Self::new()
1365 }
1366}
1367
1368impl OpFusion {
1369 pub fn new() -> Self {
1371 Self {
1372 patterns: default_fusion_patterns(),
1373 }
1374 }
1375
1376 pub fn with_patterns(patterns: Vec<FusionPattern>) -> Self {
1378 Self { patterns }
1379 }
1380
1381 fn run_resumable(
1382 &self,
1383 graph: &mut Graph,
1384 mut observe_fusion: impl FnMut(&str, ScanCandidateSource, NodeId, &[NodeId], &[NodeId], NodeId),
1385 ) -> Result<()> {
1386 for pattern in &self.patterns {
1387 let candidates: Vec<u32> = graph.nodes.keys().map(|id| id.0).collect();
1388 let mut cursor = 0;
1389 let mut revisits = BTreeSet::new();
1390 loop {
1391 let initial = candidates.get(cursor).copied();
1392 let revisit = revisits.first().copied();
1393 let (raw_id, source) = match (initial, revisit) {
1394 (None, None) => break,
1395 (Some(id), None) => {
1396 cursor += 1;
1397 (id, ScanCandidateSource::Initial)
1398 }
1399 (None, Some(_)) => {
1400 (revisits.pop_first().unwrap(), ScanCandidateSource::Revisit)
1401 }
1402 (Some(id), Some(revisit)) if id <= revisit => {
1403 cursor += 1;
1404 if id == revisit {
1405 revisits.pop_first();
1406 }
1407 (id, ScanCandidateSource::Initial)
1408 }
1409 (Some(_), Some(_)) => {
1410 (revisits.pop_first().unwrap(), ScanCandidateSource::Revisit)
1411 }
1412 };
1413 let start = NodeId(raw_id);
1414 let Some(matched) = pattern.try_match_at(graph, start) else {
1415 continue;
1416 };
1417
1418 let affected = pattern.affected_candidate_starts(graph, &matched);
1419 let fused_id = pattern.apply_fusion_returning_id(graph, &matched)?;
1420 observe_fusion(
1421 pattern.pattern_name(),
1422 source,
1423 start,
1424 &matched.nodes,
1425 &affected,
1426 fused_id,
1427 );
1428
1429 revisits.insert(fused_id.0);
1433 for candidate in affected {
1434 if graph.try_node(candidate).is_some() {
1435 revisits.insert(candidate.0);
1436 }
1437 }
1438 }
1439 }
1440 Ok(())
1441 }
1442
1443 #[cfg(test)]
1444 fn run_with_fusion_observer(
1445 &self,
1446 graph: &mut Graph,
1447 observe_fusion: impl FnMut(&str, ScanCandidateSource, NodeId, &[NodeId], &[NodeId], NodeId),
1448 ) -> Result<()> {
1449 self.run_resumable(graph, observe_fusion)
1450 }
1451}
1452
1453impl OptimizationPass for OpFusion {
1454 fn name(&self) -> &str {
1455 "OpFusion"
1456 }
1457
1458 fn run(&self, graph: &mut Graph, _ctx: &PassContext) -> Result<()> {
1459 self.run_resumable(graph, |_, _, _, _, _, _| {})
1460 }
1461}
1462
1463#[cfg(test)]
1464mod tests {
1465 use super::*;
1466 use onnx_runtime_ir::{DataType, Node, NodeId, TensorData, static_shape};
1467
1468 fn val(g: &mut Graph, name: &str) -> ValueId {
1469 g.create_named_value(name, DataType::Float32, static_shape([4]))
1470 }
1471
1472 fn matmul_add_graph() -> Graph {
1475 let mut g = Graph::new();
1476 g.opset_imports.insert(String::new(), 17);
1477 let a = val(&mut g, "a");
1478 let w = val(&mut g, "w");
1479 let bias = val(&mut g, "bias");
1480 g.add_input(a);
1481 g.add_input(w);
1482 g.add_input(bias);
1483
1484 let m = val(&mut g, "m");
1485 g.insert_node(Node::new(
1486 NodeId(0),
1487 "MatMul",
1488 vec![Some(a), Some(w)],
1489 vec![m],
1490 ));
1491 let out = val(&mut g, "out");
1492 g.insert_node(Node::new(
1493 NodeId(0),
1494 "Add",
1495 vec![Some(m), Some(bias)],
1496 vec![out],
1497 ));
1498 g.add_output(out);
1499 g
1500 }
1501
1502 #[test]
1503 fn fuses_matmul_add() {
1504 let mut g = matmul_add_graph();
1505 assert_eq!(g.num_nodes(), 2);
1506 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1507 assert_eq!(g.num_nodes(), 1);
1508 let fused = g.nodes.values().next().unwrap();
1509 assert_eq!(fused.op_type, "FusedMatMulBias");
1510 assert_eq!(fused.domain, CONTRIB_DOMAIN);
1511 assert_eq!(fused.inputs.len(), 3);
1513 assert!(g.validate().is_ok());
1514 assert_eq!(g.outputs.len(), 1);
1516 assert_eq!(fused.outputs, g.outputs);
1517 }
1518
1519 #[test]
1520 fn fuses_matmul_add_relu_before_matmul_add() {
1521 let mut g = Graph::new();
1522 g.opset_imports.insert(String::new(), 17);
1523 let a = val(&mut g, "a");
1524 let w = val(&mut g, "w");
1525 let bias = val(&mut g, "bias");
1526 g.add_input(a);
1527 g.add_input(w);
1528 g.add_input(bias);
1529 let m = val(&mut g, "m");
1530 g.insert_node(Node::new(
1531 NodeId(0),
1532 "MatMul",
1533 vec![Some(a), Some(w)],
1534 vec![m],
1535 ));
1536 let s = val(&mut g, "s");
1537 g.insert_node(Node::new(
1538 NodeId(0),
1539 "Add",
1540 vec![Some(m), Some(bias)],
1541 vec![s],
1542 ));
1543 let out = val(&mut g, "out");
1544 g.insert_node(Node::new(NodeId(0), "Relu", vec![Some(s)], vec![out]));
1545 g.add_output(out);
1546
1547 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1548 assert_eq!(g.num_nodes(), 1);
1549 let fused = g.nodes.values().next().unwrap();
1550 assert_eq!(fused.op_type, "FusedGemm");
1551 assert_eq!(fused.domain, CONTRIB_DOMAIN);
1552 assert!(g.validate().is_ok());
1553 }
1554
1555 #[test]
1556 fn does_not_fuse_when_intermediate_has_second_consumer() {
1557 let mut g = matmul_add_graph();
1559 let m = g
1561 .values
1562 .iter()
1563 .find(|(_, v)| v.name.as_deref() == Some("m"))
1564 .map(|(id, _)| id)
1565 .unwrap();
1566 let side = val(&mut g, "side");
1567 g.insert_node(Node::new(NodeId(0), "Relu", vec![Some(m)], vec![side]));
1568 g.add_output(side);
1569
1570 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1571 assert!(
1573 g.nodes.values().any(|n| n.op_type == "MatMul"),
1574 "MatMul must remain — its output has a second consumer"
1575 );
1576 assert!(g.nodes.values().all(|n| n.op_type != "FusedMatMulBias"));
1577 assert!(g.validate().is_ok());
1578 }
1579
1580 #[test]
1581 fn no_match_returns_none() {
1582 let mut g = Graph::new();
1583 g.opset_imports.insert(String::new(), 17);
1584 let a = val(&mut g, "a");
1585 g.add_input(a);
1586 let out = val(&mut g, "out");
1587 g.insert_node(Node::new(NodeId(0), "Relu", vec![Some(a)], vec![out]));
1588 g.add_output(out);
1589 let p = FusionPattern::new("MatMul+Bias", &["MatMul", "Add"], "FusedMatMulBias");
1590 assert!(p.find_match(&g).is_none());
1591 }
1592
1593 fn layernorm_graph() -> Graph {
1600 const EPS: f32 = 1e-12;
1601 let mut g = Graph::new();
1602 g.opset_imports.insert(String::new(), 17);
1603 let x = val(&mut g, "x");
1604 let two = val(&mut g, "two");
1605 let eps = val(&mut g, "eps");
1606 let scale = val(&mut g, "scale");
1607 let bias = val(&mut g, "bias");
1608 g.add_input(x);
1609 g.add_input(two);
1610 g.set_initializer(
1611 eps,
1612 WeightRef::Inline(TensorData::from_raw(
1613 DataType::Float32,
1614 vec![],
1615 EPS.to_le_bytes().to_vec(),
1616 )),
1617 );
1618 g.add_input(scale);
1619 g.add_input(bias);
1620
1621 let reduce_mean = |g: &mut Graph, input: ValueId, out: ValueId| {
1622 let mut n = Node::new(NodeId(0), "ReduceMean", vec![Some(input)], vec![out]);
1623 n.attributes
1624 .insert("axes".into(), Attribute::Ints(vec![-1]));
1625 n.attributes.insert("keepdims".into(), Attribute::Int(1));
1626 g.insert_node(n);
1627 };
1628
1629 let mean = val(&mut g, "mean");
1630 reduce_mean(&mut g, x, mean);
1631 let diff = val(&mut g, "diff");
1632 g.insert_node(Node::new(
1633 NodeId(0),
1634 "Sub",
1635 vec![Some(x), Some(mean)],
1636 vec![diff],
1637 ));
1638 let sq = val(&mut g, "sq");
1639 g.insert_node(Node::new(
1640 NodeId(0),
1641 "Pow",
1642 vec![Some(diff), Some(two)],
1643 vec![sq],
1644 ));
1645 let var = val(&mut g, "var");
1646 reduce_mean(&mut g, sq, var);
1647 let vare = val(&mut g, "vare");
1648 g.insert_node(Node::new(
1649 NodeId(0),
1650 "Add",
1651 vec![Some(var), Some(eps)],
1652 vec![vare],
1653 ));
1654 let std = val(&mut g, "std");
1655 g.insert_node(Node::new(NodeId(0), "Sqrt", vec![Some(vare)], vec![std]));
1656 let norm = val(&mut g, "norm");
1657 g.insert_node(Node::new(
1658 NodeId(0),
1659 "Div",
1660 vec![Some(diff), Some(std)],
1661 vec![norm],
1662 ));
1663 let scaled = val(&mut g, "scaled");
1664 g.insert_node(Node::new(
1665 NodeId(0),
1666 "Mul",
1667 vec![Some(norm), Some(scale)],
1668 vec![scaled],
1669 ));
1670 let out = val(&mut g, "out");
1671 g.insert_node(Node::new(
1672 NodeId(0),
1673 "Add",
1674 vec![Some(scaled), Some(bias)],
1675 vec![out],
1676 ));
1677 g.add_output(out);
1678 g
1679 }
1680
1681 #[test]
1682 fn fuses_layernorm_chain() {
1683 let mut g = layernorm_graph();
1684 assert_eq!(g.num_nodes(), 9);
1685 assert!(g.validate().is_ok());
1686
1687 let vid = |name: &str| {
1689 g.values
1690 .iter()
1691 .find(|(_, v)| v.name.as_deref() == Some(name))
1692 .map(|(id, _)| id)
1693 .unwrap()
1694 };
1695 let x = vid("x");
1696 let scale = vid("scale");
1697 let bias = vid("bias");
1698
1699 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1700
1701 assert_eq!(g.num_nodes(), 1, "9-op chain collapses to one node");
1702 let fused = g.nodes.values().next().unwrap();
1703 assert_eq!(fused.op_type, "LayerNormalization");
1704 assert_eq!(fused.domain, CONTRIB_DOMAIN);
1705 assert_eq!(fused.inputs, vec![Some(x), Some(scale), Some(bias)]);
1708 assert_eq!(
1710 fused.attr("axis").and_then(Attribute::as_int),
1711 Some(-1),
1712 "axis extracted from ReduceMean axes"
1713 );
1714 let eps = fused
1715 .attr("epsilon")
1716 .and_then(Attribute::as_float)
1717 .expect("epsilon attribute present");
1718 assert!(
1719 (eps - 1e-12).abs() < 1e-18,
1720 "epsilon extracted from the var+eps constant, got {eps}"
1721 );
1722 assert_eq!(fused.outputs, g.outputs);
1723 assert!(g.validate().is_ok());
1724 }
1725
1726 #[test]
1727 fn layernorm_count_bookkeeping() {
1728 let mut g = layernorm_graph();
1729 let ln_before = g
1730 .nodes
1731 .values()
1732 .filter(|n| n.op_type == "LayerNormalization")
1733 .count();
1734 let rm_before = g
1735 .nodes
1736 .values()
1737 .filter(|n| n.op_type == "ReduceMean")
1738 .count();
1739 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1740 let ln_after = g
1741 .nodes
1742 .values()
1743 .filter(|n| n.op_type == "LayerNormalization")
1744 .count();
1745 let rm_after = g
1746 .nodes
1747 .values()
1748 .filter(|n| n.op_type == "ReduceMean")
1749 .count();
1750 assert_eq!(ln_before, 0);
1751 assert_eq!(ln_after, 1);
1752 assert_eq!(rm_before, 2);
1753 assert_eq!(rm_after, 0);
1754 }
1755
1756 fn layernorm_split_graph(reverse_num_sub: bool) -> Graph {
1764 const EPS: f32 = 1e-12;
1765 let mut g = Graph::new();
1766 g.opset_imports.insert(String::new(), 17);
1767 let x = val(&mut g, "x");
1768 let two = val(&mut g, "two");
1769 let eps = val(&mut g, "eps");
1770 let scale = val(&mut g, "scale");
1771 let bias = val(&mut g, "bias");
1772 g.add_input(x);
1773 g.add_input(two);
1774 g.set_initializer(
1775 eps,
1776 WeightRef::Inline(TensorData::from_raw(
1777 DataType::Float32,
1778 vec![],
1779 EPS.to_le_bytes().to_vec(),
1780 )),
1781 );
1782 g.add_input(scale);
1783 g.add_input(bias);
1784
1785 let reduce_mean = |g: &mut Graph, input: ValueId, out: ValueId| {
1786 let mut n = Node::new(NodeId(0), "ReduceMean", vec![Some(input)], vec![out]);
1787 n.attributes
1788 .insert("axes".into(), Attribute::Ints(vec![-1]));
1789 n.attributes.insert("keepdims".into(), Attribute::Int(1));
1790 g.insert_node(n);
1791 };
1792
1793 let mean = val(&mut g, "mean");
1794 reduce_mean(&mut g, x, mean);
1795 let diff_pow = val(&mut g, "diff_pow");
1797 g.insert_node(Node::new(
1798 NodeId(0),
1799 "Sub",
1800 vec![Some(x), Some(mean)],
1801 vec![diff_pow],
1802 ));
1803 let diff_div = val(&mut g, "diff_div");
1806 let num_inputs = if reverse_num_sub {
1807 vec![Some(mean), Some(x)]
1808 } else {
1809 vec![Some(x), Some(mean)]
1810 };
1811 g.insert_node(Node::new(NodeId(0), "Sub", num_inputs, vec![diff_div]));
1812
1813 let sq = val(&mut g, "sq");
1814 g.insert_node(Node::new(
1815 NodeId(0),
1816 "Pow",
1817 vec![Some(diff_pow), Some(two)],
1818 vec![sq],
1819 ));
1820 let var = val(&mut g, "var");
1821 reduce_mean(&mut g, sq, var);
1822 let vare = val(&mut g, "vare");
1823 g.insert_node(Node::new(
1824 NodeId(0),
1825 "Add",
1826 vec![Some(var), Some(eps)],
1827 vec![vare],
1828 ));
1829 let std = val(&mut g, "std");
1830 g.insert_node(Node::new(NodeId(0), "Sqrt", vec![Some(vare)], vec![std]));
1831 let norm = val(&mut g, "norm");
1832 g.insert_node(Node::new(
1833 NodeId(0),
1834 "Div",
1835 vec![Some(diff_div), Some(std)],
1836 vec![norm],
1837 ));
1838 let scaled = val(&mut g, "scaled");
1839 g.insert_node(Node::new(
1840 NodeId(0),
1841 "Mul",
1842 vec![Some(norm), Some(scale)],
1843 vec![scaled],
1844 ));
1845 let out = val(&mut g, "out");
1846 g.insert_node(Node::new(
1847 NodeId(0),
1848 "Add",
1849 vec![Some(scaled), Some(bias)],
1850 vec![out],
1851 ));
1852 g.add_output(out);
1853 g
1854 }
1855
1856 #[test]
1857 fn fuses_layernorm_split_chain() {
1858 let mut g = layernorm_split_graph(false);
1861 assert_eq!(g.num_nodes(), 10, "split-diff shape has two distinct Subs");
1862 assert!(g.validate().is_ok());
1863
1864 let vid = |name: &str| {
1865 g.values
1866 .iter()
1867 .find(|(_, v)| v.name.as_deref() == Some(name))
1868 .map(|(id, _)| id)
1869 .unwrap()
1870 };
1871 let x = vid("x");
1872 let scale = vid("scale");
1873 let bias = vid("bias");
1874
1875 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1876
1877 assert_eq!(g.num_nodes(), 1, "10-op split chain collapses to one node");
1878 let fused = g.nodes.values().next().unwrap();
1879 assert_eq!(fused.op_type, "LayerNormalization");
1880 assert_eq!(fused.domain, CONTRIB_DOMAIN);
1881 assert_eq!(fused.inputs, vec![Some(x), Some(scale), Some(bias)]);
1883 assert_eq!(
1884 fused.attr("axis").and_then(Attribute::as_int),
1885 Some(-1),
1886 "axis extracted from ReduceMean axes"
1887 );
1888 let eps = fused
1889 .attr("epsilon")
1890 .and_then(Attribute::as_float)
1891 .expect("epsilon attribute present");
1892 assert!(
1893 (eps - 1e-12).abs() < 1e-18,
1894 "epsilon extracted from the var+eps constant, got {eps}"
1895 );
1896 assert_eq!(fused.outputs, g.outputs);
1897 assert!(g.validate().is_ok());
1898 }
1899
1900 #[test]
1901 fn declines_layernorm_when_numerator_sub_reversed() {
1902 let mut g = layernorm_split_graph(true);
1907 assert_eq!(g.num_nodes(), 10);
1908 assert!(g.validate().is_ok());
1909
1910 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1911
1912 assert!(
1913 g.nodes.values().all(|n| n.op_type != "LayerNormalization"),
1914 "reversed Sub(mean, x) must NOT fuse — sign-flip over-match"
1915 );
1916 assert_eq!(g.num_nodes(), 10, "all 10 ops remain (declined)");
1917 assert_eq!(
1918 g.nodes.values().filter(|n| n.op_type == "Sub").count(),
1919 2,
1920 "both centering Subs preserved"
1921 );
1922 assert!(g.validate().is_ok());
1923 }
1924
1925 #[test]
1926 fn does_not_fuse_partial_layernorm() {
1927 let mut g = layernorm_graph();
1929 let p = FusionPattern::layernorm();
1932 let diff = g
1935 .values
1936 .iter()
1937 .find(|(_, v)| v.name.as_deref() == Some("diff"))
1938 .map(|(id, _)| id)
1939 .unwrap();
1940 let side = val(&mut g, "side");
1941 g.insert_node(Node::new(NodeId(0), "Neg", vec![Some(diff)], vec![side]));
1942 g.add_output(side);
1943 assert!(
1944 p.find_match(&g).is_none(),
1945 "external consumer on `diff` blocks the fusion"
1946 );
1947 }
1948
1949 #[test]
1950 fn fuses_two_independent_matmul_adds() {
1951 let mut g = Graph::new();
1952 g.opset_imports.insert(String::new(), 17);
1953 for i in 0..2 {
1954 let a = val(&mut g, &format!("a{i}"));
1955 let w = val(&mut g, &format!("w{i}"));
1956 let bias = val(&mut g, &format!("bias{i}"));
1957 g.add_input(a);
1958 g.add_input(w);
1959 g.add_input(bias);
1960 let m = val(&mut g, &format!("m{i}"));
1961 g.insert_node(Node::new(
1962 NodeId(0),
1963 "MatMul",
1964 vec![Some(a), Some(w)],
1965 vec![m],
1966 ));
1967 let out = val(&mut g, &format!("out{i}"));
1968 g.insert_node(Node::new(
1969 NodeId(0),
1970 "Add",
1971 vec![Some(m), Some(bias)],
1972 vec![out],
1973 ));
1974 g.add_output(out);
1975 }
1976 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
1977 assert_eq!(g.num_nodes(), 2);
1978 assert!(g.nodes.values().all(|n| n.op_type == "FusedMatMulBias"));
1979 assert!(g.validate().is_ok());
1980 }
1981
1982 #[test]
1983 fn find_match_reports_correct_shape() {
1984 let g = matmul_add_graph();
1985 let p = FusionPattern::new("MatMul+Bias", &["MatMul", "Add"], "FusedMatMulBias");
1986 let m = p.find_match(&g).expect("should match");
1987 assert_eq!(m.nodes.len(), 2);
1988 assert_eq!(m.external_inputs.len(), 3);
1989 assert_eq!(p.pattern_name(), "MatMul+Bias");
1990 }
1991
1992 #[test]
1993 fn declines_layernorm_when_axes_is_input() {
1994 let mut g = layernorm_graph();
1999 let mean = g
2000 .values
2001 .iter()
2002 .find(|(_, v)| v.name.as_deref() == Some("mean"))
2003 .map(|(id, _)| id)
2004 .unwrap();
2005 let rm1 = g.value(mean).producer.unwrap();
2006 g.node_mut(rm1).attributes.remove("axes");
2008 let axes_in = g.create_named_value("axes_in", DataType::Int64, static_shape([1]));
2009 g.set_initializer(
2010 axes_in,
2011 WeightRef::Inline(TensorData::from_raw(
2012 DataType::Int64,
2013 vec![1],
2014 (-1i64).to_le_bytes().to_vec(),
2015 )),
2016 );
2017 let input_index = g.node(rm1).inputs.len();
2018 g.node_mut(rm1).inputs.push(None);
2019 g.replace_input(rm1, input_index, Some(axes_in));
2020 assert!(g.validate().is_ok());
2021
2022 assert_eq!(g.num_nodes(), 9);
2023 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2024 assert_eq!(
2025 g.num_nodes(),
2026 9,
2027 "axes-as-input LayerNorm must NOT fuse — all original ops kept"
2028 );
2029 assert!(
2030 g.nodes.values().all(|n| n.op_type != "LayerNormalization"),
2031 "no fused LayerNormalization must be emitted"
2032 );
2033 assert_eq!(
2034 g.nodes
2035 .values()
2036 .filter(|n| n.op_type == "ReduceMean")
2037 .count(),
2038 2,
2039 "both ReduceMean ops remain"
2040 );
2041 assert!(g.validate().is_ok());
2042 }
2043
2044 #[test]
2045 fn declines_layernorm_when_epsilon_not_constant() {
2046 let mut g = layernorm_graph();
2050 let eps = g
2051 .values
2052 .iter()
2053 .find(|(_, v)| v.name.as_deref() == Some("eps"))
2054 .map(|(id, _)| id)
2055 .unwrap();
2056 g.initializers.remove(&eps);
2058 g.add_input(eps);
2059 assert!(g.validate().is_ok());
2060
2061 assert_eq!(g.num_nodes(), 9);
2062 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2063 assert_eq!(
2064 g.num_nodes(),
2065 9,
2066 "non-constant epsilon LayerNorm must NOT fuse"
2067 );
2068 assert!(g.nodes.values().all(|n| n.op_type != "LayerNormalization"));
2069 assert!(g.validate().is_ok());
2070 }
2071
2072 #[test]
2073 fn declines_matmul_add_when_bias_expands() {
2074 let mut g = Graph::new();
2079 g.opset_imports.insert(String::new(), 17);
2080 let a = g.create_named_value("a", DataType::Float32, static_shape([4, 4]));
2081 let w = g.create_named_value("w", DataType::Float32, static_shape([4]));
2082 let bias = g.create_named_value("bias", DataType::Float32, static_shape([2, 4]));
2083 g.add_input(a);
2084 g.add_input(w);
2085 g.add_input(bias);
2086 let m = g.create_named_value("m", DataType::Float32, static_shape([4]));
2087 g.insert_node(Node::new(
2088 NodeId(0),
2089 "MatMul",
2090 vec![Some(a), Some(w)],
2091 vec![m],
2092 ));
2093 let out = g.create_named_value("out", DataType::Float32, static_shape([2, 4]));
2094 g.insert_node(Node::new(
2095 NodeId(0),
2096 "Add",
2097 vec![Some(m), Some(bias)],
2098 vec![out],
2099 ));
2100 g.add_output(out);
2101
2102 assert_eq!(g.num_nodes(), 2);
2103 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2104 assert_eq!(g.num_nodes(), 2, "expanding bias must NOT fuse");
2105 assert!(g.nodes.values().any(|n| n.op_type == "MatMul"));
2106 assert!(g.nodes.values().any(|n| n.op_type == "Add"));
2107 assert!(g.nodes.values().all(|n| n.op_type != "FusedMatMulBias"));
2108 assert!(g.validate().is_ok());
2109 }
2110
2111 #[test]
2112 fn fuses_matmul_add_with_trailing_broadcast_bias() {
2113 let mut g = Graph::new();
2116 g.opset_imports.insert(String::new(), 17);
2117 let a = g.create_named_value("a", DataType::Float32, static_shape([3, 4]));
2118 let w = g.create_named_value("w", DataType::Float32, static_shape([4, 4]));
2119 let bias = g.create_named_value("bias", DataType::Float32, static_shape([1, 4]));
2120 g.add_input(a);
2121 g.add_input(w);
2122 g.add_input(bias);
2123 let m = g.create_named_value("m", DataType::Float32, static_shape([3, 4]));
2124 g.insert_node(Node::new(
2125 NodeId(0),
2126 "MatMul",
2127 vec![Some(a), Some(w)],
2128 vec![m],
2129 ));
2130 let out = g.create_named_value("out", DataType::Float32, static_shape([3, 4]));
2131 g.insert_node(Node::new(
2132 NodeId(0),
2133 "Add",
2134 vec![Some(m), Some(bias)],
2135 vec![out],
2136 ));
2137 g.add_output(out);
2138
2139 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2140 assert_eq!(g.num_nodes(), 1, "trailing-broadcast bias must fuse");
2141 assert_eq!(g.nodes.values().next().unwrap().op_type, "FusedMatMulBias");
2142 assert!(g.validate().is_ok());
2143 }
2144
2145 #[test]
2146 fn declines_matmul_add_when_shape_unknown() {
2147 let mut g = Graph::new();
2150 g.opset_imports.insert(String::new(), 17);
2151 let a = g.create_named_value("a", DataType::Float32, Vec::new());
2152 let w = g.create_named_value("w", DataType::Float32, Vec::new());
2153 let bias = g.create_named_value("bias", DataType::Float32, static_shape([4]));
2154 g.add_input(a);
2155 g.add_input(w);
2156 g.add_input(bias);
2157 let m = g.create_named_value("m", DataType::Float32, Vec::new());
2159 g.insert_node(Node::new(
2160 NodeId(0),
2161 "MatMul",
2162 vec![Some(a), Some(w)],
2163 vec![m],
2164 ));
2165 let out = g.create_named_value("out", DataType::Float32, static_shape([4]));
2166 g.insert_node(Node::new(
2167 NodeId(0),
2168 "Add",
2169 vec![Some(m), Some(bias)],
2170 vec![out],
2171 ));
2172 g.add_output(out);
2173
2174 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2175 assert_eq!(g.num_nodes(), 2, "unknown matmul shape must NOT fuse");
2176 assert!(g.nodes.values().all(|n| n.op_type != "FusedMatMulBias"));
2177 assert!(g.validate().is_ok());
2178 }
2179
2180 #[test]
2181 fn declines_fused_gemm_when_bias_expands() {
2182 let mut g = Graph::new();
2188 g.opset_imports.insert(String::new(), 17);
2189 let a = g.create_named_value("a", DataType::Float32, static_shape([4, 4]));
2190 let w = g.create_named_value("w", DataType::Float32, static_shape([4]));
2191 let bias = g.create_named_value("bias", DataType::Float32, static_shape([2, 4]));
2192 g.add_input(a);
2193 g.add_input(w);
2194 g.add_input(bias);
2195 let m = g.create_named_value("m", DataType::Float32, static_shape([4]));
2196 g.insert_node(Node::new(
2197 NodeId(0),
2198 "MatMul",
2199 vec![Some(a), Some(w)],
2200 vec![m],
2201 ));
2202 let biased = g.create_named_value("biased", DataType::Float32, static_shape([2, 4]));
2203 g.insert_node(Node::new(
2204 NodeId(0),
2205 "Add",
2206 vec![Some(m), Some(bias)],
2207 vec![biased],
2208 ));
2209 let out = g.create_named_value("out", DataType::Float32, static_shape([2, 4]));
2210 g.insert_node(Node::new(NodeId(0), "Relu", vec![Some(biased)], vec![out]));
2211 g.add_output(out);
2212
2213 assert_eq!(g.num_nodes(), 3);
2214 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2215 assert_eq!(
2216 g.num_nodes(),
2217 3,
2218 "expanding bias must NOT fuse to FusedGemm"
2219 );
2220 assert!(g.nodes.values().any(|n| n.op_type == "MatMul"));
2221 assert!(g.nodes.values().any(|n| n.op_type == "Add"));
2222 assert!(g.nodes.values().any(|n| n.op_type == "Relu"));
2223 assert!(g.nodes.values().all(|n| n.op_type != "FusedGemm"));
2224 assert!(g.validate().is_ok());
2225 }
2226
2227 fn scalar_init(g: &mut Graph, name: &str, v: f32) -> ValueId {
2231 let vid = g.create_named_value(name, DataType::Float32, Vec::new());
2232 g.set_initializer(
2233 vid,
2234 WeightRef::Inline(TensorData::from_raw(
2235 DataType::Float32,
2236 vec![],
2237 v.to_le_bytes().to_vec(),
2238 )),
2239 );
2240 vid
2241 }
2242
2243 fn fval(g: &mut Graph, name: &str, dims: &[usize]) -> ValueId {
2244 g.create_named_value(name, DataType::Float32, static_shape(dims.iter().copied()))
2245 }
2246
2247 fn value_id(g: &Graph, name: &str) -> ValueId {
2249 g.values
2250 .iter()
2251 .find(|(_, v)| v.name.as_deref() == Some(name))
2252 .map(|(id, _)| id)
2253 .unwrap_or_else(|| panic!("no value named {name}"))
2254 }
2255
2256 fn sdpa_graph(masked: bool, axis: i64) -> Graph {
2261 let mut g = Graph::new();
2262 g.opset_imports.insert(String::new(), 12);
2263 let q = fval(&mut g, "Q", &[1, 2, 3, 4]);
2264 let kt = fval(&mut g, "K", &[1, 2, 4, 3]); let v = fval(&mut g, "V", &[1, 2, 3, 4]);
2266 g.add_input(q);
2267 g.add_input(kt);
2268 g.add_input(v);
2269 let c = scalar_init(&mut g, "scale_c", 2.0);
2270
2271 let scores = fval(&mut g, "scores", &[1, 2, 3, 3]);
2272 g.insert_node(Node::new(
2273 NodeId(0),
2274 "MatMul",
2275 vec![Some(q), Some(kt)],
2276 vec![scores],
2277 ));
2278 let scaled = fval(&mut g, "scaled", &[1, 2, 3, 3]);
2279 g.insert_node(Node::new(
2280 NodeId(0),
2281 "Div",
2282 vec![Some(scores), Some(c)],
2283 vec![scaled],
2284 ));
2285
2286 let sm_in = if masked {
2287 let mask = fval(&mut g, "mask", &[1, 1, 3, 3]);
2288 g.add_input(mask);
2289 let masked_v = fval(&mut g, "masked", &[1, 2, 3, 3]);
2290 g.insert_node(Node::new(
2291 NodeId(0),
2292 "Add",
2293 vec![Some(scaled), Some(mask)],
2294 vec![masked_v],
2295 ));
2296 masked_v
2297 } else {
2298 scaled
2299 };
2300
2301 let probs = fval(&mut g, "probs", &[1, 2, 3, 3]);
2302 let mut sm = Node::new(NodeId(0), "Softmax", vec![Some(sm_in)], vec![probs]);
2303 sm.attributes.insert("axis".into(), Attribute::Int(axis));
2304 g.insert_node(sm);
2305 let out = fval(&mut g, "out", &[1, 2, 3, 4]);
2306 g.insert_node(Node::new(
2307 NodeId(0),
2308 "MatMul",
2309 vec![Some(probs), Some(v)],
2310 vec![out],
2311 ));
2312 g.add_output(out);
2313 g
2314 }
2315
2316 fn fused_attention_node(g: &Graph) -> Option<&Node> {
2317 g.nodes.values().find(|n| n.op_type == "FusedAttention")
2318 }
2319
2320 #[test]
2321 fn fuses_sdpa_unmasked_pretransposed_k() {
2322 let mut g = sdpa_graph(false, 3);
2323 let q = value_id(&g, "Q");
2324 let k = value_id(&g, "K");
2325 let v = value_id(&g, "V");
2326 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2327
2328 assert_eq!(
2330 g.nodes
2331 .values()
2332 .filter(|n| n.op_type == "FusedAttention")
2333 .count(),
2334 1
2335 );
2336 assert!(g.nodes.values().all(|n| n.op_type != "Softmax"));
2337 assert!(g.nodes.values().all(|n| n.op_type != "MatMul"));
2338 assert!(g.nodes.values().all(|n| n.op_type != "Div"));
2339
2340 let fa = fused_attention_node(&g).unwrap();
2341 assert_eq!(fa.domain, CONTRIB_DOMAIN);
2342 assert_eq!(fa.inputs, vec![Some(q), Some(k), Some(v)]);
2343 assert_eq!(fa.attr("scale").and_then(Attribute::as_float), Some(0.5));
2345 assert_eq!(fa.attr("k_transposed").and_then(Attribute::as_int), Some(1));
2346 assert!(g.validate().is_ok());
2347 }
2348
2349 #[test]
2350 fn fuses_sdpa_masked() {
2351 let mut g = sdpa_graph(true, 3);
2352 let (q, k, v, mask) = (
2353 value_id(&g, "Q"),
2354 value_id(&g, "K"),
2355 value_id(&g, "V"),
2356 value_id(&g, "mask"),
2357 );
2358 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2359
2360 assert_eq!(
2361 g.nodes
2362 .values()
2363 .filter(|n| n.op_type == "FusedAttention")
2364 .count(),
2365 1
2366 );
2367 assert!(g.nodes.values().all(|n| n.op_type != "Softmax"));
2368 assert!(g.nodes.values().all(|n| n.op_type != "Add"));
2369 let fa = fused_attention_node(&g).unwrap();
2370 assert_eq!(fa.inputs, vec![Some(q), Some(k), Some(v), Some(mask)]);
2372 assert_eq!(fa.attr("k_transposed").and_then(Attribute::as_int), Some(1));
2373 assert!(g.validate().is_ok());
2374 }
2375
2376 #[test]
2377 fn fuses_sdpa_absorbing_clean_transpose_sets_k_transposed_0() {
2378 let mut g = Graph::new();
2383 g.opset_imports.insert(String::new(), 12);
2384 let q = fval(&mut g, "Q", &[1, 2, 3, 4]);
2385 let k = fval(&mut g, "K", &[1, 2, 3, 4]); let v = fval(&mut g, "V", &[1, 2, 3, 4]);
2387 g.add_input(q);
2388 g.add_input(k);
2389 g.add_input(v);
2390 let c = scalar_init(&mut g, "scale_c", 4.0);
2391
2392 let kt = fval(&mut g, "Kt", &[1, 2, 4, 3]);
2393 let mut tr = Node::new(NodeId(0), "Transpose", vec![Some(k)], vec![kt]);
2394 tr.attributes
2395 .insert("perm".into(), Attribute::Ints(vec![0, 1, 3, 2]));
2396 g.insert_node(tr);
2397 let scores = fval(&mut g, "scores", &[1, 2, 3, 3]);
2398 g.insert_node(Node::new(
2399 NodeId(0),
2400 "MatMul",
2401 vec![Some(q), Some(kt)],
2402 vec![scores],
2403 ));
2404 let scaled = fval(&mut g, "scaled", &[1, 2, 3, 3]);
2405 g.insert_node(Node::new(
2406 NodeId(0),
2407 "Div",
2408 vec![Some(scores), Some(c)],
2409 vec![scaled],
2410 ));
2411 let probs = fval(&mut g, "probs", &[1, 2, 3, 3]);
2412 let mut sm = Node::new(NodeId(0), "Softmax", vec![Some(scaled)], vec![probs]);
2413 sm.attributes.insert("axis".into(), Attribute::Int(-1));
2414 g.insert_node(sm);
2415 let out = fval(&mut g, "out", &[1, 2, 3, 4]);
2416 g.insert_node(Node::new(
2417 NodeId(0),
2418 "MatMul",
2419 vec![Some(probs), Some(v)],
2420 vec![out],
2421 ));
2422 g.add_output(out);
2423
2424 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2425 assert!(
2426 g.nodes.values().all(|n| n.op_type != "Transpose"),
2427 "clean Kᵀ Transpose absorbed"
2428 );
2429 let fa = fused_attention_node(&g).unwrap();
2430 assert_eq!(
2431 fa.inputs,
2432 vec![Some(q), Some(k), Some(v)],
2433 "K input is the natural (un-transposed) K"
2434 );
2435 assert_eq!(fa.attr("k_transposed").and_then(Attribute::as_int), Some(0));
2436 assert_eq!(fa.attr("scale").and_then(Attribute::as_float), Some(0.25));
2438 assert!(g.validate().is_ok());
2439 }
2440
2441 #[test]
2442 fn declines_sdpa_when_softmax_axis_not_last() {
2443 let mut g = sdpa_graph(false, 1);
2445 let before = g.num_nodes();
2446 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2447 assert!(g.nodes.values().all(|n| n.op_type != "FusedAttention"));
2448 assert!(g.nodes.values().any(|n| n.op_type == "Softmax"));
2449 assert_eq!(g.num_nodes(), before, "no fusion when axis is not last");
2450 }
2451
2452 #[test]
2453 fn declines_sdpa_when_scale_is_not_scalar_constant() {
2454 let mut g = Graph::new();
2457 g.opset_imports.insert(String::new(), 12);
2458 let q = fval(&mut g, "Q", &[1, 2, 3, 4]);
2459 let kt = fval(&mut g, "K", &[1, 2, 4, 3]);
2460 let v = fval(&mut g, "V", &[1, 2, 3, 4]);
2461 let c = fval(&mut g, "scale_c", &[]); g.add_input(q);
2463 g.add_input(kt);
2464 g.add_input(v);
2465 g.add_input(c);
2466 let scores = fval(&mut g, "scores", &[1, 2, 3, 3]);
2467 g.insert_node(Node::new(
2468 NodeId(0),
2469 "MatMul",
2470 vec![Some(q), Some(kt)],
2471 vec![scores],
2472 ));
2473 let scaled = fval(&mut g, "scaled", &[1, 2, 3, 3]);
2474 g.insert_node(Node::new(
2475 NodeId(0),
2476 "Div",
2477 vec![Some(scores), Some(c)],
2478 vec![scaled],
2479 ));
2480 let probs = fval(&mut g, "probs", &[1, 2, 3, 3]);
2481 let mut sm = Node::new(NodeId(0), "Softmax", vec![Some(scaled)], vec![probs]);
2482 sm.attributes.insert("axis".into(), Attribute::Int(3));
2483 g.insert_node(sm);
2484 let out = fval(&mut g, "out", &[1, 2, 3, 4]);
2485 g.insert_node(Node::new(
2486 NodeId(0),
2487 "MatMul",
2488 vec![Some(probs), Some(v)],
2489 vec![out],
2490 ));
2491 g.add_output(out);
2492
2493 let before = g.num_nodes();
2494 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2495 assert!(g.nodes.values().all(|n| n.op_type != "FusedAttention"));
2496 assert_eq!(g.num_nodes(), before, "non-constant scale must NOT fuse");
2497 }
2498
2499 #[test]
2500 fn declines_sdpa_when_softmax_is_right_operand_of_output_matmul() {
2501 let mut g = Graph::new();
2504 g.opset_imports.insert(String::new(), 12);
2505 let q = fval(&mut g, "Q", &[1, 2, 3, 4]);
2506 let kt = fval(&mut g, "K", &[1, 2, 4, 3]);
2507 let v = fval(&mut g, "V", &[1, 2, 3, 3]);
2508 g.add_input(q);
2509 g.add_input(kt);
2510 g.add_input(v);
2511 let c = scalar_init(&mut g, "scale_c", 2.0);
2512 let scores = fval(&mut g, "scores", &[1, 2, 3, 3]);
2513 g.insert_node(Node::new(
2514 NodeId(0),
2515 "MatMul",
2516 vec![Some(q), Some(kt)],
2517 vec![scores],
2518 ));
2519 let scaled = fval(&mut g, "scaled", &[1, 2, 3, 3]);
2520 g.insert_node(Node::new(
2521 NodeId(0),
2522 "Div",
2523 vec![Some(scores), Some(c)],
2524 vec![scaled],
2525 ));
2526 let probs = fval(&mut g, "probs", &[1, 2, 3, 3]);
2527 let mut sm = Node::new(NodeId(0), "Softmax", vec![Some(scaled)], vec![probs]);
2528 sm.attributes.insert("axis".into(), Attribute::Int(3));
2529 g.insert_node(sm);
2530 let out = fval(&mut g, "out", &[1, 2, 3, 3]);
2531 g.insert_node(Node::new(
2533 NodeId(0),
2534 "MatMul",
2535 vec![Some(v), Some(probs)],
2536 vec![out],
2537 ));
2538 g.add_output(out);
2539
2540 let before = g.num_nodes();
2541 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2542 assert!(g.nodes.values().all(|n| n.op_type != "FusedAttention"));
2543 assert!(g.nodes.values().any(|n| n.op_type == "Softmax"));
2544 assert_eq!(g.num_nodes(), before);
2545 }
2546
2547 fn gelu_graph(inner_div_sqrt2: bool, half_mul: bool) -> Graph {
2552 let mut g = Graph::new();
2553 g.opset_imports.insert(String::new(), 17);
2554 let x = val(&mut g, "x");
2555 g.add_input(x);
2556
2557 let half = val(&mut g, "half");
2559 if half_mul {
2560 let c = scalar_init(&mut g, "c_half", 0.5);
2561 g.insert_node(Node::new(
2562 NodeId(0),
2563 "Mul",
2564 vec![Some(x), Some(c)],
2565 vec![half],
2566 ));
2567 } else {
2568 let c = scalar_init(&mut g, "c_two", 2.0);
2569 g.insert_node(Node::new(
2570 NodeId(0),
2571 "Div",
2572 vec![Some(x), Some(c)],
2573 vec![half],
2574 ));
2575 }
2576
2577 let scaled = val(&mut g, "scaled");
2579 if inner_div_sqrt2 {
2580 let c = scalar_init(&mut g, "c_sqrt2", std::f32::consts::SQRT_2);
2581 g.insert_node(Node::new(
2582 NodeId(0),
2583 "Div",
2584 vec![Some(x), Some(c)],
2585 vec![scaled],
2586 ));
2587 } else {
2588 let c = scalar_init(&mut g, "c_isqrt2", std::f32::consts::FRAC_1_SQRT_2);
2589 g.insert_node(Node::new(
2590 NodeId(0),
2591 "Mul",
2592 vec![Some(x), Some(c)],
2593 vec![scaled],
2594 ));
2595 }
2596
2597 let e = val(&mut g, "e");
2598 g.insert_node(Node::new(NodeId(0), "Erf", vec![Some(scaled)], vec![e]));
2599 let one = scalar_init(&mut g, "c_one", 1.0);
2600 let a = val(&mut g, "a");
2601 g.insert_node(Node::new(
2602 NodeId(0),
2603 "Add",
2604 vec![Some(e), Some(one)],
2605 vec![a],
2606 ));
2607 let out = val(&mut g, "out");
2608 g.insert_node(Node::new(
2609 NodeId(0),
2610 "Mul",
2611 vec![Some(half), Some(a)],
2612 vec![out],
2613 ));
2614 g.add_output(out);
2615 g
2616 }
2617
2618 #[test]
2619 fn fuses_gelu_div_sqrt2() {
2620 let mut g = gelu_graph(true, true);
2621 assert_eq!(g.num_nodes(), 5);
2622 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2623 let gelu: Vec<_> = g.nodes.values().filter(|n| n.op_type == "Gelu").collect();
2624 assert_eq!(gelu.len(), 1, "the Erf decomposition must fuse to one Gelu");
2625 let fused = gelu[0];
2626 assert_eq!(fused.domain, CONTRIB_DOMAIN);
2627 assert_eq!(fused.inputs.len(), 1, "Gelu takes the single input x");
2628 assert!(fused.attributes.is_empty(), "exact Gelu has no attributes");
2629 let x = g
2631 .values
2632 .iter()
2633 .find(|(_, v)| v.name.as_deref() == Some("x"))
2634 .map(|(id, _)| id)
2635 .unwrap();
2636 assert_eq!(fused.inputs[0], Some(x));
2637 assert_eq!(fused.outputs, g.outputs);
2638 assert!(g.nodes.values().all(|n| n.op_type != "Erf"));
2639 assert!(g.validate().is_ok());
2640 }
2641
2642 #[test]
2643 fn fuses_gelu_mul_reciprocal_and_div_two() {
2644 let mut g = gelu_graph(false, false);
2646 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2647 assert_eq!(
2648 g.nodes.values().filter(|n| n.op_type == "Gelu").count(),
2649 1,
2650 "the reciprocal/half-divisor encoding must also fuse"
2651 );
2652 assert!(g.validate().is_ok());
2653 }
2654
2655 #[test]
2656 fn declines_gelu_wrong_inner_constant() {
2657 let mut g = Graph::new();
2659 g.opset_imports.insert(String::new(), 17);
2660 let x = val(&mut g, "x");
2661 g.add_input(x);
2662 let half = val(&mut g, "half");
2663 let ch = scalar_init(&mut g, "c_half", 0.5);
2664 g.insert_node(Node::new(
2665 NodeId(0),
2666 "Mul",
2667 vec![Some(x), Some(ch)],
2668 vec![half],
2669 ));
2670 let scaled = val(&mut g, "scaled");
2671 let cbad = scalar_init(&mut g, "c_bad", 2.0);
2672 g.insert_node(Node::new(
2673 NodeId(0),
2674 "Div",
2675 vec![Some(x), Some(cbad)],
2676 vec![scaled],
2677 ));
2678 let e = val(&mut g, "e");
2679 g.insert_node(Node::new(NodeId(0), "Erf", vec![Some(scaled)], vec![e]));
2680 let one = scalar_init(&mut g, "c_one", 1.0);
2681 let a = val(&mut g, "a");
2682 g.insert_node(Node::new(
2683 NodeId(0),
2684 "Add",
2685 vec![Some(e), Some(one)],
2686 vec![a],
2687 ));
2688 let out = val(&mut g, "out");
2689 g.insert_node(Node::new(
2690 NodeId(0),
2691 "Mul",
2692 vec![Some(half), Some(a)],
2693 vec![out],
2694 ));
2695 g.add_output(out);
2696
2697 let before = g.num_nodes();
2698 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2699 assert!(g.nodes.values().all(|n| n.op_type != "Gelu"));
2700 assert!(g.nodes.values().any(|n| n.op_type == "Erf"));
2701 assert_eq!(g.num_nodes(), before);
2702 }
2703
2704 #[test]
2705 fn declines_gelu_wrong_half_constant() {
2706 let mut g = gelu_graph(true, true);
2708 let ch = g
2710 .values
2711 .iter()
2712 .find(|(_, v)| v.name.as_deref() == Some("c_half"))
2713 .map(|(id, _)| id)
2714 .unwrap();
2715 g.set_initializer(
2716 ch,
2717 WeightRef::Inline(TensorData::from_raw(
2718 DataType::Float32,
2719 vec![],
2720 0.4f32.to_le_bytes().to_vec(),
2721 )),
2722 );
2723 let before = g.num_nodes();
2724 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2725 assert!(g.nodes.values().all(|n| n.op_type != "Gelu"));
2726 assert_eq!(g.num_nodes(), before);
2727 }
2728
2729 #[test]
2730 fn declines_gelu_when_half_uses_different_x() {
2731 let mut g = Graph::new();
2734 g.opset_imports.insert(String::new(), 17);
2735 let x = val(&mut g, "x");
2736 let y = val(&mut g, "y");
2737 g.add_input(x);
2738 g.add_input(y);
2739 let half = val(&mut g, "half");
2740 let ch = scalar_init(&mut g, "c_half", 0.5);
2741 g.insert_node(Node::new(
2743 NodeId(0),
2744 "Mul",
2745 vec![Some(y), Some(ch)],
2746 vec![half],
2747 ));
2748 let scaled = val(&mut g, "scaled");
2749 let cs = scalar_init(&mut g, "c_sqrt2", std::f32::consts::SQRT_2);
2750 g.insert_node(Node::new(
2751 NodeId(0),
2752 "Div",
2753 vec![Some(x), Some(cs)],
2754 vec![scaled],
2755 ));
2756 let e = val(&mut g, "e");
2757 g.insert_node(Node::new(NodeId(0), "Erf", vec![Some(scaled)], vec![e]));
2758 let one = scalar_init(&mut g, "c_one", 1.0);
2759 let a = val(&mut g, "a");
2760 g.insert_node(Node::new(
2761 NodeId(0),
2762 "Add",
2763 vec![Some(e), Some(one)],
2764 vec![a],
2765 ));
2766 let out = val(&mut g, "out");
2767 g.insert_node(Node::new(
2768 NodeId(0),
2769 "Mul",
2770 vec![Some(half), Some(a)],
2771 vec![out],
2772 ));
2773 g.add_output(out);
2774
2775 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2776 assert!(g.nodes.values().all(|n| n.op_type != "Gelu"));
2777 assert!(g.nodes.values().any(|n| n.op_type == "Erf"));
2778 }
2779
2780 #[test]
2781 fn declines_gelu_when_interior_escapes() {
2782 let mut g = gelu_graph(true, true);
2785 let e = g
2786 .values
2787 .iter()
2788 .find(|(_, v)| v.name.as_deref() == Some("e"))
2789 .map(|(id, _)| id)
2790 .unwrap();
2791 let side = val(&mut g, "side");
2792 g.insert_node(Node::new(NodeId(0), "Erf", vec![Some(e)], vec![side]));
2793 g.add_output(side);
2794
2795 OpFusion::new().run(&mut g, &PassContext::new()).unwrap();
2796 assert!(
2797 g.nodes.values().all(|n| n.op_type != "Gelu"),
2798 "must not fuse when an interior value escapes"
2799 );
2800 }
2801
2802 fn run_restart_reference(patterns: &[FusionPattern], graph: &mut Graph) {
2803 for pattern in patterns {
2804 while let Some(matched) = pattern.find_match(graph) {
2805 pattern.apply_fusion(graph, &matched).unwrap();
2806 }
2807 }
2808 }
2809
2810 fn serialized_graph_bytes(mut graph: Graph) -> Vec<u8> {
2811 use std::fmt::Write;
2812
2813 let mut snapshot = String::new();
2814 writeln!(&mut snapshot, "inputs={:?}", graph.inputs).unwrap();
2815 writeln!(&mut snapshot, "outputs={:?}", graph.outputs).unwrap();
2816
2817 let mut initializers: Vec<_> = graph.initializers.iter().collect();
2818 initializers.sort_by_key(|(id, _)| id.0);
2819 writeln!(&mut snapshot, "initializers={initializers:?}").unwrap();
2820 let mut constraints: Vec<_> = graph.symbol_constraints.iter().collect();
2821 constraints.sort_by_key(|(id, _)| id.0);
2822 writeln!(&mut snapshot, "constraints={constraints:?}").unwrap();
2823 let mut opsets: Vec<_> = graph.opset_imports.iter().collect();
2824 opsets.sort_by_key(|(domain, _)| *domain);
2825 writeln!(&mut snapshot, "opsets={opsets:?}").unwrap();
2826 let mut subgraphs: Vec<_> = graph.subgraphs.iter().collect();
2827 subgraphs.sort_by_key(|((id, name), _)| (id.0, name.as_str()));
2828 writeln!(&mut snapshot, "subgraphs={subgraphs:?}").unwrap();
2829
2830 for (id, node) in graph.nodes.iter() {
2831 let mut attributes: Vec<_> = node.attributes.iter().collect();
2832 attributes.sort_by_key(|(name, _)| *name);
2833 writeln!(
2834 &mut snapshot,
2835 "node={id:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{attributes:?}|{:?}|{:?}|{:?}",
2836 node.id,
2837 node.name,
2838 node.op_type,
2839 node.domain,
2840 node.inputs,
2841 node.outputs,
2842 node.doc_string,
2843 node.device,
2844 node.exec_order,
2845 )
2846 .unwrap();
2847 }
2848 for (id, value) in graph.values.iter() {
2849 writeln!(&mut snapshot, "value={id:?}|{value:?}").unwrap();
2850 }
2851 writeln!(
2852 &mut snapshot,
2853 "topological_order={:?}",
2854 graph.topological_order().unwrap()
2855 )
2856 .unwrap();
2857
2858 for _ in 0..128 {
2862 let node =
2863 graph.insert_node(Node::new(NodeId(0), "ArenaProbe", Vec::new(), Vec::new()));
2864 let value = graph.create_value(DataType::Float32, static_shape([1]));
2865 writeln!(&mut snapshot, "probe={node:?}|{value:?}").unwrap();
2866 }
2867 snapshot.into_bytes()
2868 }
2869
2870 fn assert_fusion_graphs_byte_identical(actual: Graph, expected: Graph, trial: usize) {
2871 assert_eq!(
2872 serialized_graph_bytes(actual),
2873 serialized_graph_bytes(expected),
2874 "restart and resumable fixpoints differ byte-for-byte on trial {trial}"
2875 );
2876 }
2877
2878 struct FusionTestRng(u64);
2879
2880 impl FusionTestRng {
2881 fn next(&mut self) -> u64 {
2882 self.0 ^= self.0 << 13;
2883 self.0 ^= self.0 >> 7;
2884 self.0 ^= self.0 << 17;
2885 self.0
2886 }
2887
2888 fn usize(&mut self, upper: usize) -> usize {
2889 (self.next() as usize) % upper
2890 }
2891
2892 fn coin(&mut self) -> bool {
2893 self.next() & 1 == 0
2894 }
2895
2896 fn shuffle<T>(&mut self, values: &mut [T]) {
2897 for i in (1..values.len()).rev() {
2898 values.swap(i, self.usize(i + 1));
2899 }
2900 }
2901 }
2902
2903 struct DifferentialGraphBuilder {
2904 graph: Graph,
2905 pending: Vec<Node>,
2906 next_name: usize,
2907 }
2908
2909 impl DifferentialGraphBuilder {
2910 fn new() -> Self {
2911 let mut graph = Graph::new();
2912 graph.opset_imports.insert(String::new(), 17);
2913 Self {
2914 graph,
2915 pending: Vec::new(),
2916 next_name: 0,
2917 }
2918 }
2919
2920 fn value(&mut self, prefix: &str, dims: &[usize]) -> ValueId {
2921 let name = format!("{prefix}_{}", self.next_name);
2922 self.next_name += 1;
2923 self.graph.create_named_value(
2924 name,
2925 DataType::Float32,
2926 static_shape(dims.iter().copied()),
2927 )
2928 }
2929
2930 fn input(&mut self, prefix: &str, dims: &[usize]) -> ValueId {
2931 let value = self.value(prefix, dims);
2932 self.graph.add_input(value);
2933 value
2934 }
2935
2936 fn scalar(&mut self, prefix: &str, value: f32) -> ValueId {
2937 let id = self.value(prefix, &[]);
2938 self.graph.set_initializer(
2939 id,
2940 WeightRef::Inline(TensorData::from_raw(
2941 DataType::Float32,
2942 vec![],
2943 value.to_le_bytes().to_vec(),
2944 )),
2945 );
2946 id
2947 }
2948
2949 fn node(
2950 &mut self,
2951 name: impl Into<String>,
2952 op: &str,
2953 inputs: Vec<ValueId>,
2954 output: ValueId,
2955 ) -> &mut Node {
2956 let mut node = Node::new(
2957 NodeId(0),
2958 op,
2959 inputs.into_iter().map(Some).collect(),
2960 vec![output],
2961 );
2962 node.name = name.into();
2963 self.pending.push(node);
2964 self.pending.last_mut().unwrap()
2965 }
2966
2967 fn output(&mut self, value: ValueId) {
2968 self.graph.add_output(value);
2969 }
2970
2971 fn add_matmul_bias(&mut self, rng: &mut FusionTestRng, relu: bool, overlap: bool) {
2972 let a = self.input("mm_a", &[4]);
2973 let w0 = self.input("mm_w0", &[4]);
2974 let m0 = self.value("mm_m0", &[4]);
2975 self.node("mm_left", "MatMul", vec![a, w0], m0);
2976
2977 let bias = if overlap {
2978 let w1 = self.input("mm_w1", &[4]);
2979 let m1 = self.value("mm_m1", &[4]);
2980 self.node("mm_right", "MatMul", vec![a, w1], m1);
2981 m1
2982 } else {
2983 self.input("mm_bias", &[4])
2984 };
2985
2986 let add = self.value("mm_add", &[4]);
2987 let add_inputs = if rng.coin() {
2988 vec![m0, bias]
2989 } else {
2990 vec![bias, m0]
2991 };
2992 self.node("mm_add_node", "Add", add_inputs, add);
2993 if relu {
2994 let output = self.value("mm_relu", &[4]);
2995 self.node("mm_relu_node", "Relu", vec![add], output);
2996 self.output(output);
2997 } else {
2998 self.output(add);
2999 }
3000 }
3001
3002 fn add_layernorm(&mut self, split_diff: bool) {
3003 let x = self.input("ln_x", &[4]);
3004 let two = self.input("ln_two", &[4]);
3005 let eps = self.scalar("ln_eps", 1e-12);
3006 let scale = self.input("ln_scale", &[4]);
3007 let bias = self.input("ln_bias", &[4]);
3008
3009 let mean = self.value("ln_mean", &[4]);
3010 let rm1 = self.node("ln_mean_node", "ReduceMean", vec![x], mean);
3011 rm1.attributes
3012 .insert("axes".into(), Attribute::Ints(vec![-1]));
3013 rm1.attributes.insert("keepdims".into(), Attribute::Int(1));
3014
3015 let diff_pow = self.value("ln_diff_pow", &[4]);
3016 self.node("ln_sub_pow", "Sub", vec![x, mean], diff_pow);
3017 let diff_div = if split_diff {
3018 let value = self.value("ln_diff_div", &[4]);
3019 self.node("ln_sub_div", "Sub", vec![x, mean], value);
3020 value
3021 } else {
3022 diff_pow
3023 };
3024 let sq = self.value("ln_sq", &[4]);
3025 self.node("ln_pow", "Pow", vec![diff_pow, two], sq);
3026 let var = self.value("ln_var", &[4]);
3027 let rm2 = self.node("ln_var_node", "ReduceMean", vec![sq], var);
3028 rm2.attributes
3029 .insert("axes".into(), Attribute::Ints(vec![-1]));
3030 rm2.attributes.insert("keepdims".into(), Attribute::Int(1));
3031 let vare = self.value("ln_vare", &[4]);
3032 self.node("ln_add_eps", "Add", vec![var, eps], vare);
3033 let std = self.value("ln_std", &[4]);
3034 self.node("ln_sqrt", "Sqrt", vec![vare], std);
3035 let norm = self.value("ln_norm", &[4]);
3036 self.node("ln_div", "Div", vec![diff_div, std], norm);
3037 let scaled = self.value("ln_scaled", &[4]);
3038 self.node("ln_mul", "Mul", vec![norm, scale], scaled);
3039 let output = self.value("ln_output", &[4]);
3040 self.node("ln_add_bias", "Add", vec![scaled, bias], output);
3041 self.output(output);
3042 }
3043
3044 fn add_gelu(&mut self, rng: &mut FusionTestRng) {
3045 let x = self.input("gelu_x", &[4]);
3046 let half = self.value("gelu_half", &[4]);
3047 if rng.coin() {
3048 let c = self.scalar("gelu_half_c", 0.5);
3049 let inputs = if rng.coin() { vec![x, c] } else { vec![c, x] };
3050 self.node("gelu_half_node", "Mul", inputs, half);
3051 } else {
3052 let c = self.scalar("gelu_two_c", 2.0);
3053 self.node("gelu_half_node", "Div", vec![x, c], half);
3054 }
3055
3056 let scaled = self.value("gelu_scaled", &[4]);
3057 if rng.coin() {
3058 let c = self.scalar("gelu_sqrt2", std::f32::consts::SQRT_2);
3059 self.node("gelu_inner", "Div", vec![x, c], scaled);
3060 } else {
3061 let c = self.scalar("gelu_isqrt2", std::f32::consts::FRAC_1_SQRT_2);
3062 let inputs = if rng.coin() { vec![x, c] } else { vec![c, x] };
3063 self.node("gelu_inner", "Mul", inputs, scaled);
3064 }
3065 let erf = self.value("gelu_erf", &[4]);
3066 self.node("gelu_erf_node", "Erf", vec![scaled], erf);
3067 let one = self.scalar("gelu_one", 1.0);
3068 let plus_one = self.value("gelu_plus_one", &[4]);
3069 let inputs = if rng.coin() {
3070 vec![erf, one]
3071 } else {
3072 vec![one, erf]
3073 };
3074 self.node("gelu_add", "Add", inputs, plus_one);
3075 let output = self.value("gelu_output", &[4]);
3076 let inputs = if rng.coin() {
3077 vec![half, plus_one]
3078 } else {
3079 vec![plus_one, half]
3080 };
3081 self.node("gelu_outer", "Mul", inputs, output);
3082 self.output(output);
3083 }
3084
3085 fn add_attention(&mut self, rng: &mut FusionTestRng) {
3086 let q = self.input("attn_q", &[1, 2, 3, 4]);
3087 let k = self.input("attn_k", &[1, 2, 3, 4]);
3088 let v = self.input("attn_v", &[1, 2, 3, 4]);
3089 let k_side = if rng.coin() {
3090 let kt = self.value("attn_kt", &[1, 2, 4, 3]);
3091 let transpose = self.node("attn_transpose", "Transpose", vec![k], kt);
3092 transpose
3093 .attributes
3094 .insert("perm".into(), Attribute::Ints(vec![0, 1, 3, 2]));
3095 kt
3096 } else {
3097 k
3098 };
3099 let scores = self.value("attn_scores", &[1, 2, 3, 3]);
3100 self.node("attn_score_mm", "MatMul", vec![q, k_side], scores);
3101 let scale_const = if rng.coin() {
3102 self.scalar("attn_divisor", 2.0)
3103 } else {
3104 self.scalar("attn_multiplier", 0.5)
3105 };
3106 let scaled = self.value("attn_scaled", &[1, 2, 3, 3]);
3107 if self
3108 .graph
3109 .value(scale_const)
3110 .name
3111 .as_deref()
3112 .unwrap()
3113 .contains("divisor")
3114 {
3115 self.node("attn_scale", "Div", vec![scores, scale_const], scaled);
3116 } else {
3117 let inputs = if rng.coin() {
3118 vec![scores, scale_const]
3119 } else {
3120 vec![scale_const, scores]
3121 };
3122 self.node("attn_scale", "Mul", inputs, scaled);
3123 }
3124 let softmax_input = if rng.coin() {
3125 let mask = self.input("attn_mask", &[1, 1, 3, 3]);
3126 let masked = self.value("attn_masked", &[1, 2, 3, 3]);
3127 let inputs = if rng.coin() {
3128 vec![scaled, mask]
3129 } else {
3130 vec![mask, scaled]
3131 };
3132 self.node("attn_mask_add", "Add", inputs, masked);
3133 masked
3134 } else {
3135 scaled
3136 };
3137 let probs = self.value("attn_probs", &[1, 2, 3, 3]);
3138 let softmax = self.node("attn_softmax", "Softmax", vec![softmax_input], probs);
3139 softmax.attributes.insert(
3140 "axis".into(),
3141 Attribute::Int(if rng.coin() { -1 } else { 3 }),
3142 );
3143 let output = self.value("attn_output", &[1, 2, 3, 4]);
3144 self.node("attn_output_mm", "MatMul", vec![probs, v], output);
3145 self.output(output);
3146 }
3147
3148 fn add_resumable_chain(&mut self, rng: &mut FusionTestRng) {
3149 let input = self.input("chain_input", &[4]);
3150 let first = self.value("chain_start", &[4]);
3151 self.node("chain_0", "ChainStart", vec![input], first);
3152 let mut value = first;
3153 for index in 1..(4 + rng.usize(6)) {
3154 let output = self.value("chain_link", &[4]);
3155 self.node(format!("chain_{index}"), "ChainLink", vec![value], output);
3156 value = output;
3157 }
3158 self.output(value);
3159 }
3160
3161 fn add_noise(&mut self, rng: &mut FusionTestRng) {
3162 for index in 0..rng.usize(8) {
3163 let input = self.input("noise_input", &[4]);
3164 let output = self.value("noise_output", &[4]);
3165 let op = ["Abs", "Neg", "Identity", "Tanh"][rng.usize(4)];
3166 self.node(format!("noise_{index}"), op, vec![input], output);
3167 self.output(output);
3168 }
3169 }
3170
3171 fn finish(mut self, rng: &mut FusionTestRng) -> Graph {
3172 let mut slots = Vec::with_capacity(self.pending.len());
3176 for _ in 0..self.pending.len() {
3177 slots.push(self.graph.insert_node(Node::new(
3178 NodeId(0),
3179 "IdSeed",
3180 Vec::new(),
3181 Vec::new(),
3182 )));
3183 }
3184 rng.shuffle(&mut slots);
3185 for id in slots {
3186 self.graph.remove_node(id);
3187 }
3188 rng.shuffle(&mut self.pending);
3189 for node in self.pending {
3190 self.graph.insert_node(node);
3191 }
3192 self.graph
3193 }
3194 }
3195
3196 fn randomized_fusion_graph(rng: &mut FusionTestRng) -> Graph {
3197 let mut builder = DifferentialGraphBuilder::new();
3198 builder.add_attention(rng);
3202 builder.add_matmul_bias(rng, true, true);
3203 builder.add_layernorm(rng.coin());
3204 builder.add_gelu(rng);
3205 builder.add_matmul_bias(rng, false, true);
3206
3207 for _ in 0..rng.usize(4) {
3209 match rng.usize(5) {
3210 0 => builder.add_attention(rng),
3211 1 => {
3212 let overlap = rng.coin();
3213 builder.add_matmul_bias(rng, true, overlap);
3214 }
3215 2 => builder.add_layernorm(rng.coin()),
3216 3 => builder.add_gelu(rng),
3217 _ => {
3218 let overlap = rng.coin();
3219 builder.add_matmul_bias(rng, false, overlap);
3220 }
3221 }
3222 }
3223 builder.add_resumable_chain(rng);
3224 builder.add_noise(rng);
3225 builder.finish(rng)
3226 }
3227
3228 fn differential_patterns() -> Vec<FusionPattern> {
3229 let mut patterns = default_fusion_patterns();
3230 patterns.push(
3235 FusionPattern::new("ResumableChain", &["ChainStart", "ChainLink"], "ChainStart")
3236 .with_replacement_domain(""),
3237 );
3238 patterns
3239 }
3240
3241 struct AffectedRevisitCase {
3242 graph: Graph,
3243 lower_start: NodeId,
3244 later_start: NodeId,
3245 first_middle: NodeId,
3246 first_tail: NodeId,
3247 final_tail: NodeId,
3248 }
3249
3250 fn affected_revisit_case(seed: u64) -> AffectedRevisitCase {
3251 let mut rng = FusionTestRng(seed ^ 0xbb67_ae85_84ca_a73b);
3252 let noise_count = 3 + rng.usize(6);
3253 let slot_count = 5 + noise_count;
3254 let later_start = NodeId((slot_count - 1) as u32);
3255 let mut lower_ids: Vec<u32> = (0..later_start.0).collect();
3256 rng.shuffle(&mut lower_ids);
3257 let lower_start = NodeId(lower_ids[0]);
3258 let first_middle = NodeId(lower_ids[1]);
3259 let first_tail = NodeId(lower_ids[2]);
3260 let final_tail = NodeId(lower_ids[3]);
3261
3262 let mut graph = Graph::new();
3263 graph.opset_imports.insert(String::new(), 17);
3264 let lower_input = graph.create_named_value(
3265 format!("lower_input_{seed}"),
3266 DataType::Float32,
3267 static_shape([4]),
3268 );
3269 let later_input = graph.create_named_value(
3270 format!("later_input_{seed}"),
3271 DataType::Float32,
3272 static_shape([4]),
3273 );
3274 graph.add_input(lower_input);
3275 graph.add_input(later_input);
3276 let lower_value = graph.create_named_value(
3277 format!("lower_value_{seed}"),
3278 DataType::Float32,
3279 static_shape([4]),
3280 );
3281 let middle_value = graph.create_named_value(
3282 format!("middle_value_{seed}"),
3283 DataType::Float32,
3284 static_shape([4]),
3285 );
3286 let first_output = graph.create_named_value(
3287 format!("first_output_{seed}"),
3288 DataType::Float32,
3289 static_shape([4]),
3290 );
3291 let first_tail_output = graph.create_named_value(
3292 format!("first_tail_output_{seed}"),
3293 DataType::Float32,
3294 static_shape([4]),
3295 );
3296 let final_output = graph.create_named_value(
3297 format!("final_output_{seed}"),
3298 DataType::Float32,
3299 static_shape([4]),
3300 );
3301 graph.add_output(final_output);
3302
3303 let mut placements = vec![
3304 (
3305 lower_start,
3306 Node::new(
3307 NodeId(0),
3308 "AdversaryStart",
3309 vec![Some(lower_input)],
3310 vec![lower_value],
3311 ),
3312 ),
3313 (
3314 later_start,
3315 Node::new(
3316 NodeId(0),
3317 "AdversaryStart",
3318 vec![Some(later_input)],
3319 vec![middle_value],
3320 ),
3321 ),
3322 (
3323 first_middle,
3324 Node::new(
3325 NodeId(0),
3326 "AdversaryMiddle",
3327 vec![Some(middle_value)],
3328 vec![first_output],
3329 ),
3330 ),
3331 (
3332 first_tail,
3333 Node::new(
3334 NodeId(0),
3335 "AdversaryTail",
3336 vec![Some(first_output), Some(lower_value)],
3337 vec![first_tail_output],
3338 ),
3339 ),
3340 (
3341 final_tail,
3342 Node::new(
3343 NodeId(0),
3344 "AdversaryTail",
3345 vec![Some(first_tail_output)],
3346 vec![final_output],
3347 ),
3348 ),
3349 ];
3350 let core_ids = HashSet::from([
3351 lower_start,
3352 later_start,
3353 first_middle,
3354 first_tail,
3355 final_tail,
3356 ]);
3357 let mut noise_index = 0;
3358 for raw_id in 0..slot_count as u32 {
3359 let id = NodeId(raw_id);
3360 if core_ids.contains(&id) {
3361 continue;
3362 }
3363 let input = graph.create_named_value(
3364 format!("noise_input_{seed}_{noise_index}"),
3365 DataType::Float32,
3366 static_shape([4]),
3367 );
3368 let output = graph.create_named_value(
3369 format!("noise_output_{seed}_{noise_index}"),
3370 DataType::Float32,
3371 static_shape([4]),
3372 );
3373 graph.add_input(input);
3374 graph.add_output(output);
3375 placements.push((
3376 id,
3377 Node::new(
3378 NodeId(0),
3379 ["Abs", "Neg", "Identity", "Tanh"][rng.usize(4)],
3380 vec![Some(input)],
3381 vec![output],
3382 ),
3383 ));
3384 noise_index += 1;
3385 }
3386
3387 rng.shuffle(&mut placements);
3388 for _ in 0..slot_count {
3389 graph.insert_node(Node::new(NodeId(0), "IdSeed", Vec::new(), Vec::new()));
3390 }
3391 for &(target, _) in placements.iter().rev() {
3392 graph.remove_node(target);
3393 }
3394 for (expected, node) in placements {
3395 assert_eq!(graph.insert_node(node), expected);
3396 }
3397
3398 AffectedRevisitCase {
3399 graph,
3400 lower_start,
3401 later_start,
3402 first_middle,
3403 first_tail,
3404 final_tail,
3405 }
3406 }
3407
3408 #[test]
3409 fn affected_candidate_starts_revisits_newly_eligible_lower_ids() {
3410 const TRIALS: usize = 5_000;
3411 let pattern = FusionPattern::new(
3412 "AffectedBehindCursor",
3413 &["AdversaryStart", "AdversaryMiddle", "AdversaryTail"],
3414 "AdversaryMiddle",
3415 )
3416 .with_replacement_domain("");
3417 let patterns = vec![pattern.clone()];
3418 let mut reclaimable_low_slot_trials = 0;
3419 let mut affected_scheduled = 0;
3420 let mut affected_revisit_hits = 0;
3421
3422 for trial in 0..TRIALS {
3423 let case = affected_revisit_case(trial as u64);
3424 assert!(case.graph.validate().is_ok(), "invalid trial {trial}");
3425 assert!(case.lower_start.0 < case.later_start.0);
3426 assert!(case.first_middle.0 < case.later_start.0);
3427 assert!(case.first_tail.0 < case.later_start.0);
3428 assert!(
3429 pattern
3430 .try_match_at(&case.graph, case.lower_start)
3431 .is_none()
3432 );
3433 let first_match = pattern
3434 .try_match_at(&case.graph, case.later_start)
3435 .expect("later start must be the first eligible match");
3436 assert_eq!(
3437 first_match.nodes,
3438 vec![case.later_start, case.first_middle, case.first_tail]
3439 );
3440
3441 let mut reclaim_probe = case.graph.clone();
3444 let first_fused = pattern
3445 .apply_fusion_returning_id(&mut reclaim_probe, &first_match)
3446 .unwrap();
3447 assert_eq!(first_fused, case.later_start);
3448 let probe_id = reclaim_probe.insert_node(Node::new(
3449 NodeId(0),
3450 "ReclaimProbe",
3451 Vec::new(),
3452 Vec::new(),
3453 ));
3454 assert_eq!(probe_id, case.first_middle);
3455 reclaimable_low_slot_trials += 1;
3456
3457 let mut reference = case.graph.clone();
3458 run_restart_reference(&patterns, &mut reference);
3459 let mut actual = case.graph;
3460 let mut lower_was_scheduled = false;
3461 let mut trial_hits = 0;
3462 OpFusion::with_patterns(patterns.clone())
3463 .run_with_fusion_observer(
3464 &mut actual,
3465 |name, source, start, matched, affected, fused_id| {
3466 if name != "AffectedBehindCursor" {
3467 return;
3468 }
3469 assert_eq!(
3470 fused_id, matched[0],
3471 "replacement must reuse the just-freed match-start slot"
3472 );
3473 if start == case.later_start {
3474 assert_eq!(source, ScanCandidateSource::Initial);
3475 assert_eq!(
3476 matched,
3477 &[case.later_start, case.first_middle, case.first_tail]
3478 );
3479 assert!(affected.contains(&case.lower_start));
3480 assert_ne!(fused_id, case.lower_start);
3481 lower_was_scheduled = true;
3482 affected_scheduled += 1;
3483 } else if start == case.lower_start {
3484 assert!(lower_was_scheduled);
3485 assert_eq!(source, ScanCandidateSource::Revisit);
3486 assert_eq!(
3487 matched,
3488 &[case.lower_start, case.later_start, case.final_tail]
3489 );
3490 trial_hits += 1;
3491 affected_revisit_hits += 1;
3492 }
3493 },
3494 )
3495 .unwrap();
3496
3497 assert_eq!(trial_hits, 1, "affected revisit not hit on trial {trial}");
3498 assert!(actual.validate().is_ok(), "invalid result on trial {trial}");
3499 assert_fusion_graphs_byte_identical(actual, reference, trial);
3500 }
3501
3502 assert_eq!(reclaimable_low_slot_trials, TRIALS);
3503 assert_eq!(affected_scheduled, TRIALS);
3504 assert_eq!(affected_revisit_hits, TRIALS);
3505 eprintln!(
3506 "affected behind-cursor revisit hits: {affected_revisit_hits}/{TRIALS} \
3507 ({}%); reclaimable lower slots present: {reclaimable_low_slot_trials}/{TRIALS}",
3508 affected_revisit_hits * 100 / TRIALS
3509 );
3510 }
3511
3512 fn assert_overlapping_structural_candidates(graph: &Graph, patterns: &[FusionPattern]) {
3513 let mut saw_gemm_overlap = false;
3514 let mut saw_bias_overlap = false;
3515 for (add_id, add) in graph.nodes.iter().filter(|(_, node)| node.op_type == "Add") {
3516 let starts: Vec<_> = add
3517 .input_values()
3518 .filter_map(|value| graph.value(value).producer)
3519 .filter(|&producer| graph.node(producer).op_type == "MatMul")
3520 .collect();
3521 if starts.len() != 2 {
3522 continue;
3523 }
3524 let has_relu = graph
3525 .successors(add_id)
3526 .iter()
3527 .any(|&successor| graph.node(successor).op_type == "Relu");
3528 let pattern = if has_relu { &patterns[1] } else { &patterns[4] };
3529 assert!(
3530 starts
3531 .iter()
3532 .all(|&start| pattern.try_match_at(graph, start).is_some()),
3533 "both MatMul starts must be eligible for the shared structural tail"
3534 );
3535 saw_gemm_overlap |= has_relu;
3536 saw_bias_overlap |= !has_relu;
3537 }
3538 assert!(
3539 saw_gemm_overlap,
3540 "missing shared MatMul+Add+Relu candidates"
3541 );
3542 assert!(saw_bias_overlap, "missing shared MatMul+Add candidates");
3543 }
3544
3545 #[test]
3546 fn resumable_scan_matches_restart_reference_on_randomized_graphs() {
3547 const TRIALS: usize = 5_000;
3548 const REGISTERED_PATTERNS: usize = 5;
3549 let mut rng = FusionTestRng(0x6a09_e667_f3bc_c909);
3550 let patterns = differential_patterns();
3551 assert_eq!(default_fusion_patterns().len(), REGISTERED_PATTERNS);
3552
3553 let mut saw_non_topological_ids = false;
3554 for trial in 0..TRIALS {
3555 let mut graph = randomized_fusion_graph(&mut rng);
3556 assert!(
3557 graph.validate().is_ok(),
3558 "invalid input graph on trial {trial}"
3559 );
3560
3561 for pattern in &patterns[..REGISTERED_PATTERNS] {
3562 assert!(
3563 pattern.find_match(&graph).is_some(),
3564 "{} was not exercised on trial {trial}",
3565 pattern.pattern_name()
3566 );
3567 }
3568 assert_overlapping_structural_candidates(&graph, &patterns);
3569 assert!(
3570 graph
3571 .nodes
3572 .values()
3573 .filter(|node| node.op_type == "ChainLink")
3574 .count()
3575 >= 3,
3576 "chained replacement adversary must require multiple revisits"
3577 );
3578 saw_non_topological_ids |=
3579 graph.topological_order().unwrap() != graph.nodes.keys().collect::<Vec<_>>();
3580
3581 let mut reference = graph.clone();
3582 run_restart_reference(&patterns, &mut reference);
3583 OpFusion::with_patterns(patterns.clone())
3584 .run(&mut graph, &PassContext::new())
3585 .unwrap();
3586
3587 assert!(
3588 graph.validate().is_ok(),
3589 "invalid result graph on trial {trial}"
3590 );
3591 assert!(
3592 graph.nodes.values().all(|node| node.op_type != "ChainLink"),
3593 "resumable chain did not reach its fixpoint on trial {trial}"
3594 );
3595 assert_fusion_graphs_byte_identical(graph, reference, trial);
3596 }
3597 assert!(
3598 saw_non_topological_ids,
3599 "randomized insertion must decouple NodeId from topological order"
3600 );
3601 }
3602}