Repository navigation
fix(kernel): convert fp32 to e4m3 in one rounding on sm_89 - #805
Conversation
jason-fxz
left a comment
There was a problem hiding this comment.
Thanks, the fix looks right to me. Two small things:
- Triton fixes this in triton-lang/triton#11924, which is on
release/3.9.xand not in any 3.8.x release. Could you put a FIXME on the sm_89 path so it gets dropped when we move to 3.9? For example:
if _f32_cast_rounds_once_cx():
return x.to(tl.float8e4nv)
# FIXME: remove the sm_89 inline asm once the minimum Triton is 3.9 (triton-lang/triton#11924)_f32_cast_rounds_once_cx()returns False on any non-CUDA target, so on HIPf32_to_e4m3would emit the NVIDIA-onlycvt.rn.satfinite.e4m3x2.f32. Todaye4m3_native()keeps ROCm off this path, so nothing breaks. But_static_quant_kernelcalls the helper with noe4m3_native_cx()branch of its own. Limiting the asm to CUDA would remove that trap:
@constexpr_function
def _f32_cast_rounds_once_cx():
return not target_info.is_cuda() or target_info.cuda_capability_geq(9, 0)@zihaomu could you check whether this change is OK on AMD? Specifically, are there ROCm targets where we plan to let fp8e4nv through e4m3_native(), and does the plain x.to(tl.float8e4nv) round correctly there?
|
Hi @jason-fxz, thanks for bringing this up! I'll test this locally and share the results in a follow-up comment. |
|
@ischencheng @jason-fxz AMD validation is complete. The suggested non-CUDA guard works on Radeon AI PRO R9700 ( @ischencheng, please feel free to cherry-pick this commit directly into this PR: zihaomu/FreeToken@d0f0a9b. It is based on this PR's current head, git fetch https://github.com/zihaomu/FreeToken.git fix/pr805-amd-e4m3-cast
git cherry-pick d0f0a9b4a48bfd9657fde78ec8c9732ab572d633The commit applies exactly the suggested condition: @constexpr_function
def _f32_cast_rounds_once_cx():
return not target_info.is_cuda() or target_info.cuda_capability_geq(9, 0)It also adds the requested Triton 3.9 FIXME before the sm_89 inline asm. Validation against the original PR:
The existing host and constexpr native gates remain disabled on HIP, so normal wrappers currently keep the emulated path. For future OCP E4M3 native enablement, RDNA4 ( The existing-test command in the matched ROCm environment was: python -m pytest \
tests/kernels/test_e4m3_compat.py \
tests/kernels/test_fp8_pertensor_linear.py \
tests/kernels/test_dsv41_pack.py \
tests/kernels/test_dsv4_fp8_block_linear.py \
tests/kernels/test_fp8_blockscale_moe.py \
-k "not forced_emu and not compile_gate_foreign_arch" -q |
…hML-org#805's one-rounding cast Reverts 8236bb3's kernel changes (its tests stay); FlashML-org#805 fixes the same double rounding with a direct cvt and covers more quantizers. Assisted-by: Claude Opus 5.5
FlashML-org#805 FlashML-org#797 FlashML-org#795 adopted (FlashML-org#805 replaces our 8236bb3 kernel changes), FlashML-org#806 and FlashML-org#810 deferred. Assisted-by: Claude Opus 5.5
|
@jason-fxz thanks for the review, and thanks @zihaomu for testing on AMD! Cherry-picked d0f0a9b as ab85da0, so both changes are in. Reran the tests on L4 and H100 after it: same results as in the description, and the SASS of all 46 compiled kernels is unchanged. |
jason-fxz
left a comment
There was a problem hiding this comment.
Thanks, both points are in. LGTM.
…ML-org#805 merge The merge took next's import list, which swapped round_e4m3 for f32_to_e4m3; next-speed's fused small-m kernel (52a6061) still rounds activations onto the e4m3 grid in bf16. Assisted-by: Claude Opus 5.5
Upstream FlashML-org#806 (--embed-weights host) now sits next to our --embed-device cpu: both flags stay, using them together is refused, and the host-table byte count covers tables moved by either one. FlashML-org#805/FlashML-org#795 hunks keep next's adopted versions; setup.py keeps our extra_compile with upstream's ROCm row_store args. Assisted-by: Claude Opus 5.5
On sm_89 (RTX 40, L4, RTX 6000 Ada) the native fp8 quantizers round some values one e4m3 step toward zero. Triton 3.8 lowers
fp32 -> tl.float8e4nvthere as fp32 -RTZ-> fp16 -RNE-> e4m3, so a value just above an e4m3 midpoint lands on the midpoint and then ties to even (triton-lang/triton#11753, fixed by triton-lang/triton#11924 after 3.8.0). sm_90+ converts straight from fp32 and is fine. For example 200.04 becomes 192 on L4, while torch's cast and H100 give 208.This is the bug #85 described in August. That PR was closed by its author and the bare cast is still on main.
f32_to_e4m3ine4m3_compat.pykeeps the plain cast on sm_90+ and on non-CUDA targets, and emitscvt.rn.satfinite.e4m3x2.f32on sm_89, the same instruction Triton 3.8 uses on sm_90. A FIXME marks the sm_89 asm for removal once the minimum Triton is 3.9. The native branches of all seven fp32 -> e4m3 casts call it:fp8_block_linear._act_quant_kernelfp8_pertensor_linear._static_quant_kerneldsv4/fp8_linear.py:_act_quant_fp8_kernel,_act_quant_inplace_kernel, and the in-kernel act quant of_fp8_gemv_kerneldsv4/fused_moe._swiglu_kerneldsv41/pack.py:_e4m3_codes,_e4m3_roundOnly the first two change values today. On 4096 x 7168 bf16 activations on L4, the block-fp8 act quant changes 74,586 of 29,360,128 output bytes (0.25%). The per-tensor quant scales the whole tensor by one factor, so far fewer inputs land in the gap: 1 byte on the same tensor, and the W8A8 test below is one such case. The DSV4 casts and the DSV4.1 KV pack (fp8 and fp4 rows) give the same bytes before and after on those inputs; most of them divide bf16 by a power of two, which fp16 holds exactly. They call the helper too, so every native cast goes through the same path.
test_round_to_grid_bitexactgets anf32_to_e4m3case on the same ties, neighbors and subnormals it already runs forround_e4m3.Tested with torch 2.14.1+cu130, Triton 3.8.0, driver 610.57.04, on top of c63fc13. No checkpoint is involved.
pytest tests/kernels/test_e4m3_compat.py tests/kernels/test_fp8_pertensor_linear.py tests/kernels/test_dsv41_pack.py tests/kernels/test_dsv4_fp8_block_linear.py tests/kernels/test_fp8_blockscale_moe.py tests/dsv4 tests/models/deepseek_v41, slow tests included:On L4:
test_round_to_grid_bitexact[f32_to_e4m3]: with only the sm_89 line off32_to_e4m3turned back intox.to(tl.float8e4nv),assert 6986 == 0; with this PR it passes.test_w8a8_matches_w8a8_reference[part_rows0-False-4]: rel 0.0108 > 0.01 on main, passes with this PR.test_forced_emu_matches_native: on main it stops atblk_aq_y: EMU output differs from native. With this PR every act-quant key matches the emulated path bit for bit, and it stops atmoe_prefill_fp8(1 of 512 elements: 8.0 apart at 112, atol 0.5). That key compares an fp8-MMA GEMM with a bf16-MMA one; its native-vs-emulated difference is the same on main (44 of 512 elements differ, max 64 at |y| up to 8704, relative norm 1.9e-3), it was just hidden behindblk_aq_y. So on sm_89 this test still fails, now at that key.The rest of
tests/kernels,tests/moe,tests/dsv4,tests/models/deepseek_v41andtests/models/qwen4_expon L4 shows no new failures: main and this PR fail the same 4 tests, none of them fp8 (one is #765).The cast on L4, 6.3M fp32 values (every e4m3 grid value and midpoint, one fp32 ulp either side of each midpoint, uniform samples over the range and over the subnormals), against torch's
.to(torch.float8_e4m3fn):x.to(tl.float8e4nv)differs on 17936,round_e4m3on 0. H100 and RTX PRO 6000 differ on 0.per_token_group_quant_fp8on 4096 x 512 activations: 5235 of 2,097,152 outputs differ from torch on main, 0 with this PR.sm_90 and sm_120: the SASS of every FreeToken kernel the tests above compile is identical between main and this PR (45 kernels,
cuobjdump -sassinstruction text). Only the test's own kernel changes.Kernel time on L4,
do_benchmedian, main and this PR alternated in one container, median of 3 runs each:per_token_group_quant_fp8, M x 7168act_quant_fp8(dsv4), M x 7168_static_quant(per-tensor), M x 7168quant_roundtripfp8_e8m0_b32 (dsv41), M x 512The small sizes sit on the timer's ~1 µs step.
ROCm: @zihaomu checked the non-CUDA guard on a Radeon AI PRO R9700 (gfx1201), see the thread below.
Not tested: model-level accuracy.