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    BlockSize, QuantLevel, QuantMode, QuantParam, QuantScheme, QuantStore, QuantValue,
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 `QuantParam` 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}
83
84/// A quantization parameter tensor descriptor.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct QParamTensor {
87    /// Start of the tensor in the buffer
88    pub offset_start: usize,
89    /// Offset of tensor end from the end of the buffer
90    pub offset_end: usize,
91    /// Metadata of the tensor
92    pub metadata: Metadata,
93    /// Data type of the tensor
94    pub dtype: DType,
95}
96
97/// Calculate the shape of the quantization parameters for a given tensor and level
98pub fn params_shape(data_shape: &Shape, level: QuantLevel) -> Shape {
99    match level {
100        QuantLevel::Tensor => Shape::new([1]),
101        QuantLevel::Block(block_size) => {
102            let mut params_shape = data_shape.clone();
103            let block_size = block_size.to_dim_vec(data_shape.num_dims());
104
105            for (shape, block_size) in params_shape.iter_mut().zip(block_size) {
106                *shape = (*shape).div_ceil(block_size as usize);
107            }
108
109            params_shape
110        }
111        QuantLevel::BlockTensor { .. } => {
112            unimplemented!("two-level quantization is not supported yet, got {level:?}")
113        }
114    }
115}
116
117/// Quantized data bytes representation.
118///
119/// # Notes
120/// 1) The quantized values are packed into 32-bit unsigned integers. For example, int8
121///    quantized values pack 4 grouped values into a single `u32`. When unpacking these values,
122///    we make sure to retrieve only the meaningful values (and ignore the alignment padding).
123/// 2) Quantization parameters are appended to the tensor data.
124///    As such, the last bytes always correspond to the scale parameter.
125///    If the quantization scheme includes an offset (zero-point) parameter, it is next to last.
126pub struct QuantizedBytes {
127    /// The quantized values and quantization parameters represented as bytes.
128    pub bytes: Bytes,
129    /// The quantization scheme.
130    pub scheme: QuantScheme,
131    /// The number of quantized elements.
132    pub num_elements: usize,
133}
134
135impl QuantizedBytes {
136    /// Creates a new quantized bytes representation.
137    pub fn new<E: bytemuck::CheckedBitPattern + bytemuck::NoUninit>(
138        value: Vec<E>,
139        scheme: QuantScheme,
140        scales: &[f32],
141    ) -> Self {
142        let num_elements = value.len();
143        // Only used for 8-bit quantization data comparison in tests
144        if TypeId::of::<E>() != TypeId::of::<i8>() {
145            panic!("Invalid quantized type");
146        }
147
148        // Re-interpret `Vec<E>` as `Vec<i8>` with `Vec::from_raw_parts`
149        let i8s: Vec<i8> = bytemuck::allocation::cast_vec(value);
150        let mut bytes = Bytes::from_elems(i8s);
151
152        let scales = match scheme.level {
153            QuantLevel::Tensor => &scales[..1],
154            QuantLevel::Block(_block_size) => scales,
155            QuantLevel::BlockTensor { .. } => unimplemented!(
156                "two-level quantization is not supported yet, got {:?}",
157                scheme.level
158            ),
159        };
160        let scale_bytes = encode_scales(scales, scheme.param);
161        bytes.extend_from_byte_slice_aligned(scale_bytes.as_slice(), QPARAM_ALIGN);
162
163        Self {
164            bytes,
165            scheme,
166            num_elements,
167        }
168    }
169
170    /// Returns the int8 quantized values with the quantization parameters.
171    pub fn into_vec_i8(self) -> (Vec<i8>, QParams<Vec<f32>>) {
172        let param = self.scheme.param;
173        let (values, (qparams, num_params)) = self.split_values_off();
174
175        // Quantization parameters are added at the end of the tensor data.
176        // As such, the last bytes always correspond to the scale parameter(s),
177        // stored at the scheme's param dtype. For example, per-block
178        // quantization can have multiple parameters for a single tensor:
179        // [scale, scale, scale, ...]
180        let scales_size = scale_size(param) * num_params;
181        let scales = decode_scales(&qparams[qparams.len() - scales_size..], param);
182
183        (values, QParams { scales })
184    }
185
186    fn split_i8_values(self, scale_bytes: usize) -> (Vec<i8>, Vec<u8>) {
187        let mut values = read_bytes_to_i8(self.bytes);
188
189        let values_end = values.len() - scale_bytes;
190        let qparams = values.split_off(values_end);
191
192        (values, bytemuck::cast_vec(qparams))
193    }
194
195    /// Splits the quantized values of the tensor from the quantization parameters.
196    ///
197    /// Returns the values in i8 and a newly allocated vector containing the
198    /// quantization parameter bytes.
199    fn split_values_off(self) -> (Vec<i8>, (Vec<u8>, usize)) {
200        let num_params = match self.scheme.level {
201            QuantLevel::Tensor => 1,
202            QuantLevel::Block(block_size) => self.num_elements / block_size.num_elements(),
203            QuantLevel::BlockTensor { .. } => unimplemented!(
204                "two-level quantization is not supported yet, got {:?}",
205                self.scheme.level
206            ),
207        };
208        let scale_bytes = scale_size(self.scheme.param) * num_params;
209
210        if let QuantStore::PackedU32(packed_dim) = self.scheme.store {
211            assert_eq!(
212                packed_dim, 0,
213                "Packing must be on innermost dimension for splitting off values"
214            );
215        }
216
217        let (values, qparams) = match self.scheme.store {
218            QuantStore::Native => self.split_i8_values(scale_bytes),
219            QuantStore::PackedU32(_) => match self.scheme.value {
220                QuantValue::Q8F | QuantValue::Q8S => self.split_i8_values(scale_bytes),
221                QuantValue::Q4F | QuantValue::Q4S | QuantValue::Q2F | QuantValue::Q2S => {
222                    let split_at = self.bytes.len() - scale_bytes;
223                    let qparams = self.bytes[split_at..].to_vec();
224                    let values = bytemuck::cast_slice::<_, u32>(&self.bytes[..split_at]);
225                    // Sub-byte values are unpacked as i8s for value equality tests
226                    let values = unpack_q_to_i8s(values, self.num_elements, &self.scheme.value);
227                    (values, qparams)
228                }
229                QuantValue::E4M3 | QuantValue::E5M2 | QuantValue::E2M1 => {
230                    unimplemented!("Not yet supported")
231                }
232            },
233            QuantStore::PackedNative(_) => unimplemented!("Not yet supported"),
234        };
235
236        (values, (qparams, num_params))
237    }
238}
239
240/// Round a scale up to the smallest value representable by the param dtype that is no smaller.
241///
242/// Backends that keep scales in `f32` must apply this when quantizing, so that the scale they
243/// divide by is the one that will actually be stored. Otherwise a tensor dequantizes differently
244/// after a save/load round trip.
245///
246/// Up rather than to nearest, because a scale is derived from the largest magnitude it has to
247/// cover. Rounding down puts that value past the end of the quantized range, where it clips, which
248/// measured several times worse than the coarser step rounding up costs.
249pub fn scale_to_param(scale: f32, param: QuantParam) -> f32 {
250    let nearest = match param {
251        QuantParam::F32 => return scale,
252        QuantParam::F16 => crate::f16::from_f32(scale).to_f32(),
253        QuantParam::BF16 => crate::bf16::from_f32(scale).to_f32(),
254        QuantParam::UE4M3 => e4m3::from_f32(scale).to_f32(),
255        QuantParam::UE8M0 => unimplemented!("UE8M0 scales are not yet supported"),
256    };
257
258    if nearest >= scale || scale.is_nan() {
259        return nearest;
260    }
261
262    // Positive floats are ordered by their bit pattern, so the next representable value up is the
263    // next bit pattern.
264    let next = match param {
265        QuantParam::F16 => {
266            crate::f16::from_bits(crate::f16::from_f32(nearest).to_bits() + 1).to_f32()
267        }
268        QuantParam::BF16 => {
269            crate::bf16::from_bits(crate::bf16::from_f32(nearest).to_bits() + 1).to_f32()
270        }
271        QuantParam::UE4M3 => e4m3::from_bits(e4m3::from_f32(nearest).to_bits() + 1).to_f32(),
272        QuantParam::F32 | QuantParam::UE8M0 => unreachable!(),
273    };
274
275    // Stepping off the largest finite value lands on an infinity or a NaN encoding, so the
276    // saturated value is already the best answer.
277    if next.is_finite() { next } else { nearest }
278}
279
280/// Bytes per stored scale entry for the given param dtype.
281fn scale_size(param: QuantParam) -> usize {
282    match param {
283        QuantParam::F32 => 4,
284        QuantParam::F16 | QuantParam::BF16 => 2,
285        QuantParam::UE8M0 | QuantParam::UE4M3 => 1,
286    }
287}
288
289/// Decode stored scale entries into f32.
290fn decode_scales(bytes: &[u8], param: QuantParam) -> Vec<f32> {
291    match param {
292        QuantParam::F32 => bytes
293            .chunks_exact(4)
294            .map(|c| f32::from_ne_bytes([c[0], c[1], c[2], c[3]]))
295            .collect(),
296        QuantParam::F16 => bytes
297            .chunks_exact(2)
298            .map(|c| crate::f16::from_ne_bytes([c[0], c[1]]).to_f32())
299            .collect(),
300        QuantParam::BF16 => bytes
301            .chunks_exact(2)
302            .map(|c| crate::bf16::from_ne_bytes([c[0], c[1]]).to_f32())
303            .collect(),
304        QuantParam::UE4M3 => bytes.iter().map(|b| e4m3::from_bits(*b).to_f32()).collect(),
305        QuantParam::UE8M0 => unimplemented!("UE8M0 scales are not yet supported"),
306    }
307}
308
309/// Encode f32 scales at the param dtype for serialization.
310fn encode_scales(scales: &[f32], param: QuantParam) -> Vec<u8> {
311    match param {
312        QuantParam::F32 => scales.iter().flat_map(|s| s.to_ne_bytes()).collect(),
313        QuantParam::F16 => scales
314            .iter()
315            .flat_map(|s| crate::f16::from_f32(*s).to_ne_bytes())
316            .collect(),
317        QuantParam::BF16 => scales
318            .iter()
319            .flat_map(|s| crate::bf16::from_f32(*s).to_ne_bytes())
320            .collect(),
321        QuantParam::UE4M3 => scales
322            .iter()
323            .map(|s| e4m3::from_f32(*s).to_bits())
324            .collect(),
325        QuantParam::UE8M0 => unimplemented!("UE8M0 scales are not yet supported"),
326    }
327}
328
329fn read_bytes_to_i8(bytes: Bytes) -> Vec<i8> {
330    match bytes.try_into_vec::<i8>() {
331        Ok(val) => val,
332        // Safety,
333        //
334        // `Vec<u8>` can be Re-interpreted as `Vec<i8>` since they share the same alignment.
335        Err(bytes) => unsafe { core::mem::transmute::<Vec<u8>, Vec<i8>>(bytes.to_vec()) },
336    }
337}
338
339/// Pack signed 8-bit integer values into a sequence of unsigned 32-bit integers.
340pub fn pack_i8s_to_u32s(values: Vec<i8>) -> Vec<u32> {
341    // Shift and combine groups of four 8-bit values into a u32.
342    // Same as doing this:
343    //     let result = (d_u8 & 0xFF) << 24 | (c_u8 & 0xFF) << 16 | (b_u8 & 0xFF) << 8 | (a_u8 & 0xFF);
344    #[cfg(target_endian = "big")]
345    {
346        values
347            .chunks(4)
348            .map(|x| {
349                x.iter()
350                    .enumerate()
351                    .fold(0u32, |acc, (i, x)| acc | (*x as u32 & 0xFF) << (i * 8))
352            })
353            .collect()
354    }
355
356    // The order of bytes in little endian matches the above description, we just need to
357    // handle padding when the number of values is not a factor of 4
358    #[cfg(target_endian = "little")]
359    {
360        let mut values = values;
361        let remainder = values.len() % 4;
362        if remainder != 0 {
363            // Pad with zeros
364            values.extend(core::iter::repeat_n(0, 4 - remainder));
365        }
366
367        let len = values.len() / 4;
368        let capacity = values.capacity() / 4;
369
370        // Pre-forget the old vec and re-interpret as u32
371        let mut values = core::mem::ManuallyDrop::new(values);
372        let ptr = values.as_mut_ptr() as *mut u32;
373
374        unsafe { Vec::from_raw_parts(ptr, len, capacity) }
375    }
376}
377
378/// Unpack integer values into a sequence of signed 8-bit integers.
379pub(crate) fn unpack_q_to_i8s<Q: PrimInt>(
380    values: &[Q],
381    numel: usize,
382    value: &QuantValue,
383) -> Vec<i8> {
384    let size_store = size_of::<Q>() * 8;
385    let size_quant = value.size_bits();
386    let num_quants = size_store / size_quant;
387    let mask = Q::from((1 << size_quant) - 1).unwrap();
388    let sign_shift = 8 - size_quant; // sign extension for sub-byte values
389    values
390        .iter()
391        .enumerate()
392        .flat_map(|(i, &packed)| {
393            // A single u32 could contain less than four 8-bit values...
394            let n = core::cmp::min(num_quants, numel - i * num_quants);
395            // Extract each 8-bit segment from u32 and cast back to i8
396            // Same as doing this (when 4 values are fully packed):
397            //     let a = (packed & 0xFF) as i8;
398            //     let b = ((packed >> 8) & 0xFF) as i8;
399            //     let c = ((packed >> 16) & 0xFF) as i8;
400            //     let d = ((packed >> 24) & 0xFF) as i8;
401            (0..n).map(move |i| {
402                let raw = (packed >> (i * size_quant) & mask).to_u8().unwrap();
403                ((raw << sign_shift) as i8) >> sign_shift
404            })
405        })
406        .collect()
407}
408
409#[cfg(test)]
410mod tests {
411
412    use super::*;
413    use alloc::vec;
414
415    #[test]
416    fn should_pack_i8s_to_u32() {
417        let packed = pack_i8s_to_u32s(vec![-128, 2, -3, 127]);
418
419        assert_eq!(packed, vec![2147287680]);
420    }
421
422    #[test]
423    fn should_pack_i8s_to_u32_padded() {
424        let packed = pack_i8s_to_u32s(vec![-128, 2, -3, 127, 55]);
425        let packed_padded = pack_i8s_to_u32s(vec![-128, 2, -3, 127, 55, 0, 0, 0]);
426
427        assert_eq!(packed, vec![2147287680, 55]);
428        assert_eq!(packed, packed_padded);
429    }
430
431    #[test]
432    fn should_unpack_u32s_to_i8s() {
433        let unpacked = unpack_q_to_i8s(&[2147287680u32], 4, &QuantValue::Q8S);
434
435        assert_eq!(unpacked, vec![-128, 2, -3, 127]);
436    }
437
438    #[test]
439    fn should_unpack_u32s_to_i8s_padded() {
440        let unpacked = unpack_q_to_i8s(&[55u32], 1, &QuantValue::Q8S);
441
442        assert_eq!(unpacked, vec![55]);
443    }
444
445    #[test]
446    fn should_unpack_u32s_to_i8s_arange() {
447        let unpacked = unpack_q_to_i8s(
448            &[
449                0u32, 286331136, 286331153, 572657937, 572662306, 857874978, 858993459, 858993459,
450                1145324612, 1145324612, 1431655748, 1431655765, 1717982549, 1717986918, 2003199590,
451                2004318071,
452            ],
453            128,
454            &QuantValue::Q4S,
455        );
456
457        assert_eq!(
458            unpacked,
459            vec![
460                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,
461                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,
462                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,
463                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,
464                6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7
465            ]
466        );
467    }
468
469    #[test]
470    fn should_pack_unpack_quantization_parameters_per_tensor_symmetric() {
471        // Quantized [[0.0, 1.0, 2.0], [3.0, 4.0, 5.0]]
472        let scale = 0.03937008;
473        let values = vec![0i8, 25, 51, 76, 102, 127];
474
475        let q_bytes = QuantizedBytes::new(
476            values.clone(),
477            QuantScheme::default()
478                .with_value(QuantValue::Q8S)
479                .with_store(QuantStore::Native),
480            &[scale],
481        );
482
483        let (q_values, qparams) = q_bytes.into_vec_i8();
484
485        assert_eq!(qparams.scales, vec![scale]);
486
487        assert_eq!(q_values, values);
488    }
489
490    /// Backends divide by what `scale_to_param` returns and hand that same value to
491    /// `encode_scales`. If encoding moved it, a tensor would dequantize differently after a
492    /// save/load round trip, so the codec has to leave an already-rounded scale alone.
493    #[test]
494    fn scale_to_param_survives_the_codec() {
495        // Includes values that saturate (500), land in e4m3's subnormals (1e-3), and underflow
496        // it entirely (7.7e-4).
497        let scales = [0.5f32, 0.3, 1.0 / 3.0, 500.0, 1e-3, 7.7e-4];
498
499        for param in [
500            QuantParam::F32,
501            QuantParam::F16,
502            QuantParam::BF16,
503            QuantParam::UE4M3,
504        ] {
505            let rounded: Vec<f32> = scales.iter().map(|s| scale_to_param(*s, param)).collect();
506            let via_codec = decode_scales(&encode_scales(&rounded, param), param);
507
508            assert_eq!(
509                rounded, via_codec,
510                "the codec moves a scale {param:?} can already represent"
511            );
512            // 500 is past what e4m3 can hold, so it saturates rather than rounding up.
513            for (scale, rounded) in scales.iter().zip(&rounded).filter(|(s, _)| **s < 500.0) {
514                assert!(
515                    rounded >= scale,
516                    "{scale} rounded down to {rounded} for {param:?}"
517                );
518            }
519        }
520    }
521
522    /// `scale_size` is what the readers use to locate the scales in the buffer, so an encoding
523    /// wider or narrower than it claims silently misreads every scale.
524    #[test]
525    fn encoded_scale_width_matches_scale_size() {
526        let scales = [0.5f32, 0.25, 0.125];
527
528        for param in [
529            QuantParam::F32,
530            QuantParam::F16,
531            QuantParam::BF16,
532            QuantParam::UE4M3,
533        ] {
534            assert_eq!(
535                encode_scales(&scales, param).len(),
536                scale_size(param) * scales.len(),
537                "encoded width disagrees with scale_size for {param:?}"
538            );
539        }
540    }
541
542    #[test]
543    fn should_pack_unpack_ue4m3_block_scales() {
544        // Exactly representable in e4m3, so the round trip is lossless and the test pins the
545        // layout rather than the format's rounding.
546        let scales = [0.5f32, 0.125];
547        let values = vec![0i8, 25, 51, 76, 102, 127, -128, -1];
548
549        let q_bytes = QuantizedBytes::new(
550            values.clone(),
551            QuantScheme::default()
552                .with_value(QuantValue::Q8S)
553                .with_store(QuantStore::Native)
554                .with_level(QuantLevel::block([4]))
555                .with_param(QuantParam::UE4M3),
556            &scales,
557        );
558
559        let (q_values, qparams) = q_bytes.into_vec_i8();
560
561        assert_eq!(qparams.scales, scales);
562        assert_eq!(q_values, values);
563    }
564}