Skip to main content

nxpu_analysis/
fusion.rs

1//! Kernel fusion: merges adjacent classified patterns into fused operations.
2//!
3//! After pattern classification, `fuse_patterns()` scans adjacent patterns
4//! and merges compatible sequences (e.g., Conv+BatchNorm, Add+ReLU).
5
6use crate::analyze::KernelPattern;
7
8/// Returns the output tensor names of a pattern.
9pub fn output_tensor_names(pattern: &KernelPattern) -> Vec<&str> {
10    match pattern {
11        KernelPattern::MatMul { output, .. }
12        | KernelPattern::QuantizedMatMul { output, .. }
13        | KernelPattern::ElementWise { output, .. }
14        | KernelPattern::Conv2D { output, .. }
15        | KernelPattern::Pool { output, .. }
16        | KernelPattern::Activation { output, .. }
17        | KernelPattern::Reduce { output, .. }
18        | KernelPattern::Transpose { output, .. }
19        | KernelPattern::Reshape { output, .. }
20        | KernelPattern::Normalization { output, .. }
21        | KernelPattern::Concat { output, .. }
22        | KernelPattern::Attention { output, .. }
23        | KernelPattern::Gather { output, .. }
24        | KernelPattern::Scatter { output, .. }
25        | KernelPattern::ElementWiseChain { output, .. } => vec![output.name.as_str()],
26        KernelPattern::Split { outputs, .. } => outputs.iter().map(|t| t.name.as_str()).collect(),
27        KernelPattern::Unknown { .. } => vec![],
28    }
29}
30
31/// Returns the input tensor names of a pattern.
32pub fn input_tensor_names(pattern: &KernelPattern) -> Vec<&str> {
33    match pattern {
34        KernelPattern::MatMul { inputs, .. } => inputs.iter().map(|t| t.name.as_str()).collect(),
35        // The scale and the addend are inputs of the graph like any other:
36        // both arrive per dispatch, so either could be the output of a kernel
37        // that ran before this one.
38        KernelPattern::QuantizedMatMul {
39            input,
40            weight,
41            scale,
42            bias,
43            ..
44        } => [input, weight, scale]
45            .into_iter()
46            .chain(bias.as_ref())
47            .map(|t| t.name.as_str())
48            .collect(),
49        KernelPattern::ElementWise { inputs, .. } => {
50            inputs.iter().map(|t| t.name.as_str()).collect()
51        }
52        KernelPattern::Conv2D { input, weight, .. } => {
53            vec![input.name.as_str(), weight.name.as_str()]
54        }
55        KernelPattern::Pool { input, .. }
56        | KernelPattern::Activation { input, .. }
57        | KernelPattern::Reduce { input, .. }
58        | KernelPattern::Transpose { input, .. }
59        | KernelPattern::Reshape { input, .. }
60        | KernelPattern::Split { input, .. } => vec![input.name.as_str()],
61        KernelPattern::Normalization {
62            input, scale, bias, ..
63        } => vec![input.name.as_str(), scale.name.as_str(), bias.name.as_str()],
64        KernelPattern::Concat { inputs, .. } => inputs.iter().map(|t| t.name.as_str()).collect(),
65        KernelPattern::Attention {
66            query, key, value, ..
67        } => vec![query.name.as_str(), key.name.as_str(), value.name.as_str()],
68        KernelPattern::Gather { data, indices, .. } => {
69            vec![data.name.as_str(), indices.name.as_str()]
70        }
71        KernelPattern::Scatter {
72            data,
73            indices,
74            updates,
75            ..
76        } => vec![
77            data.name.as_str(),
78            indices.name.as_str(),
79            updates.name.as_str(),
80        ],
81        // The scalars are not tensors, so they cannot connect one kernel's
82        // output to the next one's input and have no place in this list.
83        KernelPattern::ElementWiseChain { base, steps, .. } => std::iter::once(base.name.as_str())
84            .chain(steps.iter().filter_map(|s| match &s.operand {
85                crate::analyze::ChainOperand::Tensor(t) => Some(t.name.as_str()),
86                crate::analyze::ChainOperand::Scalar(_) => None,
87            }))
88            .collect(),
89        KernelPattern::Unknown { .. } => vec![],
90    }
91}
92
93/// Returns `true` if any output tensor name of `producer` matches any input
94/// tensor name of `consumer`, indicating data flows between them.
95pub fn tensors_connect(producer: &KernelPattern, consumer: &KernelPattern) -> bool {
96    let outputs = output_tensor_names(producer);
97    let inputs = input_tensor_names(consumer);
98    outputs.iter().any(|o| inputs.contains(o))
99}
100
101/// Fused activation function appended to a base operation.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum FusedActivation {
104    None,
105    Relu,
106    Sigmoid,
107    Tanh,
108}
109
110impl std::fmt::Display for FusedActivation {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        f.write_str(match self {
113            Self::None => "None",
114            Self::Relu => "Relu",
115            Self::Sigmoid => "Sigmoid",
116            Self::Tanh => "Tanh",
117        })
118    }
119}
120
121/// Try to map an `ActivationOp` to a `FusedActivation`.
122/// Returns `None` for activations that cannot be fused (e.g. Softmax).
123fn try_fuse_activation(op: &crate::analyze::ActivationOp) -> Option<FusedActivation> {
124    match op {
125        crate::analyze::ActivationOp::Relu => Some(FusedActivation::Relu),
126        crate::analyze::ActivationOp::Sigmoid => Some(FusedActivation::Sigmoid),
127        crate::analyze::ActivationOp::Tanh => Some(FusedActivation::Tanh),
128        crate::analyze::ActivationOp::Softmax => None,
129        // Complex activations are not fusible.
130        crate::analyze::ActivationOp::Gelu => None,
131        crate::analyze::ActivationOp::Silu => None,
132        crate::analyze::ActivationOp::Mish => None,
133    }
134}
135
136/// A pattern that may be fused from one or more classified patterns.
137#[derive(Debug, Clone)]
138pub enum FusedPattern {
139    /// A single unfused pattern.
140    Single(KernelPattern),
141    /// Conv2D followed by BatchNormalization.
142    ConvBatchNorm {
143        conv: KernelPattern,
144        norm: Box<KernelPattern>,
145    },
146    /// A base pattern followed by an activation function.
147    WithActivation {
148        base: Box<FusedPattern>,
149        activation: FusedActivation,
150        /// The original activation pattern (preserved for tensor connectivity).
151        activation_pattern: Box<KernelPattern>,
152    },
153    /// MatMul followed by Add (bias) — maps to ONNX Gemm.
154    MatMulBias {
155        matmul: KernelPattern,
156        bias_add: Box<KernelPattern>,
157    },
158}
159
160impl std::fmt::Display for FusedPattern {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        match self {
163            Self::Single(p) => write!(f, "{p}"),
164            Self::ConvBatchNorm { conv, .. } => write!(f, "{conv}+BatchNorm"),
165            Self::WithActivation {
166                base, activation, ..
167            } => write!(f, "{base}+{activation}"),
168            Self::MatMulBias { .. } => write!(f, "Gemm"),
169        }
170    }
171}
172
173impl FusedPattern {
174    /// Return a reference to the primary pattern (for lowering).
175    pub fn primary_pattern(&self) -> &KernelPattern {
176        match self {
177            FusedPattern::Single(p) => p,
178            FusedPattern::ConvBatchNorm { conv, .. } => conv,
179            FusedPattern::WithActivation { base, .. } => base.primary_pattern(),
180            FusedPattern::MatMulBias { matmul, .. } => matmul,
181        }
182    }
183
184    /// Return a reference to the last (output-producing) pattern in the fused
185    /// chain. For connectivity checks, this determines which tensor name
186    /// flows out of the fused operation.
187    fn output_pattern(&self) -> &KernelPattern {
188        match self {
189            FusedPattern::Single(p) => p,
190            FusedPattern::ConvBatchNorm { norm, .. } => norm,
191            FusedPattern::WithActivation {
192                activation_pattern, ..
193            } => activation_pattern,
194            FusedPattern::MatMulBias { bias_add, .. } => bias_add,
195        }
196    }
197
198    /// Return the fused activation, if any.
199    pub fn fused_activation(&self) -> FusedActivation {
200        match self {
201            FusedPattern::WithActivation { activation, .. } => *activation,
202            _ => FusedActivation::None,
203        }
204    }
205}
206
207/// Greedy adjacent fusion of classified kernel patterns.
208///
209/// Scans the pattern list and merges compatible adjacent pairs:
210/// - Conv2D + Normalization → ConvBatchNorm
211/// - MatMul + ElementWise(Add) → MatMulBias (Gemm)
212/// - Any + Activation(Relu/Sigmoid/Tanh) → WithActivation { base, activation }
213pub fn fuse_patterns(patterns: Vec<KernelPattern>) -> Vec<(FusedPattern, usize)> {
214    let mut result: Vec<(FusedPattern, usize)> = Vec::new();
215    let mut iter = patterns.into_iter().enumerate().peekable();
216
217    while let Some((idx, pattern)) = iter.next() {
218        // Unknown patterns pass through as Single — skip fusion attempts.
219        if matches!(&pattern, KernelPattern::Unknown { .. }) {
220            result.push((FusedPattern::Single(pattern), idx));
221            continue;
222        }
223
224        let fused = match &pattern {
225            KernelPattern::Conv2D { .. } => {
226                if let Some((_, next)) = iter.peek() {
227                    if matches!(next, KernelPattern::Normalization { .. })
228                        && tensors_connect(&pattern, next)
229                    {
230                        let (_, norm) = iter.next().unwrap();
231                        FusedPattern::ConvBatchNorm {
232                            conv: pattern,
233                            norm: Box::new(norm),
234                        }
235                    } else {
236                        FusedPattern::Single(pattern)
237                    }
238                } else {
239                    FusedPattern::Single(pattern)
240                }
241            }
242            KernelPattern::MatMul { .. } => {
243                if let Some((_, next)) = iter.peek() {
244                    let is_add = matches!(
245                        next,
246                        KernelPattern::ElementWise {
247                            op: crate::analyze::ElementWiseOp::Add,
248                            ..
249                        }
250                    );
251                    if is_add && tensors_connect(&pattern, next) {
252                        let (_, bias_add) = iter.next().unwrap();
253                        FusedPattern::MatMulBias {
254                            matmul: pattern,
255                            bias_add: Box::new(bias_add),
256                        }
257                    } else {
258                        FusedPattern::Single(pattern)
259                    }
260                } else {
261                    FusedPattern::Single(pattern)
262                }
263            }
264            _ => FusedPattern::Single(pattern),
265        };
266
267        // Try to fuse a trailing activation.
268        let fused = if let Some((_, next)) = iter.peek() {
269            if let KernelPattern::Activation { op, .. } = next {
270                if let Some(fused_act) = try_fuse_activation(op) {
271                    if tensors_connect(fused.output_pattern(), next) {
272                        let (_, act_pattern) = iter.next().unwrap();
273                        FusedPattern::WithActivation {
274                            base: Box::new(fused),
275                            activation: fused_act,
276                            activation_pattern: Box::new(act_pattern),
277                        }
278                    } else {
279                        fused
280                    }
281                } else {
282                    fused
283                }
284            } else {
285                fused
286            }
287        } else {
288            fused
289        };
290
291        result.push((fused, idx));
292    }
293
294    result
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::analyze::NormType;
301    use crate::analyze::data_type;
302    use crate::analyze::*;
303
304    fn dummy_handle() -> nxpu_ir::Handle<nxpu_ir::GlobalVariable> {
305        let mut arena = nxpu_ir::Arena::new();
306        arena.append(nxpu_ir::GlobalVariable {
307            name: None,
308            space: nxpu_ir::AddressSpace::Uniform,
309            binding: None,
310            ty: {
311                let mut types = nxpu_ir::UniqueArena::new();
312                types.insert(nxpu_ir::Type {
313                    name: None,
314                    inner: nxpu_ir::TypeInner::Scalar(nxpu_ir::Scalar::F32),
315                })
316            },
317            init: None,
318            layout: None,
319        })
320    }
321
322    fn make_tensor(name: &str, role: TensorRole) -> TensorBinding {
323        TensorBinding {
324            handle: dummy_handle(),
325            name: name.into(),
326            elem_type: data_type::FLOAT,
327            role,
328        }
329    }
330
331    #[test]
332    fn single_pattern_no_fusion() {
333        let patterns = vec![KernelPattern::ElementWise {
334            op: ElementWiseOp::Add,
335            inputs: [
336                make_tensor("a", TensorRole::Input),
337                make_tensor("b", TensorRole::Input),
338            ],
339            output: make_tensor("c", TensorRole::Output),
340            dim_name: "N".into(),
341        }];
342
343        let fused = fuse_patterns(patterns);
344        assert_eq!(fused.len(), 1);
345        let (ref fp, idx) = fused[0];
346        assert!(matches!(fp, FusedPattern::Single(_)));
347        assert_eq!(idx, 0);
348    }
349
350    #[test]
351    fn conv_batchnorm_fusion() {
352        let patterns = vec![
353            KernelPattern::Conv2D {
354                input: make_tensor("x", TensorRole::Input),
355                weight: make_tensor("w", TensorRole::Input),
356                output: make_tensor("conv_out", TensorRole::Output),
357                shape: Conv2DShape {
358                    batch: "N".into(),
359                    channels_in: "IC".into(),
360                    channels_out: "OC".into(),
361                    height: "H".into(),
362                    width: "W".into(),
363                    kernel_h: "KH".into(),
364                    kernel_w: "KW".into(),
365                    kernel_h_val: 3,
366                    kernel_w_val: 3,
367                    stride_h: 1,
368                    stride_w: 1,
369                    pad_h: 0,
370                    pad_w: 0,
371                    groups: 1,
372                    dilation_h: 1,
373                    dilation_w: 1,
374                },
375                bias: None,
376                activation: None,
377            },
378            KernelPattern::Normalization {
379                input: make_tensor("conv_out", TensorRole::Input),
380                scale: make_tensor("gamma", TensorRole::Input),
381                bias: make_tensor("beta", TensorRole::Input),
382                output: make_tensor("bn_out", TensorRole::Output),
383                epsilon: 1e-5,
384                norm_type: NormType::Batch,
385            },
386        ];
387
388        let fused = fuse_patterns(patterns);
389        assert_eq!(fused.len(), 1);
390        let (ref fp, idx) = fused[0];
391        assert!(matches!(fp, FusedPattern::ConvBatchNorm { .. }));
392        assert_eq!(idx, 0);
393    }
394
395    #[test]
396    fn add_relu_fusion() {
397        let patterns = vec![
398            KernelPattern::ElementWise {
399                op: ElementWiseOp::Add,
400                inputs: [
401                    make_tensor("a", TensorRole::Input),
402                    make_tensor("b", TensorRole::Input),
403                ],
404                output: make_tensor("c", TensorRole::Output),
405                dim_name: "N".into(),
406            },
407            KernelPattern::Activation {
408                op: ActivationOp::Relu,
409                input: make_tensor("c", TensorRole::Input),
410                output: make_tensor("d", TensorRole::Output),
411                dim_name: "N".into(),
412            },
413        ];
414
415        let fused = fuse_patterns(patterns);
416        assert_eq!(fused.len(), 1);
417        let (ref fp, idx) = fused[0];
418        assert!(matches!(
419            fp,
420            FusedPattern::WithActivation {
421                activation: FusedActivation::Relu,
422                ..
423            }
424        ));
425        assert_eq!(idx, 0);
426    }
427
428    #[test]
429    fn conv_bn_relu_fusion() {
430        let patterns = vec![
431            KernelPattern::Conv2D {
432                input: make_tensor("x", TensorRole::Input),
433                weight: make_tensor("w", TensorRole::Input),
434                output: make_tensor("conv_out", TensorRole::Output),
435                shape: Conv2DShape {
436                    batch: "N".into(),
437                    channels_in: "IC".into(),
438                    channels_out: "OC".into(),
439                    height: "H".into(),
440                    width: "W".into(),
441                    kernel_h: "KH".into(),
442                    kernel_w: "KW".into(),
443                    kernel_h_val: 3,
444                    kernel_w_val: 3,
445                    stride_h: 1,
446                    stride_w: 1,
447                    pad_h: 0,
448                    pad_w: 0,
449                    groups: 1,
450                    dilation_h: 1,
451                    dilation_w: 1,
452                },
453                bias: None,
454                activation: None,
455            },
456            KernelPattern::Normalization {
457                input: make_tensor("conv_out", TensorRole::Input),
458                scale: make_tensor("gamma", TensorRole::Input),
459                bias: make_tensor("beta", TensorRole::Input),
460                output: make_tensor("bn_out", TensorRole::Output),
461                epsilon: 1e-5,
462                norm_type: NormType::Batch,
463            },
464            KernelPattern::Activation {
465                op: ActivationOp::Relu,
466                input: make_tensor("bn_out", TensorRole::Input),
467                output: make_tensor("relu_out", TensorRole::Output),
468                dim_name: "N".into(),
469            },
470        ];
471
472        let fused = fuse_patterns(patterns);
473        assert_eq!(fused.len(), 1);
474        let (ref fp, idx) = fused[0];
475        assert_eq!(idx, 0);
476        match fp {
477            FusedPattern::WithActivation {
478                base,
479                activation: FusedActivation::Relu,
480                ..
481            } => {
482                assert!(matches!(**base, FusedPattern::ConvBatchNorm { .. }));
483            }
484            other => panic!("expected WithActivation(ConvBatchNorm, Relu), got {other:?}"),
485        }
486    }
487
488    #[test]
489    fn tanh_activation_now_fused() {
490        let patterns = vec![
491            KernelPattern::ElementWise {
492                op: ElementWiseOp::Add,
493                inputs: [
494                    make_tensor("a", TensorRole::Input),
495                    make_tensor("b", TensorRole::Input),
496                ],
497                output: make_tensor("c", TensorRole::Output),
498                dim_name: "N".into(),
499            },
500            KernelPattern::Activation {
501                op: ActivationOp::Tanh,
502                input: make_tensor("c", TensorRole::Input),
503                output: make_tensor("d", TensorRole::Output),
504                dim_name: "N".into(),
505            },
506        ];
507
508        let fused = fuse_patterns(patterns);
509        // Tanh is now fused.
510        assert_eq!(fused.len(), 1);
511        assert!(matches!(
512            &fused[0].0,
513            FusedPattern::WithActivation {
514                activation: FusedActivation::Tanh,
515                ..
516            }
517        ));
518    }
519
520    #[test]
521    fn no_fusion_for_softmax_activation() {
522        let patterns = vec![
523            KernelPattern::ElementWise {
524                op: ElementWiseOp::Add,
525                inputs: [
526                    make_tensor("a", TensorRole::Input),
527                    make_tensor("b", TensorRole::Input),
528                ],
529                output: make_tensor("c", TensorRole::Output),
530                dim_name: "N".into(),
531            },
532            KernelPattern::Activation {
533                op: ActivationOp::Softmax,
534                input: make_tensor("c", TensorRole::Input),
535                output: make_tensor("d", TensorRole::Output),
536                dim_name: "N".into(),
537            },
538        ];
539
540        let fused = fuse_patterns(patterns);
541        // Softmax is not fusible — remains as 2 separate patterns.
542        assert_eq!(fused.len(), 2);
543        assert_eq!(fused[0].1, 0);
544        assert_eq!(fused[1].1, 1);
545    }
546
547    #[test]
548    fn matmul_add_fusion_to_gemm() {
549        let patterns = vec![
550            KernelPattern::MatMul {
551                inputs: [
552                    make_tensor("A", TensorRole::Input),
553                    make_tensor("B", TensorRole::Input),
554                ],
555                output: make_tensor("mm_out", TensorRole::Output),
556                shape: MatMulShape {
557                    m: "M".into(),
558                    n: "N".into(),
559                    k: "K".into(),
560                },
561            },
562            KernelPattern::ElementWise {
563                op: ElementWiseOp::Add,
564                inputs: [
565                    make_tensor("mm_out", TensorRole::Input),
566                    make_tensor("bias", TensorRole::Input),
567                ],
568                output: make_tensor("out", TensorRole::Output),
569                dim_name: "N".into(),
570            },
571        ];
572
573        let fused = fuse_patterns(patterns);
574        assert_eq!(fused.len(), 1);
575        assert!(matches!(&fused[0].0, FusedPattern::MatMulBias { .. }));
576    }
577
578    #[test]
579    fn add_sigmoid_fusion() {
580        let patterns = vec![
581            KernelPattern::ElementWise {
582                op: ElementWiseOp::Add,
583                inputs: [
584                    make_tensor("a", TensorRole::Input),
585                    make_tensor("b", TensorRole::Input),
586                ],
587                output: make_tensor("c", TensorRole::Output),
588                dim_name: "N".into(),
589            },
590            KernelPattern::Activation {
591                op: ActivationOp::Sigmoid,
592                input: make_tensor("c", TensorRole::Input),
593                output: make_tensor("d", TensorRole::Output),
594                dim_name: "N".into(),
595            },
596        ];
597
598        let fused = fuse_patterns(patterns);
599        assert_eq!(fused.len(), 1);
600        assert!(matches!(
601            &fused[0].0,
602            FusedPattern::WithActivation {
603                activation: FusedActivation::Sigmoid,
604                ..
605            }
606        ));
607    }
608
609    #[test]
610    fn tensors_connect_matching_names() {
611        let producer = KernelPattern::ElementWise {
612            op: ElementWiseOp::Add,
613            inputs: [
614                make_tensor("a", TensorRole::Input),
615                make_tensor("b", TensorRole::Input),
616            ],
617            output: make_tensor("c", TensorRole::Output),
618            dim_name: "N".into(),
619        };
620        let consumer = KernelPattern::Activation {
621            op: ActivationOp::Relu,
622            input: make_tensor("c", TensorRole::Input),
623            output: make_tensor("d", TensorRole::Output),
624            dim_name: "N".into(),
625        };
626        assert!(tensors_connect(&producer, &consumer));
627    }
628
629    #[test]
630    fn tensors_connect_mismatched_names() {
631        let producer = KernelPattern::ElementWise {
632            op: ElementWiseOp::Add,
633            inputs: [
634                make_tensor("a", TensorRole::Input),
635                make_tensor("b", TensorRole::Input),
636            ],
637            output: make_tensor("c", TensorRole::Output),
638            dim_name: "N".into(),
639        };
640        let consumer = KernelPattern::Activation {
641            op: ActivationOp::Relu,
642            input: make_tensor("x", TensorRole::Input),
643            output: make_tensor("y", TensorRole::Output),
644            dim_name: "N".into(),
645        };
646        assert!(!tensors_connect(&producer, &consumer));
647    }
648
649    #[test]
650    fn no_fusion_mismatched_tensor_names() {
651        // Add outputs "c" but Relu consumes "x" — should NOT fuse.
652        let patterns = vec![
653            KernelPattern::ElementWise {
654                op: ElementWiseOp::Add,
655                inputs: [
656                    make_tensor("a", TensorRole::Input),
657                    make_tensor("b", TensorRole::Input),
658                ],
659                output: make_tensor("c", TensorRole::Output),
660                dim_name: "N".into(),
661            },
662            KernelPattern::Activation {
663                op: ActivationOp::Relu,
664                input: make_tensor("x", TensorRole::Input),
665                output: make_tensor("y", TensorRole::Output),
666                dim_name: "N".into(),
667            },
668        ];
669
670        let fused = fuse_patterns(patterns);
671        assert_eq!(fused.len(), 2);
672    }
673
674    // ---- Display tests ----
675
676    #[test]
677    fn display_fused_pattern_single() {
678        let pattern = FusedPattern::Single(KernelPattern::ElementWise {
679            op: ElementWiseOp::Add,
680            inputs: [
681                make_tensor("a", TensorRole::Input),
682                make_tensor("b", TensorRole::Input),
683            ],
684            output: make_tensor("c", TensorRole::Output),
685            dim_name: "N".into(),
686        });
687        let s = format!("{pattern}");
688        assert_ne!(s.len(), 0);
689    }
690
691    #[test]
692    fn display_fused_pattern_conv_batchnorm() {
693        let pattern = FusedPattern::ConvBatchNorm {
694            conv: KernelPattern::Conv2D {
695                input: make_tensor("x", TensorRole::Input),
696                weight: make_tensor("w", TensorRole::Input),
697                output: make_tensor("conv_out", TensorRole::Output),
698                shape: Conv2DShape {
699                    batch: "N".into(),
700                    channels_in: "IC".into(),
701                    channels_out: "OC".into(),
702                    height: "H".into(),
703                    width: "W".into(),
704                    kernel_h: "KH".into(),
705                    kernel_w: "KW".into(),
706                    kernel_h_val: 3,
707                    kernel_w_val: 3,
708                    stride_h: 1,
709                    stride_w: 1,
710                    pad_h: 0,
711                    pad_w: 0,
712                    groups: 1,
713                    dilation_h: 1,
714                    dilation_w: 1,
715                },
716                bias: None,
717                activation: None,
718            },
719            norm: Box::new(KernelPattern::Normalization {
720                input: make_tensor("conv_out", TensorRole::Input),
721                scale: make_tensor("gamma", TensorRole::Input),
722                bias: make_tensor("beta", TensorRole::Input),
723                output: make_tensor("bn_out", TensorRole::Output),
724                epsilon: 1e-5,
725                norm_type: crate::NormType::Batch,
726            }),
727        };
728        let s = format!("{pattern}");
729        assert!(s.contains("BatchNorm"), "got: {s}");
730    }
731
732    #[test]
733    fn display_fused_pattern_matmul_bias() {
734        let pattern = FusedPattern::MatMulBias {
735            matmul: KernelPattern::MatMul {
736                inputs: [
737                    make_tensor("A", TensorRole::Input),
738                    make_tensor("B", TensorRole::Input),
739                ],
740                output: make_tensor("mm_out", TensorRole::Output),
741                shape: MatMulShape {
742                    m: "M".into(),
743                    n: "N".into(),
744                    k: "K".into(),
745                },
746            },
747            bias_add: Box::new(KernelPattern::ElementWise {
748                op: ElementWiseOp::Add,
749                inputs: [
750                    make_tensor("mm_out", TensorRole::Input),
751                    make_tensor("bias", TensorRole::Input),
752                ],
753                output: make_tensor("out", TensorRole::Output),
754                dim_name: "N".into(),
755            }),
756        };
757        let s = format!("{pattern}");
758        assert_eq!(s, "Gemm");
759    }
760
761    #[test]
762    fn display_fused_pattern_with_activation() {
763        let pattern = FusedPattern::WithActivation {
764            base: Box::new(FusedPattern::Single(KernelPattern::ElementWise {
765                op: ElementWiseOp::Add,
766                inputs: [
767                    make_tensor("a", TensorRole::Input),
768                    make_tensor("b", TensorRole::Input),
769                ],
770                output: make_tensor("c", TensorRole::Output),
771                dim_name: "N".into(),
772            })),
773            activation: FusedActivation::Relu,
774            activation_pattern: Box::new(KernelPattern::Activation {
775                op: ActivationOp::Relu,
776                input: make_tensor("c", TensorRole::Input),
777                output: make_tensor("d", TensorRole::Output),
778                dim_name: "N".into(),
779            }),
780        };
781        let s = format!("{pattern}");
782        assert!(s.contains("Relu"), "got: {s}");
783    }
784
785    // ---- primary_pattern / output_pattern / fused_activation tests ----
786
787    #[test]
788    fn primary_pattern_for_all_variants() {
789        let add = KernelPattern::ElementWise {
790            op: ElementWiseOp::Add,
791            inputs: [
792                make_tensor("a", TensorRole::Input),
793                make_tensor("b", TensorRole::Input),
794            ],
795            output: make_tensor("c", TensorRole::Output),
796            dim_name: "N".into(),
797        };
798
799        let single = FusedPattern::Single(add.clone());
800        assert!(matches!(
801            single.primary_pattern(),
802            KernelPattern::ElementWise { .. }
803        ));
804
805        let conv_bn = FusedPattern::ConvBatchNorm {
806            conv: KernelPattern::Conv2D {
807                input: make_tensor("x", TensorRole::Input),
808                weight: make_tensor("w", TensorRole::Input),
809                output: make_tensor("conv_out", TensorRole::Output),
810                shape: Conv2DShape {
811                    batch: "N".into(),
812                    channels_in: "IC".into(),
813                    channels_out: "OC".into(),
814                    height: "H".into(),
815                    width: "W".into(),
816                    kernel_h: "KH".into(),
817                    kernel_w: "KW".into(),
818                    kernel_h_val: 3,
819                    kernel_w_val: 3,
820                    stride_h: 1,
821                    stride_w: 1,
822                    pad_h: 0,
823                    pad_w: 0,
824                    groups: 1,
825                    dilation_h: 1,
826                    dilation_w: 1,
827                },
828                bias: None,
829                activation: None,
830            },
831            norm: Box::new(KernelPattern::Normalization {
832                input: make_tensor("conv_out", TensorRole::Input),
833                scale: make_tensor("gamma", TensorRole::Input),
834                bias: make_tensor("beta", TensorRole::Input),
835                output: make_tensor("bn_out", TensorRole::Output),
836                epsilon: 1e-5,
837                norm_type: crate::NormType::Batch,
838            }),
839        };
840        assert!(matches!(
841            conv_bn.primary_pattern(),
842            KernelPattern::Conv2D { .. }
843        ));
844
845        let matmul_bias = FusedPattern::MatMulBias {
846            matmul: KernelPattern::MatMul {
847                inputs: [
848                    make_tensor("A", TensorRole::Input),
849                    make_tensor("B", TensorRole::Input),
850                ],
851                output: make_tensor("mm_out", TensorRole::Output),
852                shape: MatMulShape {
853                    m: "M".into(),
854                    n: "N".into(),
855                    k: "K".into(),
856                },
857            },
858            bias_add: Box::new(add.clone()),
859        };
860        assert!(matches!(
861            matmul_bias.primary_pattern(),
862            KernelPattern::MatMul { .. }
863        ));
864
865        let with_act = FusedPattern::WithActivation {
866            base: Box::new(FusedPattern::Single(add.clone())),
867            activation: FusedActivation::Sigmoid,
868            activation_pattern: Box::new(KernelPattern::Activation {
869                op: ActivationOp::Sigmoid,
870                input: make_tensor("c", TensorRole::Input),
871                output: make_tensor("d", TensorRole::Output),
872                dim_name: "N".into(),
873            }),
874        };
875        assert!(matches!(
876            with_act.primary_pattern(),
877            KernelPattern::ElementWise { .. }
878        ));
879    }
880
881    #[test]
882    fn fused_activation_returns_correct_values() {
883        let add = KernelPattern::ElementWise {
884            op: ElementWiseOp::Add,
885            inputs: [
886                make_tensor("a", TensorRole::Input),
887                make_tensor("b", TensorRole::Input),
888            ],
889            output: make_tensor("c", TensorRole::Output),
890            dim_name: "N".into(),
891        };
892
893        let single = FusedPattern::Single(add.clone());
894        assert_eq!(single.fused_activation(), FusedActivation::None);
895
896        let with_relu = FusedPattern::WithActivation {
897            base: Box::new(FusedPattern::Single(add.clone())),
898            activation: FusedActivation::Relu,
899            activation_pattern: Box::new(KernelPattern::Activation {
900                op: ActivationOp::Relu,
901                input: make_tensor("c", TensorRole::Input),
902                output: make_tensor("d", TensorRole::Output),
903                dim_name: "N".into(),
904            }),
905        };
906        assert_eq!(with_relu.fused_activation(), FusedActivation::Relu);
907    }
908
909    #[test]
910    fn fused_activation_display() {
911        assert_eq!(format!("{}", FusedActivation::None), "None");
912        assert_eq!(format!("{}", FusedActivation::Relu), "Relu");
913        assert_eq!(format!("{}", FusedActivation::Sigmoid), "Sigmoid");
914        assert_eq!(format!("{}", FusedActivation::Tanh), "Tanh");
915    }
916
917    // ---- tensor name helpers for edge cases ----
918
919    #[test]
920    fn output_tensor_names_for_split() {
921        let pattern = KernelPattern::Split {
922            input: make_tensor("x", TensorRole::Input),
923            outputs: vec![
924                make_tensor("o1", TensorRole::Output),
925                make_tensor("o2", TensorRole::Output),
926            ],
927            axis: 1,
928        };
929        let names = output_tensor_names(&pattern);
930        assert_eq!(names, vec!["o1", "o2"]);
931    }
932
933    #[test]
934    fn output_tensor_names_for_unknown() {
935        let pattern = KernelPattern::Unknown {
936            reason: "test".into(),
937        };
938        let names = output_tensor_names(&pattern);
939        assert_eq!(names.len(), 0);
940    }
941
942    #[test]
943    fn input_tensor_names_for_unknown() {
944        let pattern = KernelPattern::Unknown {
945            reason: "test".into(),
946        };
947        let names = input_tensor_names(&pattern);
948        assert_eq!(names.len(), 0);
949    }
950
951    #[test]
952    fn input_tensor_names_for_concat() {
953        let pattern = KernelPattern::Concat {
954            inputs: vec![
955                make_tensor("a", TensorRole::Input),
956                make_tensor("b", TensorRole::Input),
957            ],
958            output: make_tensor("c", TensorRole::Output),
959            axis: 0,
960        };
961        let names = input_tensor_names(&pattern);
962        assert_eq!(names, vec!["a", "b"]);
963    }
964
965    #[test]
966    fn input_tensor_names_for_attention() {
967        let pattern = KernelPattern::Attention {
968            query: make_tensor("q", TensorRole::Input),
969            key: make_tensor("k", TensorRole::Input),
970            value: make_tensor("v", TensorRole::Input),
971            output: make_tensor("o", TensorRole::Output),
972            d_k: "D".into(),
973            seq_len: "S".into(),
974            num_heads: 1,
975            num_kv_heads: 1,
976            causal: false,
977        };
978        let names = input_tensor_names(&pattern);
979        assert_eq!(names, vec!["q", "k", "v"]);
980    }
981
982    #[test]
983    fn unknown_pattern_passes_through_fuse() {
984        let patterns = vec![
985            KernelPattern::Unknown {
986                reason: "test".into(),
987            },
988            KernelPattern::ElementWise {
989                op: ElementWiseOp::Add,
990                inputs: [
991                    make_tensor("a", TensorRole::Input),
992                    make_tensor("b", TensorRole::Input),
993                ],
994                output: make_tensor("c", TensorRole::Output),
995                dim_name: "N".into(),
996            },
997        ];
998
999        let fused = fuse_patterns(patterns);
1000        assert_eq!(fused.len(), 2);
1001        assert!(matches!(
1002            &fused[0].0,
1003            FusedPattern::Single(KernelPattern::Unknown { .. })
1004        ));
1005        assert!(matches!(
1006            &fused[1].0,
1007            FusedPattern::Single(KernelPattern::ElementWise { .. })
1008        ));
1009    }
1010
1011    #[test]
1012    fn matmul_add_relu_fusion() {
1013        // MatMul + Add + Relu: MatMul+Add fuses to MatMulBias, then Relu fuses on top
1014        let patterns = vec![
1015            KernelPattern::MatMul {
1016                inputs: [
1017                    make_tensor("A", TensorRole::Input),
1018                    make_tensor("B", TensorRole::Input),
1019                ],
1020                output: make_tensor("mm_out", TensorRole::Output),
1021                shape: MatMulShape {
1022                    m: "M".into(),
1023                    n: "N".into(),
1024                    k: "K".into(),
1025                },
1026            },
1027            KernelPattern::ElementWise {
1028                op: ElementWiseOp::Add,
1029                inputs: [
1030                    make_tensor("mm_out", TensorRole::Input),
1031                    make_tensor("bias", TensorRole::Input),
1032                ],
1033                output: make_tensor("gemm_out", TensorRole::Output),
1034                dim_name: "N".into(),
1035            },
1036            KernelPattern::Activation {
1037                op: ActivationOp::Relu,
1038                input: make_tensor("gemm_out", TensorRole::Input),
1039                output: make_tensor("relu_out", TensorRole::Output),
1040                dim_name: "N".into(),
1041            },
1042        ];
1043
1044        let fused = fuse_patterns(patterns);
1045        assert_eq!(fused.len(), 1);
1046        match &fused[0].0 {
1047            FusedPattern::WithActivation {
1048                base,
1049                activation: FusedActivation::Relu,
1050                ..
1051            } => {
1052                assert!(matches!(**base, FusedPattern::MatMulBias { .. }));
1053            }
1054            other => panic!("expected WithActivation(MatMulBias, Relu), got {other:?}"),
1055        }
1056    }
1057
1058    #[test]
1059    fn conv2d_at_end_of_list_no_fusion() {
1060        // Conv2D as the last pattern with nothing to fuse
1061        let patterns = vec![KernelPattern::Conv2D {
1062            input: make_tensor("x", TensorRole::Input),
1063            weight: make_tensor("w", TensorRole::Input),
1064            output: make_tensor("conv_out", TensorRole::Output),
1065            shape: Conv2DShape {
1066                batch: "N".into(),
1067                channels_in: "IC".into(),
1068                channels_out: "OC".into(),
1069                height: "H".into(),
1070                width: "W".into(),
1071                kernel_h: "KH".into(),
1072                kernel_w: "KW".into(),
1073                kernel_h_val: 3,
1074                kernel_w_val: 3,
1075                stride_h: 1,
1076                stride_w: 1,
1077                pad_h: 0,
1078                pad_w: 0,
1079                groups: 1,
1080                dilation_h: 1,
1081                dilation_w: 1,
1082            },
1083            bias: None,
1084            activation: None,
1085        }];
1086
1087        let fused = fuse_patterns(patterns);
1088        assert_eq!(fused.len(), 1);
1089        assert!(matches!(
1090            &fused[0].0,
1091            FusedPattern::Single(KernelPattern::Conv2D { .. })
1092        ));
1093    }
1094
1095    #[test]
1096    fn matmul_at_end_of_list_no_fusion() {
1097        // MatMul as the last pattern with nothing to fuse
1098        let patterns = vec![KernelPattern::MatMul {
1099            inputs: [
1100                make_tensor("A", TensorRole::Input),
1101                make_tensor("B", TensorRole::Input),
1102            ],
1103            output: make_tensor("mm_out", TensorRole::Output),
1104            shape: MatMulShape {
1105                m: "M".into(),
1106                n: "N".into(),
1107                k: "K".into(),
1108            },
1109        }];
1110
1111        let fused = fuse_patterns(patterns);
1112        assert_eq!(fused.len(), 1);
1113        assert!(matches!(
1114            &fused[0].0,
1115            FusedPattern::Single(KernelPattern::MatMul { .. })
1116        ));
1117    }
1118
1119    #[test]
1120    fn conv2d_with_non_normalization_next() {
1121        // Conv2D followed by non-Normalization pattern
1122        let patterns = vec![
1123            KernelPattern::Conv2D {
1124                input: make_tensor("x", TensorRole::Input),
1125                weight: make_tensor("w", TensorRole::Input),
1126                output: make_tensor("conv_out", TensorRole::Output),
1127                shape: Conv2DShape {
1128                    batch: "N".into(),
1129                    channels_in: "IC".into(),
1130                    channels_out: "OC".into(),
1131                    height: "H".into(),
1132                    width: "W".into(),
1133                    kernel_h: "KH".into(),
1134                    kernel_w: "KW".into(),
1135                    kernel_h_val: 3,
1136                    kernel_w_val: 3,
1137                    stride_h: 1,
1138                    stride_w: 1,
1139                    pad_h: 0,
1140                    pad_w: 0,
1141                    groups: 1,
1142                    dilation_h: 1,
1143                    dilation_w: 1,
1144                },
1145                bias: None,
1146                activation: None,
1147            },
1148            KernelPattern::ElementWise {
1149                op: ElementWiseOp::Add,
1150                inputs: [
1151                    make_tensor("conv_out", TensorRole::Input),
1152                    make_tensor("bias", TensorRole::Input),
1153                ],
1154                output: make_tensor("add_out", TensorRole::Output),
1155                dim_name: "N".into(),
1156            },
1157        ];
1158
1159        let fused = fuse_patterns(patterns);
1160        assert_eq!(fused.len(), 2);
1161        assert!(matches!(
1162            &fused[0].0,
1163            FusedPattern::Single(KernelPattern::Conv2D { .. })
1164        ));
1165        assert!(matches!(
1166            &fused[1].0,
1167            FusedPattern::Single(KernelPattern::ElementWise { .. })
1168        ));
1169    }
1170
1171    #[test]
1172    fn matmul_with_non_add_elementwise() {
1173        // MatMul followed by Sub (not Add) -- should NOT fuse to MatMulBias
1174        let patterns = vec![
1175            KernelPattern::MatMul {
1176                inputs: [
1177                    make_tensor("A", TensorRole::Input),
1178                    make_tensor("B", TensorRole::Input),
1179                ],
1180                output: make_tensor("mm_out", TensorRole::Output),
1181                shape: MatMulShape {
1182                    m: "M".into(),
1183                    n: "N".into(),
1184                    k: "K".into(),
1185                },
1186            },
1187            KernelPattern::ElementWise {
1188                op: ElementWiseOp::Sub,
1189                inputs: [
1190                    make_tensor("mm_out", TensorRole::Input),
1191                    make_tensor("other", TensorRole::Input),
1192                ],
1193                output: make_tensor("sub_out", TensorRole::Output),
1194                dim_name: "N".into(),
1195            },
1196        ];
1197
1198        let fused = fuse_patterns(patterns);
1199        assert_eq!(fused.len(), 2);
1200    }
1201
1202    #[test]
1203    fn output_tensor_names_for_gather() {
1204        let pattern = KernelPattern::Gather {
1205            data: make_tensor("data", TensorRole::Input),
1206            indices: make_tensor("idx", TensorRole::Input),
1207            output: make_tensor("out", TensorRole::Output),
1208            axis: 0,
1209        };
1210        let names = output_tensor_names(&pattern);
1211        assert_eq!(names, vec!["out"]);
1212    }
1213
1214    #[test]
1215    fn input_tensor_names_for_gather() {
1216        let pattern = KernelPattern::Gather {
1217            data: make_tensor("data", TensorRole::Input),
1218            indices: make_tensor("idx", TensorRole::Input),
1219            output: make_tensor("out", TensorRole::Output),
1220            axis: 0,
1221        };
1222        let names = input_tensor_names(&pattern);
1223        assert_eq!(names, vec!["data", "idx"]);
1224    }
1225
1226    #[test]
1227    fn output_tensor_names_for_scatter() {
1228        let pattern = KernelPattern::Scatter {
1229            data: make_tensor("data", TensorRole::Input),
1230            indices: make_tensor("idx", TensorRole::Input),
1231            updates: make_tensor("upd", TensorRole::Input),
1232            output: make_tensor("out", TensorRole::Output),
1233            axis: 0,
1234        };
1235        let names = output_tensor_names(&pattern);
1236        assert_eq!(names, vec!["out"]);
1237    }
1238
1239    #[test]
1240    fn input_tensor_names_for_scatter() {
1241        let pattern = KernelPattern::Scatter {
1242            data: make_tensor("data", TensorRole::Input),
1243            indices: make_tensor("idx", TensorRole::Input),
1244            updates: make_tensor("upd", TensorRole::Input),
1245            output: make_tensor("out", TensorRole::Output),
1246            axis: 0,
1247        };
1248        let names = input_tensor_names(&pattern);
1249        assert_eq!(names, vec!["data", "idx", "upd"]);
1250    }
1251
1252    #[test]
1253    fn no_fusion_for_gelu_activation() {
1254        let patterns = vec![
1255            KernelPattern::ElementWise {
1256                op: ElementWiseOp::Add,
1257                inputs: [
1258                    make_tensor("a", TensorRole::Input),
1259                    make_tensor("b", TensorRole::Input),
1260                ],
1261                output: make_tensor("c", TensorRole::Output),
1262                dim_name: "N".into(),
1263            },
1264            KernelPattern::Activation {
1265                op: ActivationOp::Gelu,
1266                input: make_tensor("c", TensorRole::Input),
1267                output: make_tensor("d", TensorRole::Output),
1268                dim_name: "N".into(),
1269            },
1270        ];
1271
1272        let fused = fuse_patterns(patterns);
1273        // Gelu is not fusible -- remains as 2 separate patterns.
1274        assert_eq!(fused.len(), 2);
1275    }
1276
1277    #[test]
1278    fn no_fusion_for_silu_activation() {
1279        let patterns = vec![
1280            KernelPattern::ElementWise {
1281                op: ElementWiseOp::Add,
1282                inputs: [
1283                    make_tensor("a", TensorRole::Input),
1284                    make_tensor("b", TensorRole::Input),
1285                ],
1286                output: make_tensor("c", TensorRole::Output),
1287                dim_name: "N".into(),
1288            },
1289            KernelPattern::Activation {
1290                op: ActivationOp::Silu,
1291                input: make_tensor("c", TensorRole::Input),
1292                output: make_tensor("d", TensorRole::Output),
1293                dim_name: "N".into(),
1294            },
1295        ];
1296
1297        let fused = fuse_patterns(patterns);
1298        assert_eq!(fused.len(), 2);
1299    }
1300
1301    #[test]
1302    fn no_fusion_for_mish_activation() {
1303        let patterns = vec![
1304            KernelPattern::ElementWise {
1305                op: ElementWiseOp::Add,
1306                inputs: [
1307                    make_tensor("a", TensorRole::Input),
1308                    make_tensor("b", TensorRole::Input),
1309                ],
1310                output: make_tensor("c", TensorRole::Output),
1311                dim_name: "N".into(),
1312            },
1313            KernelPattern::Activation {
1314                op: ActivationOp::Mish,
1315                input: make_tensor("c", TensorRole::Input),
1316                output: make_tensor("d", TensorRole::Output),
1317                dim_name: "N".into(),
1318            },
1319        ];
1320
1321        let fused = fuse_patterns(patterns);
1322        assert_eq!(fused.len(), 2);
1323    }
1324}