hurray_core/quantization/mxfp.rs
1//! MXFP (OCP Microscaling) quantization descriptor (scheme tag `0x05`).
2//!
3//! A block quantization format standardised by the Open Compute Project
4//! Microscaling specification (OCP MX v1.0). Each contiguous block of elements
5//! along a chosen axis shares a single `float8_e8m0` exponent-only scale stored
6//! in a dedicated buffer. Unlike per-block affine, MXFP requires exact
7//! divisibility — partial trailing blocks are **not** permitted.
8//!
9//! See `docs/spec/quantization/mxfp.md` for the normative definition.
10
11use crate::{ElementType, Error, Result};
12
13// ── Wire layout constants ─────────────────────────────────────────────────────
14
15/// Scheme tag byte for MXFP quantization.
16pub(crate) const SCHEME_TAG: u8 = 0x05;
17
18/// Total descriptor length in bytes (header 4 + axis 4 + block_size 4 + scale_buf 4).
19pub(crate) const ENCODED_LEN: usize = 16;
20
21/// Version this implementation supports.
22pub(crate) const SUPPORTED_VERSION: u8 = 0x01;
23
24/// Minimum block size for MXFP (inclusive).
25///
26/// Values below 16 have no hardware Tensor Core support and are invalid under
27/// any OCP MX revision.
28pub const MXFP_MIN_BLOCK_SIZE: u32 = 16;
29
30/// Maximum block size for MXFP (inclusive).
31pub const MXFP_MAX_BLOCK_SIZE: u32 = 2048;
32
33/// OCP MX v1.0 canonical block size.
34pub const MXFP_CANONICAL_BLOCK_SIZE: u32 = 32;
35
36/// No flags are defined for this scheme; all 16 bits must be zero.
37const RESERVED_FLAGS_MASK: u16 = 0xFFFF;
38
39/// Wire sentinel that MUST NOT appear as `scale_buffer_index`.
40const INVALID_BUF_SENTINEL: u32 = 0xFFFF_FFFF;
41
42// Wire field offsets (relative to start of descriptor, including header).
43const OFFSET_AXIS: usize = 4;
44const OFFSET_BLOCK_SIZE: usize = 8;
45const OFFSET_SCALE_BUF: usize = 12;
46
47// ── Mxfp ──────────────────────────────────────────────────────────────────────
48
49/// Quantization parameters for MXFP (OCP Microscaling) block quantization.
50///
51/// Each block of `block_size` consecutive elements along `axis` shares a single
52/// `float8_e8m0` exponent-only scale. The scale values are stored as raw bytes
53/// in the buffer identified by `scale_buffer_index`.
54///
55/// The dequantization formula for element `q` with block index `b` and scale
56/// byte `e = scale[b]` is:
57///
58/// ```text
59/// s = 2^(e - 127) (float8_e8m0 shared exponent)
60/// x_real = s * value_of(q) (float_value_of(q) or int_value_of(q))
61/// ```
62///
63/// Unlike per-block affine, MXFP requires exact divisibility: `shape[axis]`
64/// MUST be a positive multiple of `block_size`. No partial trailing blocks are
65/// permitted.
66///
67/// # Wire format
68///
69/// Total descriptor length: **16 bytes** (including the 4-byte header).
70///
71/// | Offset | Field | Type |
72/// |--------|-------|------|
73/// | 0 | `scheme_tag` | `uint8` (must be `0x05`) |
74/// | 1 | `scheme_version` | `uint8` (must be `0x01`) |
75/// | 2 | `flags` | `uint16` LE (must be `0x0000`) |
76/// | 4 | `axis` | `uint32` LE |
77/// | 8 | `block_size` | `uint32` LE, power-of-two in `[16, 2048]` |
78/// | 12 | `scale_buffer_index` | `uint32` LE |
79///
80/// # Design notes
81///
82/// `PartialEq`, `Eq`, and `Hash` are all derived because this struct contains
83/// no floating-point fields — all fields are integers.
84///
85/// `Copy` because the struct is ≤ 24 bytes with no `Drop` glue.
86///
87/// # Examples
88///
89/// ```
90/// use hurray_core::Mxfp;
91///
92/// let q = Mxfp::new(0, 32, 1).unwrap();
93/// assert_eq!(q.axis(), 0);
94/// assert_eq!(q.block_size(), 32);
95/// assert_eq!(q.scale_buffer_index(), 1);
96/// ```
97// WHY Eq + Hash: no float fields.
98// WHY Copy: ≤24 bytes, no Drop glue (design decision #7).
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
100#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
101pub struct Mxfp {
102 axis: u32,
103 block_size: u32,
104 scale_buffer_index: u32,
105}
106
107impl Mxfp {
108 /// Creates a new [`Mxfp`] descriptor.
109 ///
110 /// # Errors
111 ///
112 /// - [`Error::InvalidBlockSize`] — `block_size` is not a power of two, or
113 /// lies outside `[`[`MXFP_MIN_BLOCK_SIZE`]`, `[`MXFP_MAX_BLOCK_SIZE`]`]` (`[16, 2048]`).
114 ///
115 /// # Examples
116 ///
117 /// ```
118 /// use hurray_core::{Mxfp, Error};
119 ///
120 /// assert!(Mxfp::new(0, 32, 1).is_ok());
121 /// assert!(Mxfp::new(0, 16, 1).is_ok());
122 /// assert!(Mxfp::new(0, 2048, 1).is_ok());
123 ///
124 /// // block_size < 16 is rejected.
125 /// assert!(Mxfp::new(0, 8, 1).is_err());
126 /// // block_size > 2048 is rejected.
127 /// assert!(Mxfp::new(0, 4096, 1).is_err());
128 /// // Non-power-of-two is rejected.
129 /// assert!(Mxfp::new(0, 48, 1).is_err());
130 /// ```
131 pub fn new(axis: u32, block_size: u32, scale_buffer_index: u32) -> Result<Self> {
132 validate_block_size(block_size)?;
133 Ok(Self {
134 axis,
135 block_size,
136 scale_buffer_index,
137 })
138 }
139
140 /// Returns the quantization axis index.
141 ///
142 /// # Examples
143 ///
144 /// ```
145 /// use hurray_core::Mxfp;
146 ///
147 /// let q = Mxfp::new(2, 32, 1).unwrap();
148 /// assert_eq!(q.axis(), 2);
149 /// ```
150 #[inline]
151 pub fn axis(&self) -> u32 {
152 self.axis
153 }
154
155 /// Returns the number of logical elements per block along `axis`.
156 ///
157 /// Always a power of two in `[`[`MXFP_MIN_BLOCK_SIZE`]`, `[`MXFP_MAX_BLOCK_SIZE`]`]`.
158 ///
159 /// # Examples
160 ///
161 /// ```
162 /// use hurray_core::Mxfp;
163 ///
164 /// let q = Mxfp::new(0, 64, 1).unwrap();
165 /// assert_eq!(q.block_size(), 64);
166 /// ```
167 #[inline]
168 pub fn block_size(&self) -> u32 {
169 self.block_size
170 }
171
172 /// Returns the buffer table index of the per-block `float8_e8m0` scale array.
173 ///
174 /// # Examples
175 ///
176 /// ```
177 /// use hurray_core::Mxfp;
178 ///
179 /// let q = Mxfp::new(0, 32, 3).unwrap();
180 /// assert_eq!(q.scale_buffer_index(), 3);
181 /// ```
182 #[inline]
183 pub fn scale_buffer_index(&self) -> u32 {
184 self.scale_buffer_index
185 }
186
187 /// Computes the number of blocks along `axis` for a given `shape_axis` size.
188 ///
189 /// MXFP requires exact divisibility: `shape_axis` MUST be a positive multiple
190 /// of `block_size`. This is stricter than per-block affine, which uses
191 /// `div_ceil` and allows partial trailing blocks.
192 ///
193 /// # Errors
194 ///
195 /// - [`Error::QuantizationShapeMismatch`] — `shape_axis` is not evenly
196 /// divisible by `block_size`.
197 ///
198 /// # Examples
199 ///
200 /// ```
201 /// use hurray_core::Mxfp;
202 ///
203 /// let q = Mxfp::new(0, 32, 1).unwrap();
204 /// assert_eq!(q.num_blocks_per_axis(64).unwrap(), 2);
205 /// assert_eq!(q.num_blocks_per_axis(32).unwrap(), 1);
206 ///
207 /// // 65 is not a multiple of 32 — error.
208 /// assert!(q.num_blocks_per_axis(65).is_err());
209 /// // 0 is not a positive multiple — error.
210 /// assert!(q.num_blocks_per_axis(0).is_err());
211 /// ```
212 pub fn num_blocks_per_axis(&self, shape_axis: u64) -> Result<u64> {
213 // MXFP requires shape_axis > 0 AND exact divisibility by block_size.
214 // Stricter than per-block-affine which uses div_ceil — exact divisibility
215 // required by MXFP spec (mxfp.md § Validity Constraints).
216 if shape_axis == 0 || !shape_axis.is_multiple_of(self.block_size as u64) {
217 return Err(Error::QuantizationShapeMismatch {
218 axis: self.axis,
219 shape_axis,
220 block_size: self.block_size,
221 reason: "MXFP requires shape[axis] to be a positive multiple of block_size; partial trailing blocks are not permitted",
222 });
223 }
224 Ok(shape_axis / self.block_size as u64)
225 }
226
227 /// Validates that `shape_axis` satisfies the MXFP divisibility constraint.
228 ///
229 /// `shape_axis` MUST be greater than zero AND evenly divisible by `block_size`.
230 ///
231 /// # Errors
232 ///
233 /// - [`Error::QuantizationShapeMismatch`] — `shape_axis == 0` or
234 /// `shape_axis % block_size != 0`.
235 ///
236 /// # Examples
237 ///
238 /// ```
239 /// use hurray_core::Mxfp;
240 ///
241 /// let q = Mxfp::new(0, 32, 1).unwrap();
242 /// assert!(q.validate_against_shape_axis(64).is_ok());
243 /// assert!(q.validate_against_shape_axis(32).is_ok());
244 ///
245 /// // Zero is rejected (not a positive multiple).
246 /// assert!(q.validate_against_shape_axis(0).is_err());
247 /// // 65 is not divisible by 32.
248 /// assert!(q.validate_against_shape_axis(65).is_err());
249 /// ```
250 pub fn validate_against_shape_axis(&self, shape_axis: u64) -> Result<()> {
251 self.num_blocks_per_axis(shape_axis).map(|_| ())
252 }
253
254 /// Validates the content of a scale buffer for MXFP conformance.
255 ///
256 /// Per `docs/spec/quantization/mxfp.md § Referenced Buffer`: the bit patterns
257 /// `0x00` and `0xFF` are reserved (`NaN` per OCP MX v1.0 § 5.6) and MUST NOT
258 /// appear in any scale byte.
259 ///
260 /// # Errors
261 ///
262 /// - [`Error::InvalidQuantization`] — any byte in `bytes` is `0x00` or `0xFF`.
263 ///
264 /// # Examples
265 ///
266 /// ```
267 /// use hurray_core::Mxfp;
268 ///
269 /// let q = Mxfp::new(0, 32, 1).unwrap();
270 ///
271 /// // Valid scale bytes: non-zero, non-0xFF.
272 /// assert!(q.validate_scale_bytes(&[0x01, 0x7F, 0x80, 0xFE]).is_ok());
273 ///
274 /// // 0x00 is forbidden.
275 /// assert!(q.validate_scale_bytes(&[0x01, 0x00, 0x7F]).is_err());
276 ///
277 /// // 0xFF is forbidden.
278 /// assert!(q.validate_scale_bytes(&[0x7F, 0xFF]).is_err());
279 /// ```
280 pub fn validate_scale_bytes(&self, bytes: &[u8]) -> Result<()> {
281 for (i, &b) in bytes.iter().enumerate() {
282 if b == 0x00 || b == 0xFF {
283 return Err(Error::InvalidQuantization(format!(
284 "MXFP scale buffer byte at index {i} is 0x{b:02X}, which is a reserved \
285 float8_e8m0 NaN pattern (OCP MX v1.0 § 5.6); values 0x00 and 0xFF are \
286 forbidden in the scale buffer"
287 )));
288 }
289 }
290 Ok(())
291 }
292
293 /// Returns the set of storage [`ElementType`]s that are valid for this scheme.
294 ///
295 /// Per `docs/spec/quantization/mxfp.md § Valid Storage Types`.
296 ///
297 /// WHY `&'static [ElementType]`: no allocation per call; `slice::contains`
298 /// over ≤10 items beats any hash structure (design decision #5).
299 ///
300 /// # Examples
301 ///
302 /// ```
303 /// use hurray_core::{ElementType, Mxfp};
304 ///
305 /// assert!(Mxfp::valid_storage_types().contains(&ElementType::Float8E4M3));
306 /// assert!(Mxfp::valid_storage_types().contains(&ElementType::Int8));
307 /// assert!(Mxfp::valid_storage_types().contains(&ElementType::Int4));
308 /// assert!(!Mxfp::valid_storage_types().contains(&ElementType::Float32));
309 /// ```
310 pub fn valid_storage_types() -> &'static [ElementType] {
311 &[
312 ElementType::Float8E4M3, // MXFP8
313 ElementType::Float8E5M2, // MXFP8
314 ElementType::Float4E2M1, // MXFP4
315 ElementType::Float6E2M3, // MXFP6
316 ElementType::Float6E3M2, // MXFP6
317 ElementType::Int8, // MXINT8
318 ElementType::Int4, // MXINT4 / MXFP4 integer surrogate
319 ]
320 }
321
322 // ── Crate-internal encode/decode ──────────────────────────────────────────
323
324 /// Decodes the scheme-specific payload from `bytes`.
325 ///
326 /// `bytes` is the full descriptor slice (including the 4-byte header that the
327 /// caller has already validated). `flags` are the header flags (must be zero
328 /// for this scheme).
329 pub(crate) fn decode_payload(flags: u16, bytes: &[u8]) -> Result<Self> {
330 if bytes.len() < ENCODED_LEN {
331 return Err(Error::QuantizationDescriptorTooShort {
332 found: bytes.len(),
333 needed: ENCODED_LEN,
334 });
335 }
336 // No flags defined for MXFP.
337 if flags & RESERVED_FLAGS_MASK != 0 {
338 return Err(Error::ReservedQuantizationFlagsBits {
339 flags,
340 mask: RESERVED_FLAGS_MASK,
341 });
342 }
343
344 let axis = u32::from_le_bytes(
345 bytes[OFFSET_AXIS..OFFSET_AXIS + 4]
346 .try_into()
347 .map_err(|_| Error::InvalidQuantization("axis slice error".into()))?,
348 );
349 let block_size = u32::from_le_bytes(
350 bytes[OFFSET_BLOCK_SIZE..OFFSET_BLOCK_SIZE + 4]
351 .try_into()
352 .map_err(|_| Error::InvalidQuantization("block_size slice error".into()))?,
353 );
354 let scale_buf = u32::from_le_bytes(
355 bytes[OFFSET_SCALE_BUF..OFFSET_SCALE_BUF + 4]
356 .try_into()
357 .map_err(|_| Error::InvalidQuantization("scale_buffer_index slice error".into()))?,
358 );
359
360 // block_size must be a power of two in [16, 2048].
361 validate_block_size(block_size)?;
362
363 // scale_buffer_index must not be the sentinel value 0xFFFFFFFF.
364 if scale_buf == INVALID_BUF_SENTINEL {
365 return Err(Error::InvalidQuantization(
366 "MXFP scale_buffer_index must not be 0xFFFFFFFF".into(),
367 ));
368 }
369
370 Ok(Self {
371 axis,
372 block_size,
373 scale_buffer_index: scale_buf,
374 })
375 }
376
377 /// Encodes the scheme-specific payload into `out`.
378 ///
379 /// `out` must be at least [`ENCODED_LEN`] bytes. The caller writes the 4-byte
380 /// header via [`super::QuantizationHeader::write`]; this method writes
381 /// bytes 4–15.
382 pub(crate) fn encode_payload(&self, out: &mut [u8]) {
383 out[OFFSET_AXIS..OFFSET_AXIS + 4].copy_from_slice(&self.axis.to_le_bytes());
384 out[OFFSET_BLOCK_SIZE..OFFSET_BLOCK_SIZE + 4]
385 .copy_from_slice(&self.block_size.to_le_bytes());
386 out[OFFSET_SCALE_BUF..OFFSET_SCALE_BUF + 4]
387 .copy_from_slice(&self.scale_buffer_index.to_le_bytes());
388 }
389}
390
391// ── Helpers ───────────────────────────────────────────────────────────────────
392
393fn validate_block_size(block_size: u32) -> Result<()> {
394 if !block_size.is_power_of_two()
395 || !(MXFP_MIN_BLOCK_SIZE..=MXFP_MAX_BLOCK_SIZE).contains(&block_size)
396 {
397 return Err(Error::InvalidBlockSize {
398 scheme_tag: SCHEME_TAG,
399 block_size,
400 min: MXFP_MIN_BLOCK_SIZE,
401 max: MXFP_MAX_BLOCK_SIZE,
402 });
403 }
404 Ok(())
405}
406
407// ── Tests ─────────────────────────────────────────────────────────────────────
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use crate::{ElementType, Error};
413
414 // ── Mxfp::new ─────────────────────────────────────────────────────────────
415
416 #[test]
417 fn new_block_size_below_min_is_err() {
418 // MXFP_MIN_BLOCK_SIZE = 16; block_size = 8 is below.
419 assert!(matches!(
420 Mxfp::new(0, 8, 1),
421 Err(Error::InvalidBlockSize { .. })
422 ));
423 }
424
425 #[test]
426 fn new_block_size_above_max_is_err() {
427 // MXFP_MAX_BLOCK_SIZE = 2048; block_size = 4096 is above.
428 assert!(matches!(
429 Mxfp::new(0, 4096, 1),
430 Err(Error::InvalidBlockSize { .. })
431 ));
432 }
433
434 #[test]
435 fn new_block_size_not_power_of_two_is_err() {
436 assert!(matches!(
437 Mxfp::new(0, 48, 1),
438 Err(Error::InvalidBlockSize { .. })
439 ));
440 }
441
442 #[test]
443 fn new_min_block_size_is_ok() {
444 assert!(Mxfp::new(0, MXFP_MIN_BLOCK_SIZE, 1).is_ok());
445 }
446
447 #[test]
448 fn new_canonical_block_size_is_ok() {
449 assert!(Mxfp::new(0, MXFP_CANONICAL_BLOCK_SIZE, 1).is_ok());
450 }
451
452 #[test]
453 fn new_max_block_size_is_ok() {
454 assert!(Mxfp::new(0, MXFP_MAX_BLOCK_SIZE, 1).is_ok());
455 }
456
457 // ── num_blocks_per_axis ───────────────────────────────────────────────────
458
459 #[test]
460 fn num_blocks_per_axis_zero_shape_is_err() {
461 // MXFP requires a positive multiple — shape_axis=0 is always rejected.
462 let q = Mxfp::new(0, 32, 1).unwrap();
463 assert!(matches!(
464 q.num_blocks_per_axis(0),
465 Err(Error::QuantizationShapeMismatch { .. })
466 ));
467 }
468
469 #[test]
470 fn num_blocks_per_axis_exact_divisibility_is_ok() {
471 let q = Mxfp::new(0, 32, 1).unwrap();
472 assert_eq!(q.num_blocks_per_axis(64).unwrap(), 2);
473 }
474
475 #[test]
476 fn num_blocks_per_axis_not_divisible_is_err() {
477 // 65 is not a multiple of 32 — MXFP forbids partial trailing blocks.
478 let q = Mxfp::new(0, 32, 1).unwrap();
479 assert!(matches!(
480 q.num_blocks_per_axis(65),
481 Err(Error::QuantizationShapeMismatch { .. })
482 ));
483 }
484
485 // ── validate_scale_bytes ──────────────────────────────────────────────────
486
487 #[test]
488 fn validate_scale_bytes_empty_slice_is_ok() {
489 let q = Mxfp::new(0, 32, 1).unwrap();
490 assert!(q.validate_scale_bytes(&[]).is_ok());
491 }
492
493 #[test]
494 fn validate_scale_bytes_valid_bytes_is_ok() {
495 let q = Mxfp::new(0, 32, 1).unwrap();
496 assert!(q.validate_scale_bytes(&[0x01, 0x7F, 0x80, 0xFE]).is_ok());
497 }
498
499 #[test]
500 fn validate_scale_bytes_zero_byte_is_err() {
501 let q = Mxfp::new(0, 32, 1).unwrap();
502 assert!(matches!(
503 q.validate_scale_bytes(&[0x01, 0x00, 0x7F]),
504 Err(Error::InvalidQuantization(_))
505 ));
506 }
507
508 #[test]
509 fn validate_scale_bytes_0xff_is_err() {
510 let q = Mxfp::new(0, 32, 1).unwrap();
511 assert!(matches!(
512 q.validate_scale_bytes(&[0x7F, 0xFF]),
513 Err(Error::InvalidQuantization(_))
514 ));
515 }
516
517 #[test]
518 fn validate_scale_bytes_mixed_valid_and_invalid_is_err() {
519 let q = Mxfp::new(0, 32, 1).unwrap();
520 // First byte is valid; second byte 0x00 is invalid.
521 assert!(matches!(
522 q.validate_scale_bytes(&[0x7F, 0x00]),
523 Err(Error::InvalidQuantization(_))
524 ));
525 }
526
527 // ── valid_storage_types ───────────────────────────────────────────────────
528
529 #[test]
530 fn valid_storage_types_contains_expected_float_and_int_types() {
531 let types = Mxfp::valid_storage_types();
532 assert!(types.contains(&ElementType::Float8E4M3));
533 assert!(types.contains(&ElementType::Float8E5M2));
534 assert!(types.contains(&ElementType::Float4E2M1));
535 assert!(types.contains(&ElementType::Float6E2M3));
536 assert!(types.contains(&ElementType::Float6E3M2));
537 assert!(types.contains(&ElementType::Int8));
538 assert!(types.contains(&ElementType::Int4));
539 }
540
541 #[test]
542 fn valid_storage_types_does_not_contain_float32() {
543 assert!(!Mxfp::valid_storage_types().contains(&ElementType::Float32));
544 }
545
546 // ── Round-trips ───────────────────────────────────────────────────────────
547
548 fn encode_decode(q: &Mxfp) -> Mxfp {
549 let mut buf = vec![0u8; ENCODED_LEN];
550 buf[0] = SCHEME_TAG;
551 buf[1] = SUPPORTED_VERSION;
552 buf[2] = 0;
553 buf[3] = 0;
554 q.encode_payload(&mut buf);
555 Mxfp::decode_payload(0, &buf).unwrap()
556 }
557
558 #[test]
559 fn round_trip_canonical_block_size() {
560 let original = Mxfp::new(0, MXFP_CANONICAL_BLOCK_SIZE, 3).unwrap();
561 let decoded = encode_decode(&original);
562 assert_eq!(decoded, original);
563 }
564
565 #[test]
566 fn round_trip_min_block_size() {
567 let original = Mxfp::new(1, MXFP_MIN_BLOCK_SIZE, 2).unwrap();
568 let decoded = encode_decode(&original);
569 assert_eq!(decoded, original);
570 }
571
572 #[test]
573 fn round_trip_max_block_size() {
574 let original = Mxfp::new(0, MXFP_MAX_BLOCK_SIZE, 1).unwrap();
575 let decoded = encode_decode(&original);
576 assert_eq!(decoded, original);
577 }
578}