Skip to content

fix(fp8): round onto the e4m3 grid before the native float8e4nv downcast - #85

Open
gdevenyi wants to merge 1 commit into
FlashML-org:mainfrom
gdevenyi:fix/fp8-native-downcast-rne
Open

fix(fp8): round onto the e4m3 grid before the native float8e4nv downcast#85
gdevenyi wants to merge 1 commit into
FlashML-org:mainfrom
gdevenyi:fix/fp8-native-downcast-rne

Conversation

@gdevenyi

@gdevenyi gdevenyi commented Aug 23, 2026

Copy link
Copy Markdown

The bug

Triton lowers fp32 -> tl.float8e4nv as a double rounding: fp32 to fp16 with truncation, then fp16 to e4m3. A value just above a grid midpoint collapses onto the midpoint in the first step, and round-half-to-even then sends it down. The error is one-sided.

Measured on sm_89, 2^22 samples drawn uniformly from [-448, 448], against torch's RNE cast:

x.to(tl.float8e4nv)               16127 / 4194304 differ  (0.3845%)
   of those:  16127 toward zero,  0 away from zero
round_e4m3(x).to(tl.float8e4nv)       0 / 4194304 differ

  x=400.199585  triton=384.0  torch=416.0     (midpoint 400, ulp 32)
  x=272.156555  triton=256.0  torch=288.0     (midpoint 272)
  x=136.002075  triton=128.0  torch=144.0     (midpoint 136)

Setting fp_downcast_rounding="rtne" does not change it.

So every native-fp8 activation quantizer shrinks about 0.4% of its values by one unit in the last place, always toward zero. On the per-token-group quantizer that is a 0.02% bias in magnitude: mean|dequant| / mean|x| moves from 0.998937 to 0.999169. The bias is small, but it is one-sided, and it costs nothing to remove.

The emulated path for pre-sm_89 GPUs never had this. It goes through round_e4m3, whose docstring already warns about an fp32 to fp16 to 3-bit chain that double-rounds on a tie. The hazard was known when the emulation was written. Only the native branch skipped the guard.

The fix

Round onto the e4m3 grid in fp32 first, on both paths. The downcast is then exact and the lowering's rounding mode stops mattering.

  • fp8_block_linear._act_quant_kernel
  • fp8_pertensor_linear._static_quant_kernel
  • dsv4/fp8_linear._act_quant_fp8_kernel
  • dsv4/fp8_linear._act_quant_inplace_kernel, where the native branch's fp8 round trip only ever re-quantized a value already on the grid, so it collapses into the shared round_e4m3 call.

Test changes

tests/kernels/test_e4m3_compat.py::test_forced_emu_matches_native fails on main on sm_89 hardware, with blk_aq_y: EMU output differs from native, 52 of 18944 elements. The forced-emulation A/B is what catches this, because the emulation was the correct side.

Two changes there:

  1. test_native_downcast_needs_the_grid_round pins the invariant directly, so a later change cannot make the emulation match the native bug instead. It deliberately does not assert that the bare downcast is wrong: a future Triton may lower it correctly, and the pre-round would then be redundant rather than incorrect.

  2. The three GEMM keys that compare an fp8 MMA against a bf16 MMA get a bound on the tensor scale instead of an elementwise one. Triton selects different MMA shapes for fp8 x fp8 and bf16 x bf16, so the fp32 reduction trees differ and the results land about one output ULP apart. The file's own comment predicted this: bit-exact on H100 is "lowering luck, not a guarantee", and it is not bit-exact on sm_89. moe_prefill_fp8 chains two such GEMMs and then sums top-k outputs of magnitude 8e3 down to about 2e2, so an error worth 2e-3 of the GEMM scale reads as 13% elementwise. Bounding against max|ref| measures the GEMM's own error rather than the cancellation:

    key max abs diff max abs ref ratio
    blk_gemm 0.5 109 4.6e-3
    dsv4_gemm 4 1240 3.2e-3
    moe_prefill_fp8 16 8256 1.9e-3

    The limit is 1e-2.

Testing

RTX 6000 Ada, sm_89. tests/kernels/test_e4m3_compat.py on main with this PR: 9 passed. On main without it: 1 failed, 7 passed.

Found while reviewing #48. That PR does not cause the failure; I reverted it and reproduced the failure on main.

triton lowers fp32 -> float8e4nv as a double-round (fp32 -> fp16 RTZ ->
e4m3), so a value a hair above a grid midpoint collapses onto the midpoint
and then ties to even. The error is one-sided: over a uniform [-448, 448]
sweep of 2^22 values, 16127 (0.38%) disagree with torch's RNE and every
one of them lands 1 ULP *toward zero*. No fp_downcast_rounding setting
changes it.

Every native-fp8 activation quantizer now rounds with round_e4m3 in fp32
first; the downcast is then exact and the lowering's rounding mode stops
mattering. The emulated (pre-sm_89) path already did this, which is why
the forced-EMU A/B in test_e4m3_compat caught it.

- fp8_block_linear._act_quant_kernel
- fp8_pertensor_linear._static_quant_kernel
- dsv4/fp8_linear._act_quant_fp8_kernel
- dsv4/fp8_linear._act_quant_inplace_kernel: the native branch's fp8
  round-trip only ever re-quantized an already-grid value, so it collapses
  into the shared round_e4m3 call.

test_forced_emu_matches_native's per-tensor bit-equality now holds for the
activation quantizers. The three fp8-MMA-vs-bf16-MMA GEMM keys still
differ by ~1 output ULP -- triton picks different MMA shapes for fp8xfp8
and bf16xbf16 tl.dot, so the fp32 reduction trees differ. That was
bit-exact on H100 by luck; on sm_89 it is not. moe_prefill_fp8 chains two
such GEMMs and then top-k-sums ~8e3-magnitude rows down to ~2e2, so an
error worth 2e-3 of the GEMM scale reads as 13% elementwise. The bound is
now on max|ref| rather than elementwise, which measures the GEMM's error
instead of the cancellation's (worst observed 4.6e-3, limit 1e-2).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GpGe2fQ5pDShGrnSuksfun
(cherry picked from commit dcc2f60a6a9f3c9fb102c3466c24102745aad219)
gdevenyi added a commit to gdevenyi/FreeToken that referenced this pull request Aug 23, 2026
The store kernel's int branch rounds half-away-from-zero before its cast because
the float->int cast truncates. The fp8 branch went straight to
.to(float8e4nv), which does not round to nearest either: triton lowers
fp32 -> float8e4nv as a double-round (fp32 -> fp16 RTZ -> e4m3), so a value just
above a grid midpoint collapses onto the midpoint and then ties to even, always
downward.

On sm_89 that made two of the PR's own tests fail:
test_store_kernel_matches_the_reference_quantizer[256-fp8_e4m3] and [512-fp8_e4m3].
q8_0 was unaffected, which is the tell -- only the fp8 path takes that cast.

round_e4m3 puts the value on the grid in fp32 first, after which the cast is
exact. 53/53 of the PR's tests pass on sm_89 with this. Same root cause as FlashML-org#85.

Deployment branch only.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant