Skip to main content

cubecl_common/quant/
scheme.rs

1use alloc::vec;
2use alloc::vec::Vec;
3use core::{default::Default, ops::Deref};
4use serde::{Deserialize, Serialize};
5
6/// Describes a quantization scheme/configuration.
7///
8/// Scales come at up to two levels, each an optional field set through
9/// [`per_tensor`](Self::per_tensor) and [`per_block`](Self::per_block) in any order:
10///
11/// ```
12/// # use cubecl_common::quant::scheme::{QuantScheme, ScaleDtype};
13/// // One scale for the whole tensor, stored as f32. Also what a scheme with no level resolves to.
14/// QuantScheme::default().per_tensor(ScaleDtype::F32);
15///
16/// // One scale per block of 32 values.
17/// QuantScheme::default().per_block([32], ScaleDtype::F32);
18///
19/// // Two levels: ue4m3 block scales, normalized by a single per-tensor f32 scale.
20/// QuantScheme::default()
21///     .per_block([16], ScaleDtype::UE4M3)
22///     .per_tensor(ScaleDtype::F32);
23/// ```
24///
25/// A two-level scheme exists so block scales can live in a narrow type: the global per-tensor scale
26/// absorbs the tensor's dynamic range, and the block dtype only covers the spread between blocks.
27/// That spread is still bounded: a block whose scale falls further below the largest one than the
28/// block dtype can express is stored at that dtype's smallest value, far too coarse for it, and
29/// every value in the block quantizes to zero. [`ScaleDtype::UE4M3`] spans about 2^18 this way, so
30/// a tensor holding a genuine outlier can lose its ordinary values.
31#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
32pub struct QuantScheme {
33    /// The logical data type of quantized input values (e.g., [`QuantValue::Q8F`]).
34    ///
35    /// This defines how values are interpreted during computation, independent of how they're stored.
36    pub value: QuantValue,
37    /// Data type used for storing quantized values.
38    pub store: QuantStore,
39    /// Quantization mode (e.g., symmetric).
40    pub mode: QuantMode,
41    /// The per-tensor scale level. Private with [`tensor_scale`](Self::tensor_scale) as the
42    /// reader, which resolves a scheme storing no level at all to a per-tensor f32 scale.
43    tensor: Option<ScaleDtype>,
44    /// The per-block scale level, the innermost when both levels are present.
45    block: Option<BlockScale>,
46}
47
48impl Default for QuantScheme {
49    fn default() -> Self {
50        Self {
51            value: QuantValue::Q8F,
52            store: QuantStore::PackedU32(0),
53            mode: QuantMode::Symmetric,
54            tensor: None,
55            block: None,
56        }
57    }
58}
59
60impl QuantScheme {
61    /// Set the quantization mode.
62    pub fn with_mode(mut self, mode: QuantMode) -> Self {
63        self.mode = mode;
64        self
65    }
66
67    /// Set the data type used for quantized values.
68    pub fn with_value(mut self, value: QuantValue) -> Self {
69        self.value = value;
70        self
71    }
72
73    /// Set the data type used to store quantized values.
74    pub fn with_store(mut self, store: QuantStore) -> Self {
75        self.store = store;
76        self
77    }
78
79    /// Set the per-tensor scale level, stored as `dtype`.
80    pub fn per_tensor(mut self, dtype: ScaleDtype) -> Self {
81        self.tensor = Some(dtype);
82        self
83    }
84
85    /// Set the per-block scale level: one scale per block of `block` values, stored as `dtype`.
86    pub fn per_block(mut self, block: impl AsRef<[u8]>, dtype: ScaleDtype) -> Self {
87        self.block = Some(BlockScale {
88            size: BlockSize::new(block),
89            dtype,
90        });
91        self
92    }
93
94    /// The per-tensor scale level, the global level when a block level is present.
95    ///
96    /// A scheme storing no level at all resolves here to a per-tensor f32 scale; the resolution
97    /// is not stored, so such a scheme compares equal to [`Default`], not to an explicit
98    /// `per_tensor(F32)`.
99    pub fn tensor_scale(&self) -> Option<ScaleDtype> {
100        if self.tensor.is_none() && self.block.is_none() {
101            return Some(ScaleDtype::F32);
102        }
103        self.tensor
104    }
105
106    /// The per-block scale level, the innermost when both levels are present.
107    pub fn block_scale(&self) -> Option<BlockScale> {
108        self.block
109    }
110
111    /// The number of scale levels: as many scale tensors ride along with the values.
112    pub fn num_levels(&self) -> usize {
113        self.block_scale().is_some() as usize + self.tensor_scale().is_some() as usize
114    }
115
116    /// The innermost level's scale dtype, the type the per-position scales are stored in.
117    pub fn scale_dtype(&self) -> ScaleDtype {
118        self.block
119            .map(|block| block.dtype)
120            .or(self.tensor)
121            .unwrap_or(ScaleDtype::F32)
122    }
123
124    /// The block level's size, or [`None`] for per-tensor quantization.
125    pub fn block_size(&self) -> Option<BlockSize> {
126        self.block.map(|block| block.size)
127    }
128
129    /// Swap two tensor dimensions in the block level, mirroring `shape.swap(dim0, dim1)`. The
130    /// per-tensor level is unaffected.
131    ///
132    /// `dim0`/`dim1` are bare indices on purpose, mirroring `[T]::swap`'s own signature.
133    pub fn swap_block_dims(&mut self, rank: usize, dim0: usize, dim1: usize) {
134        let mut axes: Vec<usize> = (0..rank).collect();
135        axes.swap(dim0, dim1);
136        self.permute_block_dims(rank, &axes);
137    }
138
139    /// Permute the block level, mirroring a permutation of the tensor's axes. The per-tensor
140    /// level is unaffected.
141    pub fn permute_block_dims(&mut self, rank: usize, axes: &[usize]) {
142        if let Some(block) = &mut self.block {
143            let dims = block.size.to_dim_vec(rank);
144            let permuted: Vec<u8> = axes.iter().map(|&axis| dims[axis]).collect();
145            block.size = BlockSize::new(permuted);
146        }
147    }
148
149    /// Returns the size of the quantization storage type in bits.
150    pub fn size_bits_stored(&self) -> usize {
151        self.store.size_bits(&self.value)
152    }
153
154    /// Returns the size of the quantization storage type in bits.
155    pub fn size_bits_value(&self) -> usize {
156        self.value.size_bits()
157    }
158
159    /// Returns the number of quantized values stored in a single element.
160    pub fn num_quants(&self) -> usize {
161        self.size_bits_stored() / self.value.size_bits()
162    }
163
164    /// Returns the native packing factor for the values. When native packing > 1, the packed
165    /// representation stores `num_quants` elements grouped into packs of `native_packing` size.
166    pub fn native_packing(&self) -> usize {
167        self.value.native_packing()
168    }
169
170    /// Returns the packing dim for the store.
171    pub fn packing_dim(&self) -> Option<usize> {
172        self.store.packing_dim()
173    }
174
175    /// Swaps the packing dim if it's either of `dim0` or `dim1`.
176    /// Executes the corresponding update to `shape.swap(dim0, dim1)`.
177    pub fn swap_packing_dim(&mut self, dim0: usize, dim1: usize) {
178        if let QuantStore::PackedU32(packed_dim) | QuantStore::PackedNative(packed_dim) =
179            &mut self.store
180        {
181            if *packed_dim == dim0 {
182                *packed_dim = dim1;
183            } else if *packed_dim == dim1 {
184                *packed_dim = dim0;
185            }
186        }
187    }
188}
189
190/// The per-block scale level of a [`QuantScheme`]: one scale per block of values.
191#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
192pub struct BlockScale {
193    /// The block of values sharing one scale.
194    pub size: BlockSize,
195    /// The dtype the level's scales are stored in.
196    pub dtype: ScaleDtype,
197}
198
199impl ScaleDtype {
200    /// The largest finite value representable by the dtype.
201    ///
202    /// A two-level scheme picks its per-tensor scale so that the largest block scale lands here,
203    /// which is what keeps the block scales inside the range their type can express. That recipe
204    /// only holds for a block dtype narrower than the scale it divides: dividing by
205    /// [`ScaleDtype::F32`]'s or [`ScaleDtype::UE8M0`]'s maximum drives the per-tensor scale
206    /// subnormal and the renormalized block scales to infinity. A two-level scheme has nothing to
207    /// gain from those params anyway, since their block scales already reach the full range.
208    pub fn max_representable(&self) -> f32 {
209        match self {
210            ScaleDtype::F32 => f32::MAX,
211            ScaleDtype::F16 => half::f16::MAX.to_f32(),
212            ScaleDtype::BF16 => half::bf16::MAX.to_f32(),
213            // Spelled out because `ue8m0` and `e4m3` sit behind the `fp8` feature and this
214            // function is not gated. The tests check both against those types when it is on.
215            ScaleDtype::UE8M0 => f32::from_bits(0x7F00_0000), // 2^127
216            ScaleDtype::UE4M3 => 448.0,
217        }
218    }
219
220    /// The smallest value representable by the dtype that is not below `scale`.
221    ///
222    /// Storing a quantization scale wants this rather than the nearest value. Rounding down puts
223    /// the scale below what calibration asked for, so every value at the block maximum clips to
224    /// the quantization range; rounding up costs one step of coarseness instead. Backends have to
225    /// agree on this, or a tensor quantized on one reconstructs differently on another.
226    ///
227    /// This is not a cast. Conversion to these types rounds to nearest, which is what a cast
228    /// should do; this is the storage policy for a scale specifically.
229    ///
230    /// `scale` must not be negative. Symmetric quantization only produces non-negative scales,
231    /// and the stepping below walks away from zero for a negative input.
232    ///
233    /// [`ScaleDtype::UE8M0`] answers [`None`]. Its minimum is 2^-127, subnormal in f32, where the
234    /// grid below no longer holds.
235    pub fn round_up(&self, scale: f32) -> Option<f32> {
236        match self {
237            ScaleDtype::F32 => {
238                return Some(scale);
239            }
240            ScaleDtype::UE8M0 => {
241                return None;
242            }
243            _ => {}
244        }
245        if scale.is_nan() {
246            return Some(scale);
247        }
248        debug_assert!(scale >= 0.0, "a quantization scale is never negative");
249
250        // Nothing representable sits above the maximum, and converting past it yields an infinity
251        // for the params that have one, which would make every reconstructed value NaN.
252        let max = self.max_representable();
253        if scale >= max {
254            return Some(max);
255        }
256
257        let grid = self.f32_grid();
258
259        if let Some(subnormals) = grid.subnormals
260            && scale < subnormals.min_normal
261        {
262            // Below the minimum normal the spacing stops halving, so the answer is a count of steps.
263            // Qualified call: the inherent `f32::ceil` lives in std, and this crate builds no_std.
264            return Some(num_traits::Float::ceil(scale / subnormals.spacing) * subnormals.spacing);
265        }
266
267        Some(f32::from_bits(
268            (scale.to_bits() + grid.round_up_bias()) & grid.truncate_mask(),
269        ))
270    }
271
272    /// The dtype's grid, expressed on the f32 bit pattern. See [`F32Grid`].
273    ///
274    /// bf16 reports no subnormal range because it does not need the separate treatment: its pattern
275    /// is f32's top half all the way down, so the bit step stays right where the others stop. Its
276    /// own subnormals start at 2^-133, which is subnormal in f32 too and flushed to zero by most
277    /// backends.
278    ///
279    /// # Panics
280    ///
281    /// For [`ScaleDtype::F32`], which is the grid itself, and [`ScaleDtype::UE8M0`], which is not
282    /// yet supported.
283    pub fn f32_grid(&self) -> F32Grid {
284        /// One f32 ulp per dtype ulp: the mantissa bits f32 carries and the dtype does not.
285        const fn bit_step(mantissa_digits: u32) -> u32 {
286            1 << (f32::MANTISSA_DIGITS - mantissa_digits)
287        }
288
289        match self {
290            ScaleDtype::F16 => F32Grid {
291                bit_step: bit_step(half::f16::MANTISSA_DIGITS),
292                subnormals: Some(SubnormalRange {
293                    min_normal: half::f16::MIN_POSITIVE.to_f32(),
294                    spacing: half::f16::MIN_POSITIVE_SUBNORMAL.to_f32(),
295                }),
296            },
297            ScaleDtype::BF16 => F32Grid {
298                bit_step: bit_step(half::bf16::MANTISSA_DIGITS),
299                subnormals: None,
300            },
301            // Spelled out rather than read off `e4m3`, which sits behind the `fp8` feature while
302            // this is not gated. The tests check them against that type when it is on.
303            ScaleDtype::UE4M3 => F32Grid {
304                bit_step: bit_step(4),
305                subnormals: Some(SubnormalRange {
306                    min_normal: 0.015625, // 2^-6
307                    spacing: 0.001953125, // 2^-9
308                }),
309            },
310            ScaleDtype::F32 => {
311                unimplemented!("F32 is the grid, it has no narrower one to round onto")
312            }
313            ScaleDtype::UE8M0 => unimplemented!("UE8M0 scales are not yet supported"),
314        }
315    }
316}
317
318/// A narrower float format's grid, laid over the f32 bit pattern.
319///
320/// f32 carries every dtype this exists for exactly, so the grid can be walked there rather than
321/// through the storage type. A value representable in the dtype leaves the low f32 mantissa bits
322/// zero, so one dtype ulp is an increment at that position and the carry into the exponent falls
323/// out on its own. Working in f32 also keeps the grid available to backends with no narrow integer,
324/// and to builds without the `fp8` feature.
325#[derive(Clone, Copy, Debug, PartialEq)]
326pub struct F32Grid {
327    /// One step up in the normal range, as an increment on the f32 bit pattern.
328    pub bit_step: u32,
329    /// The dtype's subnormals, for the formats whose subnormals land in f32's normal range.
330    pub subnormals: Option<SubnormalRange>,
331}
332
333/// Where a format's subnormals begin and how far apart they are, in f32.
334#[derive(Clone, Copy, Debug, PartialEq)]
335pub struct SubnormalRange {
336    /// The smallest normal value, below which the spacing stops halving.
337    pub min_normal: f32,
338    /// The constant distance between neighbouring subnormals.
339    pub spacing: f32,
340}
341
342impl F32Grid {
343    /// Clears the mantissa bits the dtype does not carry, truncating a bit pattern onto the grid.
344    pub fn truncate_mask(&self) -> u32 {
345        !(self.bit_step - 1)
346    }
347
348    /// Added to a bit pattern before [`truncate_mask`](Self::truncate_mask) to turn that truncation
349    /// into a round up. The carry it can produce is only safe below the dtype's maximum, which is
350    /// why callers saturate there first.
351    pub fn round_up_bias(&self) -> u32 {
352        self.bit_step - 1
353    }
354}
355
356/// Data type used to represent quantized values.
357#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
358pub enum QuantValue {
359    /// 8-bit quantization with full range.
360    Q8F,
361    /// 8-bit floating point, e5m2 format.
362    E5M2,
363    /// 8-bit floating point, e4m3 format.
364    E4M3,
365    /// 4-bit quantization with full range.
366    Q4F,
367    /// 4-bit floating point, e2m1 format.
368    E2M1,
369    /// 2-bit quantization with full range.
370    Q2F,
371    /// 8-bit quantization with symmetric range.
372    Q8S,
373    /// 4-bit quantization with symmetric range.
374    Q4S,
375    /// 2-bit quantization with symmetric range.
376    Q2S,
377}
378
379impl QuantValue {
380    /// Returns the size of the quantization input type in bits.
381    pub fn size_bits(&self) -> usize {
382        match self {
383            QuantValue::Q8F | QuantValue::Q8S | QuantValue::E4M3 | QuantValue::E5M2 => 8,
384            QuantValue::Q4F | QuantValue::Q4S | QuantValue::E2M1 => 4,
385            QuantValue::Q2F | QuantValue::Q2S => 2,
386        }
387    }
388
389    /// Packing factor for the native representation used for intermediate values. If > 1, values
390    /// should always be processed in `native_packing` sized chunks.
391    pub fn native_packing(&self) -> usize {
392        match self {
393            QuantValue::E2M1 => 2,
394            _ => 1,
395        }
396    }
397
398    /// The possible range of values allowed by the quant value.
399    pub fn range(&self) -> (f32, f32) {
400        match self {
401            QuantValue::Q8F => (i8::MIN as f32, i8::MAX as f32),
402            QuantValue::Q4F => (-8.0, 7.0),
403            QuantValue::Q2F => (-2.0, 1.0),
404            QuantValue::Q8S => (-i8::MAX as f32, i8::MAX as f32),
405            QuantValue::Q4S => (-7.0, 7.0),
406            QuantValue::Q2S => (-1.0, 1.0),
407            QuantValue::E4M3 => (-448.0, 448.0),
408            QuantValue::E5M2 => (-57344.0, 57344.0),
409            QuantValue::E2M1 => (-6.0, 6.0), // Hardcoded because of no-std
410        }
411    }
412
413    /// If the range of values is symmetric around zero.
414    pub fn is_symmetric(&self) -> bool {
415        match self {
416            Self::Q8F | Self::Q4F | Self::Q2F | Self::E4M3 | Self::E5M2 | Self::E2M1 => false,
417            Self::Q8S | Self::Q4S | Self::Q2S => true,
418        }
419    }
420}
421
422impl QuantStore {
423    /// Returns the size of the quantization input type in bits.
424    pub fn size_bits(&self, value: &QuantValue) -> usize {
425        match self {
426            QuantStore::Native => value.size_bits(),
427            QuantStore::PackedNative(_) => value.size_bits() * value.native_packing(),
428            QuantStore::PackedU32(_) => 32,
429        }
430    }
431
432    fn packing_dim(&self) -> Option<usize> {
433        match self {
434            QuantStore::Native => None,
435            QuantStore::PackedNative(packing_dim) | QuantStore::PackedU32(packing_dim) => {
436                Some(*packing_dim)
437            }
438        }
439    }
440}
441
442/// Data type used to stored quantized values.
443#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
444pub enum QuantStore {
445    /// Native quantization doesn't require packing and unpacking.
446    Native,
447    /// Store packed quantized values in a natively supported packing format (i.e. e2m1x2).
448    /// Argument is the dimension the tensor is packed on, starting from the innermost dimension.
449    PackedNative(usize),
450    /// Store packed quantized values in a 4-byte unsigned integer.
451    /// Argument is the dimension the tensor is packed on, starting from the innermost dimension.
452    PackedU32(usize),
453    // /// Store packed quantized values in a 8-bit unsigned integer.
454    // U8,
455}
456
457/// Strategy used to quantize values.
458#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
459pub enum QuantMode {
460    /// Symmetric or scale quantization.
461    Symmetric,
462    /// The stored field is an index into a lookup table of `2^bits` floats, not a number: a read
463    /// reconstructs `table[field] * scale`. (Known as a codebook in the quantization literature —
464    /// NF4, K-quants, and vector quantizers all decode this way.) The table travels as its own
465    /// binding beside the values and scales; only the field's bit width is read from
466    /// [`QuantScheme::value`], since an index has no sign or float semantics of its own.
467    Lookup,
468}
469
470/// The data type a scale level stores its scales in.
471#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
472pub enum ScaleDtype {
473    /// Full precision.
474    F32,
475    /// Half precision.
476    F16,
477    /// bfloat16 precision.
478    BF16,
479    /// unsigned floating point, e8m0 format.
480    UE8M0,
481    /// unsigned floating point, e4m3 format.
482    UE4M3,
483}
484
485const MAX_DIMS: usize = 5;
486
487/// Copyable block size, specialized version of `SmallVec`.
488#[derive(Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
489pub struct BlockSize {
490    storage: [u8; MAX_DIMS],
491    len: u8,
492}
493
494/// Hand-written: `storage` precedes `len`, so a derived `Ord` would compare filler bytes before
495/// length.
496impl PartialOrd for BlockSize {
497    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
498        Some(self.cmp(other))
499    }
500}
501
502impl Ord for BlockSize {
503    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
504        (self.len, self.as_slice()).cmp(&(other.len, other.as_slice()))
505    }
506}
507
508impl core::fmt::Debug for BlockSize {
509    fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
510        write!(f, "BlockSize({:?})", self.as_slice())
511    }
512}
513
514impl BlockSize {
515    /// Max number of dimensions for block size
516    pub const MAX_DIMS: usize = MAX_DIMS;
517
518    /// Create a new blocksize from a set of values. The number of values must be `<= MAX_DIMS`.
519    ///
520    /// The result is canonical, so equal rank-relative blocks compare and hash equal however they
521    /// are spelled: leading unit dimensions are dropped, since the missing-dimension fill restates
522    /// them. In particular, `[1, 32]` canonicalizes to `[32]`. Whole-tensor granularity is a
523    /// scheme's per-tensor level, not a block size.
524    pub fn new(values: impl AsRef<[u8]>) -> Self {
525        Self::canonicalize(values.as_ref())
526    }
527
528    fn canonicalize(values: &[u8]) -> Self {
529        let skip = values
530            .iter()
531            .position(|&value| value != 1)
532            .unwrap_or(values.len());
533        let values = &values[skip..];
534        debug_assert!(
535            values.len() <= MAX_DIMS,
536            "Tried creating a block size larger than the cap"
537        );
538        let len = values.len().min(MAX_DIMS);
539        let mut storage = [1; MAX_DIMS];
540        storage[..len].copy_from_slice(&values[..len]);
541        Self {
542            storage,
543            len: len as u8,
544        }
545    }
546
547    /// Return a slice of only the initialized values
548    pub fn as_slice(&self) -> &[u8] {
549        &self.storage[..self.len as usize]
550    }
551
552    /// Return a vec of only the initialized values
553    pub fn to_vec(&self) -> Vec<u8> {
554        self.storage[..self.len as usize].to_vec()
555    }
556
557    /// Returns `N` dimensions, unsqueezing if necessary. Missing leading dimensions fill with `1`.
558    pub fn as_dim<const N: usize>(&self) -> [u8; N] {
559        let data_len = N.min(self.len as usize);
560        let data_start = N - data_len;
561        let mut out = [1; N];
562        out[data_start..].copy_from_slice(&self.storage[..data_len]);
563        out
564    }
565
566    /// Returns a vector of `len` dimensions, unsqueezing if necessary. Missing leading dimensions
567    /// fill with `1`.
568    pub fn to_dim_vec(&self, len: usize) -> Vec<u8> {
569        let data_len = len.min(self.len as usize);
570        let data_start = len - data_len;
571        let mut out = vec![1; len];
572        out[data_start..].copy_from_slice(&self.storage[..data_len]);
573        out
574    }
575
576    /// How many blocks cover each dimension of `shape`, which is the shape of the scale grid:
577    /// one scale per block.
578    pub fn num_blocks(&self, shape: &[usize]) -> Vec<usize> {
579        self.to_dim_vec(shape.len())
580            .into_iter()
581            .zip(shape)
582            .map(|(block, &dim)| dim.div_ceil(block as usize))
583            .collect()
584    }
585
586    /// Create an iterator over all stored dimensions
587    pub fn iter(&self) -> impl Iterator<Item = &u8> {
588        self.as_slice().iter()
589    }
590
591    /// Returns the total number of elements in each block.
592    pub fn num_elements(&self) -> usize {
593        self.iter().map(|it| *it as usize).product()
594    }
595}
596
597impl Deref for BlockSize {
598    type Target = [u8];
599
600    fn deref(&self) -> &Self::Target {
601        self.as_slice()
602    }
603}
604
605impl<T: AsRef<[u8]>> From<T> for BlockSize {
606    fn from(value: T) -> Self {
607        BlockSize::new(value)
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    #[test]
616    fn blocks_remain_rank_relative() {
617        assert_ne!(BlockSize::new([32]), BlockSize::new([32, 32]));
618        assert_eq!(BlockSize::new([32]).to_dim_vec(2), vec![1, 32]);
619        assert_eq!(BlockSize::new([32, 32]).to_dim_vec(2), vec![32, 32]);
620    }
621
622    #[test]
623    fn leading_unit_dimensions_canonicalize_away() {
624        assert_eq!(BlockSize::new([1, 32]), BlockSize::new([32]));
625    }
626
627    #[test]
628    fn leading_unit_dimensions_beyond_the_cap_still_canonicalize() {
629        assert_eq!(
630            BlockSize::new([1, 1, 8, 4, 2, 3]),
631            BlockSize::new([8, 4, 2, 3])
632        );
633    }
634
635    #[test]
636    fn there_is_one_block_per_scale() {
637        assert_eq!(BlockSize::new([32]).num_blocks(&[8, 64]), vec![8, 2]);
638        assert_eq!(BlockSize::new([4, 32]).num_blocks(&[8, 64]), vec![2, 2]);
639        assert_eq!(BlockSize::new([32]).num_blocks(&[4, 8, 64]), vec![4, 8, 2]);
640    }
641
642    #[test]
643    fn a_partial_block_still_takes_a_scale() {
644        assert_eq!(BlockSize::new([32]).num_blocks(&[8, 70]), vec![8, 3]);
645    }
646
647    #[test]
648    fn the_default_scheme_resolves_to_per_tensor_f32() {
649        let scheme = QuantScheme::default();
650        assert_eq!(scheme.tensor_scale(), Some(ScaleDtype::F32));
651        assert_eq!(scheme.block_scale(), None);
652        assert_eq!(scheme.scale_dtype(), ScaleDtype::F32);
653        assert_eq!(scheme.block_size(), None);
654        assert_eq!(scheme.num_levels(), 1);
655    }
656
657    #[test]
658    fn a_block_level_stands_alone() {
659        let scheme = QuantScheme::default().per_block([32], ScaleDtype::F16);
660        assert_eq!(scheme.tensor_scale(), None);
661        assert_eq!(scheme.scale_dtype(), ScaleDtype::F16);
662        assert_eq!(scheme.block_size(), Some(BlockSize::new([32])));
663        assert_eq!(scheme.num_levels(), 1);
664    }
665
666    #[test]
667    fn both_levels_nest_the_block_inside_the_tensor() {
668        let scheme = QuantScheme::default()
669            .per_block([16], ScaleDtype::UE4M3)
670            .per_tensor(ScaleDtype::F32);
671        assert_eq!(scheme.scale_dtype(), ScaleDtype::UE4M3);
672        assert_eq!(scheme.tensor_scale(), Some(ScaleDtype::F32));
673        assert_eq!(scheme.num_levels(), 2);
674    }
675
676    #[test]
677    fn levels_set_in_any_order_are_the_same_scheme() {
678        assert_eq!(
679            QuantScheme::default()
680                .per_block([16], ScaleDtype::UE4M3)
681                .per_tensor(ScaleDtype::F32),
682            QuantScheme::default()
683                .per_tensor(ScaleDtype::F32)
684                .per_block([16], ScaleDtype::UE4M3),
685        );
686    }
687
688    #[test]
689    fn swapping_dims_rewrites_the_block_and_leaves_the_tensor_level_alone() {
690        let mut scheme = QuantScheme::default()
691            .per_block([4, 32], ScaleDtype::F16)
692            .per_tensor(ScaleDtype::F32);
693        scheme.swap_block_dims(2, 0, 1);
694        assert_eq!(
695            scheme,
696            QuantScheme::default()
697                .per_block([32, 4], ScaleDtype::F16)
698                .per_tensor(ScaleDtype::F32)
699        );
700
701        let mut per_tensor = QuantScheme::default();
702        per_tensor.swap_block_dims(2, 0, 1);
703        assert_eq!(per_tensor, QuantScheme::default());
704    }
705
706    #[test]
707    fn swapping_dims_canonicalizes_the_block() {
708        let mut scheme = QuantScheme::default().per_block([32, 1], ScaleDtype::F32);
709        scheme.swap_block_dims(2, 0, 1);
710        assert_eq!(scheme.block_size(), Some(BlockSize::new([32])));
711    }
712
713    #[test]
714    fn permuting_dims_rewrites_the_block() {
715        let mut scheme = QuantScheme::default().per_block([1, 4, 32], ScaleDtype::F16);
716        scheme.permute_block_dims(3, &[2, 0, 1]);
717        assert_eq!(scheme.block_size(), Some(BlockSize::new([32, 1, 4])));
718    }
719
720    #[test]
721    fn round_up_never_lands_below_the_scale() {
722        for dtype in [ScaleDtype::F16, ScaleDtype::BF16, ScaleDtype::UE4M3] {
723            for exp in -12..8 {
724                for step in 1..17 {
725                    let scale = (step as f32 / 16.0) * 2f32.powi(exp);
726                    let up = dtype.round_up(scale).unwrap();
727                    assert!(
728                        up >= scale,
729                        "{dtype:?}: {up} is below {scale}, which clips the block maximum"
730                    );
731                }
732            }
733        }
734    }
735
736    #[test]
737    fn round_up_saturates_rather_than_stepping_off_the_top() {
738        for dtype in [ScaleDtype::F16, ScaleDtype::BF16, ScaleDtype::UE4M3] {
739            let max = dtype.max_representable();
740            assert_eq!(dtype.round_up(max).unwrap(), max);
741            assert!(dtype.round_up(max * 2.0).unwrap().is_finite());
742        }
743    }
744
745    /// Every variant is dispatched somewhere, so none of them may panic here.
746    #[test]
747    fn round_up_answers_for_every_param() {
748        for dtype in [
749            ScaleDtype::F32,
750            ScaleDtype::F16,
751            ScaleDtype::BF16,
752            ScaleDtype::UE8M0,
753            ScaleDtype::UE4M3,
754        ] {
755            assert_eq!(
756                dtype.round_up(0.3).is_some(),
757                dtype != ScaleDtype::UE8M0,
758                "{dtype:?}"
759            );
760        }
761    }
762
763    #[test]
764    fn round_up_is_the_identity_for_f32() {
765        for scale in [1.0e-30, 0.1, 1.0, 12345.678, f32::MAX] {
766            assert_eq!(ScaleDtype::F32.round_up(scale).unwrap(), scale);
767        }
768    }
769
770    /// The checks that need the real storage types to compare against.
771    #[cfg(feature = "fp8")]
772    mod storage_types {
773        use super::*;
774
775        #[test]
776        fn round_up_is_the_nearest_representable_value_not_below() {
777            // Rounding up must not overshoot: stepping down from the answer has to land below.
778            for dtype in [ScaleDtype::F16, ScaleDtype::BF16, ScaleDtype::UE4M3] {
779                for exp in -8..6 {
780                    let scale = 1.7 * 2f32.powi(exp);
781                    let up = dtype.round_up(scale).unwrap();
782                    assert_eq!(
783                        up,
784                        dtype.round_up(up).unwrap(),
785                        "{dtype:?}: not idempotent at {scale}"
786                    );
787                    assert!(
788                        step(dtype, up, -1) < scale,
789                        "{dtype:?}: {up} overshoots {scale} by at least a step"
790                    );
791                }
792            }
793        }
794
795        /// `round_up` reads the grid instead of converting through the storage type, so a wrong
796        /// constant there is only visible against the type itself. Nothing else in this file would
797        /// catch one: a grid finer than the real thing still lands above the scale, still steps
798        /// down below it, and still looks idempotent.
799        #[test]
800        fn f32_grid_matches_the_storage_types() {
801            for dtype in [ScaleDtype::F16, ScaleDtype::BF16, ScaleDtype::UE4M3] {
802                let grid = dtype.f32_grid();
803
804                // bf16 deliberately reports no subnormal range, since its bit step covers them too.
805                if let Some(subnormals) = grid.subnormals {
806                    assert_eq!(
807                        subnormals.min_normal,
808                        min_normal(dtype),
809                        "{dtype:?}: minimum normal"
810                    );
811                    assert_eq!(
812                        subnormals.spacing,
813                        step(dtype, 0.0, 1),
814                        "{dtype:?}: subnormal spacing"
815                    );
816                }
817
818                // Walk the whole normal range: one step on the f32 pattern has to be one step in
819                // the type, at every exponent.
820                let mut value = min_normal(dtype);
821                let max = dtype.max_representable();
822                while value < max {
823                    let stepped = f32::from_bits(value.to_bits() + grid.bit_step);
824                    assert_eq!(
825                        stepped,
826                        step(dtype, value, 1),
827                        "{dtype:?}: step above {value}"
828                    );
829                    value = stepped;
830                }
831                assert_eq!(
832                    value, max,
833                    "{dtype:?}: the grid has to land exactly on the maximum"
834                );
835            }
836        }
837
838        #[test]
839        fn max_representable_matches_the_e4m3_type() {
840            assert_eq!(
841                ScaleDtype::UE4M3.max_representable(),
842                crate::e4m3::MAX.to_f32()
843            );
844        }
845
846        /// The other limit spelled out as a literal. `ue8m0` is exponent only, so its maximum is
847        /// the power of two the hex literal encodes.
848        #[test]
849        fn max_representable_matches_the_e8m0_type() {
850            assert_eq!(
851                ScaleDtype::UE8M0.max_representable(),
852                crate::ue8m0::MAX.to_f32()
853            );
854        }
855
856        /// `offset` representable steps from `value` in `dtype`, for positive values. Counted on
857        /// the storage type's own bit pattern, so this is an oracle independent of the grid under
858        /// test.
859        fn step(dtype: ScaleDtype, value: f32, offset: i32) -> f32 {
860            match dtype {
861                ScaleDtype::F16 => half::f16::from_bits(
862                    (half::f16::from_f32(value).to_bits() as i32 + offset) as u16,
863                )
864                .to_f32(),
865                ScaleDtype::BF16 => half::bf16::from_bits(
866                    (half::bf16::from_f32(value).to_bits() as i32 + offset) as u16,
867                )
868                .to_f32(),
869                ScaleDtype::UE4M3 => crate::e4m3::from_bits(
870                    (crate::e4m3::from_f32(value).to_bits() as i32 + offset) as u8,
871                )
872                .to_f32(),
873                ScaleDtype::F32 | ScaleDtype::UE8M0 => unreachable!(),
874            }
875        }
876
877        fn min_normal(dtype: ScaleDtype) -> f32 {
878            match dtype {
879                ScaleDtype::F16 => half::f16::MIN_POSITIVE.to_f32(),
880                ScaleDtype::BF16 => half::bf16::MIN_POSITIVE.to_f32(),
881                ScaleDtype::UE4M3 => crate::e4m3::MIN_POSITIVE.to_f32(),
882                ScaleDtype::F32 | ScaleDtype::UE8M0 => unreachable!(),
883            }
884        }
885    }
886}