Skip to main content

nxpu_backend_tflite/
lib.rs

1//! TFLite/LiteRT backend emitter for NxPU.
2//!
3//! Emits TFLite FlatBuffer (`.tflite`) models from NxPU IR.
4//! Targets: MediaTek APU (NeuroPilot), Google Edge TPU, Arm Ethos NPU (Vela).
5
6use nxpu_analysis::{analyze, fusion};
7use nxpu_backend_core::{
8    Backend, BackendError, BackendOptions, BackendOutput, Diagnostic, DiagnosticLevel,
9    OutputContent, OutputFile,
10};
11use nxpu_ir::Module;
12
13mod lower;
14mod schema;
15
16/// TFLite backend that compiles NxPU IR into `.tflite` FlatBuffer files.
17#[derive(Debug)]
18pub struct TfLiteBackend;
19
20/// The extent to write for dimensions the kernel leaves symbolic.
21///
22/// `None` means the caller did not say. One is the smallest value that
23/// produces a loadable model, which is the right default for a compiler that
24/// genuinely does not know the size: a WGSL kernel over `array<f32>` carries
25/// no length. It is also useless for measuring anything, so a caller who wants
26/// a model worth timing has to name a size.
27///
28/// TFLite has a place for a genuinely unknown dimension — `shape_signature` —
29/// but `shape` itself must be concrete: a negative value there makes
30/// `BytesRequired` overflow and no interpreter can be constructed, on any
31/// device, delegate or not.
32fn symbolic_extent(opts: &BackendOptions) -> i32 {
33    opts.symbolic_extent
34        .map(|e| e.max(1))
35        .unwrap_or(1)
36        .min(i32::MAX as u32) as i32
37}
38
39impl Backend for TfLiteBackend {
40    fn name(&self) -> &str {
41        "TFLite"
42    }
43
44    fn targets(&self) -> &[&str] {
45        &["tflite", "litert"]
46    }
47
48    fn compile(
49        &self,
50        module: &Module,
51        opts: &BackendOptions,
52    ) -> Result<BackendOutput, BackendError> {
53        if module.entry_points.is_empty() {
54            return Err(BackendError::Other("no entry points in module".into()));
55        }
56
57        // 1. Classify all entry points.
58        let mut patterns = Vec::new();
59        for (i, ep) in module.entry_points.iter().enumerate() {
60            let pattern = analyze::classify_entry_point(module, i).map_err(|e| {
61                BackendError::Unsupported(format!("entry point '{}': {e}", ep.name))
62            })?;
63            if let analyze::KernelPattern::Unknown { reason } = &pattern {
64                return Err(BackendError::Unsupported(format!(
65                    "entry point '{}': unrecognized pattern: {reason}",
66                    ep.name
67                )));
68            }
69            patterns.push(pattern);
70        }
71
72        // 2. Fuse adjacent patterns.
73        let fused = fusion::fuse_patterns(patterns);
74
75        // 3. Lower each fused pattern.
76        let extent = symbolic_extent(opts);
77        let mut files = Vec::new();
78        let mut diagnostics = Vec::new();
79
80        for (fp, ep_idx) in &fused {
81            let ep_name = if *ep_idx < module.entry_points.len() {
82                &module.entry_points[*ep_idx].name
83            } else {
84                "fused"
85            };
86
87            let summary = match fp {
88                fusion::FusedPattern::Single(p) => pattern_summary(p).to_string(),
89                fusion::FusedPattern::ConvBatchNorm { .. } => "Conv+BatchNorm (fused)".into(),
90                fusion::FusedPattern::WithActivation {
91                    base, activation, ..
92                } => {
93                    let base_name = match base.as_ref() {
94                        fusion::FusedPattern::Single(p) => pattern_summary(p).into_owned(),
95                        fusion::FusedPattern::ConvBatchNorm { .. } => "Conv+BatchNorm".to_string(),
96                        fusion::FusedPattern::MatMulBias { .. } => "Gemm".to_string(),
97                        _ => "fused".to_string(),
98                    };
99                    format!("{base_name}+{activation:?}")
100                }
101                fusion::FusedPattern::MatMulBias { .. } => "Gemm (fused)".into(),
102            };
103
104            diagnostics.push(Diagnostic {
105                level: DiagnosticLevel::Info,
106                message: format!("entry point '{ep_name}': classified as {summary}"),
107            });
108
109            // Emit warnings for unsupported attention features in the TFLite backend.
110            emit_attention_diagnostics(fp, ep_name, &mut diagnostics);
111            emit_runtime_filter_diagnostic(fp, ep_name, opts, &mut diagnostics);
112
113            // A caller who did not say gets the smallest valid extent, which loads
114            // but measures nothing; `--symbolic-dim` is how you ask for a size
115            // worth timing.
116            let bytes = lower::build_fused_model(
117                fp,
118                lower::Lowering {
119                    extent,
120                    constants: &opts.constant_tensors,
121                },
122            )?;
123
124            let filename = if fused.len() == 1 {
125                "output.tflite".into()
126            } else {
127                format!("{ep_name}.tflite")
128            };
129
130            files.push(OutputFile {
131                name: filename,
132                content: OutputContent::Binary(bytes),
133            });
134        }
135
136        // 4. Emit quantization parameters as a companion JSON file.
137        if !opts.quantization_params.is_empty() || !opts.per_channel_params.is_empty() {
138            let mut json = String::from("{\n");
139
140            if !opts.quantization_params.is_empty() {
141                json.push_str("  \"quantization_params\": [\n");
142                for (i, qp) in opts.quantization_params.iter().enumerate() {
143                    if i > 0 {
144                        json.push_str(",\n");
145                    }
146                    json.push_str(&format!(
147                        "    {{\"name\": \"{}\", \"scale\": {}, \"zero_point\": {}}}",
148                        qp.name, qp.scale, qp.zero_point
149                    ));
150                }
151                json.push_str("\n  ]");
152                if !opts.per_channel_params.is_empty() {
153                    json.push(',');
154                }
155                json.push('\n');
156            }
157
158            if !opts.per_channel_params.is_empty() {
159                json.push_str("  \"per_channel_params\": [\n");
160                for (i, pcp) in opts.per_channel_params.iter().enumerate() {
161                    if i > 0 {
162                        json.push_str(",\n");
163                    }
164                    let scales: Vec<String> = pcp.scales.iter().map(|s| format!("{s}")).collect();
165                    let zero_points: Vec<String> =
166                        pcp.zero_points.iter().map(|z| format!("{z}")).collect();
167                    json.push_str(&format!(
168                        "    {{\"name\": \"{}\", \"scales\": [{}], \"zero_points\": [{}], \"channel_axis\": {}}}",
169                        pcp.name,
170                        scales.join(", "),
171                        zero_points.join(", "),
172                        pcp.channel_axis
173                    ));
174                }
175                json.push_str("\n  ]\n");
176            }
177
178            json.push_str("}\n");
179            files.push(OutputFile {
180                name: "quant_params.json".into(),
181                content: OutputContent::Text(json),
182            });
183        }
184
185        Ok(BackendOutput { files, diagnostics })
186    }
187}
188
189/// Emit diagnostic warnings when the TFLite backend encounters attention patterns
190/// with features it does not yet support (multi-head splitting, causal masking).
191/// Say when a convolution will not be accelerated, and why.
192///
193/// An NNAPI driver requires a convolution's filter to be a compile-time
194/// constant. A MediaTek MT6899 accelerates a convolution whose filter is one
195/// and refuses the identical convolution, at the same shapes, whose filter is
196/// a graph input — and reports the refusal as `unsupported`, which reads as a
197/// statement about CONV_2D and is a statement about this model. Four device
198/// sweeps and a byte-level diff went into learning that from the outside.
199///
200/// So it is said here instead. Not an error: TFLite's own GPU delegate takes
201/// the convolution either way.
202///
203/// The GPU is not an equivalent answer, though, and the warning says so. It is
204/// the engine the display composites on, so occupying it contends with
205/// everything drawing on screen. Timed on the same phone with the thermal
206/// conditions reported stable throughout, it is faster than the NPU in the
207/// middle on 8 of the 12 operators both accept and twice as spread at p99 --
208/// 2.04x its own median against the NPU's 1.27x, worst case 6.23x. Reaching
209/// the NPU is what this flag is for.
210fn emit_runtime_filter_diagnostic(
211    fp: &fusion::FusedPattern,
212    ep_name: &str,
213    opts: &BackendOptions,
214    diagnostics: &mut Vec<Diagnostic>,
215) {
216    let analyze::KernelPattern::Conv2D { weight, .. } = fp.primary_pattern() else {
217        return;
218    };
219    if opts.constant_tensors.iter().any(|c| c.name == weight.name) {
220        return;
221    }
222    diagnostics.push(Diagnostic {
223        level: DiagnosticLevel::Warning,
224        message: format!(
225            "entry point '{ep_name}': `{}` is a graph input, so an NNAPI driver will \
226             refuse this convolution and it will run on the CPU. TFLite's GPU delegate \
227             takes it as it is, at the cost of occupying the engine the display \
228             composites on. To reach an NPU, supply the contents with \
229             --weights <DIR>/{}.bin",
230            weight.name, weight.name
231        ),
232    });
233}
234
235fn emit_attention_diagnostics(
236    fp: &fusion::FusedPattern,
237    ep_name: &str,
238    diagnostics: &mut Vec<Diagnostic>,
239) {
240    // Extract the inner KernelPattern from the fused pattern.
241    let inner = match fp {
242        fusion::FusedPattern::Single(p) => Some(p),
243        fusion::FusedPattern::WithActivation { base, .. } => match base.as_ref() {
244            fusion::FusedPattern::Single(p) => Some(p),
245            _ => None,
246        },
247        _ => None,
248    };
249
250    if let Some(analyze::KernelPattern::Attention {
251        num_heads, causal, ..
252    }) = inner
253    {
254        if *num_heads > 1 {
255            diagnostics.push(Diagnostic {
256                level: DiagnosticLevel::Warning,
257                message: format!(
258                    "entry point '{ep_name}': TFLite backend does not support multi-head attention \
259                     (num_heads={num_heads}); output will be single-head SDPA"
260                ),
261            });
262        }
263        if *causal {
264            diagnostics.push(Diagnostic {
265                level: DiagnosticLevel::Warning,
266                message: format!(
267                    "entry point '{ep_name}': TFLite backend does not support causal masking; \
268                     output will be unmasked SDPA"
269                ),
270            });
271        }
272    }
273}
274
275/// Name the operators a pattern lowers to, for the `classified as …`
276/// diagnostic.
277///
278/// Returns an owned string because a chain has no fixed name: it reports the
279/// operator sequence it emits — `Mul+Add`, `Cast+Mul+Mul` — since a category
280/// label like "chain" would say nothing about what the kernel computes.
281fn pattern_summary(pattern: &analyze::KernelPattern) -> std::borrow::Cow<'static, str> {
282    use std::borrow::Cow;
283    Cow::Borrowed(match pattern {
284        analyze::KernelPattern::ElementWiseChain { cast, steps, .. } => {
285            return Cow::Owned(analyze::chain_summary(*cast, steps));
286        }
287        analyze::KernelPattern::MatMul { .. } => "BATCH_MATMUL",
288        // Named by the whole graph rather than by its last operator: there is
289        // no quantized matmul operator here, and reporting "BATCH_MATMUL"
290        // would describe one node out of five and hide the dequantization
291        // that makes the answer right.
292        analyze::KernelPattern::QuantizedMatMul { bias, .. } => {
293            if bias.is_some() {
294                "TRANSPOSE+CAST+BATCH_MATMUL+MUL+ADD (int8 weights, per-channel scale)"
295            } else {
296                "TRANSPOSE+CAST+BATCH_MATMUL+MUL (int8 weights, per-channel scale)"
297            }
298        }
299        analyze::KernelPattern::ElementWise { op, .. } => op.op_name(),
300        analyze::KernelPattern::Conv2D { .. } => "CONV_2D",
301        analyze::KernelPattern::Pool { kind, .. } => kind.op_name(),
302        analyze::KernelPattern::Activation { op, .. } => op.op_name(),
303        analyze::KernelPattern::Reduce { op, .. } => op.op_name(),
304        analyze::KernelPattern::Transpose { .. } => "TRANSPOSE",
305        analyze::KernelPattern::Reshape { .. } => "RESHAPE",
306        // The kind matters: reporting every normalization as BatchNormalization
307        // hid a LayerNorm being classified correctly and described wrongly,
308        // which is worse than either mistake alone.
309        analyze::KernelPattern::Normalization { norm_type, .. } => match norm_type {
310            analyze::NormType::Batch => "BatchNormalization",
311            analyze::NormType::Layer => "LayerNormalization",
312        },
313        analyze::KernelPattern::Concat { .. } => "CONCATENATION",
314        analyze::KernelPattern::Split { .. } => "SPLIT",
315        analyze::KernelPattern::Attention { .. } => "Attention",
316        analyze::KernelPattern::Gather { .. } => "GATHER",
317        analyze::KernelPattern::Scatter { .. } => "SCATTER_ND",
318        analyze::KernelPattern::Unknown { .. } => "Unknown",
319    })
320}
321
322#[cfg(test)]
323mod extent_tests {
324    use super::*;
325
326    #[test]
327    fn unspecified_is_the_smallest_loadable_extent() {
328        assert_eq!(symbolic_extent(&BackendOptions::default()), 1);
329    }
330
331    #[test]
332    fn zero_is_raised_to_one() {
333        // A zero extent produces a zero-byte tensor, which loads and then
334        // fails on the first attempt to hand it any data.
335        let opts = BackendOptions {
336            symbolic_extent: Some(0),
337            ..Default::default()
338        };
339        assert_eq!(symbolic_extent(&opts), 1);
340    }
341
342    #[test]
343    fn a_requested_extent_is_used() {
344        let opts = BackendOptions {
345            symbolic_extent: Some(1024),
346            ..Default::default()
347        };
348        assert_eq!(symbolic_extent(&opts), 1024);
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use nxpu_backend_core::BackendOptions;
356
357    #[test]
358    fn backend_metadata() {
359        let backend = TfLiteBackend;
360        assert_eq!(backend.name(), "TFLite");
361        assert!(backend.targets().contains(&"tflite"));
362        assert!(backend.targets().contains(&"litert"));
363    }
364
365    #[test]
366    fn compile_empty_module_fails() {
367        let backend = TfLiteBackend;
368        let module = Module::default();
369        let result = backend.compile(&module, &BackendOptions::default());
370        assert!(result.is_err());
371    }
372
373    #[test]
374    fn compile_matmul_wgsl() {
375        let source = std::fs::read_to_string(concat!(
376            env!("CARGO_MANIFEST_DIR"),
377            "/../../examples/matmul.wgsl"
378        ))
379        .unwrap();
380        let module = nxpu_parser::parse(&source).unwrap();
381
382        let backend = TfLiteBackend;
383        let output = backend
384            .compile(&module, &BackendOptions::default())
385            .unwrap();
386
387        assert_eq!(output.files.len(), 1);
388        assert_eq!(output.files[0].name, "output.tflite");
389
390        let bytes = match &output.files[0].content {
391            OutputContent::Binary(b) => b,
392            _ => panic!("expected binary output"),
393        };
394
395        // Verify TFLite file identifier
396        assert_eq!(&bytes[4..8], b"TFL3");
397    }
398
399    #[test]
400    fn compile_vecadd_wgsl() {
401        let source = r#"
402@group(0) @binding(0) var<storage, read> a: array<f32>;
403@group(0) @binding(1) var<storage, read> b: array<f32>;
404@group(0) @binding(2) var<storage, read_write> c: array<f32>;
405
406struct Params { N: u32 }
407@group(0) @binding(3) var<uniform> params: Params;
408
409@compute @workgroup_size(256)
410fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
411  let idx = gid.x;
412  if (idx >= params.N) { return; }
413  c[idx] = a[idx] + b[idx];
414}
415"#;
416
417        let module = nxpu_parser::parse(source).unwrap();
418
419        let backend = TfLiteBackend;
420        let output = backend
421            .compile(&module, &BackendOptions::default())
422            .unwrap();
423
424        assert_eq!(output.files.len(), 1);
425        let bytes = match &output.files[0].content {
426            OutputContent::Binary(b) => b,
427            _ => panic!("expected binary output"),
428        };
429        assert_eq!(&bytes[4..8], b"TFL3");
430    }
431
432    // ---- Fusion code path tests ----
433
434    fn dummy_handle() -> nxpu_ir::Handle<nxpu_ir::GlobalVariable> {
435        let mut arena = nxpu_ir::Arena::new();
436        arena.append(nxpu_ir::GlobalVariable {
437            name: None,
438            space: nxpu_ir::AddressSpace::Uniform,
439            binding: None,
440            ty: {
441                let mut types = nxpu_ir::UniqueArena::new();
442                types.insert(nxpu_ir::Type {
443                    name: None,
444                    inner: nxpu_ir::TypeInner::Scalar(nxpu_ir::Scalar::F32),
445                })
446            },
447            init: None,
448            layout: None,
449        })
450    }
451
452    fn make_tensor(name: &str, role: analyze::TensorRole) -> analyze::TensorBinding {
453        analyze::TensorBinding {
454            handle: dummy_handle(),
455            name: name.into(),
456            elem_type: analyze::data_type::FLOAT,
457            role,
458        }
459    }
460
461    #[test]
462    fn build_fused_model_conv_batchnorm() {
463        use nxpu_analysis::analyze::*;
464        use nxpu_analysis::fusion::FusedPattern;
465
466        let conv = KernelPattern::Conv2D {
467            input: make_tensor("x", TensorRole::Input),
468            weight: make_tensor("w", TensorRole::Input),
469            output: make_tensor("conv_out", TensorRole::Output),
470            shape: Conv2DShape {
471                batch: "N".into(),
472                channels_in: "IC".into(),
473                channels_out: "OC".into(),
474                height: "H".into(),
475                width: "W".into(),
476                kernel_h: "KH".into(),
477                kernel_w: "KW".into(),
478                kernel_h_val: 3,
479                kernel_w_val: 3,
480                stride_h: 1,
481                stride_w: 1,
482                pad_h: 0,
483                pad_w: 0,
484                groups: 1,
485                dilation_h: 1,
486                dilation_w: 1,
487            },
488            bias: None,
489            activation: None,
490        };
491        let norm = KernelPattern::Normalization {
492            input: make_tensor("conv_out", TensorRole::Input),
493            scale: make_tensor("gamma", TensorRole::Input),
494            bias: make_tensor("beta", TensorRole::Input),
495            output: make_tensor("bn_out", TensorRole::Output),
496            epsilon: 1e-5,
497            norm_type: NormType::Batch,
498        };
499
500        let fused = FusedPattern::ConvBatchNorm {
501            conv,
502            norm: Box::new(norm),
503        };
504
505        let bytes = lower::build_fused_model(&fused, 1).unwrap();
506        assert_eq!(&bytes[4..8], b"TFL3");
507    }
508
509    #[test]
510    fn build_fused_model_matmul_bias() {
511        use nxpu_analysis::analyze::*;
512        use nxpu_analysis::fusion::FusedPattern;
513
514        let matmul = KernelPattern::MatMul {
515            inputs: [
516                make_tensor("A", TensorRole::Input),
517                make_tensor("B", TensorRole::Input),
518            ],
519            output: make_tensor("mm_out", TensorRole::Output),
520            shape: MatMulShape {
521                m: "M".into(),
522                n: "N".into(),
523                k: "K".into(),
524            },
525        };
526        let bias_add = KernelPattern::ElementWise {
527            op: ElementWiseOp::Add,
528            inputs: [
529                make_tensor("mm_out", TensorRole::Input),
530                make_tensor("bias", TensorRole::Input),
531            ],
532            output: make_tensor("out", TensorRole::Output),
533            dim_name: "N".into(),
534        };
535
536        let fused = FusedPattern::MatMulBias {
537            matmul,
538            bias_add: Box::new(bias_add),
539        };
540
541        let bytes = lower::build_fused_model(&fused, 1).unwrap();
542        assert_eq!(&bytes[4..8], b"TFL3");
543    }
544
545    #[test]
546    fn build_fused_model_with_activation_on_single() {
547        use nxpu_analysis::analyze::*;
548        use nxpu_analysis::fusion::{FusedActivation, FusedPattern};
549
550        let add = KernelPattern::ElementWise {
551            op: ElementWiseOp::Add,
552            inputs: [
553                make_tensor("a", TensorRole::Input),
554                make_tensor("b", TensorRole::Input),
555            ],
556            output: make_tensor("c", TensorRole::Output),
557            dim_name: "N".into(),
558        };
559        let relu = KernelPattern::Activation {
560            op: ActivationOp::Relu,
561            input: make_tensor("c", TensorRole::Input),
562            output: make_tensor("d", TensorRole::Output),
563            dim_name: "N".into(),
564        };
565
566        let fused = FusedPattern::WithActivation {
567            base: Box::new(FusedPattern::Single(add)),
568            activation: FusedActivation::Relu,
569            activation_pattern: Box::new(relu),
570        };
571
572        let bytes = lower::build_fused_model(&fused, 1).unwrap();
573        assert_eq!(&bytes[4..8], b"TFL3");
574    }
575
576    #[test]
577    fn build_fused_model_with_activation_on_conv_batchnorm() {
578        use nxpu_analysis::analyze::*;
579        use nxpu_analysis::fusion::{FusedActivation, FusedPattern};
580
581        let conv = KernelPattern::Conv2D {
582            input: make_tensor("x", TensorRole::Input),
583            weight: make_tensor("w", TensorRole::Input),
584            output: make_tensor("conv_out", TensorRole::Output),
585            shape: Conv2DShape {
586                batch: "N".into(),
587                channels_in: "IC".into(),
588                channels_out: "OC".into(),
589                height: "H".into(),
590                width: "W".into(),
591                kernel_h: "KH".into(),
592                kernel_w: "KW".into(),
593                kernel_h_val: 3,
594                kernel_w_val: 3,
595                stride_h: 1,
596                stride_w: 1,
597                pad_h: 0,
598                pad_w: 0,
599                groups: 1,
600                dilation_h: 1,
601                dilation_w: 1,
602            },
603            bias: None,
604            activation: None,
605        };
606        let norm = KernelPattern::Normalization {
607            input: make_tensor("conv_out", TensorRole::Input),
608            scale: make_tensor("gamma", TensorRole::Input),
609            bias: make_tensor("beta", TensorRole::Input),
610            output: make_tensor("bn_out", TensorRole::Output),
611            epsilon: 1e-5,
612            norm_type: NormType::Batch,
613        };
614        let relu = KernelPattern::Activation {
615            op: ActivationOp::Relu,
616            input: make_tensor("bn_out", TensorRole::Input),
617            output: make_tensor("relu_out", TensorRole::Output),
618            dim_name: "N".into(),
619        };
620
621        let fused = FusedPattern::WithActivation {
622            base: Box::new(FusedPattern::ConvBatchNorm {
623                conv,
624                norm: Box::new(norm),
625            }),
626            activation: FusedActivation::Relu,
627            activation_pattern: Box::new(relu),
628        };
629
630        let bytes = lower::build_fused_model(&fused, 1).unwrap();
631        assert_eq!(&bytes[4..8], b"TFL3");
632    }
633
634    #[test]
635    fn build_fused_model_with_activation_on_matmul_bias() {
636        use nxpu_analysis::analyze::*;
637        use nxpu_analysis::fusion::{FusedActivation, FusedPattern};
638
639        let matmul = KernelPattern::MatMul {
640            inputs: [
641                make_tensor("A", TensorRole::Input),
642                make_tensor("B", TensorRole::Input),
643            ],
644            output: make_tensor("mm_out", TensorRole::Output),
645            shape: MatMulShape {
646                m: "M".into(),
647                n: "N".into(),
648                k: "K".into(),
649            },
650        };
651        let bias_add = KernelPattern::ElementWise {
652            op: ElementWiseOp::Add,
653            inputs: [
654                make_tensor("mm_out", TensorRole::Input),
655                make_tensor("bias", TensorRole::Input),
656            ],
657            output: make_tensor("gemm_out", TensorRole::Output),
658            dim_name: "N".into(),
659        };
660        let relu = KernelPattern::Activation {
661            op: ActivationOp::Relu,
662            input: make_tensor("gemm_out", TensorRole::Input),
663            output: make_tensor("relu_out", TensorRole::Output),
664            dim_name: "N".into(),
665        };
666
667        let fused = FusedPattern::WithActivation {
668            base: Box::new(FusedPattern::MatMulBias {
669                matmul,
670                bias_add: Box::new(bias_add),
671            }),
672            activation: FusedActivation::Relu,
673            activation_pattern: Box::new(relu),
674        };
675
676        let bytes = lower::build_fused_model(&fused, 1).unwrap();
677        assert_eq!(&bytes[4..8], b"TFL3");
678    }
679
680    #[test]
681    fn compile_summary_conv_batchnorm() {
682        // Test that the summary strings in compile() cover the ConvBatchNorm path.
683        use nxpu_analysis::analyze::*;
684        use nxpu_analysis::fusion::{FusedActivation, FusedPattern};
685
686        let fp_conv_bn = FusedPattern::ConvBatchNorm {
687            conv: KernelPattern::Conv2D {
688                input: make_tensor("x", TensorRole::Input),
689                weight: make_tensor("w", TensorRole::Input),
690                output: make_tensor("conv_out", TensorRole::Output),
691                shape: Conv2DShape {
692                    batch: "N".into(),
693                    channels_in: "IC".into(),
694                    channels_out: "OC".into(),
695                    height: "H".into(),
696                    width: "W".into(),
697                    kernel_h: "KH".into(),
698                    kernel_w: "KW".into(),
699                    kernel_h_val: 3,
700                    kernel_w_val: 3,
701                    stride_h: 1,
702                    stride_w: 1,
703                    pad_h: 0,
704                    pad_w: 0,
705                    groups: 1,
706                    dilation_h: 1,
707                    dilation_w: 1,
708                },
709                bias: None,
710                activation: None,
711            },
712            norm: Box::new(KernelPattern::Normalization {
713                input: make_tensor("conv_out", TensorRole::Input),
714                scale: make_tensor("gamma", TensorRole::Input),
715                bias: make_tensor("beta", TensorRole::Input),
716                output: make_tensor("bn_out", TensorRole::Output),
717                epsilon: 1e-5,
718                norm_type: NormType::Batch,
719            }),
720        };
721
722        // Exercise the summary formatting in compile() match arm
723        let summary = match &fp_conv_bn {
724            FusedPattern::ConvBatchNorm { .. } => "Conv+BatchNorm (fused)".to_string(),
725            _ => panic!("expected ConvBatchNorm"),
726        };
727        assert_eq!(summary, "Conv+BatchNorm (fused)");
728
729        // Also exercise WithActivation with ConvBatchNorm as base
730        let fp_with_act = FusedPattern::WithActivation {
731            base: Box::new(fp_conv_bn),
732            activation: FusedActivation::Relu,
733            activation_pattern: Box::new(KernelPattern::Activation {
734                op: ActivationOp::Relu,
735                input: make_tensor("bn_out", TensorRole::Input),
736                output: make_tensor("relu_out", TensorRole::Output),
737                dim_name: "N".into(),
738            }),
739        };
740
741        let summary = match &fp_with_act {
742            FusedPattern::WithActivation {
743                base, activation, ..
744            } => {
745                let base_name = match base.as_ref() {
746                    FusedPattern::Single(p) => pattern_summary(p).into_owned(),
747                    FusedPattern::ConvBatchNorm { .. } => "Conv+BatchNorm".to_string(),
748                    FusedPattern::MatMulBias { .. } => "Gemm".to_string(),
749                    _ => "fused".to_string(),
750                };
751                format!("{base_name}+{activation:?}")
752            }
753            _ => panic!("expected WithActivation"),
754        };
755        assert_eq!(summary, "Conv+BatchNorm+Relu");
756
757        // Exercise MatMulBias summary
758        let fp_gemm = FusedPattern::MatMulBias {
759            matmul: KernelPattern::MatMul {
760                inputs: [
761                    make_tensor("A", TensorRole::Input),
762                    make_tensor("B", TensorRole::Input),
763                ],
764                output: make_tensor("mm_out", TensorRole::Output),
765                shape: MatMulShape {
766                    m: "M".into(),
767                    n: "N".into(),
768                    k: "K".into(),
769                },
770            },
771            bias_add: Box::new(KernelPattern::ElementWise {
772                op: ElementWiseOp::Add,
773                inputs: [
774                    make_tensor("mm_out", TensorRole::Input),
775                    make_tensor("bias", TensorRole::Input),
776                ],
777                output: make_tensor("out", TensorRole::Output),
778                dim_name: "N".into(),
779            }),
780        };
781
782        let summary = match &fp_gemm {
783            FusedPattern::MatMulBias { .. } => "Gemm (fused)".to_string(),
784            _ => panic!("expected MatMulBias"),
785        };
786        assert_eq!(summary, "Gemm (fused)");
787
788        // Exercise WithActivation with MatMulBias as base
789        let fp_gemm_relu = FusedPattern::WithActivation {
790            base: Box::new(fp_gemm),
791            activation: FusedActivation::Relu,
792            activation_pattern: Box::new(KernelPattern::Activation {
793                op: ActivationOp::Relu,
794                input: make_tensor("out", TensorRole::Input),
795                output: make_tensor("relu_out", TensorRole::Output),
796                dim_name: "N".into(),
797            }),
798        };
799
800        let summary = match &fp_gemm_relu {
801            FusedPattern::WithActivation {
802                base, activation, ..
803            } => {
804                let base_name = match base.as_ref() {
805                    FusedPattern::Single(p) => pattern_summary(p).into_owned(),
806                    FusedPattern::ConvBatchNorm { .. } => "Conv+BatchNorm".to_string(),
807                    FusedPattern::MatMulBias { .. } => "Gemm".to_string(),
808                    _ => "fused".to_string(),
809                };
810                format!("{base_name}+{activation:?}")
811            }
812            _ => panic!("expected WithActivation"),
813        };
814        assert_eq!(summary, "Gemm+Relu");
815    }
816
817    #[test]
818    fn compile_with_quantization_params_emits_json() {
819        let source = r#"
820@group(0) @binding(0) var<storage, read> a: array<f32>;
821@group(0) @binding(1) var<storage, read> b: array<f32>;
822@group(0) @binding(2) var<storage, read_write> c: array<f32>;
823
824struct Params { N: u32 }
825@group(0) @binding(3) var<uniform> params: Params;
826
827@compute @workgroup_size(256)
828fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
829  let idx = gid.x;
830  if (idx >= params.N) { return; }
831  c[idx] = a[idx] + b[idx];
832}
833"#;
834
835        let module = nxpu_parser::parse(source).unwrap();
836        let backend = TfLiteBackend;
837        let opts = BackendOptions {
838            quantization_params: vec![
839                nxpu_backend_core::QuantParam {
840                    name: "x".into(),
841                    scale: 0.5,
842                    zero_point: 0,
843                },
844                nxpu_backend_core::QuantParam {
845                    name: "y".into(),
846                    scale: 0.25,
847                    zero_point: 128,
848                },
849            ],
850            ..Default::default()
851        };
852        let output = backend.compile(&module, &opts).unwrap();
853
854        // Should have the .tflite file plus quant_params.json
855        assert!(output.files.len() >= 2);
856
857        let json_file = output
858            .files
859            .iter()
860            .find(|f| f.name == "quant_params.json")
861            .expect("expected quant_params.json file");
862
863        let json_text = match &json_file.content {
864            OutputContent::Text(t) => t,
865            _ => panic!("expected text content for quant_params.json"),
866        };
867
868        assert!(json_text.contains("\"name\": \"x\""));
869        assert!(json_text.contains("\"scale\": 0.5"));
870        assert!(json_text.contains("\"zero_point\": 0"));
871        assert!(json_text.contains("\"name\": \"y\""));
872        assert!(json_text.contains("\"scale\": 0.25"));
873        assert!(json_text.contains("\"zero_point\": 128"));
874    }
875
876    #[test]
877    fn pattern_summary_all_variants() {
878        use nxpu_analysis::analyze::*;
879
880        // Test a few representative summaries to ensure pattern_summary works
881        let matmul = KernelPattern::MatMul {
882            inputs: [
883                make_tensor("A", TensorRole::Input),
884                make_tensor("B", TensorRole::Input),
885            ],
886            output: make_tensor("C", TensorRole::Output),
887            shape: MatMulShape {
888                m: "M".into(),
889                n: "N".into(),
890                k: "K".into(),
891            },
892        };
893        assert_eq!(pattern_summary(&matmul), "BATCH_MATMUL");
894
895        let add = KernelPattern::ElementWise {
896            op: ElementWiseOp::Add,
897            inputs: [
898                make_tensor("a", TensorRole::Input),
899                make_tensor("b", TensorRole::Input),
900            ],
901            output: make_tensor("c", TensorRole::Output),
902            dim_name: "N".into(),
903        };
904        assert_eq!(pattern_summary(&add), "Add");
905
906        let conv = KernelPattern::Conv2D {
907            input: make_tensor("x", TensorRole::Input),
908            weight: make_tensor("w", TensorRole::Input),
909            output: make_tensor("y", TensorRole::Output),
910            shape: Conv2DShape {
911                batch: "N".into(),
912                channels_in: "IC".into(),
913                channels_out: "OC".into(),
914                height: "H".into(),
915                width: "W".into(),
916                kernel_h: "KH".into(),
917                kernel_w: "KW".into(),
918                kernel_h_val: 3,
919                kernel_w_val: 3,
920                stride_h: 1,
921                stride_w: 1,
922                pad_h: 0,
923                pad_w: 0,
924                groups: 1,
925                dilation_h: 1,
926                dilation_w: 1,
927            },
928            bias: None,
929            activation: None,
930        };
931        assert_eq!(pattern_summary(&conv), "CONV_2D");
932
933        let norm = KernelPattern::Normalization {
934            input: make_tensor("x", TensorRole::Input),
935            scale: make_tensor("g", TensorRole::Input),
936            bias: make_tensor("b", TensorRole::Input),
937            output: make_tensor("y", TensorRole::Output),
938            epsilon: 1e-5,
939            norm_type: NormType::Batch,
940        };
941        assert_eq!(pattern_summary(&norm), "BatchNormalization");
942    }
943
944    #[test]
945    fn compile_with_per_channel_params_emits_json() {
946        let source = r#"
947@group(0) @binding(0) var<storage, read> a: array<f32>;
948@group(0) @binding(1) var<storage, read> b: array<f32>;
949@group(0) @binding(2) var<storage, read_write> c: array<f32>;
950
951struct Params { N: u32 }
952@group(0) @binding(3) var<uniform> params: Params;
953
954@compute @workgroup_size(256)
955fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
956  let idx = gid.x;
957  if (idx >= params.N) { return; }
958  c[idx] = a[idx] + b[idx];
959}
960"#;
961
962        let module = nxpu_parser::parse(source).unwrap();
963        let backend = TfLiteBackend;
964        let opts = BackendOptions {
965            per_channel_params: vec![nxpu_backend_core::PerChannelParam {
966                name: "conv_weight".into(),
967                scales: vec![0.1, 0.2, 0.3],
968                zero_points: vec![0, 0, 0],
969                channel_axis: 0,
970            }],
971            ..Default::default()
972        };
973        let output = backend.compile(&module, &opts).unwrap();
974
975        let json_file = output
976            .files
977            .iter()
978            .find(|f| f.name == "quant_params.json")
979            .expect("expected quant_params.json file");
980
981        let json_text = match &json_file.content {
982            OutputContent::Text(t) => t,
983            _ => panic!("expected text content for quant_params.json"),
984        };
985
986        assert!(json_text.contains("\"per_channel_params\""));
987        assert!(json_text.contains("\"name\": \"conv_weight\""));
988        assert!(json_text.contains("\"scales\": [0.1, 0.2, 0.3]"));
989        assert!(json_text.contains("\"zero_points\": [0, 0, 0]"));
990        assert!(json_text.contains("\"channel_axis\": 0"));
991    }
992
993    #[test]
994    fn compile_with_both_quant_and_per_channel_params() {
995        let source = r#"
996@group(0) @binding(0) var<storage, read> a: array<f32>;
997@group(0) @binding(1) var<storage, read> b: array<f32>;
998@group(0) @binding(2) var<storage, read_write> c: array<f32>;
999
1000struct Params { N: u32 }
1001@group(0) @binding(3) var<uniform> params: Params;
1002
1003@compute @workgroup_size(256)
1004fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
1005  let idx = gid.x;
1006  if (idx >= params.N) { return; }
1007  c[idx] = a[idx] + b[idx];
1008}
1009"#;
1010
1011        let module = nxpu_parser::parse(source).unwrap();
1012        let backend = TfLiteBackend;
1013        let opts = BackendOptions {
1014            quantization_params: vec![nxpu_backend_core::QuantParam {
1015                name: "x".into(),
1016                scale: 0.5,
1017                zero_point: 0,
1018            }],
1019            per_channel_params: vec![nxpu_backend_core::PerChannelParam {
1020                name: "weight".into(),
1021                scales: vec![0.1, 0.2],
1022                zero_points: vec![0, 1],
1023                channel_axis: 0,
1024            }],
1025            ..Default::default()
1026        };
1027        let output = backend.compile(&module, &opts).unwrap();
1028
1029        let json_file = output
1030            .files
1031            .iter()
1032            .find(|f| f.name == "quant_params.json")
1033            .expect("expected quant_params.json file");
1034
1035        let json_text = match &json_file.content {
1036            OutputContent::Text(t) => t,
1037            _ => panic!("expected text content for quant_params.json"),
1038        };
1039
1040        // Both sections should be present.
1041        assert!(json_text.contains("\"quantization_params\""));
1042        assert!(json_text.contains("\"per_channel_params\""));
1043        assert!(json_text.contains("\"name\": \"x\""));
1044        assert!(json_text.contains("\"name\": \"weight\""));
1045    }
1046}