Skip to main content

nxpu_opt/
quantize.rs

1//! Precision conversion passes for NPU quantization.
2//!
3//! Rewrites `array<f32>` global variable types to lower-precision
4//! element types suitable for specific NPU backends.
5//!
6//! Supports both naive type rewriting and calibration-based quantization
7//! with proper scale/zero_point computation.
8
9use nxpu_ir::{Handle, Module, Scalar, Type, TypeInner};
10
11use crate::Pass;
12use crate::calibrate::CalibrationResult;
13
14/// Parameters describing how floating-point values were quantized to integers.
15///
16/// Supports both per-tensor (single scale/zero_point) and per-channel
17/// quantization (vectors of scales/zero_points).
18#[derive(Clone, Debug)]
19pub struct QuantizationParams {
20    pub scale: f32,
21    pub zero_point: i32,
22}
23
24impl QuantizationParams {
25    /// Compute quantization parameters from observed min/max values.
26    ///
27    /// Uses asymmetric affine quantization:
28    ///   scale = (max - min) / 255
29    ///   zero_point = round(-min / scale)
30    pub fn from_range(min: f32, max: f32) -> Self {
31        let range = max - min;
32        if range < f32::EPSILON {
33            return Self {
34                scale: 1.0,
35                zero_point: 0,
36            };
37        }
38        let scale = range / 255.0;
39        let zero_point = (-min / scale).round() as i32;
40        // Clamp zero_point to [0, 255] for uint8 or [-128, 127] for int8.
41        let zero_point = zero_point.clamp(-128, 127);
42        Self { scale, zero_point }
43    }
44}
45
46/// Per-channel quantization parameters.
47///
48/// Each output channel of a convolution weight tensor gets its own
49/// scale and zero-point, as required by the TFLite INT8 specification.
50#[derive(Clone, Debug)]
51pub struct PerChannelQuantParams {
52    /// One scale per output channel.
53    pub scales: Vec<f32>,
54    /// One zero-point per output channel.
55    pub zero_points: Vec<i32>,
56    /// The axis along which per-channel quantization is applied (typically 0).
57    pub channel_axis: u32,
58}
59
60impl PerChannelQuantParams {
61    /// Returns the number of channels.
62    pub fn num_channels(&self) -> usize {
63        self.scales.len()
64    }
65}
66
67/// Calibration data for a single tensor, identified by binding.
68#[derive(Clone, Debug)]
69pub struct TensorCalibration {
70    /// Resource binding group.
71    pub group: u32,
72    /// Resource binding index.
73    pub binding: u32,
74    /// Observed minimum value during calibration.
75    pub min: f32,
76    /// Observed maximum value during calibration.
77    pub max: f32,
78}
79
80/// Calibration data for the entire module.
81#[derive(Clone, Debug, Default)]
82pub struct CalibrationData {
83    /// Per-tensor calibration entries.
84    pub tensors: Vec<TensorCalibration>,
85}
86
87impl CalibrationData {
88    /// Create calibration data from a list of (group, binding, min, max) tuples.
89    pub fn from_entries(entries: &[(u32, u32, f32, f32)]) -> Self {
90        Self {
91            tensors: entries
92                .iter()
93                .map(|&(group, binding, min, max)| TensorCalibration {
94                    group,
95                    binding,
96                    min,
97                    max,
98                })
99                .collect(),
100        }
101    }
102
103    /// Look up calibration for a specific binding.
104    pub fn find(&self, group: u32, binding: u32) -> Option<&TensorCalibration> {
105        self.tensors
106            .iter()
107            .find(|t| t.group == group && t.binding == binding)
108    }
109}
110
111/// Target precision for a single layer/variable in mixed-precision mode.
112///
113/// This is an alias for [`nxpu_backend_core::Precision`] to avoid duplicating
114/// the enum definition across crates.
115pub type LayerPrecision = nxpu_backend_core::Precision;
116
117/// Policy for choosing per-layer precision in mixed-precision quantization.
118///
119/// Maps global variable names to their target precision. Variables not listed
120/// in the policy use the default precision.
121#[derive(Clone, Debug)]
122pub struct MixedPrecisionPolicy {
123    /// Per-variable precision overrides, keyed by variable name.
124    pub overrides: Vec<(String, LayerPrecision)>,
125    /// Default precision for variables not in the overrides.
126    pub default: LayerPrecision,
127}
128
129impl MixedPrecisionPolicy {
130    /// Returns the precision for a variable with the given name.
131    pub fn precision_for(&self, name: &str) -> LayerPrecision {
132        self.overrides
133            .iter()
134            .find(|(n, _)| n == name)
135            .map(|(_, p)| *p)
136            .unwrap_or(self.default)
137    }
138}
139
140/// Sensitivity score for a single layer (lower = more sensitive).
141///
142/// Used by automatic mixed-precision selection to decide which layers
143/// should be kept at higher precision.
144#[derive(Clone, Debug)]
145pub struct SensitivityScore {
146    /// Name of the global variable.
147    pub name: String,
148    /// Estimated accuracy loss when quantizing this layer (0.0 = no loss).
149    pub score: f32,
150}
151
152/// Compute sensitivity scores for all storage globals in a module.
153///
154/// This is a heuristic that assigns higher sensitivity to:
155/// - Smaller tensors (less data to average over)
156/// - Output tensors (error accumulation at output is worse)
157///
158/// A production implementation would run calibration data through
159/// the model and measure actual accuracy impact.
160pub fn estimate_sensitivity(module: &Module) -> Vec<SensitivityScore> {
161    use nxpu_ir::{AddressSpace, StorageAccess};
162
163    let mut scores = Vec::new();
164
165    for (_handle, gv) in module.global_variables.iter() {
166        let is_storage = matches!(gv.space, AddressSpace::Storage { .. });
167        if !is_storage {
168            continue;
169        }
170
171        let name = gv
172            .name
173            .clone()
174            .unwrap_or_else(|| format!("var_{}", _handle.index()));
175
176        let is_output = matches!(
177            gv.space,
178            AddressSpace::Storage { access } if access.contains(StorageAccess::STORE)
179        );
180
181        // Heuristic: output tensors are more sensitive
182        let score = if is_output { 0.8 } else { 0.2 };
183
184        scores.push(SensitivityScore { name, score });
185    }
186
187    // Sort by sensitivity (most sensitive first)
188    scores.sort_by(|a, b| {
189        b.score
190            .partial_cmp(&a.score)
191            .unwrap_or(std::cmp::Ordering::Equal)
192    });
193    scores
194}
195
196/// Build a mixed-precision policy from sensitivity scores.
197///
198/// Layers with sensitivity above `threshold` are kept at `sensitive_precision`,
199/// while the rest use `default_precision`.
200pub fn policy_from_sensitivity(
201    scores: &[SensitivityScore],
202    threshold: f32,
203    default_precision: LayerPrecision,
204    sensitive_precision: LayerPrecision,
205) -> MixedPrecisionPolicy {
206    let overrides: Vec<_> = scores
207        .iter()
208        .filter(|s| s.score > threshold)
209        .map(|s| (s.name.clone(), sensitive_precision))
210        .collect();
211
212    MixedPrecisionPolicy {
213        overrides,
214        default: default_precision,
215    }
216}
217
218/// Rewrite element precision from F32 to a target scalar type.
219///
220/// Handles both `Array` types (adjusting base and stride) and `Tensor` types
221/// (replacing the scalar).
222///
223/// 1. Insert the target scalar type into the module's type arena.
224/// 2. Find the existing F32 scalar handle.
225/// 3. For each Array/Tensor type with F32 elements, insert a new type
226///    with the target scalar and adjusted stride.
227/// 4. Update `GlobalVariable.ty` handles via the remap.
228#[allow(clippy::collapsible_if)] // nested if-let for MSRV 1.87 compat (no let chains)
229fn rewrite_elem_precision(module: &mut Module, target_scalar: Scalar) -> bool {
230    // Insert target scalar type.
231    let target_scalar_handle = module.types.insert(Type {
232        name: None,
233        inner: TypeInner::Scalar(target_scalar),
234    });
235
236    // Find existing F32 scalar handle.
237    let f32_handle = module.types.insert(Type {
238        name: None,
239        inner: TypeInner::Scalar(Scalar::F32),
240    });
241
242    // Collect array types that need rewriting: (old_array_handle -> new_array_handle).
243    let mut remap: Vec<(Handle<Type>, Handle<Type>)> = Vec::new();
244
245    // First pass: find all array types with f32 base.
246    let array_types: Vec<_> = module
247        .types
248        .iter()
249        .filter_map(|(handle, ty)| {
250            if let TypeInner::Array { base, size, stride } = &ty.inner {
251                if *base == f32_handle {
252                    return Some((handle, *size, *stride));
253                }
254            }
255            None
256        })
257        .collect();
258
259    // Second pass: insert new array types and build remap.
260    for (old_handle, size, old_stride) in array_types {
261        let numerator = old_stride as u64 * target_scalar.width as u64;
262        let f32_width = Scalar::F32.width as u64;
263        if !numerator.is_multiple_of(f32_width) {
264            log::warn!(
265                "stride {old_stride} not evenly divisible when converting to {:?}, skipping",
266                target_scalar,
267            );
268            continue;
269        }
270        let new_stride = (numerator / f32_width) as u32;
271        let new_handle = module.types.insert(Type {
272            name: None,
273            inner: TypeInner::Array {
274                base: target_scalar_handle,
275                size,
276                stride: new_stride,
277            },
278        });
279        if old_handle != new_handle {
280            remap.push((old_handle, new_handle));
281        }
282    }
283
284    // Also rewrite Tensor types with F32 scalar.
285    let tensor_types: Vec<_> = module
286        .types
287        .iter()
288        .filter_map(|(handle, ty)| {
289            if let TypeInner::Tensor { scalar, shape } = &ty.inner {
290                if *scalar == Scalar::F32 {
291                    return Some((handle, shape.clone()));
292                }
293            }
294            None
295        })
296        .collect();
297
298    for (old_handle, shape) in tensor_types {
299        let new_handle = module.types.insert(Type {
300            name: None,
301            inner: TypeInner::Tensor {
302                scalar: target_scalar,
303                shape,
304            },
305        });
306        if old_handle != new_handle {
307            remap.push((old_handle, new_handle));
308        }
309    }
310
311    if remap.is_empty() {
312        return false;
313    }
314
315    // Apply remap to global variables.
316    let mut changed = false;
317    for (_handle, gv) in module.global_variables.iter_mut() {
318        for (old, new) in &remap {
319            if gv.ty == *old {
320                gv.ty = *new;
321                changed = true;
322            }
323        }
324    }
325
326    changed
327}
328
329/// Mixed-precision quantization pass.
330///
331/// Applies different precisions to different global variables based on
332/// a [`MixedPrecisionPolicy`]. This allows keeping sensitive layers
333/// (like Softmax or LayerNorm) at higher precision while quantizing
334/// the bulk of computation to INT8 or F16.
335#[derive(Debug)]
336pub struct MixedPrecisionPass {
337    /// The policy dictating per-variable precision.
338    pub policy: MixedPrecisionPolicy,
339}
340
341impl Pass for MixedPrecisionPass {
342    fn name(&self) -> &str {
343        "MixedPrecision"
344    }
345
346    fn run(&self, module: &mut Module) -> bool {
347        let mut changed = false;
348
349        // Collect (handle, target_scalar) pairs — O(n) via direct handle.
350        let conversions: Vec<(Handle<nxpu_ir::GlobalVariable>, Scalar)> = module
351            .global_variables
352            .iter()
353            .filter_map(|(handle, gv)| {
354                let name = gv.name.as_deref()?;
355                let precision = self.policy.precision_for(name);
356                let target = match precision {
357                    LayerPrecision::F32 => return None,
358                    LayerPrecision::F16 => Scalar::F16,
359                    LayerPrecision::BF16 => Scalar::BF16,
360                    LayerPrecision::Int8 => Scalar::I8,
361                };
362
363                // Check if already at target precision
364                let current_scalar = match &module.types[gv.ty].inner {
365                    TypeInner::Array { base, .. } => {
366                        if let TypeInner::Scalar(s) = &module.types[*base].inner {
367                            Some(*s)
368                        } else {
369                            None
370                        }
371                    }
372                    TypeInner::Tensor { scalar, .. } => Some(*scalar),
373                    _ => None,
374                };
375
376                if current_scalar == Some(target) {
377                    return None;
378                }
379
380                // Only convert from F32
381                if current_scalar != Some(Scalar::F32) {
382                    return None;
383                }
384
385                Some((handle, target))
386            })
387            .collect();
388
389        for (gv_handle, target_scalar) in conversions {
390            let old_ty = module.global_variables[gv_handle].ty;
391
392            // Extract type info before mutating type arena.
393            let type_info = match &module.types[old_ty].inner {
394                TypeInner::Array {
395                    base: _,
396                    size,
397                    stride,
398                } => Some((true, *size, *stride, None)),
399                TypeInner::Tensor { scalar: _, shape } => {
400                    Some((false, nxpu_ir::ArraySize::Dynamic, 0, Some(shape.clone())))
401                }
402                _ => None,
403            };
404
405            let new_ty = match type_info {
406                Some((true, size, stride, _)) => {
407                    let numerator = stride as u64 * target_scalar.width as u64;
408                    let f32_width = Scalar::F32.width as u64;
409                    if !numerator.is_multiple_of(f32_width) {
410                        log::warn!(
411                            "stride {stride} not evenly divisible when converting to {:?}, skipping",
412                            target_scalar,
413                        );
414                        continue;
415                    }
416                    let target_handle = module.types.insert(Type {
417                        name: None,
418                        inner: TypeInner::Scalar(target_scalar),
419                    });
420                    let new_stride = (numerator / f32_width) as u32;
421                    Some(module.types.insert(Type {
422                        name: None,
423                        inner: TypeInner::Array {
424                            base: target_handle,
425                            size,
426                            stride: new_stride,
427                        },
428                    }))
429                }
430                Some((false, _, _, Some(shape))) => Some(module.types.insert(Type {
431                    name: None,
432                    inner: TypeInner::Tensor {
433                        scalar: target_scalar,
434                        shape,
435                    },
436                })),
437                _ => None,
438            };
439
440            if let Some(new_ty_handle) = new_ty {
441                module.global_variables[gv_handle].ty = new_ty_handle;
442                changed = true;
443            }
444        }
445
446        changed
447    }
448}
449
450/// Converts `array<f32>` global variables to `array<f16>`.
451#[derive(Debug)]
452pub struct F32ToF16;
453
454impl Pass for F32ToF16 {
455    fn name(&self) -> &str {
456        "F32ToF16"
457    }
458
459    fn run(&self, module: &mut Module) -> bool {
460        rewrite_elem_precision(module, Scalar::F16)
461    }
462}
463
464/// Converts `array<f32>` global variables to `array<bf16>`.
465#[derive(Debug)]
466pub struct F32ToBf16;
467
468impl Pass for F32ToBf16 {
469    fn name(&self) -> &str {
470        "F32ToBf16"
471    }
472
473    fn run(&self, module: &mut Module) -> bool {
474        rewrite_elem_precision(module, Scalar::BF16)
475    }
476}
477
478/// Converts `array<f32>` global variables to `array<i8>`.
479///
480/// When calibration data is provided, computes proper scale/zero_point
481/// per tensor from observed min/max ranges. When a `CalibrationResult`
482/// is attached, calibrated parameters are available for downstream
483/// backends to emit quantization metadata (QDQ nodes, TFLite quant params).
484#[derive(Debug)]
485pub struct F32ToInt8 {
486    /// Per-tensor quantization parameters.
487    pub params: QuantizationParams,
488    /// Optional calibration data for computing scale/zero_point.
489    pub calibration: Option<CalibrationData>,
490    /// Per-tensor computed parameters (populated after run).
491    pub tensor_params: Vec<(u32, u32, QuantizationParams)>,
492    /// Optional calibration result from the calibration pipeline.
493    pub calibration_result: Option<CalibrationResult>,
494}
495
496impl Default for F32ToInt8 {
497    fn default() -> Self {
498        Self {
499            params: QuantizationParams {
500                scale: 1.0,
501                zero_point: 0,
502            },
503            calibration: None,
504            tensor_params: Vec::new(),
505            calibration_result: None,
506        }
507    }
508}
509
510impl F32ToInt8 {
511    /// Create with calibration data.
512    pub fn with_calibration(calibration: CalibrationData) -> Self {
513        Self {
514            params: QuantizationParams {
515                scale: 1.0,
516                zero_point: 0,
517            },
518            calibration: Some(calibration),
519            tensor_params: Vec::new(),
520            calibration_result: None,
521        }
522    }
523
524    /// Create with a calibration result from the calibration pipeline.
525    pub fn with_calibration_result(result: CalibrationResult) -> Self {
526        Self {
527            params: QuantizationParams {
528                scale: 1.0,
529                zero_point: 0,
530            },
531            calibration: None,
532            tensor_params: Vec::new(),
533            calibration_result: Some(result),
534        }
535    }
536}
537
538impl Pass for F32ToInt8 {
539    fn name(&self) -> &str {
540        "F32ToInt8"
541    }
542
543    fn run(&self, module: &mut Module) -> bool {
544        // Log calibration info if available.
545        if let Some(result) = &self.calibration_result {
546            result.log_summary();
547        }
548        // Note: calibration data is used by downstream ONNX QDQ emission
549        // and TFLite quantization metadata, not during type rewriting.
550        rewrite_elem_precision(module, Scalar::I8)
551    }
552}
553
554/// Compute per-tensor quantization parameters from calibration data and module.
555#[allow(clippy::collapsible_if)] // nested if-let for MSRV 1.87 compat (no let chains)
556pub fn compute_calibrated_params(
557    module: &Module,
558    calibration: &CalibrationData,
559) -> Vec<(String, QuantizationParams)> {
560    let mut result = Vec::new();
561    for (_handle, gv) in module.global_variables.iter() {
562        if let Some(binding) = &gv.binding {
563            if let Some(cal) = calibration.find(binding.group, binding.binding) {
564                let params = QuantizationParams::from_range(cal.min, cal.max);
565                let name = gv
566                    .name
567                    .clone()
568                    .unwrap_or_else(|| format!("tensor_{}_{}", binding.group, binding.binding));
569                result.push((name, params));
570            }
571        }
572    }
573    result
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579    use nxpu_ir::*;
580
581    fn make_f32_array_module() -> (Module, Handle<GlobalVariable>, Handle<GlobalVariable>) {
582        let mut module = Module::default();
583
584        let f32_ty = module.types.insert(Type {
585            name: None,
586            inner: TypeInner::Scalar(Scalar::F32),
587        });
588        let array_f32 = module.types.insert(Type {
589            name: None,
590            inner: TypeInner::Array {
591                base: f32_ty,
592                size: ArraySize::Dynamic,
593                stride: 4,
594            },
595        });
596
597        let h0 = module.global_variables.append(GlobalVariable {
598            name: Some("a".into()),
599            space: AddressSpace::Storage {
600                access: StorageAccess::LOAD,
601            },
602            binding: Some(ResourceBinding {
603                group: 0,
604                binding: 0,
605            }),
606            ty: array_f32,
607            init: None,
608            layout: None,
609        });
610        let h1 = module.global_variables.append(GlobalVariable {
611            name: Some("b".into()),
612            space: AddressSpace::Storage {
613                access: StorageAccess::LOAD,
614            },
615            binding: Some(ResourceBinding {
616                group: 0,
617                binding: 1,
618            }),
619            ty: array_f32,
620            init: None,
621            layout: None,
622        });
623
624        (module, h0, h1)
625    }
626
627    fn get_array_elem_scalar(module: &Module, gv_handle: Handle<GlobalVariable>) -> Scalar {
628        let ty = &module.types[module.global_variables[gv_handle].ty];
629        match &ty.inner {
630            TypeInner::Array { base, .. } => match &module.types[*base].inner {
631                TypeInner::Scalar(s) => *s,
632                other => panic!("expected Scalar, got {other:?}"),
633            },
634            other => panic!("expected Array, got {other:?}"),
635        }
636    }
637
638    fn get_array_stride(module: &Module, gv_handle: Handle<GlobalVariable>) -> u32 {
639        let ty = &module.types[module.global_variables[gv_handle].ty];
640        match &ty.inner {
641            TypeInner::Array { stride, .. } => *stride,
642            other => panic!("expected Array, got {other:?}"),
643        }
644    }
645
646    #[test]
647    fn f32_to_f16() {
648        let (mut module, h0, h1) = make_f32_array_module();
649        let changed = F32ToF16.run(&mut module);
650        assert!(changed);
651
652        assert_eq!(get_array_elem_scalar(&module, h0), Scalar::F16);
653        assert_eq!(get_array_elem_scalar(&module, h1), Scalar::F16);
654        assert_eq!(get_array_stride(&module, h0), 2);
655    }
656
657    #[test]
658    fn f32_to_bf16() {
659        let (mut module, h0, _h1) = make_f32_array_module();
660        let changed = F32ToBf16.run(&mut module);
661        assert!(changed);
662
663        assert_eq!(get_array_elem_scalar(&module, h0), Scalar::BF16);
664        assert_eq!(get_array_stride(&module, h0), 2);
665    }
666
667    #[test]
668    fn f32_to_int8() {
669        let (mut module, h0, _h1) = make_f32_array_module();
670        let changed = F32ToInt8::default().run(&mut module);
671        assert!(changed);
672
673        assert_eq!(get_array_elem_scalar(&module, h0), Scalar::I8);
674        assert_eq!(get_array_stride(&module, h0), 1);
675    }
676
677    #[test]
678    fn no_change_when_no_f32_arrays() {
679        let mut module = Module::default();
680        let changed = F32ToF16.run(&mut module);
681        assert!(!changed);
682    }
683
684    #[test]
685    fn idempotent() {
686        let (mut module, _h0, _h1) = make_f32_array_module();
687        F32ToF16.run(&mut module);
688        let changed = F32ToF16.run(&mut module);
689        // After first rewrite there are no more f32 arrays, so no change.
690        assert!(!changed);
691    }
692
693    #[test]
694    fn per_channel_params() {
695        let params = PerChannelQuantParams {
696            scales: vec![0.1, 0.2, 0.3],
697            zero_points: vec![0, 1, -1],
698            channel_axis: 0,
699        };
700        assert_eq!(params.num_channels(), 3);
701    }
702
703    #[test]
704    fn mixed_precision_policy() {
705        let policy = MixedPrecisionPolicy {
706            overrides: vec![
707                ("softmax".into(), LayerPrecision::F16),
708                ("layernorm".into(), LayerPrecision::F32),
709            ],
710            default: LayerPrecision::Int8,
711        };
712
713        assert_eq!(policy.precision_for("softmax"), LayerPrecision::F16);
714        assert_eq!(policy.precision_for("layernorm"), LayerPrecision::F32);
715        assert_eq!(policy.precision_for("conv1"), LayerPrecision::Int8);
716    }
717
718    #[test]
719    fn mixed_precision_pass() {
720        let (mut module, h0, h1) = make_f32_array_module();
721
722        let pass = MixedPrecisionPass {
723            policy: MixedPrecisionPolicy {
724                overrides: vec![("a".into(), LayerPrecision::F16)],
725                default: LayerPrecision::Int8,
726            },
727        };
728        let changed = pass.run(&mut module);
729        assert!(changed);
730
731        // "a" should be F16
732        assert_eq!(get_array_elem_scalar(&module, h0), Scalar::F16);
733        // "b" should be Int8
734        assert_eq!(get_array_elem_scalar(&module, h1), Scalar::I8);
735    }
736
737    #[test]
738    fn sensitivity_estimation() {
739        let mut module = Module::default();
740        let f32_ty = module.types.insert(Type {
741            name: None,
742            inner: TypeInner::Scalar(Scalar::F32),
743        });
744        let array_f32 = module.types.insert(Type {
745            name: None,
746            inner: TypeInner::Array {
747                base: f32_ty,
748                size: ArraySize::Dynamic,
749                stride: 4,
750            },
751        });
752
753        module.global_variables.append(GlobalVariable {
754            name: Some("weights".into()),
755            space: AddressSpace::Storage {
756                access: StorageAccess::LOAD,
757            },
758            binding: None,
759            ty: array_f32,
760            init: None,
761            layout: None,
762        });
763        module.global_variables.append(GlobalVariable {
764            name: Some("output".into()),
765            space: AddressSpace::Storage {
766                access: StorageAccess::LOAD | StorageAccess::STORE,
767            },
768            binding: None,
769            ty: array_f32,
770            init: None,
771            layout: None,
772        });
773
774        let scores = estimate_sensitivity(&module);
775        assert_eq!(scores.len(), 2);
776        // Output should be more sensitive (sorted first)
777        assert_eq!(scores[0].name, "output");
778        assert!(scores[0].score > scores[1].score);
779    }
780
781    #[test]
782    fn policy_from_sensitivity_scores() {
783        let scores = vec![
784            SensitivityScore {
785                name: "output".into(),
786                score: 0.8,
787            },
788            SensitivityScore {
789                name: "weights".into(),
790                score: 0.2,
791            },
792        ];
793
794        let policy =
795            policy_from_sensitivity(&scores, 0.5, LayerPrecision::Int8, LayerPrecision::F16);
796
797        assert_eq!(policy.precision_for("output"), LayerPrecision::F16);
798        assert_eq!(policy.precision_for("weights"), LayerPrecision::Int8);
799    }
800
801    #[test]
802    fn quantization_params_from_range() {
803        let params = QuantizationParams::from_range(-1.0, 1.0);
804        assert!((params.scale - 2.0 / 255.0).abs() < 1e-6);
805        // zero_point = round(1.0 / (2.0/255.0)) = round(127.5) = 128 → clamped to 127
806        assert_eq!(params.zero_point, 127);
807    }
808
809    #[test]
810    fn quantization_params_positive_range() {
811        let params = QuantizationParams::from_range(0.0, 6.0);
812        assert!((params.scale - 6.0 / 255.0).abs() < 1e-6);
813        assert_eq!(params.zero_point, 0);
814    }
815
816    #[test]
817    fn quantization_params_zero_range() {
818        let params = QuantizationParams::from_range(5.0, 5.0);
819        assert_eq!(params.scale, 1.0);
820        assert_eq!(params.zero_point, 0);
821    }
822
823    #[test]
824    fn calibration_data_lookup() {
825        let cal = CalibrationData::from_entries(&[(0, 0, -1.0, 1.0), (0, 1, 0.0, 6.0)]);
826        assert!(cal.find(0, 0).is_some());
827        assert!(cal.find(0, 1).is_some());
828        assert!(cal.find(0, 2).is_none());
829    }
830
831    #[test]
832    fn compute_calibrated_params_test() {
833        let (module, _h0, _h1) = make_f32_array_module();
834        let cal = CalibrationData::from_entries(&[(0, 0, -1.0, 1.0), (0, 1, 0.0, 6.0)]);
835        let params = compute_calibrated_params(&module, &cal);
836        assert_eq!(params.len(), 2);
837        assert_eq!(params[0].0, "a");
838        assert_eq!(params[1].0, "b");
839        assert!((params[0].1.scale - 2.0 / 255.0).abs() < 1e-6);
840        assert!((params[1].1.scale - 6.0 / 255.0).abs() < 1e-6);
841    }
842
843    #[test]
844    fn int8_with_calibration() {
845        let (mut module, h0, _h1) = make_f32_array_module();
846        let cal = CalibrationData::from_entries(&[(0, 0, -1.0, 1.0), (0, 1, 0.0, 255.0)]);
847        let pass = F32ToInt8::with_calibration(cal);
848        let changed = pass.run(&mut module);
849        assert!(changed);
850        assert_eq!(get_array_elem_scalar(&module, h0), Scalar::I8);
851    }
852
853    // ---- Tensor type rewriting tests ----
854
855    fn make_f32_tensor_module() -> (Module, Handle<GlobalVariable>, Handle<GlobalVariable>) {
856        let mut module = Module::default();
857
858        let tensor_f32 = module.types.insert(Type {
859            name: None,
860            inner: TypeInner::Tensor {
861                scalar: Scalar::F32,
862                shape: TensorShape::fixed(&[1, 3, 224, 224]),
863            },
864        });
865
866        let h0 = module.global_variables.append(GlobalVariable {
867            name: Some("weights".into()),
868            space: AddressSpace::Storage {
869                access: StorageAccess::LOAD,
870            },
871            binding: Some(ResourceBinding {
872                group: 0,
873                binding: 0,
874            }),
875            ty: tensor_f32,
876            init: None,
877            layout: None,
878        });
879        let h1 = module.global_variables.append(GlobalVariable {
880            name: Some("bias".into()),
881            space: AddressSpace::Storage {
882                access: StorageAccess::LOAD,
883            },
884            binding: Some(ResourceBinding {
885                group: 0,
886                binding: 1,
887            }),
888            ty: tensor_f32,
889            init: None,
890            layout: None,
891        });
892
893        (module, h0, h1)
894    }
895
896    fn get_tensor_scalar(module: &Module, gv_handle: Handle<GlobalVariable>) -> Scalar {
897        let ty = &module.types[module.global_variables[gv_handle].ty];
898        match &ty.inner {
899            TypeInner::Tensor { scalar, .. } => *scalar,
900            other => panic!("expected Tensor, got {other:?}"),
901        }
902    }
903
904    #[test]
905    fn f32_to_f16_tensor_type() {
906        let (mut module, h0, h1) = make_f32_tensor_module();
907        let changed = F32ToF16.run(&mut module);
908        assert!(changed);
909        assert_eq!(get_tensor_scalar(&module, h0), Scalar::F16);
910        assert_eq!(get_tensor_scalar(&module, h1), Scalar::F16);
911    }
912
913    #[test]
914    fn f32_to_bf16_tensor_type() {
915        let (mut module, h0, _h1) = make_f32_tensor_module();
916        let changed = F32ToBf16.run(&mut module);
917        assert!(changed);
918        assert_eq!(get_tensor_scalar(&module, h0), Scalar::BF16);
919    }
920
921    #[test]
922    fn f32_to_int8_tensor_type() {
923        let (mut module, h0, _h1) = make_f32_tensor_module();
924        let changed = F32ToInt8::default().run(&mut module);
925        assert!(changed);
926        assert_eq!(get_tensor_scalar(&module, h0), Scalar::I8);
927    }
928
929    #[test]
930    fn tensor_type_idempotent() {
931        let (mut module, _h0, _h1) = make_f32_tensor_module();
932        F32ToF16.run(&mut module);
933        let changed = F32ToF16.run(&mut module);
934        assert!(!changed);
935    }
936
937    // ---- with_calibration_result and log_summary path ----
938
939    #[test]
940    fn int8_with_calibration_result() {
941        use crate::calibrate::CalibrationResult;
942
943        let result = CalibrationResult {
944            tensor_params: vec![
945                (
946                    "weights".into(),
947                    QuantizationParams {
948                        scale: 0.05,
949                        zero_point: 0,
950                    },
951                ),
952                (
953                    "bias".into(),
954                    QuantizationParams {
955                        scale: 0.01,
956                        zero_point: 3,
957                    },
958                ),
959            ],
960            weight_params: Vec::new(),
961            method: None,
962        };
963
964        let (mut module, h0, _h1) = make_f32_array_module();
965        let pass = F32ToInt8::with_calibration_result(result);
966
967        // Verify the calibration_result is stored
968        assert!(pass.calibration_result.is_some());
969
970        let changed = pass.run(&mut module);
971        assert!(changed);
972        assert_eq!(get_array_elem_scalar(&module, h0), Scalar::I8);
973    }
974
975    #[test]
976    fn int8_with_calibration_result_on_tensor_types() {
977        use crate::calibrate::CalibrationResult;
978
979        let result = CalibrationResult {
980            tensor_params: vec![(
981                "weights".into(),
982                QuantizationParams {
983                    scale: 0.05,
984                    zero_point: 0,
985                },
986            )],
987            weight_params: Vec::new(),
988            method: None,
989        };
990
991        let (mut module, h0, _h1) = make_f32_tensor_module();
992        let pass = F32ToInt8::with_calibration_result(result);
993        let changed = pass.run(&mut module);
994        assert!(changed);
995        assert_eq!(get_tensor_scalar(&module, h0), Scalar::I8);
996    }
997
998    // ---- MixedPrecisionPass with Tensor types ----
999
1000    #[test]
1001    fn mixed_precision_pass_with_tensor_types() {
1002        let (mut module, h0, h1) = make_f32_tensor_module();
1003
1004        let pass = MixedPrecisionPass {
1005            policy: MixedPrecisionPolicy {
1006                overrides: vec![("weights".into(), LayerPrecision::F16)],
1007                default: LayerPrecision::Int8,
1008            },
1009        };
1010        let changed = pass.run(&mut module);
1011        assert!(changed);
1012
1013        // "weights" should be F16
1014        assert_eq!(get_tensor_scalar(&module, h0), Scalar::F16);
1015        // "bias" should be Int8
1016        assert_eq!(get_tensor_scalar(&module, h1), Scalar::I8);
1017    }
1018
1019    #[test]
1020    fn mixed_precision_pass_skips_already_at_target() {
1021        let mut module = Module::default();
1022
1023        let tensor_f16 = module.types.insert(Type {
1024            name: None,
1025            inner: TypeInner::Tensor {
1026                scalar: Scalar::F16,
1027                shape: TensorShape::fixed(&[1, 3, 224, 224]),
1028            },
1029        });
1030
1031        module.global_variables.append(GlobalVariable {
1032            name: Some("already_f16".into()),
1033            space: AddressSpace::Storage {
1034                access: StorageAccess::LOAD,
1035            },
1036            binding: None,
1037            ty: tensor_f16,
1038            init: None,
1039            layout: None,
1040        });
1041
1042        let pass = MixedPrecisionPass {
1043            policy: MixedPrecisionPolicy {
1044                overrides: vec![],
1045                default: LayerPrecision::F16,
1046            },
1047        };
1048        // Should not change since it's already F16
1049        let changed = pass.run(&mut module);
1050        assert!(!changed);
1051    }
1052
1053    #[test]
1054    fn mixed_precision_pass_skips_non_f32() {
1055        let mut module = Module::default();
1056
1057        let tensor_i8 = module.types.insert(Type {
1058            name: None,
1059            inner: TypeInner::Tensor {
1060                scalar: Scalar::I8,
1061                shape: TensorShape::fixed(&[1, 256]),
1062            },
1063        });
1064
1065        module.global_variables.append(GlobalVariable {
1066            name: Some("quantized".into()),
1067            space: AddressSpace::Storage {
1068                access: StorageAccess::LOAD,
1069            },
1070            binding: None,
1071            ty: tensor_i8,
1072            init: None,
1073            layout: None,
1074        });
1075
1076        let pass = MixedPrecisionPass {
1077            policy: MixedPrecisionPolicy {
1078                overrides: vec![],
1079                default: LayerPrecision::F16,
1080            },
1081        };
1082        // Should not change since source is I8, not F32
1083        let changed = pass.run(&mut module);
1084        assert!(!changed);
1085    }
1086
1087    #[test]
1088    fn mixed_precision_bf16_on_tensor_type() {
1089        let (mut module, h0, _h1) = make_f32_tensor_module();
1090
1091        let pass = MixedPrecisionPass {
1092            policy: MixedPrecisionPolicy {
1093                overrides: vec![("weights".into(), LayerPrecision::BF16)],
1094                default: LayerPrecision::F32,
1095            },
1096        };
1097        let changed = pass.run(&mut module);
1098        assert!(changed);
1099        assert_eq!(get_tensor_scalar(&module, h0), Scalar::BF16);
1100    }
1101}