hurray_core/quantization/nf4.rs
1//! NF4 (NormalFloat4) quantization descriptor (scheme tag `0x04`).
2//!
3//! A non-linear 4-bit quantization scheme introduced by the QLoRA paper. Each
4//! storage code in `[0, 15]` decodes to one of 16 fixed real-valued levels. Each
5//! block along `axis` carries a single `absmax` scale stored in a separate buffer.
6//!
7//! See `docs/spec/quantization/nf4.md` for the normative definition.
8
9use crate::{ElementType, Error, Result};
10
11// ── Wire layout constants ─────────────────────────────────────────────────────
12
13/// Scheme tag byte for NF4 quantization.
14pub(crate) const SCHEME_TAG: u8 = 0x04;
15
16/// Total descriptor length in bytes (header 4 + axis 4 + block_size 4 + scale_buf 4).
17pub(crate) const ENCODED_LEN: usize = 16;
18
19/// Version this implementation supports.
20pub(crate) const SUPPORTED_VERSION: u8 = 0x01;
21
22/// Minimum block size for NF4 (must be a power of two ≥ 8).
23///
24/// Below 8 elements per block the 16-point NF4 information content provides no
25/// statistical benefit over a plain low-bit linear quantization.
26pub const NF4_MIN_BLOCK_SIZE: u32 = 8;
27
28/// No flags are defined for this scheme; all 16 bits must be zero.
29const RESERVED_FLAGS_MASK: u16 = 0xFFFF;
30
31/// Wire sentinel that must NOT appear in `scale_buffer_index` (symmetric sentinel).
32const INVALID_BUF_SENTINEL: u32 = 0xFFFF_FFFF;
33
34// Wire field offsets.
35const OFFSET_AXIS: usize = 4;
36const OFFSET_BLOCK_SIZE: usize = 8;
37const OFFSET_SCALE_BUF: usize = 12;
38
39// ── NF4 lookup table ──────────────────────────────────────────────────────────
40
41/// The 16 NF4 quantization levels, indexed by the unsigned 4-bit storage code.
42///
43/// These values are fixed by the specification and MUST NOT be altered. They are
44/// the `float32` decimal expansions of the exact levels from the QLoRA reference
45/// implementation (`docs/spec/quantization/nf4.md § Lookup Table`).
46///
47/// The extra precision in the source literals is intentional: these are the
48/// exact decimal representations of the IEEE 754 binary32 values; truncating
49/// them would alter the bit pattern.
50///
51/// # Examples
52///
53/// ```
54/// use hurray_core::NF4_LUT;
55///
56/// assert_eq!(NF4_LUT[0], -1.0f32);
57/// assert_eq!(NF4_LUT[7], 0.0f32);
58/// assert_eq!(NF4_LUT[15], 1.0f32);
59/// assert_eq!(NF4_LUT.len(), 16);
60/// ```
61#[allow(clippy::excessive_precision)]
62pub const NF4_LUT: [f32; 16] = [
63 -1.0,
64 -0.6961928009986877,
65 -0.5250730514526367,
66 -0.39491748809814453,
67 -0.28444138169288635,
68 -0.18477343022823334,
69 -0.09105003625154495,
70 0.0,
71 0.07958029955625534,
72 0.16093020141124725,
73 0.24611230194568634,
74 0.33791524171829224,
75 0.44070982933044434,
76 0.5626170039176941,
77 0.7229568362236023,
78 1.0,
79];
80
81// ── Nf4 ───────────────────────────────────────────────────────────────────────
82
83/// Quantization parameters for NF4 (NormalFloat4) block quantization.
84///
85/// Each block along `axis` contains `block_size` elements, all sharing a single
86/// `absmax` scale stored in `scale_buffer_index`. The dequantization formula for
87/// element `q` (a 4-bit code in `[0, 15]`) with block index `b` is:
88///
89/// ```text
90/// x_real = scale[b] * NF4_LUT[q]
91/// ```
92///
93/// The block index computation follows the same rule as Per-Block Affine
94/// (see `docs/spec/quantization/per-block-affine.md § Block Layout`).
95///
96/// # Wire format
97///
98/// Total descriptor length: **16 bytes** (including the 4-byte header).
99///
100/// | Offset | Field | Type |
101/// |--------|-------|------|
102/// | 4 | `axis` | `uint32` LE |
103/// | 8 | `block_size` | `uint32` LE |
104/// | 12 | `scale_buffer_index` | `uint32` LE |
105///
106/// No flags are defined; the `flags` field MUST be `0x0000`.
107///
108/// # Design notes
109///
110/// `PartialEq`, `Eq`, and `Hash` are all derived because this struct contains
111/// no floating-point fields.
112///
113/// `Copy` because the struct is ≤ 24 bytes with no `Drop` glue.
114///
115/// # Examples
116///
117/// ```
118/// use hurray_core::Nf4;
119///
120/// let q = Nf4::new(0, 64, 1).unwrap();
121/// assert_eq!(q.axis(), 0);
122/// assert_eq!(q.block_size(), 64);
123/// assert_eq!(q.scale_buffer_index(), 1);
124/// ```
125// WHY Eq + Hash: no float fields.
126// WHY Copy: ≤24 bytes, no Drop glue (design decision #7).
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
128#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
129pub struct Nf4 {
130 axis: u32,
131 block_size: u32,
132 scale_buffer_index: u32,
133}
134
135impl Nf4 {
136 /// Creates a new [`Nf4`] descriptor.
137 ///
138 /// # Errors
139 ///
140 /// - [`Error::InvalidBlockSize`] — `block_size` is not a power of two or is
141 /// less than [`NF4_MIN_BLOCK_SIZE`] (8).
142 ///
143 /// # Examples
144 ///
145 /// ```
146 /// use hurray_core::{Nf4, Error};
147 ///
148 /// assert!(Nf4::new(0, 64, 1).is_ok());
149 /// assert!(Nf4::new(0, 128, 1).is_ok());
150 ///
151 /// // block_size < 8 is rejected.
152 /// assert!(Nf4::new(0, 4, 1).is_err());
153 /// // Non-power-of-two is rejected.
154 /// assert!(Nf4::new(0, 48, 1).is_err());
155 /// ```
156 pub fn new(axis: u32, block_size: u32, scale_buffer_index: u32) -> Result<Self> {
157 validate_block_size(block_size)?;
158 Ok(Self {
159 axis,
160 block_size,
161 scale_buffer_index,
162 })
163 }
164
165 /// Returns the quantization axis index.
166 ///
167 /// # Examples
168 ///
169 /// ```
170 /// use hurray_core::Nf4;
171 ///
172 /// let q = Nf4::new(2, 64, 1).unwrap();
173 /// assert_eq!(q.axis(), 2);
174 /// ```
175 #[inline]
176 pub fn axis(&self) -> u32 {
177 self.axis
178 }
179
180 /// Returns the number of logical elements per block along `axis`.
181 ///
182 /// # Examples
183 ///
184 /// ```
185 /// use hurray_core::Nf4;
186 ///
187 /// let q = Nf4::new(0, 128, 1).unwrap();
188 /// assert_eq!(q.block_size(), 128);
189 /// ```
190 #[inline]
191 pub fn block_size(&self) -> u32 {
192 self.block_size
193 }
194
195 /// Returns the buffer table index of the per-block `absmax` scale array.
196 ///
197 /// # Examples
198 ///
199 /// ```
200 /// use hurray_core::Nf4;
201 ///
202 /// let q = Nf4::new(0, 64, 3).unwrap();
203 /// assert_eq!(q.scale_buffer_index(), 3);
204 /// ```
205 #[inline]
206 pub fn scale_buffer_index(&self) -> u32 {
207 self.scale_buffer_index
208 }
209
210 /// Computes the number of blocks along `axis` for a given `shape_axis` size.
211 ///
212 /// Uses `ceil(shape_axis / block_size)`.
213 ///
214 /// Returns `0` when `shape_axis == 0` per the ADR-007 empty-axis carve-out.
215 ///
216 /// # Examples
217 ///
218 /// ```
219 /// use hurray_core::Nf4;
220 ///
221 /// let q = Nf4::new(0, 64, 1).unwrap();
222 /// assert_eq!(q.num_blocks_per_axis(128), 2);
223 /// assert_eq!(q.num_blocks_per_axis(65), 2); // partial trailing block
224 /// assert_eq!(q.num_blocks_per_axis(0), 0); // ADR-007 empty-axis carve-out
225 /// ```
226 pub fn num_blocks_per_axis(&self, shape_axis: u64) -> u64 {
227 if shape_axis == 0 {
228 // ADR-007 empty-axis carve-out: zero blocks, zero-byte scale buffer.
229 return 0;
230 }
231 shape_axis.div_ceil(self.block_size as u64)
232 }
233
234 /// Validates this descriptor against the resolved `shape_axis` size.
235 ///
236 /// Rejects if `shape_axis` is the DYNAMIC sentinel (`u64::MAX`), or if
237 /// `shape_axis > 0` and `block_size > shape_axis`.
238 ///
239 /// # Errors
240 ///
241 /// - [`Error::QuantizationShapeMismatch`] — `shape_axis` is the DYNAMIC sentinel,
242 /// or `shape_axis > 0` and `block_size > shape_axis`.
243 ///
244 /// # Examples
245 ///
246 /// ```
247 /// use hurray_core::{Nf4, DYNAMIC};
248 ///
249 /// let q = Nf4::new(0, 64, 1).unwrap();
250 /// assert!(q.validate_against_shape_axis(128).is_ok());
251 /// assert!(q.validate_against_shape_axis(64).is_ok());
252 /// assert!(q.validate_against_shape_axis(0).is_ok()); // ADR-007: waived
253 /// assert!(q.validate_against_shape_axis(32).is_err()); // block_size > shape_axis
254 /// assert!(q.validate_against_shape_axis(DYNAMIC).is_err()); // dynamic dimension
255 /// ```
256 pub fn validate_against_shape_axis(&self, shape_axis: u64) -> Result<()> {
257 // Reject the DYNAMIC sentinel: a dynamic dimension cannot be validated.
258 if shape_axis == crate::shape::DYNAMIC {
259 return Err(Error::QuantizationShapeMismatch {
260 axis: self.axis,
261 shape_axis,
262 block_size: self.block_size,
263 reason: "shape[axis] must not be the DYNAMIC sentinel (0xFFFFFFFFFFFFFFFF)",
264 });
265 }
266 if shape_axis > 0 && self.block_size as u64 > shape_axis {
267 return Err(Error::QuantizationShapeMismatch {
268 axis: self.axis,
269 shape_axis,
270 block_size: self.block_size,
271 reason: "NF4 block_size must not exceed shape[axis] when shape[axis] > 0",
272 });
273 }
274 Ok(())
275 }
276
277 /// Returns the set of storage [`ElementType`]s that are valid for this scheme.
278 ///
279 /// Per `docs/spec/quantization/nf4.md § Valid Storage Types`: only `uint4`
280 /// is valid for NF4.
281 ///
282 /// WHY `&'static [ElementType]`: no allocation per call (design decision #5).
283 ///
284 /// # Examples
285 ///
286 /// ```
287 /// use hurray_core::{ElementType, Nf4};
288 ///
289 /// assert_eq!(Nf4::valid_storage_types(), &[ElementType::Uint4]);
290 /// assert!(!Nf4::valid_storage_types().contains(&ElementType::Int4));
291 /// ```
292 pub fn valid_storage_types() -> &'static [ElementType] {
293 &[ElementType::Uint4]
294 }
295
296 // ── Crate-internal encode/decode ──────────────────────────────────────────
297
298 /// Decodes the scheme-specific payload from `bytes`.
299 ///
300 /// `bytes` is the full descriptor slice (including the 4-byte header).
301 /// `flags` are the header flags already read by the caller.
302 pub(crate) fn decode_payload(flags: u16, bytes: &[u8]) -> Result<Self> {
303 if bytes.len() < ENCODED_LEN {
304 return Err(Error::QuantizationDescriptorTooShort {
305 found: bytes.len(),
306 needed: ENCODED_LEN,
307 });
308 }
309 // No flags defined for NF4.
310 if flags & RESERVED_FLAGS_MASK != 0 {
311 return Err(Error::ReservedQuantizationFlagsBits {
312 flags,
313 mask: RESERVED_FLAGS_MASK,
314 });
315 }
316
317 let axis = u32::from_le_bytes(
318 bytes[OFFSET_AXIS..OFFSET_AXIS + 4]
319 .try_into()
320 .map_err(|_| Error::InvalidQuantization("axis slice error".into()))?,
321 );
322 let block_size = u32::from_le_bytes(
323 bytes[OFFSET_BLOCK_SIZE..OFFSET_BLOCK_SIZE + 4]
324 .try_into()
325 .map_err(|_| Error::InvalidQuantization("block_size slice error".into()))?,
326 );
327 let scale_buf = u32::from_le_bytes(
328 bytes[OFFSET_SCALE_BUF..OFFSET_SCALE_BUF + 4]
329 .try_into()
330 .map_err(|_| Error::InvalidQuantization("scale_buffer_index slice error".into()))?,
331 );
332
333 // block_size must be a power of two and >= 8.
334 validate_block_size(block_size)?;
335
336 // scale_buffer_index must not be the sentinel value 0xFFFFFFFF.
337 if scale_buf == INVALID_BUF_SENTINEL {
338 return Err(Error::InvalidQuantization(
339 "NF4 scale_buffer_index must not be 0xFFFFFFFF".into(),
340 ));
341 }
342
343 Ok(Self {
344 axis,
345 block_size,
346 scale_buffer_index: scale_buf,
347 })
348 }
349
350 /// Encodes the scheme-specific payload into `out`.
351 ///
352 /// `out` must be at least [`ENCODED_LEN`] bytes. The caller writes the 4-byte
353 /// header; this method writes bytes 4–15.
354 pub(crate) fn encode_payload(&self, out: &mut [u8]) {
355 out[OFFSET_AXIS..OFFSET_AXIS + 4].copy_from_slice(&self.axis.to_le_bytes());
356 out[OFFSET_BLOCK_SIZE..OFFSET_BLOCK_SIZE + 4]
357 .copy_from_slice(&self.block_size.to_le_bytes());
358 out[OFFSET_SCALE_BUF..OFFSET_SCALE_BUF + 4]
359 .copy_from_slice(&self.scale_buffer_index.to_le_bytes());
360 }
361}
362
363// ── Helpers ───────────────────────────────────────────────────────────────────
364
365fn validate_block_size(block_size: u32) -> Result<()> {
366 if !block_size.is_power_of_two() || block_size < NF4_MIN_BLOCK_SIZE {
367 return Err(Error::InvalidBlockSize {
368 scheme_tag: SCHEME_TAG,
369 block_size,
370 min: NF4_MIN_BLOCK_SIZE,
371 // NF4 has no upper bound in the spec; use u32::MAX as sentinel.
372 max: u32::MAX,
373 });
374 }
375 Ok(())
376}
377
378// ── Tests ─────────────────────────────────────────────────────────────────────
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use crate::Error;
384
385 // ── NF4_LUT ───────────────────────────────────────────────────────────────
386
387 #[test]
388 fn nf4_lut_has_16_entries() {
389 assert_eq!(NF4_LUT.len(), 16);
390 }
391
392 #[test]
393 fn nf4_lut_all_values_are_finite() {
394 for (i, &v) in NF4_LUT.iter().enumerate() {
395 assert!(v.is_finite(), "NF4_LUT[{i}] = {v} is not finite");
396 }
397 }
398
399 #[test]
400 fn nf4_lut_values_are_in_ascending_order() {
401 // Spec requires monotonically non-decreasing values.
402 for i in 1..NF4_LUT.len() {
403 assert!(
404 NF4_LUT[i] >= NF4_LUT[i - 1],
405 "NF4_LUT is not ascending at index {i}: NF4_LUT[{}]={} > NF4_LUT[{}]={}",
406 i - 1,
407 NF4_LUT[i - 1],
408 i,
409 NF4_LUT[i]
410 );
411 }
412 }
413
414 #[test]
415 // Deliberately asserting properties of the const NF4_LUT; the values are known at
416 // compile time, which is exactly what this test pins down.
417 #[allow(clippy::assertions_on_constants)]
418 fn nf4_lut_straddles_zero_at_index_7_8() {
419 // Per QLoRA paper: index 7 is 0.0 (the zero-crossing boundary),
420 // index 8 is the first strictly positive value.
421 assert!(NF4_LUT[7] <= 0.0, "NF4_LUT[7] should be <= 0.0");
422 assert!(NF4_LUT[8] >= 0.0, "NF4_LUT[8] should be >= 0.0");
423 }
424
425 #[test]
426 fn nf4_lut_first_value_approx_neg_one() {
427 // Spec: first entry is approximately −1.0 (±0.01).
428 let diff = (NF4_LUT[0] - (-1.0f32)).abs();
429 assert!(
430 diff < 0.01,
431 "NF4_LUT[0] = {} is not within 0.01 of -1.0",
432 NF4_LUT[0]
433 );
434 }
435
436 #[test]
437 fn nf4_lut_last_value_approx_pos_one() {
438 // Spec: last entry is approximately +1.0 (±0.01).
439 let diff = (NF4_LUT[15] - 1.0f32).abs();
440 assert!(
441 diff < 0.01,
442 "NF4_LUT[15] = {} is not within 0.01 of 1.0",
443 NF4_LUT[15]
444 );
445 }
446
447 // ── Nf4::new ──────────────────────────────────────────────────────────────
448
449 #[test]
450 fn new_block_size_below_min_is_err() {
451 // NF4_MIN_BLOCK_SIZE = 8; block_size = 4 is below minimum.
452 assert!(matches!(
453 Nf4::new(0, 4, 1),
454 Err(Error::InvalidBlockSize { .. })
455 ));
456 }
457
458 #[test]
459 fn new_block_size_not_power_of_two_is_err() {
460 assert!(matches!(
461 Nf4::new(0, 12, 1),
462 Err(Error::InvalidBlockSize { .. })
463 ));
464 }
465
466 #[test]
467 fn new_block_size_1_is_err() {
468 assert!(matches!(
469 Nf4::new(0, 1, 1),
470 Err(Error::InvalidBlockSize { .. })
471 ));
472 }
473
474 #[test]
475 fn new_valid_min_block_size_is_ok() {
476 assert!(Nf4::new(0, NF4_MIN_BLOCK_SIZE, 1).is_ok());
477 }
478
479 #[test]
480 fn new_valid_block_size_16_is_ok() {
481 assert!(Nf4::new(0, 16, 1).is_ok());
482 }
483
484 #[test]
485 fn new_valid_block_size_64_is_ok() {
486 assert!(Nf4::new(0, 64, 1).is_ok());
487 }
488
489 // ── num_blocks_per_axis ───────────────────────────────────────────────────
490
491 #[test]
492 fn num_blocks_per_axis_zero_shape_returns_zero() {
493 // ADR-007 empty-axis carve-out.
494 let q = Nf4::new(0, 64, 1).unwrap();
495 assert_eq!(q.num_blocks_per_axis(0), 0);
496 }
497
498 #[test]
499 fn num_blocks_per_axis_exact_divisibility() {
500 let q = Nf4::new(0, 64, 1).unwrap();
501 assert_eq!(q.num_blocks_per_axis(128), 2);
502 }
503
504 #[test]
505 fn num_blocks_per_axis_partial_trailing_block_uses_div_ceil() {
506 let q = Nf4::new(0, 64, 1).unwrap();
507 // 65 / 64 = 1.015... → ceil = 2.
508 assert_eq!(q.num_blocks_per_axis(65), 2);
509 }
510
511 // ── Round-trips ───────────────────────────────────────────────────────────
512
513 fn encode_decode(q: &Nf4) -> Nf4 {
514 let mut buf = vec![0u8; ENCODED_LEN];
515 buf[0] = SCHEME_TAG;
516 buf[1] = SUPPORTED_VERSION;
517 buf[2] = 0;
518 buf[3] = 0;
519 q.encode_payload(&mut buf);
520 Nf4::decode_payload(0, &buf).unwrap()
521 }
522
523 #[test]
524 fn round_trip_preserves_axis_block_size_scale_buf() {
525 let original = Nf4::new(2, 64, 5).unwrap();
526 let decoded = encode_decode(&original);
527 assert_eq!(decoded.axis(), original.axis());
528 assert_eq!(decoded.block_size(), original.block_size());
529 assert_eq!(decoded.scale_buffer_index(), original.scale_buffer_index());
530 }
531
532 #[test]
533 fn round_trip_min_block_size() {
534 let original = Nf4::new(0, NF4_MIN_BLOCK_SIZE, 1).unwrap();
535 let decoded = encode_decode(&original);
536 assert_eq!(decoded, original);
537 }
538
539 // ── validate_against_shape_axis ───────────────────────────────────────────
540
541 #[test]
542 fn validate_against_shape_axis_ok_when_block_size_le_shape() {
543 let q = Nf4::new(0, 64, 1).unwrap();
544 assert!(q.validate_against_shape_axis(64).is_ok());
545 assert!(q.validate_against_shape_axis(128).is_ok());
546 }
547
548 #[test]
549 fn validate_against_shape_axis_ok_when_shape_is_zero_adr007() {
550 // ADR-007 carve-out: empty axis is accepted.
551 let q = Nf4::new(0, 64, 1).unwrap();
552 assert!(q.validate_against_shape_axis(0).is_ok());
553 }
554
555 #[test]
556 fn validate_against_shape_axis_err_when_block_size_exceeds_shape() {
557 let q = Nf4::new(0, 64, 1).unwrap();
558 assert!(matches!(
559 q.validate_against_shape_axis(32),
560 Err(Error::QuantizationShapeMismatch { .. })
561 ));
562 }
563
564 #[test]
565 fn validate_against_shape_axis_err_for_dynamic_sentinel() {
566 let q = Nf4::new(0, 64, 1).unwrap();
567 assert!(matches!(
568 q.validate_against_shape_axis(u64::MAX),
569 Err(Error::QuantizationShapeMismatch { .. })
570 ));
571 }
572
573 // ── decode_payload error paths ────────────────────────────────────────────
574
575 #[test]
576 fn decode_payload_too_short_is_err() {
577 let short = vec![0u8; ENCODED_LEN - 1];
578 assert!(matches!(
579 Nf4::decode_payload(0, &short),
580 Err(Error::QuantizationDescriptorTooShort { .. })
581 ));
582 }
583
584 #[test]
585 fn decode_payload_nonzero_flags_is_err() {
586 let q = Nf4::new(0, 64, 1).unwrap();
587 let mut buf = vec![0u8; ENCODED_LEN];
588 buf[0] = SCHEME_TAG;
589 buf[1] = SUPPORTED_VERSION;
590 q.encode_payload(&mut buf);
591 let nonzero_flags: u16 = 0x0002;
592 assert!(matches!(
593 Nf4::decode_payload(nonzero_flags, &buf),
594 Err(Error::ReservedQuantizationFlagsBits { .. })
595 ));
596 }
597
598 #[test]
599 fn decode_payload_invalid_block_size_is_err() {
600 let mut buf = vec![0u8; ENCODED_LEN];
601 buf[0] = SCHEME_TAG;
602 buf[1] = SUPPORTED_VERSION;
603 buf[2] = 0;
604 buf[3] = 0;
605 buf[4..8].copy_from_slice(&0u32.to_le_bytes()); // axis
606 // block_size = 3 (not a power of two, and < NF4_MIN_BLOCK_SIZE=8)
607 buf[8..12].copy_from_slice(&3u32.to_le_bytes());
608 buf[12..16].copy_from_slice(&1u32.to_le_bytes()); // scale_buf
609 assert!(matches!(
610 Nf4::decode_payload(0, &buf),
611 Err(Error::InvalidBlockSize { .. })
612 ));
613 }
614
615 #[test]
616 fn decode_payload_scale_buf_sentinel_is_err() {
617 let mut buf = vec![0u8; ENCODED_LEN];
618 buf[0] = SCHEME_TAG;
619 buf[1] = SUPPORTED_VERSION;
620 buf[2] = 0;
621 buf[3] = 0;
622 buf[4..8].copy_from_slice(&0u32.to_le_bytes()); // axis
623 buf[8..12].copy_from_slice(&64u32.to_le_bytes()); // block_size
624 // scale_buffer_index = 0xFFFFFFFF (invalid sentinel)
625 buf[12..16].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
626 assert!(matches!(
627 Nf4::decode_payload(0, &buf),
628 Err(Error::InvalidQuantization(_))
629 ));
630 }
631}