Skip to main content

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