rlx_ir/quant.rs
1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Quantization metadata as graph annotations (plan #57).
6//!
7//! Schemes attach per-tensor via [`QuantMap`] on [`crate::Graph`], not inside
8//! [`crate::Node`], so the common case stays lean.
9//!
10//! # GGUF schemes and backends
11//!
12//! All `Gguf*` variants use two-input `Op::DequantMatMul` (`x`, packed weights).
13//! GPU backends share integer **scheme ids** (0–23) in `dequant_gguf` kernels;
14//! legacy tail: Q4_0 = 19, Q8_0 = 20, Q4_1 = 21, Q5_0 = 22, Q5_1 = 23.
15//!
16//! Per-backend dispatch (GPU dequant, fused GEMV, ANE constexpr, TPU compile-time
17//! bake, env toggles): [`docs/gguf-backend-paths.md`](../../docs/gguf-backend-paths.md).
18
19use crate::NodeId;
20use std::collections::HashMap;
21
22/// How a tensor is quantized. Mirrors the schemes RLX needs for LLM
23/// inference on Apple Silicon: blockwise int8 (GPTQ-style),
24/// blockwise int4 (Q4_K), and per-tensor fp8 (e4m3 / e5m2).
25///
26/// Each variant carries the parameters the dequantizer needs to read
27/// at runtime — scale, zero-point, block size. Where these live in
28/// the actual weight tensor is up to the loader (#56).
29#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub enum QuantScheme {
32 /// Symmetric int8 with one scale per `block_size` elements.
33 Int8Block { block_size: u32 },
34 /// Asymmetric int8 with scale + zero-point per `block_size` elements.
35 Int8BlockAsym { block_size: u32 },
36 /// Int4 packed two-per-byte, scale per `block_size` elements
37 /// (Q4_K-ish; matches GGUF block layout).
38 Int4Block { block_size: u32 },
39 /// FP8 e4m3 (no scale; same domain as half).
40 Fp8E4m3,
41 /// FP8 e5m2 (no scale; wider range than e4m3).
42 Fp8E5m2,
43 /// GGUF / llama.cpp Q4_K super-block (256 elements / 144 bytes).
44 /// Packs an f16 super-scale + f16 super-min + 8 sub-block 6-bit
45 /// scales + 8 sub-block 6-bit mins + 128 nibbles. Block layout is
46 /// fixed by the format — there's no `block_size` knob.
47 GgufQ4K,
48 /// GGUF Q5_K (256 / 176 bytes). Adds a 32-byte high-bit plane on
49 /// top of Q4_K.
50 GgufQ5K,
51 /// GGUF Q6_K (256 / 210 bytes). Per-sub-block signed scales,
52 /// no min term.
53 GgufQ6K,
54 /// GGUF Q8_K (256 / 276 bytes). Per-super-block f32 scale plus
55 /// i8 quants and a 32-byte sum-of-blocks table that's only used
56 /// by Q8_K × Q8_K matmul accumulation paths.
57 GgufQ8K,
58 /// GGUF Q2_K (256 / 84 bytes). 2-bit quants with per-sub-block scale/min.
59 GgufQ2K,
60 /// GGUF Q3_K (256 / 110 bytes). 3-bit quants with hmask high bit plane.
61 GgufQ3K,
62 /// GGUF Q4_0 (32 / 18 bytes). Legacy llama.cpp block: f16 scale + signed nibbles (−8..7).
63 GgufQ4_0,
64 /// GGUF Q4_1 (32 / 20 bytes). Legacy block: f16 scale + f16 min + unsigned nibbles (0..15).
65 /// GPU kernel scheme id **21** (shared with Metal/CUDA/ROCm/WGPU).
66 GgufQ4_1,
67 /// GGUF Q5_0 (32 / 22 bytes). Legacy block: f16 scale + 32-bit high plane + signed 5-bit quants.
68 /// GPU kernel scheme id **22**.
69 GgufQ5_0,
70 /// GGUF Q5_1 (32 / 24 bytes). Legacy block: f16 scale + f16 min + high plane + unsigned 5-bit quants.
71 /// GPU kernel scheme id **23**.
72 GgufQ5_1,
73 /// GGUF Q8_0 (32 / 34 bytes). Legacy block: f16 scale + 32×i8 quants.
74 GgufQ8_0,
75 /// NVIDIA FP4 (E2M1) block — fixed 16-element groups, FP8 E4M3 block
76 /// scales, optional f32 global scale on input 3 (legacy `zp` slot).
77 /// Used by FLUX.2 / MLX `nvfp4` checkpoints.
78 Nvfp4Block,
79 /// MLX affine quantization (`nn.quantize` / mlx-lm default): packed
80 /// uint codes + per-group f32 scale and bias. Inputs to
81 /// `DequantMatMul`: `x`, `w_q`, `scales`, `biases` (biases in the
82 /// legacy `zp` slot). Typical `bits=4`, `group_size=64`.
83 MlxAffine { bits: u8, group_size: u32 },
84 /// MLX `mxfp4` mode — E2M1 nibbles × per-group scale (E8M0 or FP8).
85 /// Inputs: `x`, `w_q`, `scales`, unused `zp` (pass ones).
86 MlxMxfp4 { group_size: u32 },
87 /// MLX `mxfp8` mode — FP8 E4M3 codes × per-group scale.
88 MlxMxfp8 { group_size: u32 },
89 /// **MxFp4x2** — two-level residual FP4 (E2M1): `value ≈ s0·q0 + s1·q1`,
90 /// each level an E2M1 nibble plane with its own per-group scale. Roughly
91 /// doubles the effective mantissa of MXFP4 (~3.3 → ~6.7 bits) at 2× storage.
92 /// The low-precision analog of double-word; see [`crate::residual`].
93 MxFp4x2Block { group_size: u32 },
94 // ── GGUF IQ-family (sub-byte LUT-coded) ─────────────────────
95 /// IQ4_NL: 4.5 bpw non-linear. 32-element block (18 bytes).
96 GgufIQ4NL,
97 /// IQ4_XS: 4.25 bpw. 256-element super-block (136 bytes).
98 GgufIQ4XS,
99 /// IQ2_XXS: 2.0625 bpw. 256 / 66.
100 GgufIQ2XXS,
101 /// IQ2_XS: 2.3125 bpw. 256 / 74.
102 GgufIQ2XS,
103 /// IQ2_S: 2.5625 bpw. 256 / 82.
104 GgufIQ2S,
105 /// IQ3_XXS: 3.0625 bpw. 256 / 98.
106 GgufIQ3XXS,
107 /// IQ3_S: 3.4375 bpw. 256 / 110.
108 GgufIQ3S,
109 /// IQ1_S: 1.5625 bpw. 256 / 50.
110 GgufIQ1S,
111 /// IQ1_M: 1.75 bpw. 256 / 56.
112 GgufIQ1M,
113 /// TQ1_0: 1.6875 bpw ternary. 256 / 54.
114 GgufTQ1_0,
115 /// TQ2_0: 2.0625 bpw ternary. 256 / 66.
116 GgufTQ2_0,
117 /// MXFP4: OCP microscaling FP4 with E8M0 scale. 32 / 17.
118 GgufMXFP4,
119 /// NVFP4 GGUF variant: E4M3 scale + E2M1 nibbles. 16 / 9.
120 GgufNVFP4,
121 /// Custom 1-bit format (PrismML `Bonsai-27B`, ggml type 41): one
122 /// f16 group scale + 128 sign bits selecting `±d`. 128 / 18 bytes
123 /// (1.125 bpw). See [`crate`] consumers `rlx_gguf::q1_dequant`.
124 GgufQ1_0,
125 /// PrismML / upstream `Q2_0` (ggml type 42): f16 group scale + 128×2-bit
126 /// codes → `(q−1)·d`. 128 / 34 bytes (2.125 bpw). Used by Ternary Bonsai
127 /// (`prism-ml/Ternary-Bonsai-*-gguf`). See `rlx_gguf::q2_dequant`.
128 GgufQ2_0,
129 /// Fermion five-value ternary (`fermion-fv5` fork, ggml type 43): two f32
130 /// per-row scales `s_lo`/`s_hi` + three 256-bit planes (`bp`,`bn`,`br`)
131 /// → `w = (bp−bn)·(br ? s_hi : s_lo)`, five values `{0, ±s_lo, ±s_hi}`.
132 /// 256 / 104 bytes (3.25 bpw). All Neutrino transformer linears.
133 /// See `rlx_gguf::fv5_dequant`. CPU-only (no GPU kernel — matches the fork).
134 GgufFV5,
135 /// Fermion int8 companion (`fermion-fv5` fork, ggml type 44): one f32
136 /// per-row scale + 256 int8 codes → `w = s·q`. 256 / 260 bytes (8.125 bpw).
137 /// Neutrino's untied int8 `token_embd` / `output` (lm_head).
138 /// See `rlx_gguf::fv5_dequant`.
139 GgufFV5B,
140 /// Doses AI Pestle exact ternary (`mortar.cpp` fork, ggml type 143):
141 /// four bf16 scales — one per group of 8 — + 32×2-bit codes
142 /// → `w = (q−1)·d[j/8]`, three values `{0, ±d}`. 32 / 16 bytes
143 /// (4 bpw). Pestle's untied `token_embd` / `output` (lm_head); its
144 /// linears are factorized [`Self::GgufQ2_0`] pairs instead.
145 /// See `rlx_gguf::g8_dequant`.
146 GgufG8_0,
147}
148
149/// Single source of truth for GGUF → GPU `dequant_gguf` scheme ids.
150///
151/// Expanding this macro inside `impl QuantScheme` defines:
152/// - [`QuantScheme::gpu_dequant_scheme_id`]
153/// - [`QuantScheme::from_gpu_dequant_scheme_id`]
154/// - test helper [`QuantScheme::GPU_DEQUANT_SCHEME_ID_PAIRS`]
155///
156/// GPU backends (`gguf_host::gguf_scheme_id` / `scheme_from_id`) must
157/// delegate here — do not re-list variants by hand.
158macro_rules! define_gguf_gpu_dequant_ids {
159 ($(($variant:ident, $id:literal)),+ $(,)?) => {
160 /// Canonical numeric id for the shared on-device GGUF dequant kernel
161 /// (`dequant_gguf` in MSL / CU / WGSL / GLSL).
162 ///
163 /// Generated by `define_gguf_gpu_dequant_ids!` so encode and
164 /// [`Self::from_gpu_dequant_scheme_id`] share one table — backends
165 /// must only call these helpers (no hand-rolled `match` in
166 /// `gguf_host`). Adding a scheme is one macro row + the matching
167 /// kernel branch. Guarded by `gpu_dequant_scheme_id_is_stable`.
168 pub const fn gpu_dequant_scheme_id(self) -> Option<u32> {
169 match self {
170 $(Self::$variant => Some($id),)+
171 _ => None,
172 }
173 }
174
175 /// Inverse of [`Self::gpu_dequant_scheme_id`].
176 pub const fn from_gpu_dequant_scheme_id(id: u32) -> Option<Self> {
177 match id {
178 $($id => Some(Self::$variant),)+
179 _ => None,
180 }
181 }
182
183 /// `(scheme, id)` pairs for tests / docs — keep in sync via the macro.
184 #[cfg(test)]
185 pub(crate) const GPU_DEQUANT_SCHEME_ID_PAIRS: &'static [(Self, u32)] = &[
186 $((Self::$variant, $id),)+
187 ];
188 };
189}
190
191impl QuantScheme {
192 /// Bits per element after packing (×10 for K-quants since they
193 /// have fractional bit budgets — divide by 10 when comparing).
194 pub const fn bits_per_element_x10(self) -> u32 {
195 match self {
196 Self::Int8Block { .. } | Self::Int8BlockAsym { .. } => 80,
197 Self::Int4Block { .. } => 40,
198 Self::Fp8E4m3 | Self::Fp8E5m2 => 80,
199 // GGUF K-quants: header + per-element bits over a 256-element block.
200 Self::GgufQ4K => 45, // 144 bytes / 256 elems × 8 = 4.5 bpe
201 Self::GgufQ5K => 55, // 176 / 256 × 8 ≈ 5.5
202 Self::GgufQ6K => 66, // 210 / 256 × 8 ≈ 6.5625 → 66 (rounded)
203 Self::GgufQ8K => 91, // 292 / 256 × 8 ≈ 9.125 → 91
204 Self::GgufQ2K => 26, // 84 / 256 × 8 ≈ 2.625 → 26
205 Self::GgufQ3K => 34, // 110 / 256 × 8 ≈ 3.4375 → 34
206 Self::GgufQ4_0 => 45, // 18 / 32 × 8 = 4.5 bpe
207 Self::GgufQ4_1 => 50, // 20 / 32 × 8 = 5.0 bpe
208 Self::GgufQ5_0 => 55, // 22 / 32 × 8 = 5.5 bpe
209 Self::GgufQ5_1 => 60, // 24 / 32 × 8 = 6.0 bpe
210 Self::GgufQ8_0 => 85, // 34 / 32 × 8 = 8.5 bpe
211 Self::Nvfp4Block => 40,
212 Self::MlxAffine { bits, .. } => (bits as u32) * 10,
213 Self::MlxMxfp4 { .. } => 40,
214 Self::MlxMxfp8 { .. } => 80,
215 Self::MxFp4x2Block { .. } => 84, // 2× E2M1 nibbles + 2 group scales
216
217 Self::GgufIQ4NL => 45,
218 Self::GgufIQ4XS => 42, // 136/256 × 8 = 4.25
219 Self::GgufIQ2XXS => 20,
220 Self::GgufIQ2XS => 23,
221 Self::GgufIQ2S => 25,
222 Self::GgufIQ3XXS => 30,
223 Self::GgufIQ3S => 34,
224 Self::GgufIQ1S => 15,
225 Self::GgufIQ1M => 17,
226 Self::GgufTQ1_0 => 16, // 54/256 × 8 = 1.6875 → 16
227 Self::GgufTQ2_0 => 20,
228 Self::GgufMXFP4 => 42,
229 Self::GgufNVFP4 => 45,
230 Self::GgufQ1_0 => 11, // 18 bytes / 128 elems × 8 = 1.125 bpe
231 Self::GgufQ2_0 => 21, // 34 bytes / 128 elems × 8 = 2.125 bpe
232 Self::GgufFV5 => 32, // 104 bytes / 256 elems × 8 = 3.25 bpe
233 Self::GgufFV5B => 81, // 260 bytes / 256 elems × 8 = 8.125 bpe
234 Self::GgufG8_0 => 40, // 16 bytes / 32 elems × 8 = 4.0 bpe
235 }
236 }
237
238 /// Bits per element after packing (rounded down). Use
239 /// `bits_per_element_x10` for the K-quant fractional values.
240 pub const fn bits_per_element(self) -> u32 {
241 self.bits_per_element_x10() / 10
242 }
243
244 // Table defined once via `define_gguf_gpu_dequant_ids!` above.
245 define_gguf_gpu_dequant_ids! {
246 (GgufQ4K, 0),
247 (GgufQ5K, 1),
248 (GgufQ6K, 2),
249 (GgufQ8K, 3),
250 (GgufQ2K, 4),
251 (GgufQ3K, 5),
252 (GgufIQ4NL, 6),
253 (GgufIQ4XS, 7),
254 (GgufTQ1_0, 8),
255 (GgufTQ2_0, 9),
256 (GgufMXFP4, 10),
257 (GgufNVFP4, 11),
258 (GgufIQ2XXS, 12),
259 (GgufIQ2XS, 13),
260 (GgufIQ2S, 14),
261 (GgufIQ3XXS, 15),
262 (GgufIQ3S, 16),
263 (GgufIQ1S, 17),
264 (GgufIQ1M, 18),
265 (GgufQ4_0, 19),
266 (GgufQ8_0, 20),
267 (GgufQ4_1, 21),
268 (GgufQ5_0, 22),
269 (GgufQ5_1, 23),
270 (GgufQ1_0, 24),
271 (GgufQ2_0, 25),
272 (GgufFV5, 26),
273 (GgufFV5B, 27),
274 (GgufG8_0, 28),
275 }
276
277 /// True if this scheme requires a per-block scale tensor on the side.
278 pub const fn has_scale(self) -> bool {
279 matches!(
280 self,
281 Self::Int8Block { .. }
282 | Self::Int8BlockAsym { .. }
283 | Self::Int4Block { .. }
284 | Self::Nvfp4Block
285 | Self::MlxAffine { .. }
286 | Self::MlxMxfp4 { .. }
287 | Self::MlxMxfp8 { .. }
288 | Self::MxFp4x2Block { .. }
289 )
290 }
291
292 /// True for NVFP4 / MLX mxfp block scales stored as FP8 / E8M0 bytes (not f32).
293 pub const fn scale_is_fp8(self) -> bool {
294 matches!(
295 self,
296 Self::Nvfp4Block | Self::MlxMxfp4 { .. } | Self::MlxMxfp8 { .. }
297 )
298 }
299
300 /// Fixed NVFP4 group size along K (0 for other schemes).
301 pub const fn nvfp4_group_size(self) -> u32 {
302 match self {
303 Self::Nvfp4Block => crate::nvfp4::NVFP4_GROUP_SIZE as u32,
304 _ => 0,
305 }
306 }
307
308 /// For [`Self::MxFp4x2Block`]: `(per-element format, residual levels, group
309 /// size)`; `None` for every other scheme. Ties the scheme to the shared
310 /// residual codec in [`crate::residual`].
311 pub const fn mxfp4x2_config(self) -> Option<(ScaledFormat, u32, u32)> {
312 match self {
313 Self::MxFp4x2Block { group_size } => Some((ScaledFormat::F4E2M1, 2, group_size)),
314 _ => None,
315 }
316 }
317
318 /// True if this scheme requires a per-block zero-point / bias tensor.
319 pub const fn has_zero_point(self) -> bool {
320 matches!(self, Self::Int8BlockAsym { .. } | Self::MlxAffine { .. })
321 }
322
323 /// True for MLX-native affine / mxfp schemes (not GGUF, not legacy int blocks).
324 pub const fn is_mlx(self) -> bool {
325 matches!(
326 self,
327 Self::MlxAffine { .. } | Self::MlxMxfp4 { .. } | Self::MlxMxfp8 { .. }
328 )
329 }
330
331 /// MLX group size along K (0 when not an MLX scheme).
332 pub const fn mlx_group_size(self) -> u32 {
333 match self {
334 Self::MlxAffine { group_size, .. }
335 | Self::MlxMxfp4 { group_size }
336 | Self::MlxMxfp8 { group_size } => group_size,
337 _ => 0,
338 }
339 }
340
341 /// GPU launch args for MLX dequant-matmul kernels: `(kind, bits, group_size)`.
342 ///
343 /// `kind`: 0 = [`Self::MlxAffine`], 1 = [`Self::MlxMxfp4`], 2 = [`Self::MlxMxfp8`].
344 /// `bits` is 0 for mxfp schemes. Returns `None` for non-MLX schemes.
345 pub const fn mlx_gpu_launch(self) -> Option<(u32, u32, u32)> {
346 match self {
347 Self::MlxAffine { bits, group_size } => Some((0, bits as u32, group_size)),
348 Self::MlxMxfp4 { group_size } => Some((1, 0, group_size)),
349 Self::MlxMxfp8 { group_size } => Some((2, 0, group_size)),
350 _ => None,
351 }
352 }
353
354 /// GGUF K-quant block size (256 elements) — meaningless for the
355 /// non-GGUF schemes (returns 0).
356 pub const fn gguf_block_size(self) -> u32 {
357 match self {
358 Self::GgufQ4K
359 | Self::GgufQ5K
360 | Self::GgufQ6K
361 | Self::GgufQ8K
362 | Self::GgufQ2K
363 | Self::GgufQ3K
364 | Self::GgufIQ4XS
365 | Self::GgufIQ2XXS
366 | Self::GgufIQ2XS
367 | Self::GgufIQ2S
368 | Self::GgufIQ3XXS
369 | Self::GgufIQ3S
370 | Self::GgufIQ1S
371 | Self::GgufIQ1M
372 | Self::GgufTQ1_0
373 | Self::GgufTQ2_0
374 | Self::GgufFV5
375 | Self::GgufFV5B => 256,
376 Self::GgufQ4_0
377 | Self::GgufQ4_1
378 | Self::GgufQ5_0
379 | Self::GgufQ5_1
380 | Self::GgufQ8_0
381 | Self::GgufIQ4NL
382 | Self::GgufMXFP4 => 32,
383 Self::GgufNVFP4 => 16,
384 Self::GgufQ1_0 => 128,
385 Self::GgufQ2_0 => 128,
386 Self::GgufG8_0 => 32,
387 _ => 0,
388 }
389 }
390
391 /// Bytes per GGUF super-block. 0 for non-GGUF schemes.
392 pub const fn gguf_block_bytes(self) -> u32 {
393 match self {
394 Self::GgufQ4K => 144, // f16 d + f16 dmin + 12 packed scales + 128 nibbles
395 Self::GgufQ5K => 176, // + 32-byte high-bit plane
396 Self::GgufQ6K => 210, // 128 ql + 64 qh + 16 i8 scales + f16 d
397 Self::GgufQ8K => 292, // f32 d + 256 i8 + 16 i16 bsums = 4 + 256 + 32
398 Self::GgufQ2K => 84, // f16 d + f16 dmin + 16 scales + 64 qs
399 Self::GgufQ3K => 110, // f16 d + 12 scales + 32 hmask + 64 qs
400 Self::GgufQ4_0 => 18, // f16 d + 16 packed nibbles
401 Self::GgufQ4_1 => 20, // f16 d + f16 min + 16 packed nibbles
402 Self::GgufQ5_0 => 22, // f16 d + 32-bit qh + 16 packed nibbles
403 Self::GgufQ5_1 => 24, // f16 d + f16 min + 32-bit qh + 16 packed nibbles
404 Self::GgufQ8_0 => 34, // f16 d + 32 i8 quants
405 Self::GgufIQ4NL => 18,
406 Self::GgufIQ4XS => 136,
407 Self::GgufIQ2XXS => 66,
408 Self::GgufIQ2XS => 74,
409 Self::GgufIQ2S => 82,
410 Self::GgufIQ3XXS => 98,
411 Self::GgufIQ3S => 110,
412 Self::GgufIQ1S => 50,
413 Self::GgufIQ1M => 56,
414 Self::GgufTQ1_0 => 54,
415 Self::GgufTQ2_0 => 66,
416 Self::GgufMXFP4 => 17,
417 Self::GgufNVFP4 => 9,
418 Self::GgufQ1_0 => 18, // f16 scale + 128 sign bits
419 Self::GgufQ2_0 => 34, // Metal fused Q2_0 block (128 elems)
420 Self::GgufFV5 => 104, // f32 s_lo + f32 s_hi + 3×32B planes (256 elems)
421 Self::GgufFV5B => 260, // f32 s + 256 int8 (256 elems)
422 Self::GgufG8_0 => 16, // 4 bf16 scales + 32×2-bit codes (32 elems)
423 _ => 0,
424 }
425 }
426
427 /// True for any GGUF-format block scheme. GGUF schemes carry
428 /// their scales / mins / sub-block metadata *inside* the packed
429 /// weight bytes — they don't need separate `scale` / `zp`
430 /// tensors fed alongside as the legacy `Int8Block` paths do.
431 pub const fn is_gguf(self) -> bool {
432 matches!(
433 self,
434 Self::GgufQ4K
435 | Self::GgufQ5K
436 | Self::GgufQ6K
437 | Self::GgufQ8K
438 | Self::GgufQ2K
439 | Self::GgufQ3K
440 | Self::GgufQ4_0
441 | Self::GgufQ4_1
442 | Self::GgufQ5_0
443 | Self::GgufQ5_1
444 | Self::GgufQ8_0
445 | Self::GgufIQ4NL
446 | Self::GgufIQ4XS
447 | Self::GgufIQ2XXS
448 | Self::GgufIQ2XS
449 | Self::GgufIQ2S
450 | Self::GgufIQ3XXS
451 | Self::GgufIQ3S
452 | Self::GgufIQ1S
453 | Self::GgufIQ1M
454 | Self::GgufTQ1_0
455 | Self::GgufTQ2_0
456 | Self::GgufMXFP4
457 | Self::GgufNVFP4
458 | Self::GgufQ1_0
459 | Self::GgufQ2_0
460 | Self::GgufFV5
461 | Self::GgufFV5B
462 | Self::GgufG8_0
463 )
464 }
465}
466
467impl std::fmt::Display for QuantScheme {
468 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
469 match self {
470 Self::Int8Block { block_size } => write!(f, "int8/{block_size}"),
471 Self::Int8BlockAsym { block_size } => write!(f, "int8a/{block_size}"),
472 Self::Int4Block { block_size } => write!(f, "int4/{block_size}"),
473 Self::Fp8E4m3 => write!(f, "fp8e4m3"),
474 Self::Fp8E5m2 => write!(f, "fp8e5m2"),
475 Self::GgufQ4K => write!(f, "gguf_q4k"),
476 Self::GgufQ5K => write!(f, "gguf_q5k"),
477 Self::GgufQ6K => write!(f, "gguf_q6k"),
478 Self::GgufQ8K => write!(f, "gguf_q8k"),
479 Self::GgufQ2K => write!(f, "gguf_q2k"),
480 Self::GgufQ3K => write!(f, "gguf_q3k"),
481 Self::GgufQ4_0 => write!(f, "gguf_q4_0"),
482 Self::GgufQ4_1 => write!(f, "gguf_q4_1"),
483 Self::GgufQ5_0 => write!(f, "gguf_q5_0"),
484 Self::GgufQ5_1 => write!(f, "gguf_q5_1"),
485 Self::GgufQ8_0 => write!(f, "gguf_q8_0"),
486 Self::Nvfp4Block => write!(f, "nvfp4/16"),
487 Self::MlxAffine { bits, group_size } => {
488 write!(f, "mlx_affine/{bits}/{group_size}")
489 }
490 Self::MlxMxfp4 { group_size } => write!(f, "mlx_mxfp4/{group_size}"),
491 Self::MxFp4x2Block { group_size } => write!(f, "mxfp4x2/{group_size}"),
492 Self::MlxMxfp8 { group_size } => write!(f, "mlx_mxfp8/{group_size}"),
493 Self::GgufIQ4NL => write!(f, "gguf_iq4_nl"),
494 Self::GgufIQ4XS => write!(f, "gguf_iq4_xs"),
495 Self::GgufIQ2XXS => write!(f, "gguf_iq2_xxs"),
496 Self::GgufIQ2XS => write!(f, "gguf_iq2_xs"),
497 Self::GgufIQ2S => write!(f, "gguf_iq2_s"),
498 Self::GgufIQ3XXS => write!(f, "gguf_iq3_xxs"),
499 Self::GgufIQ3S => write!(f, "gguf_iq3_s"),
500 Self::GgufIQ1S => write!(f, "gguf_iq1_s"),
501 Self::GgufIQ1M => write!(f, "gguf_iq1_m"),
502 Self::GgufTQ1_0 => write!(f, "gguf_tq1_0"),
503 Self::GgufTQ2_0 => write!(f, "gguf_tq2_0"),
504 Self::GgufMXFP4 => write!(f, "gguf_mxfp4"),
505 Self::GgufNVFP4 => write!(f, "gguf_nvfp4"),
506 Self::GgufQ1_0 => write!(f, "gguf_q1_0"),
507 Self::GgufQ2_0 => write!(f, "gguf_q2_0"),
508 Self::GgufFV5 => write!(f, "gguf_fv5"),
509 Self::GgufFV5B => write!(f, "gguf_fv5b"),
510 Self::GgufG8_0 => write!(f, "gguf_g8_0"),
511 }
512 }
513}
514
515/// Element format for a **native low-precision tensor-core GEMM** operand
516/// (see [`crate::op::Op::ScaledMatMul`]).
517///
518/// Distinct from [`QuantScheme`]: a `QuantScheme` is block-scaled *storage*
519/// that the CPU/GPU decodes to f32 *before* a normal sgemm. A `ScaledFormat`
520/// is the raw element encoding hardware tensor cores consume **directly**, with
521/// f32 accumulation — the whole point of FP8/FP6/FP4 on Hopper / Ada /
522/// Blackwell / CDNA3 / CDNA4. The per-block/per-tensor scale layout is
523/// orthogonal and lives in [`ScaleLayout`].
524///
525/// Operands flow through the graph as `DType::U8` byte buffers (one code per
526/// byte on the CPU oracle; packed on GPU); the format is carried on the op,
527/// not the dtype, so no `DType` variant is needed.
528#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
529#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
530pub enum ScaledFormat {
531 /// OCP FP8 E4M3 — bias 7, no infinities, only `S.1111.111` is NaN.
532 /// Max ±448. Hopper / Ada / Blackwell; weights + forward activations.
533 F8E4M3,
534 /// OCP FP8 E5M2 — bias 15, IEEE-like inf/NaN at max exponent.
535 /// Max ±57344. Wider range — the gradient format.
536 F8E5M2,
537 /// AMD "FNUZ" FP8 E4M3 — bias 8, single NaN at `0x80`, no inf, no −0.
538 /// Max ±240. CDNA3 / MI300.
539 F8E4M3Fnuz,
540 /// AMD "FNUZ" FP8 E5M2 — bias 16, single NaN at `0x80`, no inf, no −0.
541 /// Max ±57344. CDNA3 / MI300.
542 F8E5M2Fnuz,
543 /// MX FP6 E2M3 — bias 1, all 64 codes finite. Max ±7.5. Blackwell / CDNA4.
544 F6E2M3,
545 /// MX FP6 E3M2 — bias 3, all 64 codes finite. Max ±28. Blackwell / CDNA4.
546 F6E3M2,
547 /// FP4 E2M1 — all 16 codes finite. Max ±6. Blackwell / CDNA4.
548 /// Shared by NVFP4 and MXFP4 — the [`ScaleLayout`] tells them apart.
549 F4E2M1,
550 /// Arbitrary sub-byte minifloat parameterized by exponent / mantissa width
551 /// — the `fNeXmY` family (`N = 1 + exp_bits + mant_bits` total bits).
552 ///
553 /// All-finite (no infinities, no NaN, no AMD FNUZ), IEEE-style gradual
554 /// underflow (subnormals when `exp == 0`), sign in bit `exp_bits +
555 /// mant_bits`, one code per byte. The whole code must fit in a byte, so
556 /// `1 + exp_bits + mant_bits <= 8` and `exp_bits >= 1`.
557 ///
558 /// This is a *research / experimental* encoding: it has no hardware
559 /// tensor-core GEMM, so it always runs the decode-and-accumulate path (CPU
560 /// oracle + generic GPU kernel). Build one with [`ScaledFormat::custom`]
561 /// (IEEE bias `2^(exp_bits-1) - 1`) or [`ScaledFormat::custom_with_bias`],
562 /// or parse a name via `"f4e3m0".parse()`.
563 ///
564 /// Example — `f4e3m0` (`exp_bits: 3, mant_bits: 0, bias: 3`): the 16 codes
565 /// are `±0` and `±{0.25, 0.5, 1, 2, 4, 8, 16}` — a signed power-of-two
566 /// (near-logarithmic) 4-bit format.
567 Custom {
568 exp_bits: u8,
569 mant_bits: u8,
570 bias: i8,
571 },
572}
573
574impl ScaledFormat {
575 /// Total bit width of one element code (4, 6, or 8).
576 pub const fn bit_width(self) -> u32 {
577 match self {
578 Self::F8E4M3 | Self::F8E5M2 | Self::F8E4M3Fnuz | Self::F8E5M2Fnuz => 8,
579 Self::F6E2M3 | Self::F6E3M2 => 6,
580 Self::F4E2M1 => 4,
581 Self::Custom {
582 exp_bits,
583 mant_bits,
584 ..
585 } => 1 + exp_bits as u32 + mant_bits as u32,
586 }
587 }
588
589 /// `(exponent_bits, mantissa_bits, exponent_bias)`.
590 pub const fn fields(self) -> (u32, u32, i32) {
591 match self {
592 Self::F8E4M3 => (4, 3, 7),
593 Self::F8E5M2 => (5, 2, 15),
594 Self::F8E4M3Fnuz => (4, 3, 8),
595 Self::F8E5M2Fnuz => (5, 2, 16),
596 Self::F6E2M3 => (2, 3, 1),
597 Self::F6E3M2 => (3, 2, 3),
598 Self::F4E2M1 => (2, 1, 1),
599 Self::Custom {
600 exp_bits,
601 mant_bits,
602 bias,
603 } => (exp_bits as u32, mant_bits as u32, bias as i32),
604 }
605 }
606
607 /// Build an arbitrary all-finite minifloat with `exp_bits` exponent and
608 /// `mant_bits` mantissa bits, using the IEEE-style bias `2^(exp_bits-1) - 1`
609 /// — the convention every named rlx format follows (E4M3 → 7, E5M2 → 15,
610 /// E2M1 → 1, …). The whole code must fit in a byte. `const` — usable in
611 /// `const` contexts and pattern-free construction.
612 ///
613 /// Panics if `exp_bits == 0` or `1 + exp_bits + mant_bits > 8`.
614 ///
615 /// ```
616 /// use rlx_ir::ScaledFormat;
617 /// const F4E3M0: ScaledFormat = ScaledFormat::custom(3, 0);
618 /// assert_eq!(F4E3M0.to_string(), "f4e3m0");
619 /// assert_eq!(F4E3M0.exp_bits(), 3);
620 /// assert_eq!(F4E3M0.mant_bits(), 0);
621 /// // Nearest representable value ("quantize" a single f32):
622 /// assert_eq!(F4E3M0.quantize(1.4), 1.0);
623 /// assert_eq!(F4E3M0.max_finite(), 16.0);
624 /// ```
625 pub const fn custom(exp_bits: u8, mant_bits: u8) -> Self {
626 assert!(exp_bits >= 1, "minifloat needs at least one exponent bit");
627 assert!(
628 1 + exp_bits + mant_bits <= 8,
629 "minifloat code must fit in a byte (1 + exp + mant <= 8)"
630 );
631 let bias = (1i32 << (exp_bits - 1)) - 1;
632 Self::Custom {
633 exp_bits,
634 mant_bits,
635 bias: bias as i8,
636 }
637 }
638
639 /// Like [`custom`](Self::custom) but with an explicit exponent bias
640 /// (for formats that don't use the IEEE `2^(e-1)-1` convention). `const`.
641 pub const fn custom_with_bias(exp_bits: u8, mant_bits: u8, bias: i8) -> Self {
642 assert!(exp_bits >= 1, "minifloat needs at least one exponent bit");
643 assert!(
644 1 + exp_bits + mant_bits <= 8,
645 "minifloat code must fit in a byte (1 + exp + mant <= 8)"
646 );
647 Self::Custom {
648 exp_bits,
649 mant_bits,
650 bias,
651 }
652 }
653
654 /// The seven named hardware element formats, for enumeration / format sweeps.
655 pub const NAMED: [ScaledFormat; 7] = [
656 Self::F8E4M3,
657 Self::F8E5M2,
658 Self::F8E4M3Fnuz,
659 Self::F8E5M2Fnuz,
660 Self::F6E2M3,
661 Self::F6E3M2,
662 Self::F4E2M1,
663 ];
664
665 /// Exponent-field bit count (`X` in `fNeXmY`).
666 pub const fn exp_bits(self) -> u32 {
667 self.fields().0
668 }
669 /// Mantissa-field bit count (`Y` in `fNeXmY`; `0` for a pure power-of-two
670 /// format such as `f4e3m0`).
671 pub const fn mant_bits(self) -> u32 {
672 self.fields().1
673 }
674 /// Exponent bias.
675 pub const fn bias(self) -> i32 {
676 self.fields().2
677 }
678 /// True for a parameterized [`Custom`](Self::Custom) format (no hardware
679 /// tensor core — always runs the decode path).
680 pub const fn is_custom(self) -> bool {
681 matches!(self, Self::Custom { .. })
682 }
683 /// True for one of the seven [`NAMED`](Self::NAMED) hardware formats.
684 pub const fn is_named(self) -> bool {
685 !self.is_custom()
686 }
687
688 /// AMD OCP-FNUZ encoding (single NaN at `0x80`, no inf, no −0).
689 pub const fn is_fnuz(self) -> bool {
690 matches!(self, Self::F8E4M3Fnuz | Self::F8E5M2Fnuz)
691 }
692
693 /// True for IEEE-like formats carrying inf / NaN at the max exponent
694 /// (OCP E5M2 only — E4M3 is finite-with-one-NaN, FP6/FP4 are all-finite).
695 pub const fn has_inf(self) -> bool {
696 matches!(self, Self::F8E5M2)
697 }
698
699 /// Largest finite magnitude representable (used as the amax divisor when
700 /// computing a per-tensor / per-block scale).
701 pub fn max_finite(self) -> f32 {
702 crate::lowp_codec::max_finite(self)
703 }
704
705 /// Decode one packed code to f32 (bit-exact; `±inf` / `NaN` for the formats
706 /// that encode them). Method form of [`crate::lowp_codec::decode`].
707 pub fn decode(self, code: u8) -> f32 {
708 crate::lowp_codec::decode(self, code)
709 }
710
711 /// Encode an f32 to its nearest representable code (round-half-to-even,
712 /// saturating overflow / `±inf` to `±max_finite`, `NaN → 0`). Method form of
713 /// [`crate::lowp_codec::encode`].
714 pub fn encode(self, x: f32) -> u8 {
715 crate::lowp_codec::encode(self, x)
716 }
717
718 /// Round-trip `x` through the format — the nearest value it can represent.
719 /// Handy for eyeballing a format's resolution without building a graph
720 /// (e.g. `ScaledFormat::custom(3, 0).quantize(1.4) == 1.0`).
721 pub fn quantize(self, x: f32) -> f32 {
722 self.decode(self.encode(x))
723 }
724
725 /// Every finite value the format represents, ascending and deduplicated —
726 /// the format's grid. Useful for inspecting a research minifloat, e.g.
727 /// `ScaledFormat::custom(3, 0).representable_values()` yields
728 /// `[-16, -8, -4, -2, -1, -0.5, -0.25, 0, 0.25, 0.5, 1, 2, 4, 8, 16]`.
729 pub fn representable_values(self) -> Vec<f32> {
730 let n = 1u16 << self.bit_width();
731 let mut v: Vec<f32> = (0..n)
732 .map(|c| self.decode(c as u8))
733 .filter(|x| x.is_finite())
734 .collect();
735 v.sort_by(|a, b| a.partial_cmp(b).unwrap());
736 v.dedup();
737 v
738 }
739
740 /// The GPU dispatch word passed to `scaled_lowp_general.cu` (`rlx_decode_lowp`).
741 ///
742 /// For the seven named formats this is a small stable id matching the
743 /// kernel's `switch`: e4m3=0, e5m2=1, e4m3fnuz=2, e5m2fnuz=3, e2m3=4,
744 /// e3m2=5, e2m1=6.
745 ///
746 /// For a [`Custom`](Self::Custom) format there is no fixed id, so this
747 /// returns a **packed field descriptor** with the top bit set as a
748 /// sentinel: `0x8000_0000 | exp_bits | mant_bits<<4 | (bias as u8)<<8`.
749 /// The kernel detects the sentinel bit and decodes generically from the
750 /// unpacked `(exp_bits, mant_bits, bias)` — so a new format needs no kernel
751 /// edit. The named ids stay in `0..=6` (top bit clear), so the existing
752 /// hardware `switch` path is unchanged.
753 pub const fn kernel_id(self) -> u32 {
754 match self {
755 Self::F8E4M3 => 0,
756 Self::F8E5M2 => 1,
757 Self::F8E4M3Fnuz => 2,
758 Self::F8E5M2Fnuz => 3,
759 Self::F6E2M3 => 4,
760 Self::F6E3M2 => 5,
761 Self::F4E2M1 => 6,
762 Self::Custom {
763 exp_bits,
764 mant_bits,
765 bias,
766 } => {
767 0x8000_0000
768 | (exp_bits as u32 & 0xF)
769 | ((mant_bits as u32 & 0xF) << 4)
770 | (((bias as u8) as u32) << 8)
771 }
772 }
773 }
774
775 /// True for the OCP FP8 variants that the native cublasLt / hipBLASLt FP8
776 /// GEMM accepts (per-tensor only). Other formats use the decode fallback.
777 pub const fn is_native_fp8(self) -> bool {
778 matches!(self, Self::F8E4M3 | Self::F8E5M2)
779 }
780}
781
782impl std::fmt::Display for ScaledFormat {
783 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
784 let s = match self {
785 Self::F8E4M3 => "f8e4m3",
786 Self::F8E5M2 => "f8e5m2",
787 Self::F8E4M3Fnuz => "f8e4m3fnuz",
788 Self::F8E5M2Fnuz => "f8e5m2fnuz",
789 Self::F6E2M3 => "f6e2m3",
790 Self::F6E3M2 => "f6e3m2",
791 Self::F4E2M1 => "f4e2m1",
792 // `fNeXmY`, N = 1 + exp + mant. The bias is implied by the width
793 // (IEEE convention) and not rendered; round-trips through `FromStr`.
794 Self::Custom {
795 exp_bits,
796 mant_bits,
797 ..
798 } => {
799 return write!(
800 f,
801 "f{}e{}m{}",
802 1 + exp_bits + mant_bits,
803 exp_bits,
804 mant_bits
805 );
806 }
807 };
808 f.write_str(s)
809 }
810}
811
812impl std::str::FromStr for ScaledFormat {
813 type Err = String;
814
815 /// Parse a format name. The seven named formats (`"f8e4m3"`, `"f4e2m1"`,
816 /// the `…fnuz` variants, …) keep their exact hardware semantics (FNUZ,
817 /// inf/NaN); any other `fNeXmY` string becomes an all-finite
818 /// [`Custom`](Self::Custom) with the IEEE bias. `N` must equal `1 + X + Y`
819 /// and the code must fit in a byte (`N <= 8`, `X >= 1`).
820 fn from_str(s: &str) -> Result<Self, Self::Err> {
821 match s {
822 "f8e4m3" => return Ok(Self::F8E4M3),
823 "f8e5m2" => return Ok(Self::F8E5M2),
824 "f8e4m3fnuz" => return Ok(Self::F8E4M3Fnuz),
825 "f8e5m2fnuz" => return Ok(Self::F8E5M2Fnuz),
826 "f6e2m3" => return Ok(Self::F6E2M3),
827 "f6e3m2" => return Ok(Self::F6E3M2),
828 "f4e2m1" => return Ok(Self::F4E2M1),
829 _ => {}
830 }
831 let err = || format!("invalid minifloat format {s:?} (expected e.g. \"f4e3m0\")");
832 let rest = s.strip_prefix('f').ok_or_else(err)?;
833 let (total, rest) = take_leading_u32(rest).ok_or_else(err)?;
834 let rest = rest.strip_prefix('e').ok_or_else(err)?;
835 let (exp, rest) = take_leading_u32(rest).ok_or_else(err)?;
836 let rest = rest.strip_prefix('m').ok_or_else(err)?;
837 let (mant, rest) = take_leading_u32(rest).ok_or_else(err)?;
838 if !rest.is_empty() || exp == 0 || total != 1 + exp + mant || 1 + exp + mant > 8 {
839 return Err(err());
840 }
841 Ok(Self::custom(exp as u8, mant as u8))
842 }
843}
844
845/// Split the leading run of ASCII digits off `s`, returning `(value, tail)`.
846/// `None` if `s` doesn't start with a digit.
847fn take_leading_u32(s: &str) -> Option<(u32, &str)> {
848 let end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
849 if end == 0 {
850 return None;
851 }
852 Some((s[..end].parse().ok()?, &s[end..]))
853}
854
855/// How scale factors are laid out for an [`crate::op::Op::ScaledMatMul`]
856/// operand. The reconstructed value of element `i` is
857/// `decode(code[i]) * scale(block_of(i))`.
858#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
859#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
860pub enum ScaleLayout {
861 /// One f32 amax scale for the whole operand (classic per-tensor FP8 GEMM).
862 PerTensor,
863 /// OCP microscaling: one power-of-two **E8M0** scale per `block`
864 /// consecutive elements along K (MXFP8 / MXFP6 / MXFP4). Scale tensor
865 /// is `DType::U8` (E8M0 bytes).
866 BlockMxE8M0 { block: u32 },
867 /// NVFP4: one FP8 **E4M3** scale per `group` (16) elements along K, plus
868 /// an optional per-tensor f32 global. Scale tensor is `DType::U8`.
869 Nvfp4 { group: u32 },
870}
871
872impl ScaleLayout {
873 /// OCP microscaling default (32-element blocks).
874 pub const fn mx() -> Self {
875 Self::BlockMxE8M0 { block: 32 }
876 }
877 /// NVFP4 default (16-element groups).
878 pub fn nvfp4() -> Self {
879 Self::Nvfp4 {
880 group: crate::nvfp4::NVFP4_GROUP_SIZE as u32,
881 }
882 }
883 /// Element dtype of the scale tensor for this layout.
884 pub const fn scale_dtype(self) -> crate::DType {
885 match self {
886 Self::PerTensor => crate::DType::F32,
887 Self::BlockMxE8M0 { .. } | Self::Nvfp4 { .. } => crate::DType::U8,
888 }
889 }
890 /// Number of consecutive elements sharing one scale (1 for per-tensor).
891 pub const fn block(self) -> u32 {
892 match self {
893 Self::PerTensor => 1,
894 Self::BlockMxE8M0 { block } => block,
895 Self::Nvfp4 { group } => group,
896 }
897 }
898
899 /// `(scale_mode, block)` for GPU kernels (`scaled_lowp_general.cu`):
900 /// per-tensor=0, block-E8M0=1, NVFP4-E4M3=2.
901 pub const fn mode_block(self) -> (u32, u32) {
902 match self {
903 Self::PerTensor => (0, 1),
904 Self::BlockMxE8M0 { block } => (1, block),
905 Self::Nvfp4 { group } => (2, group),
906 }
907 }
908}
909
910impl std::fmt::Display for ScaleLayout {
911 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
912 match self {
913 Self::PerTensor => write!(f, "per_tensor"),
914 Self::BlockMxE8M0 { block } => write!(f, "mx_e8m0/{block}"),
915 Self::Nvfp4 { group } => write!(f, "nvfp4/{group}"),
916 }
917 }
918}
919
920impl std::str::FromStr for ScaleLayout {
921 type Err = String;
922
923 /// Parse a scale-layout name: `"per_tensor"`, `"mx"` (32-element E8M0
924 /// microscaling), `"nvfp4"` (16-element E4M3), or an explicit block size
925 /// `"mx/<block>"` / `"nvfp4/<group>"`. Mirrors [`Display`](Self) round-trip.
926 fn from_str(s: &str) -> Result<Self, Self::Err> {
927 match s {
928 "per_tensor" | "pertensor" => return Ok(Self::PerTensor),
929 "mx" | "mxfp8" | "block" => return Ok(Self::mx()),
930 "nvfp4" => return Ok(Self::nvfp4()),
931 _ => {}
932 }
933 if let Some(rest) = s.strip_prefix("mx/").or_else(|| s.strip_prefix("mx_e8m0/")) {
934 if let Ok(block) = rest.parse::<u32>() {
935 return Ok(Self::BlockMxE8M0 { block });
936 }
937 }
938 if let Some(rest) = s.strip_prefix("nvfp4/") {
939 if let Ok(group) = rest.parse::<u32>() {
940 return Ok(Self::Nvfp4 { group });
941 }
942 }
943 Err(format!(
944 "unknown scale layout {s:?} (expected \"per_tensor\", \"mx\", \"nvfp4\", or \"mx/<block>\")"
945 ))
946 }
947}
948
949/// Per-graph map of quantized tensors. Lookup is O(1).
950#[derive(Debug, Clone, Default)]
951pub struct QuantMap {
952 map: HashMap<NodeId, QuantScheme>,
953}
954
955impl QuantMap {
956 pub fn new() -> Self {
957 Self::default()
958 }
959 pub fn get(&self, id: NodeId) -> Option<QuantScheme> {
960 self.map.get(&id).copied()
961 }
962 pub fn insert(&mut self, id: NodeId, scheme: QuantScheme) -> Option<QuantScheme> {
963 self.map.insert(id, scheme)
964 }
965 pub fn is_empty(&self) -> bool {
966 self.map.is_empty()
967 }
968 pub fn len(&self) -> usize {
969 self.map.len()
970 }
971 pub fn iter(&self) -> impl Iterator<Item = (&NodeId, &QuantScheme)> {
972 self.map.iter()
973 }
974}
975
976#[cfg(test)]
977mod tests {
978 use super::*;
979
980 #[test]
981 fn scheme_traits() {
982 assert_eq!(
983 QuantScheme::Int4Block { block_size: 32 }.bits_per_element(),
984 4
985 );
986 assert!(QuantScheme::Int8BlockAsym { block_size: 64 }.has_zero_point());
987 assert!(!QuantScheme::Fp8E4m3.has_scale());
988 }
989
990 /// Pins the shared GGUF dequant-kernel ids so they can NEVER drift out
991 /// from under the compiled MSL/CU/WGSL/GLSL `switch (scheme_id)` in any
992 /// backend. Every GPU backend delegates to `gpu_dequant_scheme_id`; if
993 /// someone reorders a variant here this test fails before a mis-decode
994 /// ships. Non-GGUF schemes must report `None`.
995 #[test]
996 fn gpu_dequant_scheme_id_is_stable() {
997 use QuantScheme::*;
998 assert_eq!(QuantScheme::GPU_DEQUANT_SCHEME_ID_PAIRS.len(), 29);
999 for &(scheme, id) in QuantScheme::GPU_DEQUANT_SCHEME_ID_PAIRS {
1000 assert_eq!(scheme.gpu_dequant_scheme_id(), Some(id), "{scheme:?}");
1001 assert_eq!(
1002 QuantScheme::from_gpu_dequant_scheme_id(id),
1003 Some(scheme),
1004 "id {id}"
1005 );
1006 }
1007 for scheme in [
1008 Int8Block { block_size: 32 },
1009 Int8BlockAsym { block_size: 32 },
1010 Int4Block { block_size: 32 },
1011 Fp8E4m3,
1012 Fp8E5m2,
1013 Nvfp4Block,
1014 MlxAffine {
1015 bits: 4,
1016 group_size: 64,
1017 },
1018 MlxMxfp4 { group_size: 32 },
1019 MlxMxfp8 { group_size: 32 },
1020 ] {
1021 assert_eq!(scheme.gpu_dequant_scheme_id(), None, "{scheme:?}");
1022 }
1023 assert_eq!(QuantScheme::from_gpu_dequant_scheme_id(999), None);
1024 }
1025
1026 #[test]
1027 fn quant_map_lookup() {
1028 let mut q = QuantMap::new();
1029 let id = NodeId(7);
1030 q.insert(id, QuantScheme::Int8Block { block_size: 32 });
1031 assert_eq!(q.get(id), Some(QuantScheme::Int8Block { block_size: 32 }));
1032 assert_eq!(q.get(NodeId(99)), None);
1033 }
1034
1035 #[test]
1036 fn custom_format_ieee_bias_and_width() {
1037 // IEEE bias 2^(e-1)-1, matching every named format.
1038 assert_eq!(ScaledFormat::custom(3, 0).fields(), (3, 0, 3)); // f4e3m0
1039 assert_eq!(ScaledFormat::custom(4, 3).fields(), (4, 3, 7)); // e4m3 bias
1040 assert_eq!(ScaledFormat::custom(5, 2).fields(), (5, 2, 15)); // e5m2 bias
1041 assert_eq!(ScaledFormat::custom(2, 1).fields(), (2, 1, 1)); // e2m1 bias
1042 assert_eq!(ScaledFormat::custom(3, 0).bit_width(), 4);
1043 assert_eq!(ScaledFormat::custom(3, 4).bit_width(), 8);
1044 }
1045
1046 #[test]
1047 fn custom_format_parse_display_round_trip() {
1048 let f: ScaledFormat = "f4e3m0".parse().unwrap();
1049 assert_eq!(f, ScaledFormat::custom(3, 0));
1050 assert_eq!(f.to_string(), "f4e3m0");
1051 // A few more splits.
1052 assert_eq!(
1053 "f5e2m2".parse::<ScaledFormat>().unwrap().to_string(),
1054 "f5e2m2"
1055 );
1056 assert_eq!(
1057 "f8e3m4".parse::<ScaledFormat>().unwrap().to_string(),
1058 "f8e3m4"
1059 );
1060 // Named formats parse to their exact hardware variants, not Custom.
1061 assert_eq!(
1062 "f8e4m3".parse::<ScaledFormat>().unwrap(),
1063 ScaledFormat::F8E4M3
1064 );
1065 assert_eq!(
1066 "f4e2m1".parse::<ScaledFormat>().unwrap(),
1067 ScaledFormat::F4E2M1
1068 );
1069 assert_eq!(
1070 "f8e5m2fnuz".parse::<ScaledFormat>().unwrap(),
1071 ScaledFormat::F8E5M2Fnuz
1072 );
1073 }
1074
1075 #[test]
1076 fn custom_format_parse_rejects_invalid() {
1077 assert!("f4e3m1".parse::<ScaledFormat>().is_err()); // 1+3+1 != 4
1078 assert!("f9e4m4".parse::<ScaledFormat>().is_err()); // 9 bits > byte
1079 assert!("f1e0m0".parse::<ScaledFormat>().is_err()); // 0 exponent bits
1080 assert!("e3m0".parse::<ScaledFormat>().is_err()); // missing 'f'
1081 assert!("f4e3m0x".parse::<ScaledFormat>().is_err()); // trailing junk
1082 assert!("garbage".parse::<ScaledFormat>().is_err());
1083 }
1084
1085 #[test]
1086 fn custom_gpu_word_is_packed_descriptor() {
1087 // Named formats keep their small ids (top bit clear).
1088 assert_eq!(ScaledFormat::F8E4M3.kernel_id(), 0);
1089 assert_eq!(ScaledFormat::F4E2M1.kernel_id(), 6);
1090 assert!(ScaledFormat::F4E2M1.kernel_id() & 0x8000_0000 == 0);
1091 // Custom packs (exp, mant, bias) with the sentinel bit set.
1092 let w = ScaledFormat::custom(3, 0).kernel_id(); // e=3, m=0, bias=3
1093 assert_eq!(w & 0x8000_0000, 0x8000_0000);
1094 assert_eq!(w & 0xF, 3); // exp_bits
1095 assert_eq!((w >> 4) & 0xF, 0); // mant_bits
1096 assert_eq!((w >> 8) & 0xFF, 3); // bias
1097 // Negative bias round-trips through the u8 reinterpretation.
1098 let wn = ScaledFormat::custom_with_bias(3, 0, -2).kernel_id();
1099 assert_eq!(((wn >> 8) & 0xFF) as u8 as i8, -2);
1100 }
1101
1102 #[test]
1103 fn format_is_const_and_introspectable() {
1104 // Usable in const context.
1105 const F: ScaledFormat = ScaledFormat::custom(3, 0);
1106 assert!(F.is_custom() && !F.is_named());
1107 assert_eq!((F.exp_bits(), F.mant_bits(), F.bias()), (3, 0, 3));
1108 assert!(ScaledFormat::F8E4M3.is_named() && !ScaledFormat::F8E4M3.is_custom());
1109 assert_eq!(ScaledFormat::NAMED.len(), 7);
1110 assert!(ScaledFormat::NAMED.iter().all(|f| f.is_named()));
1111 }
1112
1113 #[test]
1114 fn format_codec_methods_and_quantize() {
1115 // Method forms match the free functions for every named format.
1116 for f in ScaledFormat::NAMED {
1117 for c in 0..(1u16 << f.bit_width()) {
1118 assert_eq!(
1119 f.decode(c as u8).to_bits(),
1120 crate::lowp_codec::decode(f, c as u8).to_bits()
1121 );
1122 }
1123 }
1124 let cf = ScaledFormat::custom(3, 0); // f4e3m0 grid: ±{0.25,..,16}, ±0
1125 assert_eq!(cf.quantize(1.4), 1.0);
1126 assert_eq!(cf.quantize(1.6), 2.0);
1127 assert_eq!(cf.quantize(-3.0), -2.0); // nearer 2 than 4 in magnitude
1128 assert_eq!(cf.decode(cf.encode(100.0)), 16.0); // saturates
1129 assert_eq!(cf.decode(cf.encode(f32::INFINITY)), 16.0);
1130 }
1131
1132 #[test]
1133 fn representable_values_grid() {
1134 assert_eq!(
1135 ScaledFormat::custom(3, 0).representable_values(),
1136 vec![
1137 -16.0, -8.0, -4.0, -2.0, -1.0, -0.5, -0.25, 0.0, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0,
1138 16.0
1139 ]
1140 );
1141 // Named formats: the grid size never exceeds the finite code count.
1142 for f in ScaledFormat::NAMED {
1143 let g = f.representable_values();
1144 assert!(!g.is_empty() && g.windows(2).all(|w| w[0] < w[1]));
1145 }
1146 }
1147
1148 #[test]
1149 fn enumerate_all_fnexmy() {
1150 // Every valid fNeXmY: exp >= 1, mant >= 0, 1 + exp + mant <= 8.
1151 let mut all = vec![];
1152 for exp in 1u8..=7 {
1153 for mant in 0u8..=(7 - exp) {
1154 all.push(ScaledFormat::custom(exp, mant));
1155 }
1156 }
1157 assert_eq!(all.len(), 28, "there are exactly 28 fNeXmY formats");
1158 eprintln!(
1159 "{:<8} {:>4} {:>3} {:>3} {:>4} {:>12} {:>12} {:>5}",
1160 "name", "bits", "e", "m", "bias", "max_finite", "min_pos", "vals"
1161 );
1162 for f in &all {
1163 let vals = f.representable_values();
1164 let min_pos = vals.iter().copied().find(|&x| x > 0.0).unwrap();
1165 assert!(f.bit_width() <= 8 && f.exp_bits() >= 1 && !vals.is_empty());
1166 eprintln!(
1167 "{:<8} {:>4} {:>3} {:>3} {:>4} {:>12.3e} {:>12.3e} {:>5}",
1168 f.to_string(),
1169 f.bit_width(),
1170 f.exp_bits(),
1171 f.mant_bits(),
1172 f.bias(),
1173 f.max_finite(),
1174 min_pos,
1175 vals.len()
1176 );
1177 }
1178 }
1179
1180 #[test]
1181 fn scale_layout_parse_round_trip() {
1182 assert_eq!(
1183 "per_tensor".parse::<ScaleLayout>().unwrap(),
1184 ScaleLayout::PerTensor
1185 );
1186 assert_eq!("mx".parse::<ScaleLayout>().unwrap(), ScaleLayout::mx());
1187 assert_eq!(
1188 "nvfp4".parse::<ScaleLayout>().unwrap(),
1189 ScaleLayout::nvfp4()
1190 );
1191 assert_eq!(
1192 "mx/64".parse::<ScaleLayout>().unwrap(),
1193 ScaleLayout::BlockMxE8M0 { block: 64 }
1194 );
1195 assert!("bogus".parse::<ScaleLayout>().is_err());
1196 // Display round-trips through FromStr.
1197 let mx = ScaleLayout::mx();
1198 assert_eq!(mx.to_string().parse::<ScaleLayout>().unwrap(), mx);
1199 }
1200}