Skip to main content

nxpu_opt/
calibrate.rs

1//! Calibration pipeline for quantization.
2//!
3//! Provides infrastructure for loading calibration datasets, collecting
4//! activation histograms, and computing optimal quantization parameters
5//! using various calibration methods (MinMax, Percentile, KL-divergence).
6
7use std::path::{Path, PathBuf};
8
9use crate::quantize::{PerChannelQuantParams, QuantizationParams};
10
11/// Errors that can occur during calibration.
12#[derive(Debug, thiserror::Error)]
13pub enum CalibrationError {
14    /// Failed to read a calibration data file.
15    #[error("failed to read calibration file {path}: {source}")]
16    IoError {
17        path: PathBuf,
18        source: std::io::Error,
19    },
20    /// Calibration data file has invalid size (not a multiple of 4 bytes for f32).
21    #[error("calibration file {path} has invalid size {size} (must be a multiple of 4 bytes)")]
22    InvalidFileSize { path: PathBuf, size: u64 },
23    /// No calibration samples found in the directory.
24    #[error("no .bin calibration files found in {0}")]
25    NoSamples(PathBuf),
26    /// Empty histogram (no values collected).
27    #[error("empty histogram: no values were collected")]
28    EmptyHistogram,
29}
30
31/// Calibration method for determining quantization parameters.
32#[derive(Clone, Debug, PartialEq)]
33pub enum CalibrationMethod {
34    /// Use the observed min/max values directly.
35    MinMax,
36    /// Clip to the given percentile (e.g. 99.99) to reduce outlier impact.
37    Percentile(f32),
38    /// Find the optimal clipping threshold that minimizes KL divergence
39    /// between the original and quantized distributions (TensorRT-style).
40    KlDivergence,
41}
42
43impl CalibrationMethod {
44    /// Parse a calibration method from a CLI string.
45    pub fn from_str_name(s: &str) -> Option<Self> {
46        match s {
47            "minmax" => Some(Self::MinMax),
48            "percentile" => Some(Self::Percentile(99.99)),
49            "kl-divergence" | "kl" | "entropy" => Some(Self::KlDivergence),
50            _ => None,
51        }
52    }
53}
54
55/// A set of calibration samples loaded from binary files.
56#[derive(Clone, Debug)]
57pub struct CalibrationDataset {
58    /// Each sample is a flat vector of f32 values.
59    pub samples: Vec<Vec<f32>>,
60    /// The directory these samples were loaded from.
61    pub source_dir: PathBuf,
62}
63
64impl CalibrationDataset {
65    /// Load calibration tensors from a directory of `.bin` files.
66    ///
67    /// Each `.bin` file must contain raw little-endian f32 values.
68    /// Files are sorted by name for deterministic ordering.
69    pub fn load_from_dir(dir: &Path) -> Result<Self, CalibrationError> {
70        let mut bin_files: Vec<PathBuf> = Vec::new();
71
72        let entries = std::fs::read_dir(dir).map_err(|e| CalibrationError::IoError {
73            path: dir.to_path_buf(),
74            source: e,
75        })?;
76
77        for entry in entries {
78            let entry = entry.map_err(|e| CalibrationError::IoError {
79                path: dir.to_path_buf(),
80                source: e,
81            })?;
82            let path = entry.path();
83            if path.extension().is_some_and(|ext| ext == "bin") {
84                bin_files.push(path);
85            }
86        }
87
88        bin_files.sort();
89
90        if bin_files.is_empty() {
91            return Err(CalibrationError::NoSamples(dir.to_path_buf()));
92        }
93
94        let mut samples = Vec::with_capacity(bin_files.len());
95        for path in &bin_files {
96            let data = std::fs::read(path).map_err(|e| CalibrationError::IoError {
97                path: path.clone(),
98                source: e,
99            })?;
100
101            if data.len() % 4 != 0 {
102                return Err(CalibrationError::InvalidFileSize {
103                    path: path.clone(),
104                    size: data.len() as u64,
105                });
106            }
107
108            // clippy::chunks_exact_to_as_chunks, new in the 1.98 toolchain,
109            // suggests `as_chunks::<4>()`. That is newer than the rust-version
110            // this workspace declares, so the suggestion cannot be taken until
111            // the MSRV moves; then this allow should go with it.
112            #[allow(clippy::chunks_exact_to_as_chunks)]
113            let floats: Vec<f32> = data
114                .chunks_exact(4)
115                .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
116                .collect();
117
118            samples.push(floats);
119        }
120
121        Ok(Self {
122            samples,
123            source_dir: dir.to_path_buf(),
124        })
125    }
126
127    /// Create a dataset from in-memory samples (useful for testing).
128    pub fn from_samples(samples: Vec<Vec<f32>>) -> Self {
129        Self {
130            samples,
131            source_dir: PathBuf::new(),
132        }
133    }
134
135    /// Returns the number of calibration samples.
136    pub fn num_samples(&self) -> usize {
137        self.samples.len()
138    }
139}
140
141/// Histogram of activation values for a single tensor.
142#[derive(Clone, Debug)]
143pub struct TensorHistogram {
144    /// Observed minimum value.
145    pub min: f32,
146    /// Observed maximum value.
147    pub max: f32,
148    /// Bin counts.
149    pub bins: Vec<u32>,
150    /// Bin edges (len = bins.len() + 1).
151    pub bin_edges: Vec<f32>,
152    /// Total number of values collected.
153    pub total_count: u64,
154}
155
156/// Default number of histogram bins.
157const DEFAULT_NUM_BINS: usize = 2048;
158
159impl TensorHistogram {
160    /// Create a new histogram from a collection of f32 values.
161    ///
162    /// The histogram covers the range [min, max] of the input values
163    /// with `num_bins` equal-width bins.
164    pub fn from_values(values: &[f32], num_bins: usize) -> Result<Self, CalibrationError> {
165        if values.is_empty() {
166            return Err(CalibrationError::EmptyHistogram);
167        }
168
169        let mut min = f32::INFINITY;
170        let mut max = f32::NEG_INFINITY;
171        for &v in values {
172            if v < min {
173                min = v;
174            }
175            if v > max {
176                max = v;
177            }
178        }
179
180        // Handle constant tensors.
181        if (max - min).abs() < f32::EPSILON {
182            return Ok(Self {
183                min,
184                max,
185                bins: vec![values.len() as u32],
186                bin_edges: vec![min, max + f32::EPSILON],
187                total_count: values.len() as u64,
188            });
189        }
190
191        let mut bins = vec![0u32; num_bins];
192        let mut bin_edges = Vec::with_capacity(num_bins + 1);
193        let bin_width = (max - min) / num_bins as f32;
194
195        for i in 0..=num_bins {
196            bin_edges.push(min + bin_width * i as f32);
197        }
198
199        for &v in values {
200            let idx = ((v - min) / bin_width) as usize;
201            let idx = idx.min(num_bins - 1);
202            bins[idx] += 1;
203        }
204
205        Ok(Self {
206            min,
207            max,
208            bins,
209            bin_edges,
210            total_count: values.len() as u64,
211        })
212    }
213
214    /// Merge another histogram's values into this one.
215    ///
216    /// If the ranges differ, the histogram is rebuilt with the union range.
217    pub fn merge(&mut self, other: &TensorHistogram) {
218        let new_min = self.min.min(other.min);
219        let new_max = self.max.max(other.max);
220        let num_bins = self.bins.len();
221
222        if (new_min - self.min).abs() < f32::EPSILON
223            && (new_max - self.max).abs() < f32::EPSILON
224            && other.bins.len() == num_bins
225        {
226            // Same range, just add counts.
227            for (a, b) in self.bins.iter_mut().zip(other.bins.iter()) {
228                *a += b;
229            }
230            self.total_count += other.total_count;
231            return;
232        }
233
234        // Rebuild with new range. Redistribute existing counts.
235        let new_width = (new_max - new_min) / num_bins as f32;
236        let mut new_bins = vec![0u32; num_bins];
237        let mut new_edges = Vec::with_capacity(num_bins + 1);
238        for i in 0..=num_bins {
239            new_edges.push(new_min + new_width * i as f32);
240        }
241
242        // Redistribute self's bins.
243        redistribute_bins(&self.bins, &self.bin_edges, &mut new_bins, &new_edges);
244        // Redistribute other's bins.
245        redistribute_bins(&other.bins, &other.bin_edges, &mut new_bins, &new_edges);
246
247        self.min = new_min;
248        self.max = new_max;
249        self.bins = new_bins;
250        self.bin_edges = new_edges;
251        self.total_count += other.total_count;
252    }
253
254    /// Compute the value at a given percentile (0.0 to 100.0).
255    pub fn percentile(&self, pct: f32) -> f32 {
256        let target_count = (pct / 100.0 * self.total_count as f32) as u64;
257        let mut cumulative = 0u64;
258
259        for (i, &count) in self.bins.iter().enumerate() {
260            cumulative += count as u64;
261            if cumulative >= target_count {
262                // Interpolate within this bin.
263                let bin_start = self.bin_edges[i];
264                let bin_end = self.bin_edges[i + 1];
265                if count == 0 {
266                    return bin_start;
267                }
268                let prev_cumulative = cumulative - count as u64;
269                let frac = (target_count - prev_cumulative) as f32 / count as f32;
270                return bin_start + frac * (bin_end - bin_start);
271            }
272        }
273
274        self.max
275    }
276}
277
278/// Redistribute counts from old bins to new bins using proportional overlap.
279fn redistribute_bins(old_bins: &[u32], old_edges: &[f32], new_bins: &mut [u32], new_edges: &[f32]) {
280    let new_num = new_bins.len();
281    for (i, &count) in old_bins.iter().enumerate() {
282        if count == 0 {
283            continue;
284        }
285        let old_lo = old_edges[i];
286        let old_hi = old_edges[i + 1];
287        let old_width = old_hi - old_lo;
288        if old_width <= 0.0 {
289            continue;
290        }
291
292        for j in 0..new_num {
293            let new_lo = new_edges[j];
294            let new_hi = new_edges[j + 1];
295
296            // Compute overlap.
297            let overlap_lo = old_lo.max(new_lo);
298            let overlap_hi = old_hi.min(new_hi);
299            if overlap_lo >= overlap_hi {
300                continue;
301            }
302            let overlap_frac = (overlap_hi - overlap_lo) / old_width;
303            new_bins[j] += (count as f32 * overlap_frac).round() as u32;
304        }
305    }
306}
307
308/// Histogram collector that aggregates activation values across calibration samples.
309#[derive(Clone, Debug)]
310pub struct HistogramCollector {
311    /// Number of bins for histograms.
312    pub num_bins: usize,
313    /// Collected histograms, keyed by tensor name.
314    pub histograms: Vec<(String, TensorHistogram)>,
315}
316
317impl Default for HistogramCollector {
318    fn default() -> Self {
319        Self {
320            num_bins: DEFAULT_NUM_BINS,
321            histograms: Vec::new(),
322        }
323    }
324}
325
326impl HistogramCollector {
327    /// Create a collector with a custom number of bins.
328    pub fn with_bins(num_bins: usize) -> Self {
329        Self {
330            num_bins,
331            histograms: Vec::new(),
332        }
333    }
334
335    /// Add values for a named tensor. If a histogram already exists for this
336    /// tensor, the new values are merged into it.
337    pub fn add_values(&mut self, name: &str, values: &[f32]) -> Result<(), CalibrationError> {
338        let new_hist = TensorHistogram::from_values(values, self.num_bins)?;
339
340        if let Some((_n, existing)) = self.histograms.iter_mut().find(|(n, _)| n == name) {
341            existing.merge(&new_hist);
342        } else {
343            self.histograms.push((name.to_string(), new_hist));
344        }
345
346        Ok(())
347    }
348
349    /// Collect histograms from a calibration dataset.
350    ///
351    /// Each sample is treated as a single tensor named `tensor_name`.
352    pub fn collect_from_dataset(
353        &mut self,
354        dataset: &CalibrationDataset,
355        tensor_name: &str,
356    ) -> Result<(), CalibrationError> {
357        for sample in &dataset.samples {
358            self.add_values(tensor_name, sample)?;
359        }
360        Ok(())
361    }
362
363    /// Get the histogram for a named tensor.
364    pub fn get(&self, name: &str) -> Option<&TensorHistogram> {
365        self.histograms
366            .iter()
367            .find(|(n, _)| n == name)
368            .map(|(_, h)| h)
369    }
370}
371
372// ---- Calibration methods ----
373
374/// Compute quantization parameters using MinMax calibration.
375///
376/// For asymmetric (uint8): scale = (max - min) / 255, zero_point = round(-min / scale)
377/// For symmetric (int8): scale = max(|min|, |max|) / 127, zero_point = 0
378pub fn calibrate_minmax(hist: &TensorHistogram, symmetric: bool) -> QuantizationParams {
379    if symmetric {
380        calibrate_symmetric(hist.min, hist.max)
381    } else {
382        QuantizationParams::from_range(hist.min, hist.max)
383    }
384}
385
386/// Compute symmetric quantization parameters from min/max.
387fn calibrate_symmetric(min: f32, max: f32) -> QuantizationParams {
388    let abs_max = min.abs().max(max.abs());
389    if abs_max < f32::EPSILON {
390        return QuantizationParams {
391            scale: 1.0,
392            zero_point: 0,
393        };
394    }
395    let scale = abs_max / 127.0;
396    QuantizationParams {
397        scale,
398        zero_point: 0,
399    }
400}
401
402/// Compute quantization parameters using Percentile calibration.
403///
404/// Clips to the given percentile (e.g. 99.99) instead of absolute min/max,
405/// reducing the impact of outliers.
406pub fn calibrate_percentile(
407    hist: &TensorHistogram,
408    percentile: f32,
409    symmetric: bool,
410) -> QuantizationParams {
411    let low_pct = 100.0 - percentile;
412    let clipped_min = hist.percentile(low_pct);
413    let clipped_max = hist.percentile(percentile);
414
415    if symmetric {
416        calibrate_symmetric(clipped_min, clipped_max)
417    } else {
418        QuantizationParams::from_range(clipped_min, clipped_max)
419    }
420}
421
422/// Compute quantization parameters using KL-divergence (entropy) calibration.
423///
424/// Finds the optimal symmetric clipping threshold that minimizes the KL
425/// divergence between the original float distribution and the quantized
426/// distribution. This is the standard TensorRT-style calibration approach.
427///
428/// The search expands outward from the histogram center (value 0),
429/// trying symmetric clip ranges `[-T, T]` and selecting the T that
430/// minimizes KL divergence.
431pub fn calibrate_kl_divergence(hist: &TensorHistogram) -> QuantizationParams {
432    let num_bins = hist.bins.len();
433    if num_bins < 128 {
434        // Not enough bins for KL calibration; fall back to MinMax.
435        return calibrate_minmax(hist, true);
436    }
437
438    let total: u64 = hist.bins.iter().map(|&c| c as u64).sum();
439    if total == 0 {
440        return QuantizationParams {
441            scale: 1.0,
442            zero_point: 0,
443        };
444    }
445
446    // Normalize histogram to probability distribution.
447    let reference: Vec<f64> = hist.bins.iter().map(|&c| c as f64 / total as f64).collect();
448
449    let target_bins: usize = 128; // int8 quantization levels
450    let bin_width = (hist.max - hist.min) / num_bins as f32;
451
452    // Find the bin index closest to value 0 (center for symmetric quantization).
453    let zero_bin = if hist.min >= 0.0 {
454        0
455    } else if hist.max <= 0.0 {
456        num_bins
457    } else {
458        ((-hist.min) / bin_width) as usize
459    };
460    let zero_bin = zero_bin.min(num_bins);
461
462    // Search for the optimal symmetric threshold by expanding from center.
463    // `extent` is the number of bins on each side of zero_bin.
464    let max_extent = zero_bin.max(num_bins - zero_bin);
465    let min_extent = target_bins / 2; // need at least 64 bins per side
466
467    let mut best_divergence = f64::INFINITY;
468    let mut best_abs_threshold = hist.min.abs().max(hist.max.abs());
469
470    for extent in min_extent..=max_extent {
471        let lo = zero_bin.saturating_sub(extent);
472        let hi = (zero_bin + extent).min(num_bins);
473        let clip_len = hi - lo;
474
475        if clip_len < target_bins {
476            continue;
477        }
478
479        // Create the clipped distribution, folding outliers from both sides.
480        let mut truncated = reference[lo..hi].to_vec();
481
482        let left_outlier: f64 = reference[..lo].iter().sum();
483        let right_outlier: f64 = reference[hi..].iter().sum();
484        if let Some(first) = truncated.first_mut() {
485            *first += left_outlier;
486        }
487        if let Some(last) = truncated.last_mut() {
488            *last += right_outlier;
489        }
490
491        let truncated_sum: f64 = truncated.iter().sum();
492        if truncated_sum < 1e-12 {
493            continue;
494        }
495
496        // Quantize: map clip_len bins down to target_bins.
497        let bins_per_quant = clip_len as f64 / target_bins as f64;
498        let mut quantized = vec![0.0f64; target_bins];
499
500        for (i, &val) in truncated.iter().enumerate() {
501            let q_idx = (i as f64 / bins_per_quant) as usize;
502            let q_idx = q_idx.min(target_bins - 1);
503            quantized[q_idx] += val;
504        }
505
506        // Expand quantized distribution back to clip_len bins.
507        let mut expanded = vec![0.0f64; clip_len];
508        for (q_idx, &q_val) in quantized.iter().enumerate() {
509            let start = (q_idx as f64 * bins_per_quant) as usize;
510            let end = (((q_idx + 1) as f64 * bins_per_quant) as usize).min(clip_len);
511            let num_expanded = end - start;
512            if num_expanded == 0 {
513                continue;
514            }
515            let nonzero_count = truncated[start..end].iter().filter(|&&v| v > 1e-12).count();
516            if nonzero_count == 0 {
517                continue;
518            }
519            let avg = q_val / nonzero_count as f64;
520            for j in start..end {
521                if truncated[j] > 1e-12 {
522                    expanded[j] = avg;
523                }
524            }
525        }
526
527        // Compute KL divergence: sum p * log(p / q).
528        let divergence = kl_divergence(&truncated, &expanded);
529        if divergence < best_divergence {
530            best_divergence = divergence;
531            let lo_val = hist.min + bin_width * lo as f32;
532            let hi_val = hist.min + bin_width * hi as f32;
533            best_abs_threshold = lo_val.abs().max(hi_val.abs());
534        }
535    }
536
537    if best_abs_threshold < f32::EPSILON {
538        return QuantizationParams {
539            scale: 1.0,
540            zero_point: 0,
541        };
542    }
543
544    let scale = best_abs_threshold / 127.0;
545    QuantizationParams {
546        scale,
547        zero_point: 0,
548    }
549}
550
551/// Compute KL divergence between distributions p and q.
552/// Both must be the same length. Skips bins where p is near zero.
553fn kl_divergence(p: &[f64], q: &[f64]) -> f64 {
554    let mut divergence = 0.0f64;
555    for (i, &pi) in p.iter().enumerate() {
556        if pi < 1e-12 {
557            continue;
558        }
559        let qi = q[i];
560        if qi < 1e-12 {
561            continue;
562        }
563        divergence += pi * (pi / qi).ln();
564    }
565    divergence
566}
567
568/// Compute quantization parameters for a histogram using the specified method.
569pub fn calibrate(
570    hist: &TensorHistogram,
571    method: &CalibrationMethod,
572    symmetric: bool,
573) -> QuantizationParams {
574    match method {
575        CalibrationMethod::MinMax => calibrate_minmax(hist, symmetric),
576        CalibrationMethod::Percentile(pct) => calibrate_percentile(hist, *pct, symmetric),
577        CalibrationMethod::KlDivergence => calibrate_kl_divergence(hist),
578    }
579}
580
581// ---- Per-channel weight quantization ----
582
583/// Compute per-channel quantization parameters for a weight tensor.
584///
585/// For Conv2D weights in OIHW layout, quantizes along the output channel
586/// axis (axis 0). Each output channel gets its own scale/zero_point.
587///
588/// # Arguments
589/// * `weights` - The flat weight tensor values.
590/// * `shape` - The shape of the weight tensor [O, I, H, W] or [O, I].
591/// * `channel_axis` - The axis along which to compute per-channel params (typically 0).
592pub fn per_channel_quantize(
593    weights: &[f32],
594    shape: &[usize],
595    channel_axis: usize,
596) -> PerChannelQuantParams {
597    assert!(
598        channel_axis < shape.len(),
599        "channel_axis {} out of bounds for shape with {} dims",
600        channel_axis,
601        shape.len()
602    );
603
604    let num_channels = shape[channel_axis];
605    let total_elements: usize = shape.iter().product();
606    assert_eq!(
607        weights.len(),
608        total_elements,
609        "weights length {} doesn't match shape product {}",
610        weights.len(),
611        total_elements
612    );
613
614    // Compute stride for the channel axis.
615    let inner_size: usize = shape[channel_axis + 1..].iter().product();
616
617    let mut scales = Vec::with_capacity(num_channels);
618    let mut zero_points = Vec::with_capacity(num_channels);
619
620    for ch in 0..num_channels {
621        // Collect all elements belonging to this channel.
622        let mut ch_min = f32::INFINITY;
623        let mut ch_max = f32::NEG_INFINITY;
624
625        // Iterate over all elements for this channel.
626        let outer_size: usize = shape[..channel_axis].iter().product();
627        let outer_stride: usize = shape[channel_axis..].iter().product();
628
629        for outer in 0..outer_size {
630            let base = outer * outer_stride + ch * inner_size;
631            for inner in 0..inner_size {
632                let val = weights[base + inner];
633                if val < ch_min {
634                    ch_min = val;
635                }
636                if val > ch_max {
637                    ch_max = val;
638                }
639            }
640        }
641
642        // Symmetric quantization for weights.
643        let abs_max = ch_min.abs().max(ch_max.abs());
644        let scale = if abs_max < f32::EPSILON {
645            1.0
646        } else {
647            abs_max / 127.0
648        };
649
650        scales.push(scale);
651        zero_points.push(0);
652    }
653
654    PerChannelQuantParams {
655        scales,
656        zero_points,
657        channel_axis: channel_axis as u32,
658    }
659}
660
661/// Results from running the calibration pipeline on a module.
662#[derive(Clone, Debug, Default)]
663pub struct CalibrationResult {
664    /// Per-tensor quantization parameters, keyed by tensor name.
665    pub tensor_params: Vec<(String, QuantizationParams)>,
666    /// Per-channel weight quantization parameters, keyed by weight name.
667    pub weight_params: Vec<(String, PerChannelQuantParams)>,
668    /// The calibration method that was used.
669    pub method: Option<CalibrationMethod>,
670}
671
672impl CalibrationResult {
673    /// Look up per-tensor parameters by name.
674    pub fn find_tensor(&self, name: &str) -> Option<&QuantizationParams> {
675        self.tensor_params
676            .iter()
677            .find(|(n, _)| n == name)
678            .map(|(_, p)| p)
679    }
680
681    /// Look up per-channel parameters by name.
682    pub fn find_weight(&self, name: &str) -> Option<&PerChannelQuantParams> {
683        self.weight_params
684            .iter()
685            .find(|(n, _)| n == name)
686            .map(|(_, p)| p)
687    }
688
689    /// Print a summary of calibration results to the log.
690    pub fn log_summary(&self) {
691        log::info!(
692            "Calibration results ({} tensors):",
693            self.tensor_params.len()
694        );
695        for (name, params) in &self.tensor_params {
696            log::info!(
697                "  {}: scale={:.6}, zero_point={}",
698                name,
699                params.scale,
700                params.zero_point
701            );
702        }
703        if !self.weight_params.is_empty() {
704            log::info!(
705                "Per-channel weight params ({} tensors):",
706                self.weight_params.len()
707            );
708            for (name, params) in &self.weight_params {
709                log::info!(
710                    "  {}: {} channels, axis={}",
711                    name,
712                    params.num_channels(),
713                    params.channel_axis
714                );
715            }
716        }
717    }
718}
719
720/// Run the full calibration pipeline on a calibration dataset.
721///
722/// Collects histograms from the dataset and computes quantization parameters
723/// using the specified method.
724pub fn run_calibration(
725    dataset: &CalibrationDataset,
726    method: &CalibrationMethod,
727    symmetric: bool,
728) -> Result<CalibrationResult, CalibrationError> {
729    let mut collector = HistogramCollector::default();
730
731    // Each sample is treated as a single activation tensor.
732    collector.collect_from_dataset(dataset, "input")?;
733
734    let mut tensor_params = Vec::new();
735    for (name, hist) in &collector.histograms {
736        let params = calibrate(hist, method, symmetric);
737        tensor_params.push((name.clone(), params));
738    }
739
740    Ok(CalibrationResult {
741        tensor_params,
742        weight_params: Vec::new(),
743        method: Some(method.clone()),
744    })
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    // ---- TensorHistogram tests ----
752
753    #[test]
754    fn histogram_from_uniform_values() {
755        let values: Vec<f32> = (0..1000).map(|i| i as f32 / 999.0).collect();
756        let hist = TensorHistogram::from_values(&values, 100).unwrap();
757        assert!((hist.min - 0.0).abs() < 1e-6);
758        assert!((hist.max - 1.0).abs() < 1e-4);
759        assert_eq!(hist.bins.len(), 100);
760        assert_eq!(hist.total_count, 1000);
761    }
762
763    #[test]
764    fn histogram_from_constant_values() {
765        let values = vec![5.0; 100];
766        let hist = TensorHistogram::from_values(&values, 100).unwrap();
767        assert!((hist.min - 5.0).abs() < 1e-6);
768        assert_eq!(hist.bins.len(), 1);
769        assert_eq!(hist.total_count, 100);
770    }
771
772    #[test]
773    fn histogram_empty_values_returns_error() {
774        let result = TensorHistogram::from_values(&[], 100);
775        assert!(result.is_err());
776    }
777
778    #[test]
779    fn histogram_percentile() {
780        let values: Vec<f32> = (0..10000).map(|i| i as f32).collect();
781        let hist = TensorHistogram::from_values(&values, 1000).unwrap();
782
783        let p50 = hist.percentile(50.0);
784        assert!((p50 - 5000.0).abs() < 100.0, "p50 = {p50}");
785
786        let p99 = hist.percentile(99.0);
787        assert!((p99 - 9900.0).abs() < 100.0, "p99 = {p99}");
788    }
789
790    #[test]
791    fn histogram_merge_same_range() {
792        let values1: Vec<f32> = (0..100).map(|i| i as f32).collect();
793        let values2: Vec<f32> = (0..100).map(|i| i as f32).collect();
794
795        let mut hist1 = TensorHistogram::from_values(&values1, 50).unwrap();
796        let hist2 = TensorHistogram::from_values(&values2, 50).unwrap();
797
798        hist1.merge(&hist2);
799        assert_eq!(hist1.total_count, 200);
800    }
801
802    #[test]
803    fn histogram_merge_different_ranges() {
804        let values1: Vec<f32> = (0..100).map(|i| i as f32).collect();
805        let values2: Vec<f32> = (100..200).map(|i| i as f32).collect();
806
807        let mut hist1 = TensorHistogram::from_values(&values1, 50).unwrap();
808        let hist2 = TensorHistogram::from_values(&values2, 50).unwrap();
809
810        hist1.merge(&hist2);
811        assert!((hist1.min - 0.0).abs() < 1e-6);
812        assert!((hist1.max - 199.0).abs() < 1e-1);
813        assert_eq!(hist1.total_count, 200);
814    }
815
816    // ---- MinMax calibration tests ----
817
818    #[test]
819    fn minmax_asymmetric() {
820        let values: Vec<f32> = vec![-1.0, 0.0, 1.0, 2.0, 3.0];
821        let hist = TensorHistogram::from_values(&values, 100).unwrap();
822        let params = calibrate_minmax(&hist, false);
823
824        // scale = (3.0 - (-1.0)) / 255 = 4.0 / 255
825        assert!((params.scale - 4.0 / 255.0).abs() < 1e-5);
826    }
827
828    #[test]
829    fn minmax_symmetric() {
830        let values: Vec<f32> = vec![-3.0, -1.0, 0.0, 1.0, 2.0];
831        let hist = TensorHistogram::from_values(&values, 100).unwrap();
832        let params = calibrate_minmax(&hist, true);
833
834        // abs_max = 3.0, scale = 3.0 / 127
835        assert!((params.scale - 3.0 / 127.0).abs() < 1e-5);
836        assert_eq!(params.zero_point, 0);
837    }
838
839    #[test]
840    fn minmax_all_positive() {
841        let values: Vec<f32> = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
842        let hist = TensorHistogram::from_values(&values, 100).unwrap();
843        let params = calibrate_minmax(&hist, false);
844
845        assert!((params.scale - 6.0 / 255.0).abs() < 1e-5);
846        assert_eq!(params.zero_point, 0);
847    }
848
849    // ---- Percentile calibration tests ----
850
851    #[test]
852    fn percentile_clips_outliers() {
853        // Most values in [0, 1], with one extreme outlier at 100.
854        let mut values: Vec<f32> = (0..999).map(|i| i as f32 / 999.0).collect();
855        values.push(100.0);
856
857        let hist = TensorHistogram::from_values(&values, 2048).unwrap();
858
859        let minmax_params = calibrate_minmax(&hist, false);
860        let pct_params = calibrate_percentile(&hist, 99.0, false);
861
862        // Percentile scale should be much smaller since it ignores the outlier.
863        assert!(
864            pct_params.scale < minmax_params.scale,
865            "percentile scale {} should be less than minmax scale {}",
866            pct_params.scale,
867            minmax_params.scale
868        );
869    }
870
871    // ---- KL-Divergence calibration tests ----
872
873    #[test]
874    fn kl_divergence_on_normal_distribution() {
875        // Generate a normal-like distribution using the Box-Muller approximation.
876        let mut values = Vec::with_capacity(10000);
877        for i in 0..10000 {
878            // Simple deterministic "normal-ish" distribution
879            let x = (i as f32 - 5000.0) / 1000.0;
880            let density = (-x * x / 2.0).exp();
881            // Add proportional number of samples
882            let count = (density * 10.0) as usize;
883            for _ in 0..count.max(1) {
884                values.push(x);
885            }
886        }
887
888        let hist = TensorHistogram::from_values(&values, 2048).unwrap();
889        let params = calibrate_kl_divergence(&hist);
890
891        // Should produce reasonable parameters.
892        assert!(params.scale > 0.0);
893        assert_eq!(params.zero_point, 0); // KL is always symmetric.
894    }
895
896    #[test]
897    fn kl_divergence_small_histogram_fallback() {
898        let values: Vec<f32> = (0..50).map(|i| i as f32).collect();
899        let hist = TensorHistogram::from_values(&values, 64).unwrap();
900        let params = calibrate_kl_divergence(&hist);
901
902        // Should fallback to minmax symmetric.
903        assert!(params.scale > 0.0);
904        assert_eq!(params.zero_point, 0);
905    }
906
907    // ---- Per-channel weight quantization tests ----
908
909    #[test]
910    fn per_channel_conv2d_weights() {
911        // OIHW layout: 3 output channels, 1 input channel, 3x3 kernel
912        let shape = [3, 1, 3, 3];
913        #[rustfmt::skip]
914        let weights = vec![
915            // Channel 0: values in [-1, 1]
916            -1.0, -0.5, 0.0, 0.5, 1.0, -0.5, 0.0, 0.5, -1.0,
917            // Channel 1: values in [-2, 2]
918            -2.0, -1.0, 0.0, 1.0, 2.0, -1.0, 0.0, 1.0, -2.0,
919            // Channel 2: values in [-0.5, 0.5]
920            -0.5, -0.25, 0.0, 0.25, 0.5, -0.25, 0.0, 0.25, -0.5,
921        ];
922
923        let params = per_channel_quantize(&weights, &shape, 0);
924        assert_eq!(params.num_channels(), 3);
925        assert_eq!(params.channel_axis, 0);
926
927        // Channel 0: abs_max = 1.0, scale = 1.0 / 127
928        assert!((params.scales[0] - 1.0 / 127.0).abs() < 1e-5);
929        // Channel 1: abs_max = 2.0, scale = 2.0 / 127
930        assert!((params.scales[1] - 2.0 / 127.0).abs() < 1e-5);
931        // Channel 2: abs_max = 0.5, scale = 0.5 / 127
932        assert!((params.scales[2] - 0.5 / 127.0).abs() < 1e-5);
933
934        // All zero points should be 0 (symmetric).
935        assert!(params.zero_points.iter().all(|&zp| zp == 0));
936    }
937
938    #[test]
939    fn per_channel_matmul_weights() {
940        // Shape: [4, 8] — 4 output features, 8 input features
941        let shape = [4, 8];
942        let mut weights = vec![0.0f32; 32];
943        // Channel 0: max = 3.0
944        weights[0] = 3.0;
945        weights[7] = -3.0;
946        // Channel 1: max = 1.0
947        weights[8] = 1.0;
948        // Channel 2: all zeros
949        // Channel 3: max = 0.5
950        weights[24] = 0.5;
951
952        let params = per_channel_quantize(&weights, &shape, 0);
953        assert_eq!(params.num_channels(), 4);
954
955        assert!((params.scales[0] - 3.0 / 127.0).abs() < 1e-5);
956        assert!((params.scales[1] - 1.0 / 127.0).abs() < 1e-5);
957        assert!((params.scales[2] - 1.0).abs() < 1e-5); // degenerate: all zeros
958        assert!((params.scales[3] - 0.5 / 127.0).abs() < 1e-5);
959    }
960
961    // ---- CalibrationDataset tests ----
962
963    #[test]
964    fn dataset_from_samples() {
965        let samples = vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]];
966        let dataset = CalibrationDataset::from_samples(samples);
967        assert_eq!(dataset.num_samples(), 2);
968    }
969
970    #[test]
971    fn dataset_load_from_dir() {
972        // Create temp dir with sample .bin files.
973        let dir = std::env::temp_dir().join("nxpu_calibrate_test");
974        let _ = std::fs::remove_dir_all(&dir);
975        std::fs::create_dir_all(&dir).unwrap();
976
977        let values: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0];
978        let bytes: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
979        std::fs::write(dir.join("sample_000.bin"), &bytes).unwrap();
980        std::fs::write(dir.join("sample_001.bin"), &bytes).unwrap();
981
982        let dataset = CalibrationDataset::load_from_dir(&dir).unwrap();
983        assert_eq!(dataset.num_samples(), 2);
984        assert_eq!(dataset.samples[0], values);
985
986        let _ = std::fs::remove_dir_all(&dir);
987    }
988
989    #[test]
990    fn dataset_load_empty_dir() {
991        let dir = std::env::temp_dir().join("nxpu_calibrate_empty_test");
992        let _ = std::fs::remove_dir_all(&dir);
993        std::fs::create_dir_all(&dir).unwrap();
994
995        let result = CalibrationDataset::load_from_dir(&dir);
996        assert!(result.is_err());
997
998        let _ = std::fs::remove_dir_all(&dir);
999    }
1000
1001    #[test]
1002    fn dataset_load_invalid_size() {
1003        let dir = std::env::temp_dir().join("nxpu_calibrate_invalid_test");
1004        let _ = std::fs::remove_dir_all(&dir);
1005        std::fs::create_dir_all(&dir).unwrap();
1006
1007        // Write 5 bytes (not a multiple of 4).
1008        std::fs::write(dir.join("bad.bin"), [0u8; 5]).unwrap();
1009
1010        let result = CalibrationDataset::load_from_dir(&dir);
1011        assert!(result.is_err());
1012
1013        let _ = std::fs::remove_dir_all(&dir);
1014    }
1015
1016    // ---- HistogramCollector tests ----
1017
1018    #[test]
1019    fn collector_accumulates_histograms() {
1020        let mut collector = HistogramCollector::default();
1021        collector.add_values("tensor_a", &[1.0, 2.0, 3.0]).unwrap();
1022        collector.add_values("tensor_b", &[4.0, 5.0, 6.0]).unwrap();
1023        collector.add_values("tensor_a", &[7.0, 8.0, 9.0]).unwrap();
1024
1025        assert!(collector.get("tensor_a").is_some());
1026        assert!(collector.get("tensor_b").is_some());
1027        assert_eq!(collector.get("tensor_a").unwrap().total_count, 6);
1028    }
1029
1030    #[test]
1031    fn collector_from_dataset() {
1032        let dataset =
1033            CalibrationDataset::from_samples(vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]]);
1034
1035        let mut collector = HistogramCollector::default();
1036        collector
1037            .collect_from_dataset(&dataset, "activations")
1038            .unwrap();
1039
1040        let hist = collector.get("activations").unwrap();
1041        assert_eq!(hist.total_count, 6);
1042    }
1043
1044    // ---- CalibrationMethod tests ----
1045
1046    #[test]
1047    fn calibration_method_from_str() {
1048        assert_eq!(
1049            CalibrationMethod::from_str_name("minmax"),
1050            Some(CalibrationMethod::MinMax)
1051        );
1052        assert_eq!(
1053            CalibrationMethod::from_str_name("percentile"),
1054            Some(CalibrationMethod::Percentile(99.99))
1055        );
1056        assert_eq!(
1057            CalibrationMethod::from_str_name("kl-divergence"),
1058            Some(CalibrationMethod::KlDivergence)
1059        );
1060        assert_eq!(
1061            CalibrationMethod::from_str_name("kl"),
1062            Some(CalibrationMethod::KlDivergence)
1063        );
1064        assert_eq!(
1065            CalibrationMethod::from_str_name("entropy"),
1066            Some(CalibrationMethod::KlDivergence)
1067        );
1068        assert_eq!(CalibrationMethod::from_str_name("invalid"), None);
1069    }
1070
1071    // ---- Full pipeline test ----
1072
1073    #[test]
1074    fn run_calibration_pipeline() {
1075        let dataset = CalibrationDataset::from_samples(vec![
1076            vec![-1.0, 0.0, 1.0, 2.0],
1077            vec![-0.5, 0.5, 1.5, 2.5],
1078            vec![-2.0, -0.5, 0.0, 3.0],
1079        ]);
1080
1081        let result = run_calibration(&dataset, &CalibrationMethod::MinMax, false).unwrap();
1082        assert_eq!(result.tensor_params.len(), 1);
1083        assert_eq!(result.tensor_params[0].0, "input");
1084        assert!(result.tensor_params[0].1.scale > 0.0);
1085    }
1086
1087    // ---- CalibrationResult tests ----
1088
1089    #[test]
1090    fn calibration_result_lookup() {
1091        let result = CalibrationResult {
1092            tensor_params: vec![
1093                (
1094                    "a".into(),
1095                    QuantizationParams {
1096                        scale: 0.1,
1097                        zero_point: 5,
1098                    },
1099                ),
1100                (
1101                    "b".into(),
1102                    QuantizationParams {
1103                        scale: 0.2,
1104                        zero_point: 10,
1105                    },
1106                ),
1107            ],
1108            weight_params: vec![(
1109                "w".into(),
1110                PerChannelQuantParams {
1111                    scales: vec![0.01, 0.02],
1112                    zero_points: vec![0, 0],
1113                    channel_axis: 0,
1114                },
1115            )],
1116            method: Some(CalibrationMethod::MinMax),
1117        };
1118
1119        assert!(result.find_tensor("a").is_some());
1120        assert!((result.find_tensor("a").unwrap().scale - 0.1).abs() < 1e-6);
1121        assert!(result.find_tensor("c").is_none());
1122        assert!(result.find_weight("w").is_some());
1123        assert_eq!(result.find_weight("w").unwrap().num_channels(), 2);
1124    }
1125
1126    // ---- Calibrate dispatch tests ----
1127
1128    #[test]
1129    fn calibrate_dispatch_minmax() {
1130        let values: Vec<f32> = vec![-1.0, 0.0, 1.0];
1131        let hist = TensorHistogram::from_values(&values, 100).unwrap();
1132        let params = calibrate(&hist, &CalibrationMethod::MinMax, true);
1133        assert!((params.scale - 1.0 / 127.0).abs() < 1e-5);
1134    }
1135
1136    #[test]
1137    fn calibrate_dispatch_percentile() {
1138        let values: Vec<f32> = (0..1000).map(|i| i as f32).collect();
1139        let hist = TensorHistogram::from_values(&values, 2048).unwrap();
1140        let params = calibrate(&hist, &CalibrationMethod::Percentile(99.0), false);
1141        assert!(params.scale > 0.0);
1142    }
1143
1144    #[test]
1145    fn calibrate_dispatch_kl() {
1146        let values: Vec<f32> = (0..1000).map(|i| (i as f32 - 500.0) / 100.0).collect();
1147        let hist = TensorHistogram::from_values(&values, 2048).unwrap();
1148        let params = calibrate(&hist, &CalibrationMethod::KlDivergence, true);
1149        assert!(params.scale > 0.0);
1150        assert_eq!(params.zero_point, 0);
1151    }
1152
1153    // ---- KL vs MinMax accuracy comparison ----
1154
1155    /// Compute mean squared quantization error for a set of values given params.
1156    fn quantization_mse(values: &[f32], params: &QuantizationParams) -> f64 {
1157        let mut mse = 0.0f64;
1158        for &v in values {
1159            let q = ((v / params.scale) + params.zero_point as f32).round();
1160            let q = q.clamp(-128.0, 127.0);
1161            let dequant = (q - params.zero_point as f32) * params.scale;
1162            let err = (v - dequant) as f64;
1163            mse += err * err;
1164        }
1165        mse / values.len() as f64
1166    }
1167
1168    #[test]
1169    fn kl_produces_tighter_range_than_minmax_for_nonuniform() {
1170        // Generate a peaked (approximately normal) distribution with heavy tails.
1171        // KL calibration should find a tighter clipping threshold that reduces
1172        // quantization error compared to MinMax, which uses the full range
1173        // including distant outliers.
1174        let mut values = Vec::new();
1175        // Core distribution: many values clustered around 0 (range ~[-3,3])
1176        for i in 0..50000 {
1177            let x = (i as f32 - 25000.0) / 8000.0; // range ~ [-3.1, 3.1]
1178            let density = (-x * x / 2.0).exp();
1179            let count = (density * 20.0) as usize;
1180            for _ in 0..count.max(1) {
1181                values.push(x);
1182            }
1183        }
1184        // Add very sparse, extreme outliers to stretch the MinMax range
1185        // These are few compared to the core (< 0.1%) but push min/max far out.
1186        for i in 0..5 {
1187            values.push(200.0 + i as f32);
1188            values.push(-200.0 - i as f32);
1189        }
1190
1191        let hist = TensorHistogram::from_values(&values, 2048).unwrap();
1192
1193        let minmax_params = calibrate_minmax(&hist, true);
1194        let kl_params = calibrate_kl_divergence(&hist);
1195
1196        // KL should find a smaller scale (tighter range) than MinMax because
1197        // it ignores the sparse outliers.
1198        assert!(
1199            kl_params.scale < minmax_params.scale,
1200            "KL scale ({}) should be less than MinMax scale ({})",
1201            kl_params.scale,
1202            minmax_params.scale,
1203        );
1204
1205        // KL should have lower quantization error on the core distribution
1206        // (the non-outlier values).
1207        let core_values: Vec<f32> = values
1208            .iter()
1209            .filter(|&&v| v.abs() < 10.0)
1210            .copied()
1211            .collect();
1212
1213        let mse_minmax = quantization_mse(&core_values, &minmax_params);
1214        let mse_kl = quantization_mse(&core_values, &kl_params);
1215
1216        assert!(
1217            mse_kl < mse_minmax,
1218            "KL MSE ({mse_kl:.6}) should be less than MinMax MSE ({mse_minmax:.6}) on core distribution",
1219        );
1220    }
1221
1222    #[test]
1223    fn kl_always_produces_tighter_or_equal_range_to_minmax() {
1224        // For any distribution, KL should never produce a LARGER scale than
1225        // MinMax, since MinMax uses the absolute range and KL optimizes for
1226        // the best fit.
1227        let values: Vec<f32> = (0..10000).map(|i| i as f32 / 10000.0 * 6.0 - 3.0).collect();
1228
1229        let hist = TensorHistogram::from_values(&values, 2048).unwrap();
1230
1231        let minmax_params = calibrate_minmax(&hist, true);
1232        let kl_params = calibrate_kl_divergence(&hist);
1233
1234        assert!(
1235            kl_params.scale <= minmax_params.scale + 1e-6,
1236            "KL scale ({}) should be <= MinMax scale ({})",
1237            kl_params.scale,
1238            minmax_params.scale,
1239        );
1240        assert!(kl_params.scale > 0.0);
1241    }
1242
1243    #[test]
1244    fn per_channel_more_accurate_than_per_tensor() {
1245        // Weights with different ranges per channel — per-channel should be more accurate
1246        let weights = vec![
1247            // Channel 0: range [0, 1]
1248            0.1, 0.5, 0.9, // Channel 1: range [0, 100]
1249            10.0, 50.0, 90.0, // Channel 2: range [0, 0.01]
1250            0.001, 0.005, 0.009,
1251        ];
1252        let shape = [3usize, 3]; // 3 channels, 3 elements each
1253
1254        // Per-tensor: single scale for all values
1255        let global_max = weights.iter().copied().fold(f32::NEG_INFINITY, f32::max);
1256        let global_min = weights.iter().copied().fold(f32::INFINITY, f32::min);
1257        let pt_scale = (global_max - global_min) / 255.0;
1258        let pt_zp = (-global_min / pt_scale).round() as i8;
1259        let pt_error: f32 = weights
1260            .iter()
1261            .map(|&w| {
1262                let q = ((w / pt_scale) + pt_zp as f32).round().clamp(-128.0, 127.0) as i8;
1263                let dq = (q as f32 - pt_zp as f32) * pt_scale;
1264                (w - dq).abs()
1265            })
1266            .sum();
1267
1268        // Per-channel
1269        let pc_result = per_channel_quantize(&weights, &shape, 0);
1270        let pc_error: f32 = weights
1271            .chunks(3)
1272            .zip(pc_result.scales.iter().zip(pc_result.zero_points.iter()))
1273            .flat_map(|(ch, (&s, &zp))| {
1274                ch.iter().map(move |&w| {
1275                    let q = ((w / s) + zp as f32).round().clamp(-128.0, 127.0) as i8;
1276                    let dq = (q as f32 - zp as f32) * s;
1277                    (w - dq).abs()
1278                })
1279            })
1280            .sum();
1281
1282        assert!(
1283            pc_error < pt_error,
1284            "per-channel error ({pc_error}) should be less than per-tensor error ({pt_error})"
1285        );
1286    }
1287}