Repository navigation
perf(moe): dequant nvfp4 prefill experts by bit placement and load activations contiguously - #578
YevheniiKotyrlo wants to merge 4 commits into
Conversation
…xperts by bit placement and load activations contiguously
Logs FlashML-org#578, FlashML-org#577, FlashML-org#575, FlashML-org#574 and FlashML-org#572, all adopted by merge. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
e428b06 to
5ab873e
Compare
|
Validated this PR on an RTX 4090 (PCIe 4.0 x16) with Qwen3.8-Flash-Next NVFP4, cherry-picked onto a d3512b4-based fork. Cold 32K prompt (32,012 tokens), prefilled in a single pass over the experts (we run a local layer-major prefill patch, so each layer's experts are streamed once):
The 128-token output on the same prompt was bit-identical to the unpatched build. With the default 4096-token chunked prefill (8 passes for 32K), TTFT barely moved (about 21–25 s either way). Each chunk re-streams every expert (#579), so that path stays transfer-bound; the kernel speedup shows once prefill is down to one pass. Thanks for the work! |
…tivations contiguously The prefill grouped GEMM read each e2m1 code through a table gather and loaded the activations at stride two for the even and odd k of a byte. On an RTX 3090 Ti at Qwen3.8-Flash-Next's shapes (8192 tokens, 512 experts, top-10) that held it at 8.6 TFLOPS; placing the codes in fp16 bits and splitting one contiguous activation tile in registers reaches ~50, with bit-identical output.
…ode with the prefill GEMM
…th compiles at a ragged K
|
Thanks for the 4090 run. The chunked result fits what I measured: on my 3090 Ti the old kernel took about 6 s for a 7.8k-token chunk, longer than the 2.6 s expert stream, so time to first token fell about 2x; on your 4090 it already finished under the stream, so only the single pass shows the gain. |
5ab873e to
c0714c0
Compare
Problem
The NVFP4 MoE prefill GEMM decodes every weight through the 16-entry table, decodes its e4m3 scale in fp32, and reads the activations twice at stride two, once for each nibble of a byte (
_prefill_nvfp4_moe_kernelinkernel/triton/nvfp4_fused_moe.py):At Qwen3.8-Flash-Next's prefill shapes (8,192 tokens, 512 experts, top-10) its gate_up GEMM runs at 8.6 TFLOPS on an RTX 3090 Ti, and it is most of a long prompt's compute: 8.66 s of the 11.98 s of compute in a 15,972-token prompt, traced with Nsight Systems. Every NVFP4 MoE prefill through the Triton grouped GEMM runs this kernel, offloaded or resident.
nvfp4_linear.pyalready decodes without the table:_nvfp4_pair_f16places each code's bits in an fp16 field and the e4m3 scale is pre-multiplied by 128, and its docstring measures the table gather serializing the LSU. The MoE prefill kernel never took that path.Solution
_nvfp4_pair_f16, once the byte's high nibble also sits at bits [19:16], and the scale withe4m3_to_f16_x128, a newe4m3_compat.pyhelper for theif e4m3_native_cx()branchnvfp4_linear.pyinlined twice; the epilogue multiplies the global scale by 128. Every e2m1 x e4m3 product is exact in fp16 and in bf16, so each dot operand is the table's times exactly 2^-7, the epilogue's 128 restores it, and the output is bit-identical.tl.split(tl.reshape(a, (BLOCK_SIZE_M, BLOCK_SIZE_KB, 2)))), instead of two strided loads.EVEN_Kdrops the byte and k masks when the byte tile divides K.nvfp4_linear.py's two scale decodes call the helper;_nvfp4_gemm_kernelcompiles to the same SASS as before in all 16 configurations I compiled (sm_86 and sm_89, BLOCK_M 16 and 64, split-K 1 and 2, even and ragged K).Tests
tests/moe/test_nvfp4_backends.py: every byte against every scale code through the kernel's own decode (each code's value times 2^-14, signs included, every finite product exact in bf16), and the prefill GEMM against the dequant reference past the small-batch tile, at a K the byte tile divides and at one it does not (272 and 80).tests/kernels/test_e4m3_compat.py: the cross-arch compile gate and the native-vs-emulated comparison also drive the dense GEMM and the MoE prefill at a ragged K. The gate's shapes had every K a multiple of the tiles, so it never compiled a ragged branch for sm_89. With an integerotherin the fp8 scale load, as I first wrote it, the new cases fail for sm_89 and sm_120 withcannot cast int32[constexpr[32], constexpr[64]] to fp8e4nvand pass for sm_80 and sm_86.A/B
RTX 3090 Ti 24 GB (PCIe 4.0 x16), Ryzen 9 9950X3D, driver 617.14, Windows 11, every arm over my three Windows fixes starting at #575.
fused_experts_nvfp4called at Flash-Next's prefill shapes: 8,192 bf16 tokens, 512 experts of random NVFP4 weights (hidden 2,560, intermediate 640), random top-10 routing, one warm-up call and then the median of 7 calls timed with CUDA events, arms in the ordermain, branch, branch,main:main3f995662625c27283f995662625c2728End to end,
maincannot hold Flash-Next's 1M window on a 24 GB card (#568), so the A/B runs on that setup with and without this change, arms before, after, after, before. Each prompt is a code review over TypeScript source cut to length, streamed withmax_tokens8 and timed to its first token; the 43,105-token one then gets a greedy 2,048-token answer:On this card the old kernel took longer than a chunk's 2.6 s expert stream (6.0-6.1 s for the 7,837-token prompt, one chunk), so time to first token falls 1.8-2.2x on prompts of thousands of tokens. A prompt of about a thousand tokens or fewer is bound by that stream and does not move, and neither does decode. oscarlius's RTX 4090 run in this thread measures the MoE compute 4.2x faster with bit-identical output; there the old kernel already finished under the stream of a 4,096-token chunk, so only a single-pass prefill shows it in time to first token. These rows are from the first version of this change; the branch then took
nvfp4_linear.py's helpers, 24 fewer SASS instructions on sm_86, and the kernel A/B above is the branch's.The end-to-end server command
--hf-overridesis #573 and--vram-reserve-mb#574;--kv-placement,--token-embedding-placement,--mamba-host-slots,--moe-cache-policy lrfu,--moe-collect-stats,--pin-reasoning-effortand--bind-when-readycome from my local build.Verification
tests/moe/test_nvfp4_backends.pyandtests/kernels/test_e4m3_compat.py: 12 passed, 11 skipped (marlin needs vllm, b12x needs sm_120, ROCm, native-fp8 references) on Linux overmain(WSL2 Ubuntu 24.04, Python 3.12, torch 2.14.0, triton 3.8.0) and on Windows 11 over the Windows fixes (triton-windows 3.8.0), wheremainfails the newtest_triton_prefill_dequant_reads_every_code_and_scale_exactlyon both platforms; the compile gate passes for sm_80, 86, 89 and 120.tests/moe,tests/kernelsandtests/modelson Linux: 767 passed with this branch and 764 onmain, with the same 3 failures on both:test_adjust_config_converts_moe_cache_rate_to_cache_sizeneeds flashinfer, which that environment lacks, andtest_dsv41_pack.py's twofp4_e4m3_b16roundtrips come back 0.015625 off the reference quantizer on this sm_86 card.No issue or PR covers this kernel's speed: searched the tracker for
nvfp4 prefill,e2m1,LUT gather,fused_nvfp4,prefill_nvfp4andprefill throughput.Known limits
Untested on ROCm. #260 routes HIP to the serial kernel because this grouped kernel trips a memory-aperture violation on gfx1151; this change alters the kernel's loads, and I have no AMD card to check it on.