Skip to main content

burn_std/tensor/
quantization.rs

1//! Quantization data representation.
2
3// Re-exported types
4pub use cubecl_common::quant::scheme::{
5    BlockScale, BlockSize, QuantMode, QuantScheme, QuantStore, QuantValue, ScaleDtype,
6};
7
8/// Alignment (in bytes) for quantization parameters in serialized tensor data.
9///
10/// NOTE: This is currently f32-based since scales were originally always f32.
11/// With `ScaleDtype` now supporting different precisions (F16, BF16, etc.),
12/// this alignment may need to be revisited in the future.
13pub const QPARAM_ALIGN: usize = core::mem::align_of::<f32>();
14
15use alloc::vec::Vec;
16use core::any::TypeId;
17use cubecl_common::e4m3;
18use num_traits::PrimInt;
19use serde::{Deserialize, Serialize};
20
21use crate::{DType, Metadata, Shape, bytes::Bytes};
22
23/// Configuration for a device quantization behavior.
24///
25/// This configuration determines how tensors are quantized and how quantization rules
26/// propagate through operations on a given device. It is applied once during device
27/// initialization. See also the [device settings](crate::DeviceSettings).
28#[derive(new, Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
29pub struct QuantConfig {
30    /// Defines how a tensor is quantized.
31    pub scheme: QuantScheme,
32    /// How quantization is propagated during computation.
33    pub propagation: QuantPropagation,
34    // NOTE: accumulation is currently unused, only scheme and propagation have an impact
35    // /// The precision used for the accumulation in various kernels.
36    // pub acc: QuantAcc,
37}
38
39#[derive(
40    Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default,
41)]
42/// The precision of accumulating elements.
43pub enum QuantAcc {
44    /// Full precision.
45    #[default]
46    F32,
47    /// Half precision.
48    F16,
49    /// bfloat16 precision.
50    BF16,
51}
52
53/// Calibration method used to compute the quantization range mapping.
54pub enum Calibration {
55    /// Computes quantization range mapping based on the min and max values.
56    MinMax,
57    /// Absolute-mean calibration for BitNet b1.58-style `{-1, 0, +1}` weight quantization.
58    ///
59    /// The range is `[-γ, +γ]` where γ = `mean(|W|)` per tensor or per block (BitNet b1.58
60    /// §3.1). Use with `QuantValue::Q2S` and `QuantStore::PackedU32` for 2-bit packed storage.
61    AbsMean,
62}
63
64/// Specify if the output of an operation is quantized using the scheme of the input
65/// or returned unquantized.
66#[derive(
67    Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default,
68)]
69pub enum QuantPropagation {
70    /// The output is quantized using the scheme of the input.
71    Propagate,
72    /// The output is not quantized.
73    #[default]
74    Inhibit,
75}
76
77/// The quantization tensor data parameters.
78#[derive(Clone, Debug)]
79pub struct QParams<S> {
80    /// The scaling factor.
81    pub scales: S,
82    /// The per-tensor scale [`scales`](Self::scales) are relative to, for a two-level scheme.
83    pub global: Option<S>,
84}
85
86/// Scales recovered from a quantized byte buffer.
87#[derive(Clone, Debug, PartialEq)]
88pub struct DecodedScales {
89    /// One scale per block, or a single entry for a per-tensor level.
90    pub block: Vec<f32>,
91    /// The per-tensor scale, for a level that carries one.
92    pub global: Option<f32>,
93}
94
95/// A quantization parameter tensor descriptor.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct QParamTensor {
98    /// Start of the tensor in the buffer
99    pub offset_start: usize,
100    /// Offset of tensor end from the end of the buffer
101    pub offset_end: usize,
102    /// Metadata of the tensor
103    pub metadata: Metadata,
104    /// Data type of the tensor
105    pub dtype: DType,
106}
107
108/// Whether a backend can quantize against this scheme's scales.
109///
110/// A backend answers `supports_dtype` with this, so an unsupported scheme is declined where it is
111/// chosen rather than at the first quantize. Each condition is asserted again where it is relied
112/// on, so bypassing this reports the specific rule rather than this general one.
113pub fn quantizable(scheme: &QuantScheme) -> bool {
114    // Quantizing divides by the scale it will store, which needs the round-up rule.
115    if scheme.scale_dtype().round_up(1.0).is_none() {
116        return false;
117    }
118
119    match (scheme.block_scale(), global_scale_dtype(scheme)) {
120        (Some(block), Some(global)) => {
121            // The per-tensor scale is the largest block scale over the block dtype's maximum, so a
122            // block dtype reaching f32's range drives it subnormal. Any precision the per-tensor
123            // scale itself loses becomes a mismatch applied to every block.
124            block.dtype.max_representable() <= crate::f16::MAX.to_f32() && global == ScaleDtype::F32
125        }
126        _ => true,
127    }
128}
129
130/// The dtype of the per-tensor scale block scales are normalized against, for a two-level scheme.
131///
132/// [`QuantScheme::tensor_scale`] answers for a per-tensor scheme too, where the scale is the whole
133/// reconstruction rather than a factor over block scales.
134pub fn global_scale_dtype(scheme: &QuantScheme) -> Option<ScaleDtype> {
135    scheme.block_scale().and(scheme.tensor_scale())
136}
137
138/// Calculate the shape of the block scale grid for a given tensor and scheme.
139///
140/// A two-level scheme's per-tensor scale is not part of this grid.
141pub fn params_shape(data_shape: &Shape, scheme: &QuantScheme) -> Shape {
142    match scheme.block_size() {
143        None => Shape::new([1]),
144        Some(block_size) => Shape::from(block_size.num_blocks(data_shape.as_slice())),
145    }
146}
147
148/// Which block each element of a tensor falls in, for a block scheme. A block is a rectangle, so
149/// its members are a run of the flat storage only when it spans the trailing dimension; anything
150/// walking values against block scales asks here rather than chunking.
151#[derive(Debug, Clone)]
152pub struct BlockLayout {
153    shape: Shape,
154    block: Vec<u8>,
155    blocks: Shape,
156}
157
158impl BlockLayout {
159    /// How `block` tiles a tensor of `shape`.
160    pub fn new(shape: &Shape, block: &BlockSize) -> Self {
161        Self {
162            shape: shape.clone(),
163            block: block.to_dim_vec(shape.num_dims()),
164            blocks: Shape::from(block.num_blocks(shape.as_slice())),
165        }
166    }
167
168    /// How many blocks the tensor holds, which is how many block scales it has.
169    pub fn num_blocks(&self) -> usize {
170        self.blocks.num_elements()
171    }
172
173    /// Whether every dimension is a whole number of blocks.
174    pub fn divides(&self) -> bool {
175        self.shape
176            .iter()
177            .zip(&self.block)
178            .all(|(&dim, &extent)| dim.is_multiple_of(extent as usize))
179    }
180
181    /// The row-major index of the block holding row-major element `index`.
182    pub fn block_of(&self, mut index: usize) -> usize {
183        let mut block = 0;
184        let mut stride = 1;
185        for dim in (0..self.shape.num_dims()).rev() {
186            let coordinate = index % self.shape[dim];
187            index /= self.shape[dim];
188            block += coordinate / self.block[dim] as usize * stride;
189            stride *= self.blocks[dim];
190        }
191        block
192    }
193}
194
195/// Quantized data bytes representation.
196///
197/// # Notes
198/// 1) The quantized values are packed into 32-bit unsigned integers. For example, int8
199///    quantized values pack 4 grouped values into a single `u32`. When unpacking these values,
200///    we make sure to retrieve only the meaningful values (and ignore the alignment padding).
201/// 2) Quantization parameters are appended to the tensor data.
202///    As such, the last bytes always correspond to the scale parameter.
203///    If the quantization scheme includes an offset (zero-point) parameter, it is next to last.
204pub struct QuantizedBytes {
205    /// The quantized values and quantization parameters represented as bytes.
206    pub bytes: Bytes,
207    /// The quantization scheme.
208    pub scheme: QuantScheme,
209    /// The shape of the quantized tensor. The block count, and so the scale count, follows from
210    /// it per axis: a block that does not span the trailing dimension is not a run of elements.
211    pub shape: Shape,
212}
213
214impl QuantizedBytes {
215    /// Creates a new quantized bytes representation.
216    ///
217    /// `global` is the per-tensor scale, required by a two-level scheme and rejected by a
218    /// one-level one.
219    pub fn new<E: bytemuck::CheckedBitPattern + bytemuck::NoUninit>(
220        value: Vec<E>,
221        shape: impl Into<Shape>,
222        scheme: QuantScheme,
223        scales: &[f32],
224        global: Option<f32>,
225    ) -> Self {
226        let shape = shape.into();
227        assert_eq!(
228            value.len(),
229            shape.num_elements(),
230            "{} quantized values do not fill a tensor of shape {shape:?}",
231            value.len()
232        );
233        // Only used for 8-bit quantization data comparison in tests
234        if TypeId::of::<E>() != TypeId::of::<i8>() {
235            panic!("Invalid quantized type");
236        }
237
238        // Re-interpret `Vec<E>` as `Vec<i8>` with `Vec::from_raw_parts`
239        let i8s: Vec<i8> = bytemuck::allocation::cast_vec(value);
240        let mut bytes = Bytes::from_elems(i8s);
241
242        let scales = match scheme.block_size() {
243            None => &scales[..1],
244            Some(_) => scales,
245        };
246        let scale_bytes = encode_scales(scales, scheme.scale_dtype());
247        bytes.extend_from_byte_slice_aligned(scale_bytes.as_slice(), QPARAM_ALIGN);
248
249        // Last, so a reader can peel it off the end before the block scales it normalizes.
250        match (global_scale_dtype(&scheme), global) {
251            (Some(dtype), Some(global)) => {
252                // Encoding the per-tensor scale narrower would round it, and the block scales were
253                // normalized against the unrounded one.
254                assert_eq!(
255                    dtype,
256                    ScaleDtype::F32,
257                    "a two-level scheme stores its per-tensor scale as f32, got {scheme:?}"
258                );
259                let global_bytes = encode_scales(&[global], dtype);
260                bytes.extend_from_byte_slice_aligned(global_bytes.as_slice(), QPARAM_ALIGN);
261            }
262            (Some(_), None) => panic!("{scheme:?} requires a per-tensor scale"),
263            (None, Some(_)) => panic!("{scheme:?} does not take a per-tensor scale"),
264            (None, None) => {}
265        }
266
267        Self {
268            bytes,
269            scheme,
270            shape,
271        }
272    }
273
274    /// The number of quantized elements.
275    pub fn num_elements(&self) -> usize {
276        self.shape.num_elements()
277    }
278
279    /// Returns the int8 quantized values with the quantization parameters.
280    pub fn into_vec_i8(self) -> (Vec<i8>, DecodedScales) {
281        let scheme = self.scheme;
282        let (values, (qparams, num_params)) = self.split_values_off();
283
284        // Laid out as `[block scale, ...]` optionally followed by the per-tensor scale.
285        let global_bytes = global_scale_size(&scheme);
286        let block_end = qparams
287            .len()
288            .checked_sub(global_bytes)
289            .expect("quantized parameter buffer is shorter than the scheme's global scale");
290        let block_start = block_end
291            .checked_sub(scale_size(scheme.scale_dtype()) * num_params)
292            .expect("quantized parameter buffer is shorter than the scheme's block scales");
293
294        let block = decode_scales(&qparams[block_start..block_end], scheme.scale_dtype());
295        let global =
296            global_scale_dtype(&scheme).map(|dtype| decode_scales(&qparams[block_end..], dtype)[0]);
297
298        (values, DecodedScales { block, global })
299    }
300
301    fn split_i8_values(self, scale_bytes: usize) -> (Vec<i8>, Vec<u8>) {
302        let mut values = read_bytes_to_i8(self.bytes);
303
304        let values_end = values
305            .len()
306            .checked_sub(scale_bytes)
307            .expect("quantized tensor data is shorter than its scheme's parameters");
308        let qparams = values.split_off(values_end);
309
310        (values, bytemuck::cast_vec(qparams))
311    }
312
313    /// Splits the quantized values of the tensor from the quantization parameters.
314    ///
315    /// Returns the values in i8 and a newly allocated vector containing the
316    /// quantization parameter bytes.
317    fn split_values_off(self) -> (Vec<i8>, (Vec<u8>, usize)) {
318        let num_params = params_shape(&self.shape, &self.scheme).num_elements();
319        let scale_bytes =
320            scale_size(self.scheme.scale_dtype()) * num_params + global_scale_size(&self.scheme);
321
322        if let QuantStore::PackedU32(packed_dim) = self.scheme.store {
323            assert_eq!(
324                packed_dim, 0,
325                "Packing must be on innermost dimension for splitting off values"
326            );
327        }
328
329        let (values, qparams) = match self.scheme.store {
330            QuantStore::Native => self.split_i8_values(scale_bytes),
331            QuantStore::PackedU32(_) => match self.scheme.value {
332                QuantValue::Q8F | QuantValue::Q8S => self.split_i8_values(scale_bytes),
333                QuantValue::Q4F | QuantValue::Q4S | QuantValue::Q2F | QuantValue::Q2S => {
334                    let split_at =
335                        self.bytes.len().checked_sub(scale_bytes).expect(
336                            "quantized tensor data is shorter than its scheme's parameters",
337                        );
338                    let qparams = self.bytes[split_at..].to_vec();
339                    let values = bytemuck::cast_slice::<_, u32>(&self.bytes[..split_at]);
340                    // Sub-byte values are unpacked as i8s for value equality tests
341                    let values = unpack_q_to_i8s(values, self.num_elements(), &self.scheme.value);
342                    (values, qparams)
343                }
344                QuantValue::E4M3 | QuantValue::E5M2 | QuantValue::E2M1 => {
345                    unimplemented!("Not yet supported")
346                }
347            },
348            QuantStore::PackedNative(_) => unimplemented!("Not yet supported"),
349        };
350
351        (values, (qparams, num_params))
352    }
353}
354
355/// Round a scale up to the smallest value representable by the scale dtype that is no smaller.
356///
357/// Backends that keep scales in `f32` must apply this when quantizing, so that the scale they
358/// divide by is the one that will actually be stored. Otherwise a tensor dequantizes differently
359/// after a save/load round trip.
360///
361/// Up rather than to nearest, because a scale is derived from the largest magnitude it has to
362/// cover. Rounding down puts that value past the end of the quantized range, where it clips, which
363/// measured several times worse than the coarser step rounding up costs.
364pub fn scale_to_dtype(scale: f32, dtype: ScaleDtype) -> f32 {
365    dtype
366        .round_up(scale)
367        .expect("UE8M0 scales are not yet supported")
368}
369
370/// Bytes taken by the per-tensor scale, zero for a scheme that does not carry one over blocks.
371pub fn global_scale_size(scheme: &QuantScheme) -> usize {
372    global_scale_dtype(scheme).map_or(0, scale_size)
373}
374
375/// Number of storage elements a tensor of `shape` occupies under `scheme`.
376///
377/// A packed store divides only the packed dimension, rounding that extent up on its own and
378/// leaving the others intact, so a non-divisible extent pads once per line rather than once
379/// over the flattened tensor. This mirrors the storage shape the allocation actually uses (see
380/// `CubeTensor::quantized_storage` in burn-cubecl); flattening first would under-count, e.g. a
381/// `[3, 3]` Q4 `PackedU32` tensor occupies `3 * ceil(3 / 8) = 3` words, not `ceil(9 / 8) = 2`.
382fn storage_elements(scheme: &QuantScheme, shape: &Shape) -> usize {
383    let num_quants = scheme.num_quants();
384
385    match scheme.store {
386        QuantStore::PackedU32(packed_dim) | QuantStore::PackedNative(packed_dim)
387            if num_quants > 1 && !shape.is_empty() =>
388        {
389            let packed_dim = shape.num_dims() - packed_dim - 1;
390            let mut storage = shape.clone();
391            storage[packed_dim] = storage[packed_dim].div_ceil(num_quants);
392            storage.num_elements()
393        }
394        _ => shape.num_elements().div_ceil(num_quants),
395    }
396}
397
398/// Total bytes a tensor of `shape` occupies under `scheme`, laid out as [`QuantizedBytes::new`]
399/// writes it: values, then block scales, then (for a two-level scheme) the per-tensor scale.
400pub fn quantized_data_len(scheme: &QuantScheme, shape: &Shape) -> usize {
401    let value_bytes = storage_elements(scheme, shape) * scheme.size_bits_stored().div_ceil(8);
402
403    let num_params = params_shape(shape, scheme).num_elements();
404    let scale_bytes = num_params * scale_size(scheme.scale_dtype());
405
406    value_bytes + scale_bytes + global_scale_size(scheme)
407}
408
409/// Bytes per stored scale entry for the given scale dtype.
410pub fn scale_size(dtype: ScaleDtype) -> usize {
411    match dtype {
412        ScaleDtype::F32 => 4,
413        ScaleDtype::F16 | ScaleDtype::BF16 => 2,
414        ScaleDtype::UE8M0 | ScaleDtype::UE4M3 => 1,
415    }
416}
417
418/// Decode stored scale entries into f32.
419fn decode_scales(bytes: &[u8], dtype: ScaleDtype) -> Vec<f32> {
420    match dtype {
421        ScaleDtype::F32 => bytes
422            .as_chunks::<4>()
423            .0
424            .iter()
425            .map(|c| f32::from_ne_bytes([c[0], c[1], c[2], c[3]]))
426            .collect(),
427        ScaleDtype::F16 => bytes
428            .as_chunks::<2>()
429            .0
430            .iter()
431            .map(|c| crate::f16::from_ne_bytes([c[0], c[1]]).to_f32())
432            .collect(),
433        ScaleDtype::BF16 => bytes
434            .as_chunks::<2>()
435            .0
436            .iter()
437            .map(|c| crate::bf16::from_ne_bytes([c[0], c[1]]).to_f32())
438            .collect(),
439        ScaleDtype::UE4M3 => bytes.iter().map(|b| e4m3::from_bits(*b).to_f32()).collect(),
440        ScaleDtype::UE8M0 => unimplemented!("UE8M0 scales are not yet supported"),
441    }
442}
443
444/// Encode f32 scales at the scale dtype for serialization.
445fn encode_scales(scales: &[f32], dtype: ScaleDtype) -> Vec<u8> {
446    match dtype {
447        ScaleDtype::F32 => scales.iter().flat_map(|s| s.to_ne_bytes()).collect(),
448        ScaleDtype::F16 => scales
449            .iter()
450            .flat_map(|s| crate::f16::from_f32(*s).to_ne_bytes())
451            .collect(),
452        ScaleDtype::BF16 => scales
453            .iter()
454            .flat_map(|s| crate::bf16::from_f32(*s).to_ne_bytes())
455            .collect(),
456        ScaleDtype::UE4M3 => scales
457            .iter()
458            .map(|s| e4m3::from_f32(*s).to_bits())
459            .collect(),
460        ScaleDtype::UE8M0 => unimplemented!("UE8M0 scales are not yet supported"),
461    }
462}
463
464fn read_bytes_to_i8(bytes: Bytes) -> Vec<i8> {
465    match bytes.try_into_vec::<i8>() {
466        Ok(val) => val,
467        // Safety,
468        //
469        // `Vec<u8>` can be Re-interpreted as `Vec<i8>` since they share the same alignment.
470        Err(bytes) => unsafe { core::mem::transmute::<Vec<u8>, Vec<i8>>(bytes.to_vec()) },
471    }
472}
473
474/// Pack signed 8-bit integer values into a sequence of unsigned 32-bit integers.
475pub fn pack_i8s_to_u32s(values: Vec<i8>) -> Vec<u32> {
476    // Shift and combine groups of four 8-bit values into a u32.
477    // Same as doing this:
478    //     let result = (d_u8 & 0xFF) << 24 | (c_u8 & 0xFF) << 16 | (b_u8 & 0xFF) << 8 | (a_u8 & 0xFF);
479    #[cfg(target_endian = "big")]
480    {
481        values
482            .chunks(4)
483            .map(|x| {
484                x.iter()
485                    .enumerate()
486                    .fold(0u32, |acc, (i, x)| acc | (*x as u32 & 0xFF) << (i * 8))
487            })
488            .collect()
489    }
490
491    // The order of bytes in little endian matches the above description, we just need to
492    // handle padding when the number of values is not a factor of 4
493    #[cfg(target_endian = "little")]
494    {
495        let mut values = values;
496        let remainder = values.len() % 4;
497        if remainder != 0 {
498            // Pad with zeros
499            values.extend(core::iter::repeat_n(0, 4 - remainder));
500        }
501
502        let len = values.len() / 4;
503        let capacity = values.capacity() / 4;
504
505        // Pre-forget the old vec and re-interpret as u32
506        let mut values = core::mem::ManuallyDrop::new(values);
507        let ptr = values.as_mut_ptr() as *mut u32;
508
509        unsafe { Vec::from_raw_parts(ptr, len, capacity) }
510    }
511}
512
513/// Unpack integer values into a sequence of signed 8-bit integers.
514pub(crate) fn unpack_q_to_i8s<Q: PrimInt>(
515    values: &[Q],
516    numel: usize,
517    value: &QuantValue,
518) -> Vec<i8> {
519    let size_store = size_of::<Q>() * 8;
520    let size_quant = value.size_bits();
521    let num_quants = size_store / size_quant;
522    let mask = Q::from((1 << size_quant) - 1).unwrap();
523    let sign_shift = 8 - size_quant; // sign extension for sub-byte values
524    values
525        .iter()
526        .enumerate()
527        .flat_map(|(i, &packed)| {
528            // A single u32 could contain less than four 8-bit values...
529            let n = core::cmp::min(num_quants, numel - i * num_quants);
530            // Extract each 8-bit segment from u32 and cast back to i8
531            // Same as doing this (when 4 values are fully packed):
532            //     let a = (packed & 0xFF) as i8;
533            //     let b = ((packed >> 8) & 0xFF) as i8;
534            //     let c = ((packed >> 16) & 0xFF) as i8;
535            //     let d = ((packed >> 24) & 0xFF) as i8;
536            (0..n).map(move |i| {
537                let raw = (packed >> (i * size_quant) & mask).to_u8().unwrap();
538                ((raw << sign_shift) as i8) >> sign_shift
539            })
540        })
541        .collect()
542}
543
544#[cfg(test)]
545mod tests {
546
547    use super::*;
548    use alloc::vec;
549
550    #[test]
551    fn should_pack_i8s_to_u32() {
552        let packed = pack_i8s_to_u32s(vec![-128, 2, -3, 127]);
553
554        assert_eq!(packed, vec![2147287680]);
555    }
556
557    #[test]
558    fn should_pack_i8s_to_u32_padded() {
559        let packed = pack_i8s_to_u32s(vec![-128, 2, -3, 127, 55]);
560        let packed_padded = pack_i8s_to_u32s(vec![-128, 2, -3, 127, 55, 0, 0, 0]);
561
562        assert_eq!(packed, vec![2147287680, 55]);
563        assert_eq!(packed, packed_padded);
564    }
565
566    #[test]
567    fn should_unpack_u32s_to_i8s() {
568        let unpacked = unpack_q_to_i8s(&[2147287680u32], 4, &QuantValue::Q8S);
569
570        assert_eq!(unpacked, vec![-128, 2, -3, 127]);
571    }
572
573    #[test]
574    fn should_unpack_u32s_to_i8s_padded() {
575        let unpacked = unpack_q_to_i8s(&[55u32], 1, &QuantValue::Q8S);
576
577        assert_eq!(unpacked, vec![55]);
578    }
579
580    #[test]
581    fn should_unpack_u32s_to_i8s_arange() {
582        let unpacked = unpack_q_to_i8s(
583            &[
584                0u32, 286331136, 286331153, 572657937, 572662306, 857874978, 858993459, 858993459,
585                1145324612, 1145324612, 1431655748, 1431655765, 1717982549, 1717986918, 2003199590,
586                2004318071,
587            ],
588            128,
589            &QuantValue::Q4S,
590        );
591
592        assert_eq!(
593            unpacked,
594            vec![
595                0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
596                2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
597                3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5,
598                5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
599                6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7
600            ]
601        );
602    }
603
604    #[test]
605    fn should_pack_unpack_quantization_parameters_per_tensor_symmetric() {
606        // Quantized [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]
607        let scale = 0.03937008;
608        let values = vec![0i8, 25, 51, 76, 102, 127];
609
610        let q_bytes = QuantizedBytes::new(
611            values.clone(),
612            [2, 3],
613            QuantScheme::default()
614                .with_value(QuantValue::Q8S)
615                .with_store(QuantStore::Native),
616            &[scale],
617            None,
618        );
619
620        let (q_values, qparams) = q_bytes.into_vec_i8();
621
622        assert_eq!(qparams.block, vec![scale]);
623
624        assert_eq!(q_values, values);
625    }
626
627    /// Backends divide by what `scale_to_dtype` returns and hand that same value to
628    /// `encode_scales`. If encoding moved it, a tensor would dequantize differently after a
629    /// save/load round trip, so the codec has to leave an already-rounded scale alone.
630    #[test]
631    fn scale_to_dtype_survives_the_codec() {
632        // Includes values that saturate (500), land in e4m3's subnormals (1e-3), and underflow
633        // it entirely (7.7e-4).
634        let scales = [0.5f32, 0.3, 1.0 / 3.0, 500.0, 1e-3, 7.7e-4];
635
636        for dtype in [
637            ScaleDtype::F32,
638            ScaleDtype::F16,
639            ScaleDtype::BF16,
640            ScaleDtype::UE4M3,
641        ] {
642            let rounded: Vec<f32> = scales.iter().map(|s| scale_to_dtype(*s, dtype)).collect();
643            let via_codec = decode_scales(&encode_scales(&rounded, dtype), dtype);
644
645            assert_eq!(
646                rounded, via_codec,
647                "the codec moves a scale {dtype:?} can already represent"
648            );
649            // 500 is past what e4m3 can hold, so it saturates rather than rounding up.
650            for (scale, rounded) in scales.iter().zip(&rounded).filter(|(s, _)| **s < 500.0) {
651                assert!(
652                    rounded >= scale,
653                    "{scale} rounded down to {rounded} for {dtype:?}"
654                );
655            }
656        }
657    }
658
659    /// The two scale regions have different widths, and the length assertion pins the layout as
660    /// dense: nothing is padded between them.
661    #[test]
662    fn should_pack_unpack_two_level_scales() {
663        // Exactly representable, so this pins the layout rather than the formats' rounding.
664        let block_scales = [0.5f32, 0.125];
665        let global = 3.0f32;
666        let values = vec![0i8, 25, 51, 76, 102, 127, -128, -1];
667
668        let scheme = QuantScheme::default()
669            .with_value(QuantValue::Q8S)
670            .with_store(QuantStore::Native)
671            .per_block([4], ScaleDtype::UE4M3)
672            .per_tensor(ScaleDtype::F32);
673
674        let q_bytes = QuantizedBytes::new(values.clone(), [8], scheme, &block_scales, Some(global));
675
676        // 8 values, one byte per UE4M3 block scale, a 4 byte f32 per-tensor scale.
677        assert_eq!(q_bytes.bytes.len(), 8 + 2 + 4);
678
679        let (q_values, scales) = q_bytes.into_vec_i8();
680
681        assert_eq!(q_values, values);
682        assert_eq!(scales.block, block_scales);
683        assert_eq!(scales.global, Some(global));
684    }
685
686    #[test]
687    #[should_panic(expected = "requires a per-tensor scale")]
688    fn two_level_scheme_without_a_global_scale_is_rejected() {
689        let scheme = QuantScheme::default()
690            .with_value(QuantValue::Q8S)
691            .with_store(QuantStore::Native)
692            .per_block([4], ScaleDtype::F32)
693            .per_tensor(ScaleDtype::F32);
694
695        QuantizedBytes::new(vec![0i8; 8], [8], scheme, &[0.5, 0.125], None);
696    }
697
698    #[test]
699    #[should_panic(expected = "stores its per-tensor scale as f32")]
700    fn a_narrower_per_tensor_scale_is_rejected() {
701        let scheme = QuantScheme::default()
702            .with_value(QuantValue::Q8S)
703            .with_store(QuantStore::Native)
704            .per_block([4], ScaleDtype::UE4M3)
705            .per_tensor(ScaleDtype::F16);
706
707        QuantizedBytes::new(vec![0i8; 8], [8], scheme, &[0.5, 0.125], Some(3.0));
708    }
709
710    /// What a backend answers `supports_dtype` with, so a scheme it declines here is one no path
711    /// reaches: each of these panics further in, where the rule is relied on.
712    #[test]
713    fn quantizable_declines_what_no_backend_can_store() {
714        assert!(quantizable(&QuantScheme::default()));
715        assert!(quantizable(
716            &QuantScheme::default().per_block([4], ScaleDtype::F16)
717        ));
718        assert!(quantizable(
719            &QuantScheme::default()
720                .per_block([4], ScaleDtype::UE4M3)
721                .per_tensor(ScaleDtype::F32)
722        ));
723
724        // No round-up rule, so quantizing cannot store the scale it divides by.
725        assert!(!quantizable(
726            &QuantScheme::default().per_block([4], ScaleDtype::UE8M0)
727        ));
728        assert!(!quantizable(
729            &QuantScheme::default().per_tensor(ScaleDtype::UE8M0)
730        ));
731
732        // Block scales reaching f32's range leave the per-tensor scale subnormal, and a narrower
733        // per-tensor scale rounds away precision every block was normalized against.
734        assert!(!quantizable(
735            &QuantScheme::default()
736                .per_block([4], ScaleDtype::F32)
737                .per_tensor(ScaleDtype::F32)
738        ));
739        assert!(!quantizable(
740            &QuantScheme::default()
741                .per_block([4], ScaleDtype::UE4M3)
742                .per_tensor(ScaleDtype::BF16)
743        ));
744    }
745
746    /// `scale_size` is what the readers use to locate the scales in the buffer, so an encoding
747    /// wider or narrower than it claims silently misreads every scale.
748    #[test]
749    fn encoded_scale_width_matches_scale_size() {
750        let scales = [0.5f32, 0.25, 0.125];
751
752        for dtype in [
753            ScaleDtype::F32,
754            ScaleDtype::F16,
755            ScaleDtype::BF16,
756            ScaleDtype::UE4M3,
757        ] {
758            assert_eq!(
759                encode_scales(&scales, dtype).len(),
760                scale_size(dtype) * scales.len(),
761                "encoded width disagrees with scale_size for {dtype:?}"
762            );
763        }
764    }
765
766    #[test]
767    fn should_pack_unpack_ue4m3_block_scales() {
768        // Exactly representable in e4m3, so the round trip is lossless and the test pins the
769        // layout rather than the format's rounding.
770        let scales = [0.5f32, 0.125];
771        let values = vec![0i8, 25, 51, 76, 102, 127, -128, -1];
772
773        let q_bytes = QuantizedBytes::new(
774            values.clone(),
775            [8],
776            QuantScheme::default()
777                .with_value(QuantValue::Q8S)
778                .with_store(QuantStore::Native)
779                .per_block([4], ScaleDtype::UE4M3),
780            &scales,
781            None,
782        );
783
784        let (q_values, qparams) = q_bytes.into_vec_i8();
785
786        assert_eq!(qparams.block, scales);
787        assert_eq!(q_values, values);
788    }
789}