Skip to main content

rlx_tensor/
lowp.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//! Generic low-precision numerics for **emulated mixed-precision training** —
6//! quantize an `f32` to the grid of an arbitrary binary float format
7//! `fXmYeZ` (`X` total bits, `Y` mantissa bits, `Z` exponent bits, 1 sign),
8//! then keep computing in `f32`. This makes training work at *any* precision on
9//! *any* backend (including exotic formats with no hardware kernel — `nvf4`,
10//! `f8`, `bf8`) by round-tripping values through the format's representable set
11//! instead of relying on native half/quarter-precision matmul.
12//!
13//! Pair with a straight-through estimator (quantize on the forward, identity on
14//! the backward) for QAT-style low-precision training — see
15//! [`Func`](crate::Func)'s QAT helpers / the `rlx-tinystories` `--fake-quant`.
16//!
17//! The [`float_format!`] macro generates a marker type per format with a
18//! `const`-parameterized [`FloatFormat`](crate::lowp::FloatFormat) impl;
19//! [`parse_format`](crate::lowp::parse_format) resolves a
20//! runtime spec string (`"nvf4"`, `"f8e4m3"`, `"bf8"`, or generic `"f8m3e4"`).
21
22/// Round `x` to the nearest value representable by a binary float format with a
23/// sign bit, `exp_bits` exponent bits (IEEE bias `2^(exp_bits-1) - 1`) and
24/// `man_bits` explicit mantissa bits, saturating at `±max_normal` (no Inf
25/// produced). Round-to-nearest-**even**; subnormals supported; `0`/NaN pass
26/// through; `±Inf` saturates to `±max_normal`.
27///
28/// The whole grid is emulated in `f32`, so the result is an ordinary `f32` that
29/// happens to be one of the format's representable values.
30#[inline]
31pub fn quantize(x: f32, exp_bits: u32, man_bits: u32, max_normal: f32) -> f32 {
32    if x == 0.0 || x.is_nan() {
33        return x;
34    }
35    let neg = x.is_sign_negative();
36    let ax = x.abs();
37    if ax >= max_normal {
38        return if neg { -max_normal } else { max_normal };
39    }
40    let bias = (1i32 << (exp_bits - 1)) - 1;
41    let emin = 1 - bias; // smallest normal exponent
42    // Exponent of `ax`, floored, clamped up into the subnormal region so the
43    // ULP never shrinks below the format's smallest step.
44    let e = ax.log2().floor() as i32;
45    let e_eff = e.max(emin);
46    // Unit-in-the-last-place at this magnitude.
47    let ulp = exp2i(e_eff - man_bits as i32);
48    let q = round_half_even(ax / ulp) * ulp;
49    let q = q.min(max_normal);
50    if neg { -q } else { q }
51}
52
53/// `2^n` for a (possibly negative) integer exponent, without `powi` overflow
54/// pitfalls at the extremes.
55#[inline]
56fn exp2i(n: i32) -> f32 {
57    // f32 exponent range is [-149, 127]; clamp keeps subnormal ULPs finite.
58    2f32.powi(n.clamp(-149, 127))
59}
60
61/// Round to nearest integer, ties to even (banker's rounding).
62#[inline]
63fn round_half_even(v: f32) -> f32 {
64    let f = v.floor();
65    let diff = v - f;
66    if diff < 0.5 {
67        f
68    } else if diff > 0.5 {
69        f + 1.0
70    } else if (f as i64) & 1 == 0 {
71        f // already even
72    } else {
73        f + 1.0
74    }
75}
76
77/// Quantize every element of `xs` in place to `(exp_bits, man_bits, max_normal)`.
78#[inline]
79pub fn quantize_slice(xs: &mut [f32], exp_bits: u32, man_bits: u32, max_normal: f32) {
80    for x in xs {
81        *x = quantize(*x, exp_bits, man_bits, max_normal);
82    }
83}
84
85/// Quantize `xs` in place with **per-tensor (absmax) scaling** — the standard
86/// microscaling trick (MXFP4 / NVFP4): the largest-magnitude element is mapped
87/// to the format's max, so narrow formats use their full dynamic range instead
88/// of flushing everything below the smallest step to zero. `q = Q(x/s)·s` with
89/// `s = absmax / max_normal`. Essential for ≤6-bit formats (e.g. `nvf4`, whose
90/// smallest nonzero magnitude is 0.5 — without scaling, ~0.02 weights vanish).
91#[inline]
92pub fn quantize_slice_scaled(xs: &mut [f32], exp_bits: u32, man_bits: u32, max_normal: f32) {
93    let absmax = xs.iter().fold(0f32, |a, &x| a.max(x.abs()));
94    if absmax == 0.0 {
95        return;
96    }
97    let scale = absmax / max_normal;
98    for x in xs {
99        *x = quantize(*x / scale, exp_bits, man_bits, max_normal) * scale;
100    }
101}
102
103/// A compile-time-known binary float format.
104pub trait FloatFormat: Copy {
105    /// Exponent bits.
106    const EXP_BITS: u32;
107    /// Explicit mantissa bits.
108    const MAN_BITS: u32;
109    /// Largest finite magnitude (format-specific: e.g. 448 for E4M3, 6 for E2M1).
110    const MAX_NORMAL: f32;
111    /// Canonical spec string (`"f8e4m3"`, `"nvf4"`, …).
112    const NAME: &'static str;
113    /// Total bits (`1 + EXP_BITS + MAN_BITS`).
114    const BITS: u32 = 1 + Self::EXP_BITS + Self::MAN_BITS;
115
116    /// Round `x` to this format's grid.
117    #[inline]
118    fn quantize(x: f32) -> f32 {
119        quantize(x, Self::EXP_BITS, Self::MAN_BITS, Self::MAX_NORMAL)
120    }
121}
122
123/// Generate a marker type + [`FloatFormat`] impl per format, plus a `BUILTIN`
124/// lookup table. `$name = (exp_bits, man_bits, max_normal, "spec")`.
125#[macro_export]
126macro_rules! float_format {
127    ($($(#[$m:meta])* $name:ident = ($exp:expr, $man:expr, $max:expr, $spec:literal);)*) => {
128        $(
129            $(#[$m])*
130            #[derive(Clone, Copy, Debug, Default)]
131            pub struct $name;
132            impl $crate::lowp::FloatFormat for $name {
133                const EXP_BITS: u32 = $exp;
134                const MAN_BITS: u32 = $man;
135                const MAX_NORMAL: f32 = $max;
136                const NAME: &'static str = $spec;
137            }
138        )*
139        /// All built-in formats: `(spec, exp_bits, man_bits, max_normal)`.
140        pub const BUILTIN: &[(&str, u32, u32, f32)] = &[ $(($spec, $exp, $man, $max)),* ];
141    };
142}
143
144float_format! {
145    /// IEEE binary16 (half).
146    F16   = (5, 10, 65504.0,        "f16");
147    /// bfloat16 (truncated f32; 8 exp / 7 man).
148    Bf16  = (8, 7,  3.3895314e38,   "bf16");
149    /// OCP FP8 **E4M3** (max 448) — the common fp8 weight/activation format.
150    F8E4M3 = (4, 3, 448.0,          "f8e4m3");
151    /// OCP FP8 **E5M2** — a.k.a. **bf8** (more range, less mantissa).
152    F8E5M2 = (5, 2, 57344.0,        "bf8");
153    /// **NVFP4** element format (**E2M1**, grid {0,±.5,±1,±1.5,±2,±3,±4,±6}).
154    Nvf4  = (2, 1, 6.0,             "nvf4");
155    /// FP6 E3M2 (a middle ground).
156    F6E3M2 = (3, 2, 28.0,           "f6e3m2");
157}
158
159/// Resolve a runtime precision spec to `(exp_bits, man_bits, max_normal)`.
160///
161/// Accepts a built-in [`BUILTIN`] name / common alias (`"f8"`, `"e4m3"`,
162/// `"e5m2"`, `"fp8"`, …), or a **generic** `fXmYeZ` string — `X` total bits,
163/// `Y` mantissa bits, `Z` exponent bits — e.g. `"f8m3e4"` (== E4M3),
164/// `"f4m1e2"` (== NVFP4). For generic specs `max_normal` uses the IEEE
165/// Inf-reserving convention `(2 − 2^-Y)·2^bias`.
166pub fn parse_format(spec: &str) -> Option<(u32, u32, f32)> {
167    let s = spec.trim().to_ascii_lowercase();
168    for (name, e, m, max) in BUILTIN {
169        if s == *name {
170            return Some((*e, *m, *max));
171        }
172    }
173    match s.as_str() {
174        "fp8" | "f8" | "e4m3" => return Some((4, 3, 448.0)),
175        "e5m2" => return Some((5, 2, 57344.0)),
176        "fp4" | "f4" | "e2m1" => return Some((2, 1, 6.0)),
177        "half" => return Some((5, 10, 65504.0)),
178        _ => {}
179    }
180    // Generic `f{X}m{Y}e{Z}`.
181    let rest = s.strip_prefix('f')?;
182    let (x_str, rest) = rest.split_once('m')?;
183    let (y_str, z_str) = rest.split_once('e')?;
184    let total: u32 = x_str.parse().ok()?;
185    let man: u32 = y_str.parse().ok()?;
186    let exp: u32 = z_str.parse().ok()?;
187    if exp == 0 || 1 + man + exp != total {
188        return None; // sign + exp + man must account for every bit
189    }
190    let bias = (1i32 << (exp - 1)) - 1;
191    let max = (2.0 - exp2i(-(man as i32))) * exp2i(bias);
192    Some((exp, man, max))
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn exact_values_pass_through() {
201        // Powers of two and simple fractions are representable everywhere.
202        for &(e, m, max) in &[(5u32, 10u32, 65504.0f32), (4, 3, 448.0), (2, 1, 6.0)] {
203            for &v in &[0.0, 1.0, -1.0, 0.5, 2.0, -4.0] {
204                assert_eq!(quantize(v, e, m, max), v, "fmt e{e}m{m} v{v}");
205            }
206        }
207    }
208
209    #[test]
210    fn saturates_no_inf() {
211        assert_eq!(quantize(1e30, 4, 3, 448.0), 448.0);
212        assert_eq!(quantize(-1e30, 4, 3, 448.0), -448.0);
213        assert_eq!(quantize(f32::INFINITY, 5, 10, 65504.0), 65504.0);
214        assert_eq!(quantize(100.0, 2, 1, 6.0), 6.0); // nvf4 saturates at 6
215    }
216
217    #[test]
218    fn nvf4_grid_is_exact() {
219        // E2M1 representable magnitudes.
220        for &v in &[0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0] {
221            assert_eq!(quantize(v, 2, 1, 6.0), v, "nvf4 {v}");
222            assert_eq!(quantize(-v, 2, 1, 6.0), -v);
223        }
224        // 5.0 rounds to the nearest of {4,6} → tie → even (4 = 2²·1.0).
225        assert_eq!(quantize(5.0, 2, 1, 6.0), 4.0);
226        // 2.5 → nearest of {2,3}; ulp at e=1 is 1 → RNE(2.5)=2 (even).
227        assert_eq!(quantize(2.5, 2, 1, 6.0), 2.0);
228    }
229
230    #[test]
231    fn rounds_toward_nearest() {
232        // f16 has 10 mantissa bits: 1 + 2^-11 rounds back to 1.0.
233        assert_eq!(quantize(1.0 + 2f32.powi(-11), 5, 10, 65504.0), 1.0);
234        // 1 + 2^-9 is representable (a multiple of 2^-10).
235        assert_eq!(
236            quantize(1.0 + 2f32.powi(-9), 5, 10, 65504.0),
237            1.0 + 2f32.powi(-9)
238        );
239        // f8e4m3 has 3 mantissa bits → step of 1/8 at [1,2): 1.1 → 1.125.
240        assert_eq!(quantize(1.1, 4, 3, 448.0), 1.125);
241    }
242
243    #[test]
244    fn parse_named_and_generic() {
245        assert_eq!(parse_format("nvf4"), Some((2, 1, 6.0)));
246        assert_eq!(parse_format("bf8"), Some((5, 2, 57344.0)));
247        assert_eq!(parse_format("f8e4m3"), Some((4, 3, 448.0)));
248        // Generic fXmYeZ resolves the (exp, man) split (max via IEEE convention).
249        assert_eq!(parse_format("f8m3e4").map(|(e, m, _)| (e, m)), Some((4, 3)));
250        assert_eq!(parse_format("f4m1e2").map(|(e, m, _)| (e, m)), Some((2, 1)));
251        assert_eq!(
252            parse_format("f16m10e5").map(|(e, m, _)| (e, m)),
253            Some((5, 10))
254        );
255        // Bad specs.
256        assert_eq!(parse_format("f8m4e4"), None); // 1+4+4 != 8
257        assert_eq!(parse_format("nonsense"), None);
258    }
259
260    #[test]
261    fn scaled_uses_full_range() {
262        // Small weights (~0.02) under nvf4 *without* scaling all vanish to 0.
263        let mut unscaled = vec![0.02f32, -0.05, 0.1, -0.01];
264        quantize_slice(&mut unscaled, 2, 1, 6.0);
265        assert!(unscaled.iter().all(|&x| x == 0.0), "{unscaled:?}");
266        // *With* per-tensor scaling they survive, and the absmax maps exactly.
267        let mut scaled = vec![0.02f32, -0.05, 0.1, -0.01];
268        quantize_slice_scaled(&mut scaled, 2, 1, 6.0);
269        assert!(scaled.iter().any(|&x| x != 0.0), "{scaled:?}");
270        assert!(
271            (scaled[2] - 0.1).abs() < 1e-6,
272            "absmax preserved: {}",
273            scaled[2]
274        );
275    }
276
277    #[test]
278    fn format_trait_consts() {
279        assert_eq!(Nvf4::BITS, 4);
280        assert_eq!(F8E4M3::BITS, 8);
281        assert_eq!(F16::BITS, 16);
282        assert_eq!(F8E4M3::quantize(1.1), 1.125);
283    }
284}