atlas_core/numeric.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Host-side numeric conversions shared by every weight loader: the FP8
4//! E4M3 decode table and the f32 -> BF16 cast.
5//!
6//! This is the single copy. It used to exist twice — once in
7//! `spark-model/src/weight_map/fp8_lut.rs` (live) and once in
8//! `atlas-quant/src/fp8.rs` (unreachable, zero dependents) — with the
9//! byte-exactness tests attached to the copy that never ran. Both crates
10//! already depended on `atlas-core`, so the fix was to move the arithmetic
11//! down here and bring the tests with it.
12//!
13//! Pure arithmetic: no CUDA, no allocation, compiles under every feature
14//! combination. The CUDA-side mirrors are `E4M3_LUT_GMOE` and
15//! `__float2bfloat16_rn` in `kernels/gb10/common/moe_fp8_grouped_gemm.cu`;
16//! they must agree with this file element for element.
17
18/// FP8 E4M3 -> f32 lookup table (256 entries, one per byte value).
19///
20/// OCP FP8 E4M3FN: sign(1) | exponent(4) | mantissa(3), bias = 7. There
21/// are no infinities; `0x7F` / `0xFF` are NaN and max finite is +/-448.0
22/// (exp = 15, mant = 6).
23///
24/// NaN entries decode to `0.0`. A NaN weight should not exist in a
25/// checkpoint, and zero stops one bad byte from poisoning an entire
26/// dequanted tensor — which is what propagating NaN through the loader
27/// would do.
28///
29/// Built at compile time so the hot dequant loop is a single indexed load
30/// with no branches.
31#[allow(clippy::if_same_then_else)]
32pub static FP8_E4M3_LUT: [f32; 256] = {
33 let mut table = [0.0f32; 256];
34 let mut i: u32 = 0;
35 while i < 256 {
36 let bits = i as u8;
37 let sign = (bits >> 7) & 1;
38 let exp = (bits >> 3) & 0x0F;
39 let mantissa = bits & 0x07;
40
41 let val = if exp == 0 && mantissa == 0 {
42 0.0f32
43 } else if exp == 0x0F && mantissa == 0x07 {
44 0.0f32 // NaN -> 0.0
45 } else if exp == 0 {
46 // Subnormal: 2^(-6) * (mantissa / 8).
47 (mantissa as f32) * (0.015625f32 / 8.0)
48 } else {
49 // Normal: 2^(exp-7) * (1 + mantissa/8), assembled directly in
50 // f32 bits — f32 exponent = fp8_exp - 7 + 127 = fp8_exp + 120,
51 // f32 mantissa = fp8_mant << 20 (3 bits left-aligned into 23).
52 let f32_exp = (exp as u32 + 120) << 23;
53 let f32_mant = (mantissa as u32) << 20;
54 f32::from_bits(f32_exp | f32_mant)
55 };
56
57 table[i as usize] = if sign == 1 { -val } else { val };
58 i += 1;
59 }
60 table
61};
62
63/// Decode one FP8 E4M3 byte to f32 (branchless, single array lookup).
64#[inline(always)]
65pub fn fp8_e4m3_to_f32(bits: u8) -> f32 {
66 FP8_E4M3_LUT[bits as usize]
67}
68
69/// Convert f32 to BF16 with IEEE-754 round-to-nearest-even.
70///
71/// Must stay byte-identical to PyTorch's `torch.float32 -> torch.bfloat16`
72/// cast: reference activations and the dequanted-weight snapshots Atlas is
73/// scored against are produced that way, so any drift here shows up as an
74/// accuracy regression with no other symptom.
75///
76/// Phase 2b (FP8 dequant audit, 2026-05-24) replaced truncation
77/// (`bits >> 16`) with ties-to-even. Truncation is biased toward zero and
78/// the bias accumulated across the 31745 dequanted tensors of
79/// Qwen3.6-35B-FP8 to a mean per-layer cosine of 0.969.
80///
81/// NaN maps to the canonical quiet-NaN pattern with the sign preserved,
82/// which is also what PyTorch does.
83///
84/// `ATLAS_DISABLE_RNE` is a bisect escape hatch that reverts to
85/// truncation. It is a PRESENCE check, not a value check — `=0` disables
86/// RNE just as `=1` does.
87///
88/// ★ THE ESCAPE HATCH IS READ ONCE PER PROCESS. This is a scalar primitive —
89/// five arithmetic operations, `#[inline(always)]` — called ONCE PER ELEMENT
90/// over whole weight tensors (`weight_map::quant_helpers` iterates every byte
91/// of an FP8 tensor; `fp8_lut` walks every NVFP4 group). A `std::env::var`
92/// here allocates a `String` and takes the process-wide environment lock, and
93/// dominated the arithmetic by roughly three orders of magnitude. The
94/// dequantisation path this serves has a MEASURED cost of ~80 s
95/// (`weight_map/fp8_dequant.rs`), which is ~1.4e8 elements at the per-read
96/// rate — so the getenv plausibly accounted for most of it.
97///
98/// Caching is safe here specifically because the test that varies this
99/// re-execs the test binary as a CHILD PROCESS with the variable set
100/// (`disable_rne_presence_uses_truncation` below), so each process resolves it
101/// once at its own start. Do NOT convert that test to `set_var` in-process.
102#[inline(always)]
103pub fn f32_to_bf16(val: f32) -> u16 {
104 static DISABLE_RNE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
105 if *DISABLE_RNE.get_or_init(|| std::env::var("ATLAS_DISABLE_RNE").is_ok()) {
106 return (val.to_bits() >> 16) as u16;
107 }
108 let bits = val.to_bits();
109 if val.is_nan() {
110 let sign = ((bits >> 16) & 0x8000) as u16;
111 return sign | 0x7FC0;
112 }
113 let lsb = (bits >> 16) & 1;
114 let rounding_bias = 0x7FFFu32 + lsb;
115 (bits.wrapping_add(rounding_bias) >> 16) as u16
116}
117
118/// Widen little-endian BF16 bytes to f32. Exact — BF16 is the top 16 bits
119/// of an f32, so this is a shift, never a rounding.
120#[inline(always)]
121pub fn bf16_bytes_to_f32(bytes: [u8; 2]) -> f32 {
122 let bits = u16::from_le_bytes(bytes);
123 f32::from_bits((bits as u32) << 16)
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn fp8_lut_reference_values() {
132 assert_eq!(fp8_e4m3_to_f32(0x00).to_bits(), 0x0000_0000); // +0
133 assert_eq!(fp8_e4m3_to_f32(0x80).to_bits(), 0x8000_0000); // -0
134 assert_eq!(fp8_e4m3_to_f32(0x38), 1.0); // exp=7, mant=0
135 assert_eq!(fp8_e4m3_to_f32(0xB8), -1.0);
136 assert_eq!(fp8_e4m3_to_f32(0x3C), 1.5); // exp=7, mant=4
137 assert_eq!(fp8_e4m3_to_f32(0x7E), 448.0); // max finite
138 assert_eq!(fp8_e4m3_to_f32(0xFE), -448.0); // min finite
139 assert_eq!(fp8_e4m3_to_f32(0x7F).to_bits(), 0x0000_0000); // NaN -> +0
140 assert_eq!(fp8_e4m3_to_f32(0xFF).to_bits(), 0x8000_0000); // -NaN -> -0
141
142 // Subnormals: 2^(-6) * mant/8.
143 let eps = 1e-10;
144 assert!((fp8_e4m3_to_f32(0x01) - 0.001953125).abs() < eps);
145 assert!((fp8_e4m3_to_f32(0x07) - 0.013671875).abs() < eps);
146 }
147
148 #[test]
149 #[allow(clippy::if_same_then_else)]
150 fn fp8_lut_matches_ocp_values_and_atlas_nan_policy_for_all_bytes() {
151 // Re-derived from the OCP finite-value definition with float math,
152 // independently of the table's bit assembly. Atlas deliberately maps
153 // the two OCP NaN encodings to signed zero, matching its CUDA decoder.
154 for i in 0u16..256 {
155 let bits = i as u8;
156 let sign = (bits >> 7) & 1;
157 let exp = (bits >> 3) & 0x0F;
158 let mant = bits & 0x07;
159
160 let magnitude = if exp == 0x0F && mant == 0x07 {
161 0.0f32
162 } else if exp == 0 && mant == 0 {
163 0.0f32
164 } else if exp == 0 {
165 (mant as f32 / 8.0) * 2.0f32.powi(-6)
166 } else {
167 (1.0 + mant as f32 / 8.0) * 2.0f32.powi(exp as i32 - 7)
168 };
169 let expected = if sign == 1 { -magnitude } else { magnitude };
170 let actual = fp8_e4m3_to_f32(bits);
171 assert_eq!(
172 actual.to_bits(),
173 expected.to_bits(),
174 "LUT mismatch at {i:#04x}: expected {expected:?}, got {actual:?}"
175 );
176 }
177 }
178
179 /// The assertions that separate round-to-nearest-even from
180 /// truncation-toward-zero. Truncation FAILS every "round up" case here.
181 #[test]
182 fn f32_to_bf16_is_rne_byte_exact() {
183 fn convert(bits: u32) -> u16 {
184 f32_to_bf16(f32::from_bits(bits))
185 }
186
187 // Below half-ULP: round DOWN. Truncation agrees.
188 assert_eq!(convert(0x3F80_0800), 0x3F80, "1.0 + below-half-ULP -> 1.0");
189 // Exactly half-ULP, LSB=0: tie -> round to EVEN (down). Does not
190 // distinguish RNE from truncation; kept for the tie coverage.
191 assert_eq!(
192 convert(0x3F80_8000),
193 0x3F80,
194 "1.0 + exact-half-ULP, LSB=0 -> 1.0 (even)"
195 );
196 // Above half-ULP: round UP. Truncation would give 0x3F80.
197 assert_eq!(
198 convert(0x3F80_8001),
199 0x3F81,
200 "1.0 + above-half-ULP -> next bf16 (truncation would give 0x3F80)"
201 );
202 // Exactly half-ULP, LSB=1: tie -> round to EVEN (up). Truncation
203 // would give 0x3F81.
204 assert_eq!(
205 convert(0x3F81_8000),
206 0x3F82,
207 "1.0078125 + exact-half-ULP, LSB=1 -> 1.015625"
208 );
209 // Negative parity: magnitude grows the same way.
210 assert_eq!(convert(0xBF80_8001), 0xBF81, "negative round up");
211 // Zero: exact, no rounding, sign preserved.
212 assert_eq!(convert(0x0000_0000), 0x0000, "+0.0");
213 assert_eq!(convert(0x8000_0000), 0x8000, "-0.0");
214 // Smallest f32 subnormal (2^-149) -> nearest bf16 is 0 (LSB=0 tie).
215 assert_eq!(convert(0x0000_0001), 0x0000, "tiny subnormal -> 0");
216 // Infinities pass through.
217 assert_eq!(convert(0x7F80_0000), 0x7F80, "+inf");
218 assert_eq!(convert(0xFF80_0000), 0xFF80, "-inf");
219 // Max-finite f32 rounds UP to +inf in bf16 — the closest
220 // representable value. PyTorch does the same.
221 assert_eq!(
222 convert(0x7F7F_FFFF),
223 0x7F80,
224 "max-finite f32 rounds to +inf bf16"
225 );
226 // NaN -> canonical quiet NaN, sign preserved; signalling NaN is
227 // quieted rather than passed through.
228 assert_eq!(convert(0x7FC0_0000), 0x7FC0, "qnan +");
229 assert_eq!(convert(0xFFC0_0000), 0xFFC0, "qnan -");
230 assert_eq!(convert(0x7F80_0001), 0x7FC0, "snan + -> qnan +");
231 }
232
233 /// Byte-exact match against values captured from PyTorch 2.9 via
234 /// `torch.tensor([x], dtype=torch.float32).bfloat16()`. If this fails
235 /// after a math change, the converter has drifted from PyTorch's RNE
236 /// and every dequanted weight in the engine is off by a bit.
237 #[test]
238 fn f32_to_bf16_matches_pytorch() {
239 let cases: &[(u32, u16, &str)] = &[
240 (0x3F80_0000, 0x3F80, "1.0"),
241 (0x4000_0000, 0x4000, "2.0"),
242 (0xC000_0000, 0xC000, "-2.0"),
243 (0x3FC0_0000, 0x3FC0, "1.5"),
244 (
245 0x3DCC_CCCD,
246 0x3DCD,
247 "0.1 -> RNE rounds UP to 0x3DCD (trunc=0x3DCC)",
248 ),
249 (0x3F4C_CCCD, 0x3F4D, "0.8 -> RNE rounds UP to 0x3F4D"),
250 (0x40C9_0FDB, 0x40C9, "pi -> truncates (next bit < half)"),
251 (0x402D_F854, 0x402E, "e -> RNE rounds UP (next bit > half)"),
252 (0x4490_0000, 0x4490, "1152.0"),
253 (0x3727_C5AC, 0x3728, "1e-5 -> RNE rounds UP"),
254 ];
255 for (f32_bits, want, desc) in cases {
256 let got = f32_to_bf16(f32::from_bits(*f32_bits));
257 assert_eq!(
258 got, *want,
259 "f32={f32_bits:#010x} ({desc}): want bf16={want:#06x}, got {got:#06x}"
260 );
261 }
262 }
263
264 #[test]
265 fn disable_rne_presence_uses_truncation() {
266 const THIS_TEST: &str = "numeric::tests::disable_rne_presence_uses_truncation";
267 const CHILD_MARKER: &str = "ATLAS_NUMERIC_RNE_CHILD";
268
269 if std::env::var_os(CHILD_MARKER).is_some() {
270 assert_eq!(
271 f32_to_bf16(f32::from_bits(0x3F80_8001)),
272 0x3F80,
273 "the escape hatch must truncate an above-half-ULP value"
274 );
275 return;
276 }
277
278 for value in ["0", "1"] {
279 let output = std::process::Command::new(std::env::current_exe().unwrap())
280 .args(["--exact", THIS_TEST])
281 .env(CHILD_MARKER, "1")
282 .env("ATLAS_DISABLE_RNE", value)
283 .output()
284 .unwrap();
285 assert!(
286 output.status.success(),
287 "ATLAS_DISABLE_RNE={value} child failed:\n{}",
288 String::from_utf8_lossy(&output.stdout)
289 );
290 }
291 }
292
293 #[test]
294 fn bf16_widening_is_byte_exact_for_every_pattern() {
295 for bits in 0u32..=0xFFFF {
296 let bf16 = bits as u16;
297 let widened = bf16_bytes_to_f32(bf16.to_le_bytes());
298 assert_eq!(
299 widened.to_bits(),
300 bits << 16,
301 "widening moved bits for bf16 {bf16:#06x}"
302 );
303 }
304 }
305
306 #[test]
307 fn bf16_narrowing_preserves_every_non_nan_bf16_value() {
308 for bits in 0u32..=0xFFFF {
309 let bf16 = bits as u16;
310 let widened = f32::from_bits(bits << 16);
311 if widened.is_nan() {
312 continue;
313 }
314 assert_eq!(
315 f32_to_bf16(widened),
316 bf16,
317 "round trip failed for bf16 {bf16:#06x}"
318 );
319 }
320 }
321}