Skip to main content

nxpu/
main.rs

1use std::path::PathBuf;
2use std::process::ExitCode;
3
4use clap::Parser;
5use miette::{Context, IntoDiagnostic};
6
7use nxpu_backend_core::{
8    BackendOptions, BackendRegistry, ConstantTensor, OutputContent, Precision, PrecisionPolicy,
9};
10use nxpu_opt::{OptLevel, PassManager};
11
12/// NxPU — WGSL to NPU transpiler
13#[derive(Parser)]
14#[command(version, about)]
15#[allow(clippy::struct_excessive_bools)]
16struct Cli {
17    /// Input WGSL file
18    input: Option<PathBuf>,
19
20    /// Target backend (default: ir-dump)
21    #[arg(short, long, default_value = "ir-dump")]
22    target: String,
23
24    /// Output path (default: stdout)
25    #[arg(short, long)]
26    output: Option<PathBuf>,
27
28    /// Optimization level: 0, 1, or 2
29    #[arg(long, default_value = "1", value_parser = parse_opt_level)]
30    opt_level: OptLevel,
31
32    /// Dump IR to stderr before backend compilation
33    #[arg(long)]
34    emit_ir: bool,
35
36    /// Print memory plan to stderr (peak memory, buffer count, reuse ratio)
37    #[arg(long)]
38    emit_memory_plan: bool,
39
40    /// Dump operation schedule to stderr (dataflow analysis + scheduling)
41    #[arg(long)]
42    emit_schedule: bool,
43
44    /// Validate and optimize without producing output
45    #[arg(long)]
46    dry_run: bool,
47
48    /// Precision policy: keep, f16, bf16, int8, or auto (default: auto)
49    #[arg(long, default_value = "auto", value_parser = parse_precision)]
50    precision: PrecisionPolicy,
51
52    /// Mark the first dimension of all input tensors as dynamic (variable batch size)
53    #[arg(long)]
54    dynamic_batch: bool,
55
56    /// Concrete extent for tensor dimensions the kernel leaves symbolic
57    ///
58    /// A WGSL kernel over `array<f32>` carries no length, so most dimensions
59    /// have no size at compile time. ONNX names them and lets the runtime
60    /// resolve them; TFLite requires a concrete extent and will not load a
61    /// model without one. Without this flag the smallest valid extent (1) is
62    /// used, which loads but measures nothing.
63    #[arg(long, value_name = "N")]
64    symbolic_dim: Option<u32>,
65
66    /// Directory of tensor contents, one `<tensor-name>.bin` per tensor
67    ///
68    /// A WGSL kernel takes its weights through `var<storage, read>`, so the
69    /// source says nothing about what they are — the host binds them per
70    /// dispatch. An NNAPI driver will not take a convolution on those terms.
71    /// Measured on a MediaTek MT6899: `mtk-neuron_shim` accelerates a
72    /// convolution whose filter is a compile-time constant and refuses the
73    /// identical convolution, at the same shapes, whose filter is a graph
74    /// input. Naming a tensor here emits it as a constant instead.
75    ///
76    /// Raw little-endian, in the tensor's own element type. A file whose size
77    /// does not match the tensor is an error rather than a truncation.
78    ///
79    /// Not needed for the GPU: TFLite's own delegate takes a convolution with
80    /// a runtime filter, and on that phone it accelerates more of what this
81    /// compiler emits than any NNAPI driver does.
82    #[arg(long, value_name = "DIR")]
83    weights: Option<PathBuf>,
84
85    /// Directory containing calibration data (.bin files with f32 values)
86    #[arg(long)]
87    calibration_data: Option<PathBuf>,
88
89    /// Calibration method: minmax, percentile, kl-divergence (default: minmax)
90    #[arg(long, default_value = "minmax", value_parser = parse_calibration_method)]
91    calibration_method: nxpu_opt::CalibrationMethod,
92
93    /// Print per-op cost estimation and roofline latency analysis to stderr
94    #[arg(long)]
95    estimate_latency: bool,
96
97    /// Verbose output (print calibration statistics, etc.)
98    #[arg(short, long)]
99    verbose: bool,
100
101    /// List all available target backends and exit
102    #[arg(long)]
103    list_targets: bool,
104}
105
106/// Read `<tensor-name>.bin` for every tensor the caller supplied.
107///
108/// The naming follows `--calibration-data`, which is the convention this
109/// project already has for feeding a directory of external data in. Only the
110/// file stem is used, so `weight.bin` supplies the tensor a WGSL kernel binds
111/// as `weight`.
112///
113/// Nothing is invented here. A tensor with no file stays a graph input, and
114/// the backend says what that costs; filling one with zeros to win an
115/// acceleration would produce a model that runs, is attributed to the
116/// accelerator, and computes the wrong thing.
117fn load_constant_tensors(dir: &std::path::Path) -> miette::Result<Vec<ConstantTensor>> {
118    let entries = std::fs::read_dir(dir)
119        .map_err(|e| miette::miette!("cannot read the weights directory {}: {e}", dir.display()))?;
120    let mut tensors = Vec::new();
121    for entry in entries {
122        let path = entry
123            .map_err(|e| miette::miette!("cannot read the weights directory: {e}"))?
124            .path();
125        if path.extension().and_then(|e| e.to_str()) != Some("bin") {
126            continue;
127        }
128        let Some(name) = path.file_stem().and_then(|s| s.to_str()) else {
129            continue;
130        };
131        let data = std::fs::read(&path)
132            .map_err(|e| miette::miette!("cannot read {}: {e}", path.display()))?;
133        tensors.push(ConstantTensor {
134            name: name.to_string(),
135            data,
136        });
137    }
138    // Deterministic order, so two runs over the same directory produce the
139    // same bytes and a comparison between them means something.
140    tensors.sort_by(|a, b| a.name.cmp(&b.name));
141    Ok(tensors)
142}
143
144fn parse_calibration_method(s: &str) -> Result<nxpu_opt::CalibrationMethod, String> {
145    nxpu_opt::CalibrationMethod::from_str_name(s).ok_or_else(|| {
146        format!("invalid calibration method '{s}', expected minmax, percentile, or kl-divergence")
147    })
148}
149
150fn parse_precision(s: &str) -> Result<PrecisionPolicy, String> {
151    match s {
152        "keep" => Ok(PrecisionPolicy::Keep),
153        "f16" => Ok(PrecisionPolicy::Explicit(Precision::F16)),
154        "bf16" => Ok(PrecisionPolicy::Explicit(Precision::BF16)),
155        "int8" => Ok(PrecisionPolicy::Explicit(Precision::Int8)),
156        "auto" => Ok(PrecisionPolicy::Auto),
157        _ => Err(format!(
158            "invalid precision '{s}', expected keep, f16, bf16, int8, or auto"
159        )),
160    }
161}
162
163fn parse_opt_level(s: &str) -> Result<OptLevel, String> {
164    match s {
165        "0" => Ok(OptLevel::O0),
166        "1" => Ok(OptLevel::O1),
167        "2" => Ok(OptLevel::O2),
168        _ => Err(format!(
169            "invalid optimization level '{s}', expected 0, 1, or 2"
170        )),
171    }
172}
173
174fn main() -> ExitCode {
175    match run() {
176        Ok(()) => ExitCode::SUCCESS,
177        Err(err) => {
178            eprintln!("Error: {err:?}");
179            ExitCode::FAILURE
180        }
181    }
182}
183
184fn build_registry() -> BackendRegistry {
185    #[allow(unused_mut)]
186    let mut registry = BackendRegistry::with_builtins();
187    #[cfg(feature = "backend-onnx")]
188    registry.register(Box::new(nxpu_backend_onnx::OnnxBackend));
189    #[cfg(feature = "backend-tflite")]
190    registry.register(Box::new(nxpu_backend_tflite::TfLiteBackend));
191    #[cfg(feature = "backend-coreml")]
192    registry.register(Box::new(nxpu_backend_coreml::CoreMlBackend));
193    #[cfg(feature = "backend-stablehlo")]
194    registry.register(Box::new(nxpu_backend_stablehlo::StableHloBackend));
195    #[cfg(feature = "backend-samsung")]
196    registry.register(Box::new(nxpu_backend_samsung::SamsungBackend));
197    #[cfg(feature = "backend-mediatek")]
198    registry.register(Box::new(nxpu_backend_mediatek::MediaTekBackend));
199    #[cfg(feature = "backend-intel")]
200    registry.register(Box::new(nxpu_backend_intel::IntelBackend));
201    #[cfg(feature = "backend-amd")]
202    registry.register(Box::new(nxpu_backend_amd::AmdBackend));
203    #[cfg(feature = "backend-qualcomm")]
204    registry.register(Box::new(nxpu_backend_qualcomm::QualcommBackend));
205    #[cfg(feature = "backend-arm-ethos")]
206    registry.register(Box::new(nxpu_backend_arm_ethos::ArmEthosBackend));
207    #[cfg(feature = "backend-ceva")]
208    registry.register(Box::new(nxpu_backend_ceva::CevaBackend));
209    #[cfg(feature = "backend-rockchip")]
210    registry.register(Box::new(nxpu_backend_rockchip::RockchipBackend));
211    registry
212}
213
214fn run() -> miette::Result<()> {
215    env_logger::try_init().ok();
216
217    let cli = Cli::parse();
218
219    // --list-targets: print available backends and exit.
220    if cli.list_targets {
221        let registry = build_registry();
222        for target in registry.list_targets() {
223            println!("{target}");
224        }
225        return Ok(());
226    }
227
228    let input = cli.input.ok_or_else(|| {
229        miette::miette!("input file is required (use --list-targets to list backends)")
230    })?;
231
232    // 1. Read source file.
233    let source = std::fs::read_to_string(&input)
234        .into_diagnostic()
235        .wrap_err_with(|| format!("failed to read {}", input.display()))?;
236
237    // 2. Parse WGSL to IR.
238    let mut module = nxpu_parser::parse(&source)
239        .map_err(|e| miette::miette!("{e}"))
240        .wrap_err("WGSL parse failed")?;
241
242    // 3. Optimize.
243    PassManager::for_level(cli.opt_level).run(&mut module);
244
245    // 3b. Apply --dynamic-batch: mark the first dimension of all input
246    //     storage buffer tensor types as Symbolic("batch").
247    if cli.dynamic_batch {
248        apply_dynamic_batch(&mut module);
249    }
250
251    // 4. Optionally dump IR to stderr.
252    if cli.emit_ir {
253        eprintln!("{}", nxpu_ir::dump_module(&module));
254    }
255
256    // 4b. Memory planning (always computed; optionally printed).
257    let memory_plan = nxpu_opt::plan_memory(&module);
258    if cli.emit_memory_plan {
259        eprint!("{memory_plan}");
260    }
261
262    // 4c. Optionally dump schedule to stderr.
263    if cli.emit_schedule {
264        let schedules = nxpu_opt::compute_schedules(&module);
265        for (name, dfg, schedule) in &schedules {
266            eprintln!("{}", nxpu_opt::format_schedule(name, dfg, schedule));
267        }
268    }
269
270    // 4d. Optionally estimate per-op latency using roofline model.
271    if cli.estimate_latency {
272        let profiles = nxpu_analysis::default_profiles();
273        for (i, ep) in module.entry_points.iter().enumerate() {
274            let pattern = match nxpu_analysis::classify_entry_point(&module, i) {
275                Ok(p) => p,
276                Err(e) => {
277                    eprintln!("Entry point '{}': classification failed: {e}", ep.name);
278                    continue;
279                }
280            };
281            let cost = nxpu_analysis::estimate_kernel_cost(&pattern);
282            eprintln!("Entry point '{}': {}", ep.name, pattern);
283            eprintln!("  {cost}");
284            eprintln!(
285                "  Arithmetic intensity: {:.2} FLOP/byte",
286                cost.arithmetic_intensity()
287            );
288            for (target, profile) in &profiles {
289                let latency = profile.predict_latency_secs(&cost);
290                let bottleneck = profile.bottleneck(&cost);
291                eprintln!(
292                    "  [{target}] {}: {latency:.6}s ({bottleneck})",
293                    profile.name
294                );
295            }
296        }
297    }
298
299    // 5. Dry-run: stop here.
300    if cli.dry_run {
301        return Ok(());
302    }
303
304    // 6. Backend dispatch.
305    let registry = build_registry();
306    let backend = registry.find(&cli.target).ok_or_else(|| {
307        let available = registry.list_targets().join(", ");
308        miette::miette!("unknown target '{}' (available: {})", cli.target, available)
309    })?;
310
311    // 6b. Resolve precision and run quantization pass.
312    let mut quantization_params: Vec<nxpu_backend_core::QuantParam> = Vec::new();
313    let mut per_channel_params: Vec<nxpu_backend_core::PerChannelParam> = Vec::new();
314    let resolved_precision = match cli.precision {
315        PrecisionPolicy::Keep => None,
316        PrecisionPolicy::Explicit(p) => Some(p),
317        PrecisionPolicy::Auto => {
318            let pref = backend.preferred_precision();
319            if pref == Precision::F32 {
320                None
321            } else {
322                Some(pref)
323            }
324        }
325    };
326
327    if let Some(precision) = resolved_precision {
328        use nxpu_opt::Pass;
329        match precision {
330            Precision::F16 => {
331                nxpu_opt::F32ToF16.run(&mut module);
332            }
333            Precision::BF16 => {
334                nxpu_opt::F32ToBf16.run(&mut module);
335            }
336            Precision::Int8 => {
337                // If calibration data directory is provided, run the calibration pipeline.
338                if let Some(cal_dir) = &cli.calibration_data {
339                    let dataset = nxpu_opt::CalibrationDataset::load_from_dir(cal_dir)
340                        .map_err(|e| miette::miette!("calibration failed: {e}"))?;
341
342                    if cli.verbose {
343                        eprintln!(
344                            "Loaded {} calibration samples from {}",
345                            dataset.num_samples(),
346                            cal_dir.display()
347                        );
348                    }
349
350                    let cal_result = nxpu_opt::run_calibration(
351                        &dataset,
352                        &cli.calibration_method,
353                        true, // symmetric for INT8
354                    )
355                    .map_err(|e| miette::miette!("calibration failed: {e}"))?;
356
357                    if cli.verbose {
358                        eprintln!("Calibration results:");
359                        for (name, params) in &cal_result.tensor_params {
360                            eprintln!(
361                                "  {}: scale={:.6}, zero_point={}",
362                                name, params.scale, params.zero_point
363                            );
364                        }
365                    }
366
367                    // Capture quantization params for embedding in output.
368                    for (name, params) in &cal_result.tensor_params {
369                        quantization_params.push(nxpu_backend_core::QuantParam {
370                            name: name.clone(),
371                            scale: params.scale,
372                            zero_point: params.zero_point,
373                        });
374                    }
375
376                    // Wire per-channel weight params.
377                    for (name, pcp) in &cal_result.weight_params {
378                        per_channel_params.push(nxpu_backend_core::PerChannelParam {
379                            name: name.clone(),
380                            scales: pcp.scales.clone(),
381                            zero_points: pcp.zero_points.clone(),
382                            channel_axis: pcp.channel_axis,
383                        });
384                    }
385
386                    nxpu_opt::F32ToInt8::with_calibration_result(cal_result).run(&mut module);
387                } else {
388                    nxpu_opt::F32ToInt8::default().run(&mut module);
389                }
390            }
391            Precision::F32 => {}
392        }
393    }
394
395    // 6c. Compute tiling plans and vectorization hints for each entry point.
396    let mut tiling_plans = Vec::new();
397    let mut vectorization_hints = Vec::new();
398    for i in 0..module.entry_points.len() {
399        if let Ok(pattern) = nxpu_analysis::classify_entry_point(&module, i) {
400            // Tiling plans.
401            match &pattern {
402                nxpu_analysis::KernelPattern::MatMul { shape, .. } => {
403                    let m = shape.m.parse::<u32>().unwrap_or(256);
404                    let n = shape.n.parse::<u32>().unwrap_or(256);
405                    let k = shape.k.parse::<u32>().unwrap_or(256);
406                    let defaults = nxpu_opt::TilingDefaults::default();
407                    let plan =
408                        nxpu_opt::tile_matmul(&nxpu_opt::TilingMatMulShape { m, n, k }, &defaults);
409                    tiling_plans.push(nxpu_backend_core::TilingPlanInfo {
410                        op_name: plan.op_name,
411                        tiles: plan
412                            .tiles
413                            .iter()
414                            .map(|t| (t.dim_name.clone(), t.tile_size))
415                            .collect(),
416                        reuse_factor: plan.reuse_factor,
417                    });
418                }
419                nxpu_analysis::KernelPattern::Conv2D { shape, .. } => {
420                    let oh = shape.height.parse::<u32>().unwrap_or(32);
421                    let ow = shape.width.parse::<u32>().unwrap_or(32);
422                    let defaults = nxpu_opt::TilingDefaults::default();
423                    let plan = nxpu_opt::tile_conv2d(
424                        &nxpu_opt::TilingConv2DShape {
425                            oh,
426                            ow,
427                            kh: shape.kernel_h_val as u32,
428                            kw: shape.kernel_w_val as u32,
429                        },
430                        &defaults,
431                    );
432                    tiling_plans.push(nxpu_backend_core::TilingPlanInfo {
433                        op_name: plan.op_name,
434                        tiles: plan
435                            .tiles
436                            .iter()
437                            .map(|t| (t.dim_name.clone(), t.tile_size))
438                            .collect(),
439                        reuse_factor: plan.reuse_factor,
440                    });
441                }
442                _ => {}
443            }
444            // Vectorization hints.
445            for hint in nxpu_opt::analyze_vectorization(&pattern, 128) {
446                vectorization_hints.push(nxpu_backend_core::VectorizationHintInfo {
447                    op_name: hint.op_name,
448                    dim_name: hint.dim_name,
449                    lanes: hint.vector_width.lanes,
450                    is_reduction: hint.is_reduction,
451                    is_contiguous: hint.is_contiguous,
452                });
453            }
454        }
455    }
456
457    // Read before compiling, so a missing or unreadable file is reported here
458    // rather than after the work.
459    let constant_tensors = match &cli.weights {
460        None => Vec::new(),
461        Some(dir) => load_constant_tensors(dir)?,
462    };
463
464    let opts = BackendOptions {
465        opt_level: match cli.opt_level {
466            OptLevel::O0 => 0,
467            OptLevel::O1 => 1,
468            OptLevel::O2 => 2,
469        },
470        precision: cli.precision,
471        memory_plan: Some(memory_plan),
472        quantization_params,
473        per_channel_params,
474        tiling_plans,
475        vectorization_hints,
476        symbolic_extent: cli.symbolic_dim,
477        constant_tensors,
478    };
479
480    let output = backend
481        .compile(&module, &opts)
482        .map_err(|e| miette::miette!("{e}"))
483        .wrap_err("backend compilation failed")?;
484
485    // 7. Print diagnostics.
486    for diag in &output.diagnostics {
487        eprintln!("{:?}: {}", diag.level, diag.message);
488    }
489
490    // 8. Write output.
491    if let Some(base) = &cli.output {
492        if output.files.len() > 1 {
493            // Multi-file output: derive per-file paths from the base output path.
494            let stem = base
495                .file_stem()
496                .map(|s| s.to_string_lossy().into_owned())
497                .unwrap_or_else(|| "output".into());
498            let parent = base.parent().unwrap_or_else(|| std::path::Path::new("."));
499
500            for file in &output.files {
501                let dest = parent.join(format!("{stem}_{}", file.name));
502                write_output_file(&dest, &file.content)?;
503            }
504        } else {
505            for file in &output.files {
506                write_output_file(base, &file.content)?;
507            }
508        }
509    } else {
510        for file in &output.files {
511            match &file.content {
512                OutputContent::Text(text) => print!("{text}"),
513                OutputContent::Binary(_) => {
514                    return Err(miette::miette!(
515                        "backend produced binary output but no --output path was specified"
516                    ));
517                }
518            }
519        }
520    }
521
522    Ok(())
523}
524
525/// Apply `--dynamic-batch`: for every storage-buffer global variable whose
526/// type is an `Array` (the common WGSL pattern), wrap it in a rank-2+ Tensor
527/// type with the first dimension set to `Symbolic("batch")`.
528///
529/// This is a best-effort transformation: it only affects storage buffers
530/// (both read-only and read-write), leaving uniforms and other address spaces
531/// untouched.
532fn apply_dynamic_batch(module: &mut nxpu_ir::Module) {
533    use nxpu_ir::{AddressSpace, Dimension, Scalar, TensorShape, Type, TypeInner};
534
535    // Collect storage variable handles and their current array element scalar.
536    let targets: Vec<(nxpu_ir::Handle<nxpu_ir::GlobalVariable>, Scalar)> = module
537        .global_variables
538        .iter()
539        .filter_map(|(handle, gv)| {
540            if let AddressSpace::Storage { .. } = &gv.space {
541                match &module.types[gv.ty].inner {
542                    TypeInner::Array { base, .. } => {
543                        if let TypeInner::Scalar(s) = &module.types[*base].inner {
544                            return Some((handle, *s));
545                        }
546                    }
547                    TypeInner::Tensor { scalar, .. } => {
548                        return Some((handle, *scalar));
549                    }
550                    _ => {}
551                }
552            }
553            None
554        })
555        .collect();
556
557    for (handle, scalar) in targets {
558        let gv = &module.global_variables[handle];
559        let old_ty = gv.ty;
560        let new_ty = match &module.types[old_ty].inner {
561            TypeInner::Array { .. } => {
562                // Convert array<scalar> to tensor<scalar>[batch, ?]
563                module.types.insert(Type {
564                    name: None,
565                    inner: TypeInner::Tensor {
566                        scalar,
567                        shape: TensorShape {
568                            dims: vec![
569                                Dimension::Symbolic("batch".into()),
570                                Dimension::Dynamic(None),
571                            ],
572                        },
573                    },
574                })
575            }
576            TypeInner::Tensor { shape, .. } => {
577                // Tensor type: replace the first dimension with Symbolic("batch")
578                let mut new_dims = shape.dims.clone();
579                if !new_dims.is_empty() {
580                    new_dims[0] = Dimension::Symbolic("batch".into());
581                }
582                module.types.insert(Type {
583                    name: None,
584                    inner: TypeInner::Tensor {
585                        scalar,
586                        shape: TensorShape { dims: new_dims },
587                    },
588                })
589            }
590            _ => continue,
591        };
592        // Update the global variable to use the new type.
593        module.global_variables[handle].ty = new_ty;
594    }
595}
596
597fn write_output_file(path: &std::path::Path, content: &OutputContent) -> miette::Result<()> {
598    match content {
599        OutputContent::Text(text) => std::fs::write(path, text),
600        OutputContent::Binary(data) => std::fs::write(path, data),
601    }
602    .into_diagnostic()
603    .wrap_err_with(|| format!("failed to write {}", path.display()))
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609    use clap::Parser;
610
611    // ---- Argument parsing ----
612
613    #[test]
614    fn cli_defaults() {
615        let cli = Cli::try_parse_from(["nxpu", "input.wgsl"]).unwrap();
616        assert_eq!(cli.input.unwrap(), PathBuf::from("input.wgsl"));
617        assert_eq!(cli.target, "ir-dump");
618        assert!(cli.output.is_none());
619        assert_eq!(cli.opt_level, OptLevel::O1);
620        assert!(!cli.emit_ir);
621        assert!(!cli.emit_memory_plan);
622        assert!(!cli.emit_schedule);
623        assert!(!cli.dry_run);
624        assert!(!cli.dynamic_batch);
625        assert_eq!(cli.precision, PrecisionPolicy::Auto);
626        assert!(cli.calibration_data.is_none());
627        assert_eq!(cli.calibration_method, nxpu_opt::CalibrationMethod::MinMax);
628        assert!(!cli.verbose);
629        assert!(!cli.list_targets);
630    }
631
632    #[test]
633    fn cli_all_flags() {
634        let cli = Cli::try_parse_from([
635            "nxpu",
636            "model.wgsl",
637            "--target",
638            "onnx",
639            "--output",
640            "out.onnx",
641            "--opt-level",
642            "2",
643            "--emit-ir",
644            "--emit-memory-plan",
645            "--emit-schedule",
646            "--precision",
647            "f16",
648        ])
649        .unwrap();
650        assert_eq!(cli.input.unwrap(), PathBuf::from("model.wgsl"));
651        assert_eq!(cli.target, "onnx");
652        assert_eq!(cli.output.unwrap(), PathBuf::from("out.onnx"));
653        assert_eq!(cli.opt_level, OptLevel::O2);
654        assert!(cli.emit_ir);
655        assert!(cli.emit_memory_plan);
656        assert!(cli.emit_schedule);
657        assert_eq!(cli.precision, PrecisionPolicy::Explicit(Precision::F16));
658    }
659
660    #[test]
661    fn cli_short_flags() {
662        let cli =
663            Cli::try_parse_from(["nxpu", "in.wgsl", "-t", "tflite", "-o", "out.tflite"]).unwrap();
664        assert_eq!(cli.target, "tflite");
665        assert_eq!(cli.output.unwrap(), PathBuf::from("out.tflite"));
666    }
667
668    #[test]
669    fn cli_dynamic_batch_flag() {
670        let cli = Cli::try_parse_from(["nxpu", "model.wgsl", "--dynamic-batch"]).unwrap();
671        assert!(cli.dynamic_batch);
672    }
673
674    #[test]
675    fn cli_estimate_latency_flag() {
676        let cli = Cli::try_parse_from(["nxpu", "model.wgsl", "--estimate-latency"]).unwrap();
677        assert!(cli.estimate_latency);
678    }
679
680    #[test]
681    fn cli_estimate_latency_default_off() {
682        let cli = Cli::try_parse_from(["nxpu", "model.wgsl"]).unwrap();
683        assert!(!cli.estimate_latency);
684    }
685
686    #[test]
687    fn cli_list_targets_no_input() {
688        let cli = Cli::try_parse_from(["nxpu", "--list-targets"]).unwrap();
689        assert!(cli.list_targets);
690        assert!(cli.input.is_none());
691    }
692
693    #[test]
694    fn cli_invalid_opt_level() {
695        let result = Cli::try_parse_from(["nxpu", "in.wgsl", "--opt-level", "3"]);
696        assert!(result.is_err());
697    }
698
699    #[test]
700    fn cli_invalid_precision() {
701        let result = Cli::try_parse_from(["nxpu", "in.wgsl", "--precision", "f64"]);
702        assert!(result.is_err());
703    }
704
705    // ---- parse_precision ----
706
707    #[test]
708    fn precision_valid_values() {
709        assert_eq!(parse_precision("keep").unwrap(), PrecisionPolicy::Keep);
710        assert_eq!(
711            parse_precision("f16").unwrap(),
712            PrecisionPolicy::Explicit(Precision::F16)
713        );
714        assert_eq!(
715            parse_precision("bf16").unwrap(),
716            PrecisionPolicy::Explicit(Precision::BF16)
717        );
718        assert_eq!(
719            parse_precision("int8").unwrap(),
720            PrecisionPolicy::Explicit(Precision::Int8)
721        );
722        assert_eq!(parse_precision("auto").unwrap(), PrecisionPolicy::Auto);
723    }
724
725    #[test]
726    fn precision_invalid_value() {
727        let err = parse_precision("f64").unwrap_err();
728        assert!(err.contains("invalid precision"));
729        assert!(err.contains("f64"));
730    }
731
732    // ---- parse_opt_level ----
733
734    #[test]
735    fn opt_level_valid_values() {
736        assert_eq!(parse_opt_level("0").unwrap(), OptLevel::O0);
737        assert_eq!(parse_opt_level("1").unwrap(), OptLevel::O1);
738        assert_eq!(parse_opt_level("2").unwrap(), OptLevel::O2);
739    }
740
741    #[test]
742    fn opt_level_invalid_value() {
743        let err = parse_opt_level("3").unwrap_err();
744        assert!(err.contains("invalid optimization level"));
745        assert!(err.contains('3'));
746    }
747
748    // ---- Target validation (build_registry) ----
749
750    #[test]
751    fn registry_always_has_ir_dump() {
752        let registry = build_registry();
753        assert!(
754            registry.find("ir-dump").is_some(),
755            "ir-dump should always be available"
756        );
757    }
758
759    #[test]
760    fn registry_unknown_target_returns_none() {
761        let registry = build_registry();
762        assert!(registry.find("nonexistent-backend").is_none());
763    }
764
765    #[test]
766    fn registry_list_targets_includes_ir_dump() {
767        let registry = build_registry();
768        let targets = registry.list_targets();
769        assert!(targets.contains(&"ir-dump"));
770    }
771
772    #[cfg(feature = "backend-onnx")]
773    #[test]
774    fn registry_has_onnx_when_enabled() {
775        let registry = build_registry();
776        assert!(registry.find("onnx").is_some());
777    }
778
779    #[cfg(feature = "backend-tflite")]
780    #[test]
781    fn registry_has_tflite_when_enabled() {
782        let registry = build_registry();
783        assert!(registry.find("tflite").is_some());
784    }
785
786    // ---- Output path generation ----
787
788    #[test]
789    fn multi_file_output_path_derivation() {
790        let base = PathBuf::from("/tmp/model.onnx");
791        let stem = base
792            .file_stem()
793            .map(|s| s.to_string_lossy().into_owned())
794            .unwrap_or_else(|| "output".into());
795        let parent = base.parent().unwrap_or_else(|| std::path::Path::new("."));
796
797        let dest = parent.join(format!("{stem}_{}", "weights.bin"));
798        assert_eq!(dest, PathBuf::from("/tmp/model_weights.bin"));
799    }
800
801    #[test]
802    fn multi_file_output_path_no_extension() {
803        let base = PathBuf::from("output");
804        let stem = base
805            .file_stem()
806            .map(|s| s.to_string_lossy().into_owned())
807            .unwrap_or_else(|| "output".into());
808        let parent = base.parent().unwrap_or_else(|| std::path::Path::new("."));
809
810        let dest = parent.join(format!("{stem}_{}", "data.bin"));
811        assert_eq!(dest, PathBuf::from("output_data.bin"));
812    }
813
814    // ---- Error formatting ----
815
816    #[test]
817    fn unknown_target_error_lists_available() {
818        let registry = build_registry();
819        let result = registry.find("bogus");
820        assert!(result.is_none());
821        let available = registry.list_targets().join(", ");
822        let msg = format!("unknown target 'bogus' (available: {available})");
823        assert!(msg.contains("bogus"));
824        assert!(msg.contains("ir-dump"));
825    }
826
827    // ---- apply_dynamic_batch ----
828
829    #[test]
830    fn apply_dynamic_batch_rewrites_array_to_tensor() {
831        use nxpu_ir::*;
832
833        let mut module = Module::default();
834        let f32_ty = module.types.insert(Type {
835            name: None,
836            inner: TypeInner::Scalar(Scalar::F32),
837        });
838        let array_ty = module.types.insert(Type {
839            name: None,
840            inner: TypeInner::Array {
841                base: f32_ty,
842                size: ArraySize::Dynamic,
843                stride: 4,
844            },
845        });
846        let handle = module.global_variables.append(GlobalVariable {
847            name: Some("input".into()),
848            space: AddressSpace::Storage {
849                access: StorageAccess::LOAD,
850            },
851            binding: None,
852            ty: array_ty,
853            init: None,
854            layout: None,
855        });
856
857        apply_dynamic_batch(&mut module);
858
859        let new_ty = &module.types[module.global_variables[handle].ty].inner;
860        match new_ty {
861            TypeInner::Tensor { scalar, shape } => {
862                assert_eq!(*scalar, Scalar::F32);
863                assert_eq!(shape.rank(), 2);
864                assert_eq!(shape.dims[0], Dimension::Symbolic("batch".into()));
865                assert_eq!(shape.dims[1], Dimension::Dynamic(None));
866            }
867            _ => panic!("expected Tensor type after apply_dynamic_batch"),
868        }
869    }
870
871    #[test]
872    fn apply_dynamic_batch_rewrites_existing_tensor() {
873        use nxpu_ir::*;
874
875        let mut module = Module::default();
876        let tensor_ty = module.types.insert(Type {
877            name: None,
878            inner: TypeInner::Tensor {
879                scalar: Scalar::F32,
880                shape: TensorShape {
881                    dims: vec![
882                        Dimension::Fixed(1),
883                        Dimension::Fixed(224),
884                        Dimension::Fixed(224),
885                        Dimension::Fixed(3),
886                    ],
887                },
888            },
889        });
890        let handle = module.global_variables.append(GlobalVariable {
891            name: Some("image".into()),
892            space: AddressSpace::Storage {
893                access: StorageAccess::LOAD,
894            },
895            binding: None,
896            ty: tensor_ty,
897            init: None,
898            layout: None,
899        });
900
901        apply_dynamic_batch(&mut module);
902
903        let new_ty = &module.types[module.global_variables[handle].ty].inner;
904        match new_ty {
905            TypeInner::Tensor { shape, .. } => {
906                assert_eq!(shape.dims[0], Dimension::Symbolic("batch".into()));
907                assert_eq!(shape.dims[1], Dimension::Fixed(224));
908                assert_eq!(shape.dims[2], Dimension::Fixed(224));
909                assert_eq!(shape.dims[3], Dimension::Fixed(3));
910            }
911            _ => panic!("expected Tensor type"),
912        }
913    }
914
915    #[test]
916    fn apply_dynamic_batch_skips_uniform() {
917        use nxpu_ir::*;
918
919        let mut module = Module::default();
920        let u32_ty = module.types.insert(Type {
921            name: None,
922            inner: TypeInner::Scalar(Scalar::U32),
923        });
924        let params_ty = module.types.insert(Type {
925            name: Some("Params".into()),
926            inner: TypeInner::Struct {
927                members: vec![StructMember {
928                    name: Some("N".into()),
929                    ty: u32_ty,
930                    offset: 0,
931                }],
932                span: 4,
933            },
934        });
935        let handle = module.global_variables.append(GlobalVariable {
936            name: Some("params".into()),
937            space: AddressSpace::Uniform,
938            binding: None,
939            ty: params_ty,
940            init: None,
941            layout: None,
942        });
943
944        apply_dynamic_batch(&mut module);
945
946        // Uniform variable should not be modified.
947        assert_eq!(module.global_variables[handle].ty, params_ty);
948    }
949
950    #[test]
951    fn missing_input_error_message() {
952        let err = miette::miette!("input file is required (use --list-targets to list backends)");
953        let msg = format!("{err}");
954        assert!(msg.contains("input file is required"));
955        assert!(msg.contains("--list-targets"));
956    }
957
958    // ---- Calibration CLI flags ----
959
960    #[test]
961    fn cli_calibration_defaults() {
962        let cli = Cli::try_parse_from(["nxpu", "input.wgsl"]).unwrap();
963        assert!(cli.calibration_data.is_none());
964        assert_eq!(cli.calibration_method, nxpu_opt::CalibrationMethod::MinMax);
965        assert!(!cli.verbose);
966    }
967
968    #[test]
969    fn cli_calibration_data_flag() {
970        let cli =
971            Cli::try_parse_from(["nxpu", "input.wgsl", "--calibration-data", "/tmp/cal_data"])
972                .unwrap();
973        assert_eq!(
974            cli.calibration_data.unwrap(),
975            PathBuf::from("/tmp/cal_data")
976        );
977    }
978
979    #[test]
980    fn cli_calibration_method_minmax() {
981        let cli =
982            Cli::try_parse_from(["nxpu", "input.wgsl", "--calibration-method", "minmax"]).unwrap();
983        assert_eq!(cli.calibration_method, nxpu_opt::CalibrationMethod::MinMax);
984    }
985
986    #[test]
987    fn cli_calibration_method_percentile() {
988        let cli = Cli::try_parse_from(["nxpu", "input.wgsl", "--calibration-method", "percentile"])
989            .unwrap();
990        assert_eq!(
991            cli.calibration_method,
992            nxpu_opt::CalibrationMethod::Percentile(99.99)
993        );
994    }
995
996    #[test]
997    fn cli_calibration_method_kl() {
998        let cli = Cli::try_parse_from([
999            "nxpu",
1000            "input.wgsl",
1001            "--calibration-method",
1002            "kl-divergence",
1003        ])
1004        .unwrap();
1005        assert_eq!(
1006            cli.calibration_method,
1007            nxpu_opt::CalibrationMethod::KlDivergence
1008        );
1009    }
1010
1011    #[test]
1012    fn cli_calibration_method_invalid() {
1013        let result = Cli::try_parse_from(["nxpu", "input.wgsl", "--calibration-method", "invalid"]);
1014        assert!(result.is_err());
1015    }
1016
1017    #[test]
1018    fn cli_verbose_flag() {
1019        let cli = Cli::try_parse_from(["nxpu", "input.wgsl", "--verbose"]).unwrap();
1020        assert!(cli.verbose);
1021    }
1022
1023    #[test]
1024    fn cli_verbose_short_flag() {
1025        let cli = Cli::try_parse_from(["nxpu", "input.wgsl", "-v"]).unwrap();
1026        assert!(cli.verbose);
1027    }
1028
1029    // ---- parse_calibration_method ----
1030
1031    #[test]
1032    fn calibration_method_valid_values() {
1033        assert_eq!(
1034            parse_calibration_method("minmax").unwrap(),
1035            nxpu_opt::CalibrationMethod::MinMax
1036        );
1037        assert_eq!(
1038            parse_calibration_method("percentile").unwrap(),
1039            nxpu_opt::CalibrationMethod::Percentile(99.99)
1040        );
1041        assert_eq!(
1042            parse_calibration_method("kl-divergence").unwrap(),
1043            nxpu_opt::CalibrationMethod::KlDivergence
1044        );
1045    }
1046
1047    #[test]
1048    fn calibration_method_invalid_value() {
1049        let err = parse_calibration_method("bogus").unwrap_err();
1050        assert!(err.contains("invalid calibration method"));
1051        assert!(err.contains("bogus"));
1052    }
1053}