1use std::path::{Path, PathBuf};
8
9use crate::quantize::{PerChannelQuantParams, QuantizationParams};
10
11#[derive(Debug, thiserror::Error)]
13pub enum CalibrationError {
14 #[error("failed to read calibration file {path}: {source}")]
16 IoError {
17 path: PathBuf,
18 source: std::io::Error,
19 },
20 #[error("calibration file {path} has invalid size {size} (must be a multiple of 4 bytes)")]
22 InvalidFileSize { path: PathBuf, size: u64 },
23 #[error("no .bin calibration files found in {0}")]
25 NoSamples(PathBuf),
26 #[error("empty histogram: no values were collected")]
28 EmptyHistogram,
29}
30
31#[derive(Clone, Debug, PartialEq)]
33pub enum CalibrationMethod {
34 MinMax,
36 Percentile(f32),
38 KlDivergence,
41}
42
43impl CalibrationMethod {
44 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#[derive(Clone, Debug)]
57pub struct CalibrationDataset {
58 pub samples: Vec<Vec<f32>>,
60 pub source_dir: PathBuf,
62}
63
64impl CalibrationDataset {
65 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 #[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 pub fn from_samples(samples: Vec<Vec<f32>>) -> Self {
129 Self {
130 samples,
131 source_dir: PathBuf::new(),
132 }
133 }
134
135 pub fn num_samples(&self) -> usize {
137 self.samples.len()
138 }
139}
140
141#[derive(Clone, Debug)]
143pub struct TensorHistogram {
144 pub min: f32,
146 pub max: f32,
148 pub bins: Vec<u32>,
150 pub bin_edges: Vec<f32>,
152 pub total_count: u64,
154}
155
156const DEFAULT_NUM_BINS: usize = 2048;
158
159impl TensorHistogram {
160 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 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 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 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 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_bins(&self.bins, &self.bin_edges, &mut new_bins, &new_edges);
244 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 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 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
278fn 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 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#[derive(Clone, Debug)]
310pub struct HistogramCollector {
311 pub num_bins: usize,
313 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 pub fn with_bins(num_bins: usize) -> Self {
329 Self {
330 num_bins,
331 histograms: Vec::new(),
332 }
333 }
334
335 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 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 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
372pub 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
386fn 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
402pub 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
422pub fn calibrate_kl_divergence(hist: &TensorHistogram) -> QuantizationParams {
432 let num_bins = hist.bins.len();
433 if num_bins < 128 {
434 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 let reference: Vec<f64> = hist.bins.iter().map(|&c| c as f64 / total as f64).collect();
448
449 let target_bins: usize = 128; let bin_width = (hist.max - hist.min) / num_bins as f32;
451
452 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 let max_extent = zero_bin.max(num_bins - zero_bin);
465 let min_extent = target_bins / 2; 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 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 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 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 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
551fn 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
568pub 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
581pub 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 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 let mut ch_min = f32::INFINITY;
623 let mut ch_max = f32::NEG_INFINITY;
624
625 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 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#[derive(Clone, Debug, Default)]
663pub struct CalibrationResult {
664 pub tensor_params: Vec<(String, QuantizationParams)>,
666 pub weight_params: Vec<(String, PerChannelQuantParams)>,
668 pub method: Option<CalibrationMethod>,
670}
671
672impl CalibrationResult {
673 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 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 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
720pub fn run_calibration(
725 dataset: &CalibrationDataset,
726 method: &CalibrationMethod,
727 symmetric: bool,
728) -> Result<CalibrationResult, CalibrationError> {
729 let mut collector = HistogramCollector::default();
730
731 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 #[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 #[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 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 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 #[test]
852 fn percentile_clips_outliers() {
853 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 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 #[test]
874 fn kl_divergence_on_normal_distribution() {
875 let mut values = Vec::with_capacity(10000);
877 for i in 0..10000 {
878 let x = (i as f32 - 5000.0) / 1000.0;
880 let density = (-x * x / 2.0).exp();
881 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 assert!(params.scale > 0.0);
893 assert_eq!(params.zero_point, 0); }
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 assert!(params.scale > 0.0);
904 assert_eq!(params.zero_point, 0);
905 }
906
907 #[test]
910 fn per_channel_conv2d_weights() {
911 let shape = [3, 1, 3, 3];
913 #[rustfmt::skip]
914 let weights = vec![
915 -1.0, -0.5, 0.0, 0.5, 1.0, -0.5, 0.0, 0.5, -1.0,
917 -2.0, -1.0, 0.0, 1.0, 2.0, -1.0, 0.0, 1.0, -2.0,
919 -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 assert!((params.scales[0] - 1.0 / 127.0).abs() < 1e-5);
929 assert!((params.scales[1] - 2.0 / 127.0).abs() < 1e-5);
931 assert!((params.scales[2] - 0.5 / 127.0).abs() < 1e-5);
933
934 assert!(params.zero_points.iter().all(|&zp| zp == 0));
936 }
937
938 #[test]
939 fn per_channel_matmul_weights() {
940 let shape = [4, 8];
942 let mut weights = vec![0.0f32; 32];
943 weights[0] = 3.0;
945 weights[7] = -3.0;
946 weights[8] = 1.0;
948 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); assert!((params.scales[3] - 0.5 / 127.0).abs() < 1e-5);
959 }
960
961 #[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 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 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 #[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 #[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 #[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 #[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 #[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 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 let mut values = Vec::new();
1175 for i in 0..50000 {
1177 let x = (i as f32 - 25000.0) / 8000.0; 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 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 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 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 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 let weights = vec![
1247 0.1, 0.5, 0.9, 10.0, 50.0, 90.0, 0.001, 0.005, 0.009,
1251 ];
1252 let shape = [3usize, 3]; 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 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}