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`] takes its own path rather than the shared grid below: its range runs
234 /// to 2^-127, which is subnormal in f32, so the two ends need clamping before the bit stepping
235 /// is meaningful. Between them the rule is the same one — a ue8m0 value is a bare exponent, so
236 /// rounding up to it is rounding up to a power of two.
237 pub fn round_up(&self, scale: f32) -> f32 {
238 match self {
239 ScaleDtype::F32 => {
240 return scale;
241 }
242 ScaleDtype::UE8M0 => {
243 return round_up_to_power_of_two(scale);
244 }
245 _ => {}
246 }
247 if scale.is_nan() {
248 return scale;
249 }
250 debug_assert!(scale >= 0.0, "a quantization scale is never negative");
251
252 // Nothing representable sits above the maximum, and converting past it yields an infinity
253 // for the params that have one, which would make every reconstructed value NaN.
254 let max = self.max_representable();
255 if scale >= max {
256 return max;
257 }
258
259 let grid = self.f32_grid();
260
261 if let Some(subnormals) = grid.subnormals
262 && scale < subnormals.min_normal
263 {
264 // Below the minimum normal the spacing stops halving, so the answer is a count of steps.
265 // Qualified call: the inherent `f32::ceil` lives in std, and this crate builds no_std.
266 return num_traits::Float::ceil(scale / subnormals.spacing) * subnormals.spacing;
267 }
268
269 f32::from_bits((scale.to_bits() + grid.round_up_bias()) & grid.truncate_mask())
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.
282 pub fn f32_grid(&self) -> F32Grid {
283 /// One f32 ulp per dtype ulp: the mantissa bits f32 carries and the dtype does not.
284 const fn bit_step(mantissa_digits: u32) -> u32 {
285 1 << (f32::MANTISSA_DIGITS - mantissa_digits)
286 }
287
288 match self {
289 ScaleDtype::F16 => F32Grid {
290 bit_step: bit_step(half::f16::MANTISSA_DIGITS),
291 subnormals: Some(SubnormalRange {
292 min_normal: half::f16::MIN_POSITIVE.to_f32(),
293 spacing: half::f16::MIN_POSITIVE_SUBNORMAL.to_f32(),
294 }),
295 },
296 ScaleDtype::BF16 => F32Grid {
297 bit_step: bit_step(half::bf16::MANTISSA_DIGITS),
298 subnormals: None,
299 },
300 // Spelled out rather than read off `e4m3`, which sits behind the `fp8` feature while
301 // this is not gated. The tests check them against that type when it is on.
302 ScaleDtype::UE4M3 => F32Grid {
303 bit_step: bit_step(4),
304 subnormals: Some(SubnormalRange {
305 min_normal: 0.015625, // 2^-6
306 spacing: 0.001953125, // 2^-9
307 }),
308 },
309 ScaleDtype::F32 => {
310 unimplemented!("F32 is the grid, it has no narrower one to round onto")
311 }
312 // No mantissa at all: the grid is the powers of two, so the step clears every f32
313 // mantissa bit. `subnormals` stays `None` because ue8m0 has no subnormal *ladder* —
314 // its bottom is the single value 2^-127, which the callers clamp to.
315 ScaleDtype::UE8M0 => F32Grid {
316 bit_step: bit_step(1),
317 subnormals: None,
318 },
319 }
320 }
321
322 /// The smallest and largest values [`ScaleDtype::UE8M0`] represents: 2^-127 and 2^127.
323 ///
324 /// The minimum is subnormal in f32 and the maximum is the largest power of two it holds, so
325 /// both are spelled as bit patterns rather than computed.
326 pub const UE8M0_MIN: f32 = f32::from_bits(0x0040_0000);
327 /// See [`ScaleDtype::UE8M0_MIN`].
328 pub const UE8M0_MAX: f32 = f32::from_bits(0x7F00_0000);
329}
330
331/// A `ue8m0` scale as its stored byte: the code is the exponent, biased by 127.
332///
333/// Rounds up, which is both the storage rule for a scale and what the host `ue8m0` codec and
334/// CUDA's `__nv_cvt_bfloat16raw_to_e8m0` (at `cudaRoundPosInf`) already do — a scale rounded down
335/// puts the block's largest value outside the quantization range.
336///
337/// Lives here rather than on the `ue8m0` type so it is available without the `float4` feature:
338/// serialization needs it, and `ue8m0` is a bare exponent, so the byte is the whole of it.
339/// (`ue8m0` itself is behind `fp8`, but its *conversions* come from `float4`, which is the gate
340/// that would otherwise reach serialization.) It has to keep answering what those conversions
341/// answer, which `the_ue8m0_codec_matches_the_storage_type` checks wherever they are compiled in.
342pub fn f32_to_ue8m0(scale: f32) -> u8 {
343 let rounded = round_up_to_power_of_two(scale);
344 if rounded.is_nan() {
345 return 0xFF;
346 }
347 // Codes 1..=254 are f32's own exponent field; the clamping above keeps the shift in range,
348 // and 2^-127 is subnormal in f32, so it lands on the exponent field 0 that code 0 names.
349 (rounded.to_bits() >> 23) as u8
350}
351
352/// The value a `ue8m0` byte stands for. Inverse of [`f32_to_ue8m0`] on every code it produces.
353pub fn ue8m0_to_f32(code: u8) -> f32 {
354 match code {
355 // f32 has no exponent field 0 to spare: its own is the subnormals.
356 0 => ScaleDtype::UE8M0_MIN,
357 0xFF => f32::NAN,
358 code => f32::from_bits((code as u32) << 23),
359 }
360}
361
362/// The smallest power of two not below `scale`, saturated into ue8m0's range.
363///
364/// A ue8m0 code *is* an exponent, so this is the whole storage rule for that dtype. Clamping both
365/// ends first is what lets the middle be the same mantissa-clearing step the other dtypes use:
366/// below 2^-127 there is nothing to round onto, and above 2^127 the step would carry into f32's
367/// infinity and take every value scaled by it with it. Zero clamps up to the minimum — ue8m0 has
368/// no zero, and a zero scale reconstructs an all-zero block correctly at any scale.
369fn round_up_to_power_of_two(scale: f32) -> f32 {
370 if scale.is_nan() {
371 return scale;
372 }
373 debug_assert!(scale >= 0.0, "a quantization scale is never negative");
374
375 if scale <= ScaleDtype::UE8M0_MIN {
376 return ScaleDtype::UE8M0_MIN;
377 }
378 if scale >= ScaleDtype::UE8M0_MAX {
379 return ScaleDtype::UE8M0_MAX;
380 }
381
382 let grid = ScaleDtype::UE8M0.f32_grid();
383 f32::from_bits((scale.to_bits() + grid.round_up_bias()) & grid.truncate_mask())
384}
385
386/// A narrower float format's grid, laid over the f32 bit pattern.
387///
388/// f32 carries every dtype this exists for exactly, so the grid can be walked there rather than
389/// through the storage type. A value representable in the dtype leaves the low f32 mantissa bits
390/// zero, so one dtype ulp is an increment at that position and the carry into the exponent falls
391/// out on its own. Working in f32 also keeps the grid available to backends with no narrow integer,
392/// and to builds without the `fp8` feature.
393#[derive(Clone, Copy, Debug, PartialEq)]
394pub struct F32Grid {
395 /// One step up in the normal range, as an increment on the f32 bit pattern.
396 pub bit_step: u32,
397 /// The dtype's subnormals, for the formats whose subnormals land in f32's normal range.
398 pub subnormals: Option<SubnormalRange>,
399}
400
401/// Where a format's subnormals begin and how far apart they are, in f32.
402#[derive(Clone, Copy, Debug, PartialEq)]
403pub struct SubnormalRange {
404 /// The smallest normal value, below which the spacing stops halving.
405 pub min_normal: f32,
406 /// The constant distance between neighbouring subnormals.
407 pub spacing: f32,
408}
409
410impl F32Grid {
411 /// Clears the mantissa bits the dtype does not carry, truncating a bit pattern onto the grid.
412 pub fn truncate_mask(&self) -> u32 {
413 !(self.bit_step - 1)
414 }
415
416 /// Added to a bit pattern before [`truncate_mask`](Self::truncate_mask) to turn that truncation
417 /// into a round up. The carry it can produce is only safe below the dtype's maximum, which is
418 /// why callers saturate there first.
419 pub fn round_up_bias(&self) -> u32 {
420 self.bit_step - 1
421 }
422}
423
424/// Data type used to represent quantized values.
425#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
426pub enum QuantValue {
427 /// 8-bit quantization with full range.
428 Q8F,
429 /// 8-bit floating point, e5m2 format.
430 E5M2,
431 /// 8-bit floating point, e4m3 format.
432 E4M3,
433 /// 4-bit quantization with full range.
434 Q4F,
435 /// 4-bit floating point, e2m1 format.
436 E2M1,
437 /// 2-bit quantization with full range.
438 Q2F,
439 /// 8-bit quantization with symmetric range.
440 Q8S,
441 /// 4-bit quantization with symmetric range.
442 Q4S,
443 /// 2-bit quantization with symmetric range.
444 Q2S,
445}
446
447impl QuantValue {
448 /// Returns the size of the quantization input type in bits.
449 pub fn size_bits(&self) -> usize {
450 match self {
451 QuantValue::Q8F | QuantValue::Q8S | QuantValue::E4M3 | QuantValue::E5M2 => 8,
452 QuantValue::Q4F | QuantValue::Q4S | QuantValue::E2M1 => 4,
453 QuantValue::Q2F | QuantValue::Q2S => 2,
454 }
455 }
456
457 /// Packing factor for the native representation used for intermediate values. If > 1, values
458 /// should always be processed in `native_packing` sized chunks.
459 pub fn native_packing(&self) -> usize {
460 match self {
461 QuantValue::E2M1 => 2,
462 _ => 1,
463 }
464 }
465
466 /// The possible range of values allowed by the quant value.
467 pub fn range(&self) -> (f32, f32) {
468 match self {
469 QuantValue::Q8F => (i8::MIN as f32, i8::MAX as f32),
470 QuantValue::Q4F => (-8.0, 7.0),
471 QuantValue::Q2F => (-2.0, 1.0),
472 QuantValue::Q8S => (-i8::MAX as f32, i8::MAX as f32),
473 QuantValue::Q4S => (-7.0, 7.0),
474 QuantValue::Q2S => (-1.0, 1.0),
475 QuantValue::E4M3 => (-448.0, 448.0),
476 QuantValue::E5M2 => (-57344.0, 57344.0),
477 QuantValue::E2M1 => (-6.0, 6.0), // Hardcoded because of no-std
478 }
479 }
480
481 /// If the range of values is symmetric around zero.
482 pub fn is_symmetric(&self) -> bool {
483 match self {
484 Self::Q8F | Self::Q4F | Self::Q2F | Self::E4M3 | Self::E5M2 | Self::E2M1 => false,
485 Self::Q8S | Self::Q4S | Self::Q2S => true,
486 }
487 }
488}
489
490impl QuantStore {
491 /// Returns the size of the quantization input type in bits.
492 pub fn size_bits(&self, value: &QuantValue) -> usize {
493 match self {
494 QuantStore::Native => value.size_bits(),
495 QuantStore::PackedNative(_) => value.size_bits() * value.native_packing(),
496 QuantStore::PackedU32(_) => 32,
497 }
498 }
499
500 fn packing_dim(&self) -> Option<usize> {
501 match self {
502 QuantStore::Native => None,
503 QuantStore::PackedNative(packing_dim) | QuantStore::PackedU32(packing_dim) => {
504 Some(*packing_dim)
505 }
506 }
507 }
508}
509
510/// Data type used to stored quantized values.
511#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
512pub enum QuantStore {
513 /// Native quantization doesn't require packing and unpacking.
514 Native,
515 /// Store packed quantized values in a natively supported packing format (i.e. e2m1x2).
516 /// Argument is the dimension the tensor is packed on, starting from the innermost dimension.
517 PackedNative(usize),
518 /// Store packed quantized values in a 4-byte unsigned integer.
519 /// Argument is the dimension the tensor is packed on, starting from the innermost dimension.
520 PackedU32(usize),
521 // /// Store packed quantized values in a 8-bit unsigned integer.
522 // U8,
523}
524
525/// Strategy used to quantize values.
526#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
527pub enum QuantMode {
528 /// Symmetric or scale quantization.
529 Symmetric,
530 /// The stored field is an index into a lookup table of `2^bits` floats, not a number: a read
531 /// reconstructs `table[field] * scale`. (Known as a codebook in the quantization literature —
532 /// NF4, K-quants, and vector quantizers all decode this way.) The table travels as its own
533 /// binding beside the values and scales; only the field's bit width is read from
534 /// [`QuantScheme::value`], since an index has no sign or float semantics of its own.
535 Lookup,
536}
537
538/// The data type a scale level stores its scales in.
539#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
540pub enum ScaleDtype {
541 /// Full precision.
542 F32,
543 /// Half precision.
544 F16,
545 /// bfloat16 precision.
546 BF16,
547 /// unsigned floating point, e8m0 format.
548 UE8M0,
549 /// unsigned floating point, e4m3 format.
550 UE4M3,
551}
552
553const MAX_DIMS: usize = 5;
554
555/// Copyable block size, specialized version of `SmallVec`.
556#[derive(Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
557pub struct BlockSize {
558 storage: [u8; MAX_DIMS],
559 len: u8,
560}
561
562/// Hand-written: `storage` precedes `len`, so a derived `Ord` would compare filler bytes before
563/// length.
564impl PartialOrd for BlockSize {
565 fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
566 Some(self.cmp(other))
567 }
568}
569
570impl Ord for BlockSize {
571 fn cmp(&self, other: &Self) -> core::cmp::Ordering {
572 (self.len, self.as_slice()).cmp(&(other.len, other.as_slice()))
573 }
574}
575
576impl core::fmt::Debug for BlockSize {
577 fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
578 write!(f, "BlockSize({:?})", self.as_slice())
579 }
580}
581
582impl BlockSize {
583 /// Max number of dimensions for block size
584 pub const MAX_DIMS: usize = MAX_DIMS;
585
586 /// Create a new blocksize from a set of values. The number of values must be `<= MAX_DIMS`.
587 ///
588 /// The result is canonical, so equal rank-relative blocks compare and hash equal however they
589 /// are spelled: leading unit dimensions are dropped, since the missing-dimension fill restates
590 /// them. In particular, `[1, 32]` canonicalizes to `[32]`. Whole-tensor granularity is a
591 /// scheme's per-tensor level, not a block size.
592 pub fn new(values: impl AsRef<[u8]>) -> Self {
593 Self::canonicalize(values.as_ref())
594 }
595
596 fn canonicalize(values: &[u8]) -> Self {
597 let skip = values
598 .iter()
599 .position(|&value| value != 1)
600 .unwrap_or(values.len());
601 let values = &values[skip..];
602 debug_assert!(
603 values.len() <= MAX_DIMS,
604 "Tried creating a block size larger than the cap"
605 );
606 let len = values.len().min(MAX_DIMS);
607 let mut storage = [1; MAX_DIMS];
608 storage[..len].copy_from_slice(&values[..len]);
609 Self {
610 storage,
611 len: len as u8,
612 }
613 }
614
615 /// Return a slice of only the initialized values
616 pub fn as_slice(&self) -> &[u8] {
617 &self.storage[..self.len as usize]
618 }
619
620 /// Return a vec of only the initialized values
621 pub fn to_vec(&self) -> Vec<u8> {
622 self.storage[..self.len as usize].to_vec()
623 }
624
625 /// Returns `N` dimensions, unsqueezing if necessary. Missing leading dimensions fill with `1`.
626 pub fn as_dim<const N: usize>(&self) -> [u8; N] {
627 let data_len = N.min(self.len as usize);
628 let data_start = N - data_len;
629 let mut out = [1; N];
630 out[data_start..].copy_from_slice(&self.storage[..data_len]);
631 out
632 }
633
634 /// Returns a vector of `len` dimensions, unsqueezing if necessary. Missing leading dimensions
635 /// fill with `1`.
636 pub fn to_dim_vec(&self, len: usize) -> Vec<u8> {
637 let data_len = len.min(self.len as usize);
638 let data_start = len - data_len;
639 let mut out = vec![1; len];
640 out[data_start..].copy_from_slice(&self.storage[..data_len]);
641 out
642 }
643
644 /// How many blocks cover each dimension of `shape`, which is the shape of the scale grid:
645 /// one scale per block.
646 pub fn num_blocks(&self, shape: &[usize]) -> Vec<usize> {
647 self.to_dim_vec(shape.len())
648 .into_iter()
649 .zip(shape)
650 .map(|(block, &dim)| dim.div_ceil(block as usize))
651 .collect()
652 }
653
654 /// Create an iterator over all stored dimensions
655 pub fn iter(&self) -> impl Iterator<Item = &u8> {
656 self.as_slice().iter()
657 }
658
659 /// Returns the total number of elements in each block.
660 pub fn num_elements(&self) -> usize {
661 self.iter().map(|it| *it as usize).product()
662 }
663}
664
665impl Deref for BlockSize {
666 type Target = [u8];
667
668 fn deref(&self) -> &Self::Target {
669 self.as_slice()
670 }
671}
672
673impl<T: AsRef<[u8]>> From<T> for BlockSize {
674 fn from(value: T) -> Self {
675 BlockSize::new(value)
676 }
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682
683 #[test]
684 fn blocks_remain_rank_relative() {
685 assert_ne!(BlockSize::new([32]), BlockSize::new([32, 32]));
686 assert_eq!(BlockSize::new([32]).to_dim_vec(2), vec![1, 32]);
687 assert_eq!(BlockSize::new([32, 32]).to_dim_vec(2), vec![32, 32]);
688 }
689
690 #[test]
691 fn leading_unit_dimensions_canonicalize_away() {
692 assert_eq!(BlockSize::new([1, 32]), BlockSize::new([32]));
693 }
694
695 #[test]
696 fn leading_unit_dimensions_beyond_the_cap_still_canonicalize() {
697 assert_eq!(
698 BlockSize::new([1, 1, 8, 4, 2, 3]),
699 BlockSize::new([8, 4, 2, 3])
700 );
701 }
702
703 #[test]
704 fn there_is_one_block_per_scale() {
705 assert_eq!(BlockSize::new([32]).num_blocks(&[8, 64]), vec![8, 2]);
706 assert_eq!(BlockSize::new([4, 32]).num_blocks(&[8, 64]), vec![2, 2]);
707 assert_eq!(BlockSize::new([32]).num_blocks(&[4, 8, 64]), vec![4, 8, 2]);
708 }
709
710 #[test]
711 fn a_partial_block_still_takes_a_scale() {
712 assert_eq!(BlockSize::new([32]).num_blocks(&[8, 70]), vec![8, 3]);
713 }
714
715 #[test]
716 fn the_default_scheme_resolves_to_per_tensor_f32() {
717 let scheme = QuantScheme::default();
718 assert_eq!(scheme.tensor_scale(), Some(ScaleDtype::F32));
719 assert_eq!(scheme.block_scale(), None);
720 assert_eq!(scheme.scale_dtype(), ScaleDtype::F32);
721 assert_eq!(scheme.block_size(), None);
722 assert_eq!(scheme.num_levels(), 1);
723 }
724
725 #[test]
726 fn a_block_level_stands_alone() {
727 let scheme = QuantScheme::default().per_block([32], ScaleDtype::F16);
728 assert_eq!(scheme.tensor_scale(), None);
729 assert_eq!(scheme.scale_dtype(), ScaleDtype::F16);
730 assert_eq!(scheme.block_size(), Some(BlockSize::new([32])));
731 assert_eq!(scheme.num_levels(), 1);
732 }
733
734 #[test]
735 fn both_levels_nest_the_block_inside_the_tensor() {
736 let scheme = QuantScheme::default()
737 .per_block([16], ScaleDtype::UE4M3)
738 .per_tensor(ScaleDtype::F32);
739 assert_eq!(scheme.scale_dtype(), ScaleDtype::UE4M3);
740 assert_eq!(scheme.tensor_scale(), Some(ScaleDtype::F32));
741 assert_eq!(scheme.num_levels(), 2);
742 }
743
744 #[test]
745 fn levels_set_in_any_order_are_the_same_scheme() {
746 assert_eq!(
747 QuantScheme::default()
748 .per_block([16], ScaleDtype::UE4M3)
749 .per_tensor(ScaleDtype::F32),
750 QuantScheme::default()
751 .per_tensor(ScaleDtype::F32)
752 .per_block([16], ScaleDtype::UE4M3),
753 );
754 }
755
756 #[test]
757 fn swapping_dims_rewrites_the_block_and_leaves_the_tensor_level_alone() {
758 let mut scheme = QuantScheme::default()
759 .per_block([4, 32], ScaleDtype::F16)
760 .per_tensor(ScaleDtype::F32);
761 scheme.swap_block_dims(2, 0, 1);
762 assert_eq!(
763 scheme,
764 QuantScheme::default()
765 .per_block([32, 4], ScaleDtype::F16)
766 .per_tensor(ScaleDtype::F32)
767 );
768
769 let mut per_tensor = QuantScheme::default();
770 per_tensor.swap_block_dims(2, 0, 1);
771 assert_eq!(per_tensor, QuantScheme::default());
772 }
773
774 #[test]
775 fn swapping_dims_canonicalizes_the_block() {
776 let mut scheme = QuantScheme::default().per_block([32, 1], ScaleDtype::F32);
777 scheme.swap_block_dims(2, 0, 1);
778 assert_eq!(scheme.block_size(), Some(BlockSize::new([32])));
779 }
780
781 #[test]
782 fn permuting_dims_rewrites_the_block() {
783 let mut scheme = QuantScheme::default().per_block([1, 4, 32], ScaleDtype::F16);
784 scheme.permute_block_dims(3, &[2, 0, 1]);
785 assert_eq!(scheme.block_size(), Some(BlockSize::new([32, 1, 4])));
786 }
787
788 #[test]
789 fn round_up_never_lands_below_the_scale() {
790 for dtype in [ScaleDtype::F16, ScaleDtype::BF16, ScaleDtype::UE4M3] {
791 for exp in -12..8 {
792 for step in 1..17 {
793 let scale = (step as f32 / 16.0) * 2f32.powi(exp);
794 let up = dtype.round_up(scale);
795 assert!(
796 up >= scale,
797 "{dtype:?}: {up} is below {scale}, which clips the block maximum"
798 );
799 }
800 }
801 }
802 }
803
804 #[test]
805 fn round_up_saturates_rather_than_stepping_off_the_top() {
806 for dtype in [ScaleDtype::F16, ScaleDtype::BF16, ScaleDtype::UE4M3] {
807 let max = dtype.max_representable();
808 assert_eq!(dtype.round_up(max), max);
809 assert!(dtype.round_up(max * 2.0).is_finite());
810 }
811 }
812
813 /// Every variant is dispatched somewhere, so none of them may panic here, and every answer is
814 /// a scale something can be divided by.
815 #[test]
816 fn round_up_answers_for_every_param() {
817 for dtype in [
818 ScaleDtype::F32,
819 ScaleDtype::F16,
820 ScaleDtype::BF16,
821 ScaleDtype::UE8M0,
822 ScaleDtype::UE4M3,
823 ] {
824 let up = dtype.round_up(0.3);
825 assert!(up.is_finite() && up > 0.0, "{dtype:?} answered {up:e}");
826 }
827 }
828
829 /// 2^`exp`, from the exponent field rather than through `powi`.
830 ///
831 /// `powi` is only ever approximate — Miri returns a value a few ulp off, deliberately, so
832 /// nothing comes to depend on one host's precision — and a power of two that is a hair off is
833 /// a different power of two once its mantissa is cleared. Only for exponents f32 has a normal
834 /// for: -126..=127.
835 fn power_of_two(exp: i32) -> f32 {
836 f32::from_bits(((exp + 127) as u32) << 23)
837 }
838
839 /// `ue8m0` stores a bare exponent, so rounding up to it is rounding up to a power of two.
840 #[test]
841 fn ue8m0_rounds_up_to_a_power_of_two() {
842 for exp in -120..120 {
843 let power = power_of_two(exp);
844 // Already a power of two: nothing to round.
845 assert_eq!(ScaleDtype::UE8M0.round_up(power), power, "2^{exp}");
846 // Anything above it goes to the next one up, however little above.
847 for scale in [power * 1.0001, power * 1.5, power * 1.9999] {
848 assert_eq!(
849 ScaleDtype::UE8M0.round_up(scale),
850 power * 2.0,
851 "{scale} (2^{exp} scaled)"
852 );
853 }
854 }
855 }
856
857 /// Both ends saturate. The bottom is the reason `ue8m0` needs its own path at all: 2^-127 is
858 /// subnormal in f32, and zero — which a fully-zero block calibrates to — is not a `ue8m0`
859 /// value in the first place.
860 #[test]
861 fn ue8m0_saturates_at_both_ends() {
862 let min = ScaleDtype::UE8M0_MIN;
863 let max = ScaleDtype::UE8M0_MAX;
864
865 for scale in [0.0, f32::MIN_POSITIVE * 0.5, min * 0.5, min] {
866 assert_eq!(ScaleDtype::UE8M0.round_up(scale), min, "{scale:e}");
867 }
868 for scale in [max, max * 2.0, f32::MAX, f32::INFINITY] {
869 assert_eq!(ScaleDtype::UE8M0.round_up(scale), max, "{scale:e}");
870 }
871 }
872
873 /// Every byte stands for a value that encodes back to it — the codec is a bijection on the
874 /// codes, which is what serializing a scale and reading it back depends on.
875 #[test]
876 fn every_ue8m0_code_round_trips() {
877 for code in 0..=0xFEu8 {
878 let value = ue8m0_to_f32(code);
879 assert_eq!(
880 f32_to_ue8m0(value),
881 code,
882 "code {code} decoded to {value:e}"
883 );
884 }
885 assert!(ue8m0_to_f32(0xFF).is_nan());
886 }
887
888 /// The codec agrees with `round_up`, so a scale stored through either lands on the same value.
889 #[test]
890 fn the_ue8m0_codec_agrees_with_the_round_up_rule() {
891 for exp in -130..130 {
892 for factor in [1.0, 1.3, 1.9] {
893 let scale = factor * 2f32.powi(exp);
894 assert_eq!(
895 ue8m0_to_f32(f32_to_ue8m0(scale)),
896 ScaleDtype::UE8M0.round_up(scale),
897 "{scale:e}"
898 );
899 }
900 }
901 }
902
903 /// The answer is always representable, so rounding it again changes nothing.
904 #[test]
905 fn ue8m0_round_up_is_idempotent() {
906 for exp in -130..130 {
907 for factor in [1.0, 1.3, 1.7] {
908 let scale = factor * 2f32.powi(exp);
909 let up = ScaleDtype::UE8M0.round_up(scale);
910 assert_eq!(
911 ScaleDtype::UE8M0.round_up(up),
912 up,
913 "not idempotent at {scale:e}"
914 );
915 assert!(up >= scale.min(ScaleDtype::UE8M0_MAX), "{up:e} < {scale:e}");
916 }
917 }
918 }
919
920 #[test]
921 fn round_up_is_the_identity_for_f32() {
922 for scale in [1.0e-30, 0.1, 1.0, 12345.678, f32::MAX] {
923 assert_eq!(ScaleDtype::F32.round_up(scale), scale);
924 }
925 }
926
927 /// The checks that need the real storage types to compare against.
928 #[cfg(feature = "fp8")]
929 mod storage_types {
930 use super::*;
931
932 #[test]
933 fn round_up_is_the_nearest_representable_value_not_below() {
934 // Rounding up must not overshoot: stepping down from the answer has to land below.
935 for dtype in [ScaleDtype::F16, ScaleDtype::BF16, ScaleDtype::UE4M3] {
936 for exp in -8..6 {
937 let scale = 1.7 * 2f32.powi(exp);
938 let up = dtype.round_up(scale);
939 assert_eq!(
940 up,
941 dtype.round_up(up),
942 "{dtype:?}: not idempotent at {scale}"
943 );
944 assert!(
945 step(dtype, up, -1) < scale,
946 "{dtype:?}: {up} overshoots {scale} by at least a step"
947 );
948 }
949 }
950 }
951
952 /// `round_up` reads the grid instead of converting through the storage type, so a wrong
953 /// constant there is only visible against the type itself. Nothing else in this file would
954 /// catch one: a grid finer than the real thing still lands above the scale, still steps
955 /// down below it, and still looks idempotent.
956 #[test]
957 fn f32_grid_matches_the_storage_types() {
958 for dtype in [ScaleDtype::F16, ScaleDtype::BF16, ScaleDtype::UE4M3] {
959 let grid = dtype.f32_grid();
960
961 // bf16 deliberately reports no subnormal range, since its bit step covers them too.
962 if let Some(subnormals) = grid.subnormals {
963 assert_eq!(
964 subnormals.min_normal,
965 min_normal(dtype),
966 "{dtype:?}: minimum normal"
967 );
968 assert_eq!(
969 subnormals.spacing,
970 step(dtype, 0.0, 1),
971 "{dtype:?}: subnormal spacing"
972 );
973 }
974
975 // Walk the whole normal range: one step on the f32 pattern has to be one step in
976 // the type, at every exponent.
977 let mut value = min_normal(dtype);
978 let max = dtype.max_representable();
979 while value < max {
980 let stepped = f32::from_bits(value.to_bits() + grid.bit_step);
981 assert_eq!(
982 stepped,
983 step(dtype, value, 1),
984 "{dtype:?}: step above {value}"
985 );
986 value = stepped;
987 }
988 assert_eq!(
989 value, max,
990 "{dtype:?}: the grid has to land exactly on the maximum"
991 );
992 }
993 }
994
995 #[test]
996 fn max_representable_matches_the_e4m3_type() {
997 assert_eq!(
998 ScaleDtype::UE4M3.max_representable(),
999 crate::e4m3::MAX.to_f32()
1000 );
1001 }
1002
1003 /// The other limit spelled out as a literal. `ue8m0` is exponent only, so its maximum is
1004 /// the power of two the hex literal encodes.
1005 #[test]
1006 fn max_representable_matches_the_e8m0_type() {
1007 assert_eq!(
1008 ScaleDtype::UE8M0.max_representable(),
1009 crate::ue8m0::MAX.to_f32()
1010 );
1011 }
1012
1013 /// [`f32_to_ue8m0`] and [`ue8m0_to_f32`] restate what the `ue8m0` type already does, so
1014 /// that this module stays usable without the `fp8` feature. Restating it is only safe
1015 /// while the two agree: a scale written through one and read through the other has to be
1016 /// the same scale, and the two ends of that trip are on opposite sides of the gate.
1017 ///
1018 /// Includes the rounding, which is the part that could plausibly drift — `ue8m0` rounds
1019 /// up, where a conversion would normally round to nearest.
1020 ///
1021 /// Gated on `float4` rather than on this module's `fp8`, because that is where `ue8m0`'s
1022 /// conversions live — and it is exactly that split which is the reason the pair above
1023 /// exists at all.
1024 #[test]
1025 #[cfg(feature = "fp4")]
1026 fn the_ue8m0_codec_matches_the_storage_type() {
1027 for code in 0..=0xFFu8 {
1028 let ours = ue8m0_to_f32(code);
1029 let theirs = crate::ue8m0::from_bits(code).to_f32();
1030 if theirs.is_nan() {
1031 assert!(ours.is_nan(), "code {code}: {ours:e} is not a NaN");
1032 } else {
1033 assert_eq!(ours.to_bits(), theirs.to_bits(), "code {code}");
1034 }
1035 }
1036
1037 for exp in -130..130 {
1038 for factor in [1.0, 1.25, 1.5, 1.9] {
1039 let scale = factor * 2f32.powi(exp);
1040 assert_eq!(
1041 f32_to_ue8m0(scale),
1042 crate::ue8m0::from_f32(scale).to_bits(),
1043 "{scale:e}"
1044 );
1045 }
1046 }
1047 }
1048
1049 /// `offset` representable steps from `value` in `dtype`, for positive values. Counted on
1050 /// the storage type's own bit pattern, so this is an oracle independent of the grid under
1051 /// test.
1052 fn step(dtype: ScaleDtype, value: f32, offset: i32) -> f32 {
1053 match dtype {
1054 ScaleDtype::F16 => half::f16::from_bits(
1055 (half::f16::from_f32(value).to_bits() as i32 + offset) as u16,
1056 )
1057 .to_f32(),
1058 ScaleDtype::BF16 => half::bf16::from_bits(
1059 (half::bf16::from_f32(value).to_bits() as i32 + offset) as u16,
1060 )
1061 .to_f32(),
1062 ScaleDtype::UE4M3 => crate::e4m3::from_bits(
1063 (crate::e4m3::from_f32(value).to_bits() as i32 + offset) as u8,
1064 )
1065 .to_f32(),
1066 ScaleDtype::F32 | ScaleDtype::UE8M0 => unreachable!(),
1067 }
1068 }
1069
1070 fn min_normal(dtype: ScaleDtype) -> f32 {
1071 match dtype {
1072 ScaleDtype::F16 => half::f16::MIN_POSITIVE.to_f32(),
1073 ScaleDtype::BF16 => half::bf16::MIN_POSITIVE.to_f32(),
1074 ScaleDtype::UE4M3 => crate::e4m3::MIN_POSITIVE.to_f32(),
1075 ScaleDtype::F32 | ScaleDtype::UE8M0 => unreachable!(),
1076 }
1077 }
1078 }
1079}