Skip to main content

rlx_ir/
quant.rs

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