Skip to main content

quantize_rs/calibration/
mod.rs

1//! Calibration datasets and activation-based range estimation.
2//!
3//! - [`CalibrationDataset`] — load or generate calibration samples
4//! - [`methods::CalibrationMethod`] — range optimization strategies
5//! - [`stats::ActivationStats`] — incremental min/max/histogram tracker
6//! - [`inference::ActivationEstimator`] — run inference to collect activation stats
7
8use crate::errors::{QuantizeError, Result};
9#[cfg(feature = "calibration")]
10use std::path::Path;
11
12#[cfg(feature = "calibration")]
13pub mod inference;
14pub mod methods;
15pub mod stats;
16
17#[cfg(feature = "calibration")]
18pub use inference::ActivationEstimator;
19
20/// A collection of FP32 calibration samples used for range estimation.
21#[derive(Clone)]
22pub struct CalibrationDataset {
23    /// Individual samples, each flattened to match `shape`.
24    pub samples: Vec<Vec<f32>>,
25
26    /// Shape of a single sample (excluding batch dimension).
27    pub shape: Vec<usize>,
28}
29
30impl std::fmt::Debug for CalibrationDataset {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        f.debug_struct("CalibrationDataset")
33            .field("num_samples", &self.samples.len())
34            .field("shape", &self.shape)
35            .finish()
36    }
37}
38
39impl CalibrationDataset {
40    /// Load calibration samples from a NumPy `.npy` file.
41    ///
42    /// The array must be at least 2-dimensional `[batch, ...]`.
43    ///
44    /// Requires the `calibration` feature (enabled by default).
45    ///
46    /// # Errors
47    ///
48    /// Returns [`QuantizeError::Calibration`] if the file is missing, not `.npy`,
49    /// or has an invalid shape.
50    #[cfg(feature = "calibration")]
51    pub fn from_numpy(path: impl AsRef<Path>) -> Result<Self> {
52        use ndarray::{Array, IxDyn};
53
54        let path = path.as_ref();
55
56        if !path.exists() {
57            return Err(QuantizeError::Calibration {
58                reason: format!("File not found: {}", path.display()),
59            });
60        }
61
62        let array: Array<f32, IxDyn> = if path.extension().and_then(|s| s.to_str()) == Some("npy") {
63            ndarray_npy::read_npy(path).map_err(|e| QuantizeError::Calibration {
64                reason: format!("Failed to read NPY file '{}': {e}", path.display()),
65            })?
66        } else {
67            return Err(QuantizeError::Calibration {
68                reason: "Only .npy files supported currently".into(),
69            });
70        };
71
72        let shape: Vec<usize> = array.shape().to_vec();
73
74        if shape.is_empty() {
75            return Err(QuantizeError::Calibration {
76                reason: "Invalid array shape".into(),
77            });
78        }
79
80        if shape.len() < 2 {
81            return Err(QuantizeError::Calibration {
82                reason: format!(
83                    "Calibration data must be at least 2-dimensional (batch, ...). Got shape {:?}",
84                    shape
85                ),
86            });
87        }
88
89        let num_samples = shape[0];
90        let sample_size: usize = shape[1..].iter().product();
91
92        // `into_raw_vec` returns data in memory order, so the array must be
93        // C-contiguous for the per-sample slicing below to be correct.  Move the
94        // buffer out directly in the common (already-standard) case; only a
95        // Fortran-ordered `.npy` needs a re-layout copy.
96        let data = if array.is_standard_layout() {
97            array.into_raw_vec()
98        } else {
99            array.as_standard_layout().into_owned().into_raw_vec()
100        };
101        let mut samples = Vec::with_capacity(num_samples);
102
103        for i in 0..num_samples {
104            let start = i * sample_size;
105            let end = start + sample_size;
106            samples.push(data[start..end].to_vec());
107        }
108
109        Ok(Self {
110            samples,
111            shape: shape[1..].to_vec(),
112        })
113    }
114
115    /// Load calibration samples from a HuggingFace `.safetensors` file that
116    /// contains exactly one tensor.
117    ///
118    /// The tensor must be f32 and at least 2-dimensional `[batch, ...]`.
119    /// Requires the `safetensors-input` feature.
120    ///
121    /// For files with multiple named tensors, use
122    /// [`from_safetensors_named`](Self::from_safetensors_named) instead.
123    #[cfg(feature = "safetensors-input")]
124    pub fn from_safetensors(path: impl AsRef<Path>) -> Result<Self> {
125        let path = path.as_ref();
126        let buffer = std::fs::read(path).map_err(|e| QuantizeError::Calibration {
127            reason: format!("Failed to read safetensors file '{}': {e}", path.display()),
128        })?;
129        let tensors = safetensors::SafeTensors::deserialize(&buffer).map_err(|e| {
130            QuantizeError::Calibration {
131                reason: format!("Failed to parse safetensors file: {e}"),
132            }
133        })?;
134        let names: Vec<String> = tensors.names().into_iter().map(|s| s.to_string()).collect();
135        if names.is_empty() {
136            return Err(QuantizeError::Calibration {
137                reason: "safetensors file contains no tensors".into(),
138            });
139        }
140        if names.len() > 1 {
141            return Err(QuantizeError::Calibration {
142                reason: format!(
143                    "safetensors file contains {} tensors; pass one explicitly via \
144                     from_safetensors_named().  Available tensors: {}",
145                    names.len(),
146                    names.join(", ")
147                ),
148            });
149        }
150        Self::from_safetensors_view(&tensors, &names[0])
151    }
152
153    /// Load calibration samples from a specific named tensor inside a
154    /// `.safetensors` file.
155    ///
156    /// Requires the `safetensors-input` feature.
157    #[cfg(feature = "safetensors-input")]
158    pub fn from_safetensors_named(path: impl AsRef<Path>, tensor_name: &str) -> Result<Self> {
159        let path = path.as_ref();
160        let buffer = std::fs::read(path).map_err(|e| QuantizeError::Calibration {
161            reason: format!("Failed to read safetensors file '{}': {e}", path.display()),
162        })?;
163        let tensors = safetensors::SafeTensors::deserialize(&buffer).map_err(|e| {
164            QuantizeError::Calibration {
165                reason: format!("Failed to parse safetensors file: {e}"),
166            }
167        })?;
168        Self::from_safetensors_view(&tensors, tensor_name)
169    }
170
171    #[cfg(feature = "safetensors-input")]
172    fn from_safetensors_view(
173        tensors: &safetensors::SafeTensors<'_>,
174        tensor_name: &str,
175    ) -> Result<Self> {
176        use safetensors::Dtype;
177
178        let view = tensors
179            .tensor(tensor_name)
180            .map_err(|e| QuantizeError::Calibration {
181                reason: format!(
182                    "Tensor '{}' not found in safetensors file: {e}",
183                    tensor_name
184                ),
185            })?;
186
187        if view.dtype() != Dtype::F32 {
188            return Err(QuantizeError::Calibration {
189                reason: format!(
190                    "Tensor '{}' has dtype {:?}; only F32 is supported for calibration input",
191                    tensor_name,
192                    view.dtype()
193                ),
194            });
195        }
196
197        let shape: Vec<usize> = view.shape().to_vec();
198        if shape.len() < 2 {
199            return Err(QuantizeError::Calibration {
200                reason: format!(
201                    "Calibration tensor must be at least 2-dimensional (batch, ...). \
202                     Got shape {:?}",
203                    shape
204                ),
205            });
206        }
207        let expected_bytes: usize = shape.iter().product::<usize>() * std::mem::size_of::<f32>();
208        let raw = view.data();
209        if raw.len() != expected_bytes {
210            return Err(QuantizeError::Calibration {
211                reason: format!(
212                    "Tensor '{}' data size {} bytes does not match shape {:?} \
213                     × 4 = {} bytes",
214                    tensor_name,
215                    raw.len(),
216                    shape,
217                    expected_bytes
218                ),
219            });
220        }
221
222        // safetensors stores data little-endian, which matches every target
223        // quantize-rs builds on today.  Decode per-f32 explicitly to stay
224        // endian-safe rather than relying on an unchecked cast.
225        let data: Vec<f32> = raw
226            .chunks_exact(4)
227            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
228            .collect();
229
230        let num_samples = shape[0];
231        let sample_size: usize = shape[1..].iter().product();
232        let mut samples = Vec::with_capacity(num_samples);
233        for i in 0..num_samples {
234            let start = i * sample_size;
235            let end = start + sample_size;
236            samples.push(data[start..end].to_vec());
237        }
238
239        Ok(Self {
240            samples,
241            shape: shape[1..].to_vec(),
242        })
243    }
244
245    /// Generate random calibration samples uniformly distributed in `range`.
246    ///
247    /// # Errors
248    ///
249    /// Returns [`QuantizeError::Calibration`] if shape is empty, `num_samples` is 0,
250    /// or the range is invalid.
251    pub fn random(shape: Vec<usize>, num_samples: usize, range: (f32, f32)) -> Result<Self> {
252        if shape.is_empty() || shape.contains(&0) {
253            return Err(QuantizeError::Calibration {
254                reason: format!("Invalid shape: {:?} - all dimensions must be > 0", shape),
255            });
256        }
257        if num_samples == 0 {
258            return Err(QuantizeError::Calibration {
259                reason: "num_samples must be > 0".into(),
260            });
261        }
262        if range.0 >= range.1 {
263            return Err(QuantizeError::Calibration {
264                reason: format!(
265                    "Invalid range: ({}, {}) - min must be less than max",
266                    range.0, range.1
267                ),
268            });
269        }
270        use rand::Rng;
271        let mut rng = rand::thread_rng();
272
273        let sample_size: usize = shape.iter().product();
274        let mut samples = Vec::with_capacity(num_samples);
275
276        for _ in 0..num_samples {
277            let sample: Vec<f32> = (0..sample_size)
278                .map(|_| rng.gen_range(range.0..range.1))
279                .collect();
280            samples.push(sample);
281        }
282
283        Ok(Self { samples, shape })
284    }
285
286    /// Create a dataset from pre-existing sample vectors.
287    ///
288    /// # Errors
289    ///
290    /// Returns [`QuantizeError::Calibration`] if `samples` is empty or any
291    /// sample has the wrong length for the given `shape`.
292    pub fn from_samples(samples: Vec<Vec<f32>>, shape: Vec<usize>) -> Result<Self> {
293        let num_samples = samples.len();
294
295        if num_samples == 0 {
296            return Err(QuantizeError::Calibration {
297                reason: "No samples provided".into(),
298            });
299        }
300
301        let expected_size: usize = shape.iter().product();
302
303        for (i, sample) in samples.iter().enumerate() {
304            if sample.len() != expected_size {
305                return Err(QuantizeError::Calibration {
306                    reason: format!(
307                        "Sample {} has size {} but expected {} (shape: {:?})",
308                        i,
309                        sample.len(),
310                        expected_size,
311                        shape
312                    ),
313                });
314            }
315        }
316
317        Ok(Self { samples, shape })
318    }
319
320    /// Shape of a single sample (excluding batch dimension).
321    pub fn sample_shape(&self) -> &[usize] {
322        &self.shape
323    }
324
325    /// Number of samples in the dataset.
326    pub fn len(&self) -> usize {
327        self.samples.len()
328    }
329
330    /// Whether the dataset contains no samples.
331    pub fn is_empty(&self) -> bool {
332        self.samples.is_empty()
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn test_random_dataset() {
342        let dataset = CalibrationDataset::random(vec![3, 224, 224], 10, (-1.0, 1.0)).unwrap();
343
344        assert_eq!(dataset.len(), 10);
345        assert_eq!(dataset.sample_shape(), &[3, 224, 224]);
346        assert_eq!(dataset.samples[0].len(), 3 * 224 * 224);
347    }
348
349    #[test]
350    fn test_from_samples() {
351        let samples = vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]];
352
353        let dataset = CalibrationDataset::from_samples(samples, vec![3]).unwrap();
354        assert_eq!(dataset.len(), 2);
355    }
356
357    #[cfg(feature = "calibration")]
358    #[test]
359    fn test_from_numpy_fortran_order_slices_by_logical_samples() {
360        use ndarray::{Array2, ShapeBuilder};
361
362        // Logical content: 3 samples × 4 features.
363        //   sample 0 = [10, 11, 12, 13]
364        //   sample 1 = [20, 21, 22, 23]
365        //   sample 2 = [30, 31, 32, 33]
366        // Built in column-major (Fortran) memory so the on-disk `.npy` is
367        // fortran_order — the layout that used to mis-slice into transposed
368        // samples before from_numpy forced a standard layout.
369        let f_memory: Vec<f32> = vec![
370            10., 20., 30., // column 0
371            11., 21., 31., // column 1
372            12., 22., 32., // column 2
373            13., 23., 33., // column 3
374        ];
375        let arr = Array2::from_shape_vec((3, 4).f(), f_memory).unwrap();
376        assert!(
377            !arr.is_standard_layout(),
378            "test setup: array should be Fortran-ordered"
379        );
380
381        let tmp = tempfile::NamedTempFile::with_suffix(".npy").unwrap();
382        ndarray_npy::write_npy(tmp.path(), &arr).unwrap();
383
384        let dataset = CalibrationDataset::from_numpy(tmp.path()).unwrap();
385        assert_eq!(dataset.len(), 3);
386        assert_eq!(dataset.sample_shape(), &[4]);
387        assert_eq!(dataset.samples[0], vec![10., 11., 12., 13.]);
388        assert_eq!(dataset.samples[1], vec![20., 21., 22., 23.]);
389        assert_eq!(dataset.samples[2], vec![30., 31., 32., 33.]);
390    }
391
392    #[cfg(feature = "safetensors-input")]
393    #[test]
394    fn test_from_safetensors_round_trip() {
395        use safetensors::{serialize, tensor::TensorView, Dtype};
396        use std::collections::HashMap;
397
398        // Build 3 samples of shape [2, 4] = 24 floats.
399        let data: Vec<f32> = (0..24).map(|i| i as f32 * 0.1).collect();
400        let raw: Vec<u8> = data.iter().flat_map(|&f| f.to_le_bytes()).collect();
401        let view = TensorView::new(Dtype::F32, vec![3, 2, 4], &raw).unwrap();
402        let mut tensors = HashMap::new();
403        tensors.insert("input".to_string(), view);
404        let bytes = serialize(&tensors, &None).unwrap();
405
406        let tmp = tempfile::NamedTempFile::with_suffix(".safetensors").unwrap();
407        std::fs::write(tmp.path(), &bytes).unwrap();
408
409        let dataset = CalibrationDataset::from_safetensors(tmp.path()).unwrap();
410        assert_eq!(dataset.len(), 3);
411        assert_eq!(dataset.sample_shape(), &[2, 4]);
412        // Each sample holds 8 floats.
413        assert_eq!(dataset.samples[0].len(), 8);
414        // First float of sample 0 is 0.0, first of sample 1 is 0.8 (index 8 * 0.1).
415        assert!((dataset.samples[0][0] - 0.0).abs() < 1e-6);
416        assert!((dataset.samples[1][0] - 0.8).abs() < 1e-6);
417    }
418
419    #[cfg(feature = "safetensors-input")]
420    #[test]
421    fn test_from_safetensors_multi_tensor_errors_without_name() {
422        use safetensors::{serialize, tensor::TensorView, Dtype};
423        use std::collections::HashMap;
424
425        let data: Vec<f32> = (0..8).map(|i| i as f32).collect();
426        let raw: Vec<u8> = data.iter().flat_map(|&f| f.to_le_bytes()).collect();
427        let v1 = TensorView::new(Dtype::F32, vec![2, 4], &raw).unwrap();
428        let v2 = TensorView::new(Dtype::F32, vec![2, 4], &raw).unwrap();
429        let mut tensors = HashMap::new();
430        tensors.insert("a".to_string(), v1);
431        tensors.insert("b".to_string(), v2);
432        let bytes = serialize(&tensors, &None).unwrap();
433
434        let tmp = tempfile::NamedTempFile::with_suffix(".safetensors").unwrap();
435        std::fs::write(tmp.path(), &bytes).unwrap();
436
437        let err = CalibrationDataset::from_safetensors(tmp.path()).unwrap_err();
438        assert!(err.to_string().contains("contains 2 tensors"));
439
440        // But named access works.
441        let dataset = CalibrationDataset::from_safetensors_named(tmp.path(), "a").unwrap();
442        assert_eq!(dataset.len(), 2);
443        assert_eq!(dataset.sample_shape(), &[4]);
444    }
445}