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}