1use 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#[derive(Debug)]
18pub struct TfLiteBackend;
19
20fn 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 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 let fused = fusion::fuse_patterns(patterns);
74
75 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_attention_diagnostics(fp, ep_name, &mut diagnostics);
111 emit_runtime_filter_diagnostic(fp, ep_name, opts, &mut diagnostics);
112
113 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 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
189fn 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 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
275fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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}