Skip to main content

burn_std/tensor/quantization/
base.rs

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