[CUDA] Coalesce QMoE MXFP4/NVFP4 weight dequantization - #31349
Merged
Conversation
The scalar dequantize kernels map one thread to one output element with k varying fastest. Packed weights are [E, K, N/2] (n-packed) while the output is [E, N, K], so consecutive lanes read packed bytes packed_n apart: a separate 32-byte sector per lane for half a byte of payload, plus a 64-bit div/mod per element. Measured at ~5% of HBM peak on a 40-layer NVFP4 MoE prefill. Add a tiled kernel where a block covers 64 consecutive rows x 64 consecutive k, threadIdx.x selects the k group and threadIdx.y the row, and each thread emits 8 values as one 16-byte store. Eight lanes of the same row issue one 128-byte contiguous store, and 64 rows exactly consume each 32-byte packed sector. The index decomposition is pure grid arithmetic, so there is no integer division. The scalar kernels remain the fallback for shapes the tiled mapping cannot cover. Also make DecodeFp4E2M1 branch-free by assembling the float bits instead of indexing a local table (which compiles to a replayed constant-bank load), and add the missing cudaGetLastError check after the scalar launches.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR optimizes CUDA QMoE MXFP4/NVFP4 weight dequantization by adding a coalescing-friendly, tiled/vectorized kernel path while keeping the existing scalar kernels as a fallback.
Changes:
- Added
QMoEDequantizeFp4WeightsVecKerneland gating (QMoEDequantizeFp4VecApplies) to use a tiled output mapping for better memory coalescing. - Updated FP4 and NVFP4 dequantize launchers to use the vector kernel when applicable and added
cudaGetLastError()checks after the scalar launches. - Made
DecodeFp4E2M1branch-free and movedDecodeFloat8E4M3FNearlier for shared use.
Avoid the implementation-defined uint32->int conversion when the sign bit is set before the bit-cast.
tianleiwu
requested review from
baijumeswani,
hariharans29,
kunal-vaishnavi and
titaiwangms
August 1, 2026 21:50
kunal-vaishnavi
approved these changes
Aug 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Speeds up MXFP4/NVFP4 QMoE weight dequantization (
LaunchQMoEDequantizeFp4Weights/LaunchQMoEDequantizeNvfp4Weights) by fixing the memory access pattern.The existing scalar kernels map one thread to one output element with
kvarying fastest. Packed weights are stored[E, K, N/2](n-packed) while the output is[E, N, K], so consecutive lanes read packed bytespacked_napart — a separate 32-byte sector per lane for half a byte of payload, plus a 64-bit div/mod per element. Measured at ~5% of HBM peak on a 40-layer NVFP4 MoE prefill (3.8 ms per launch, 307 ms per forward).This PR adds
QMoEDequantizeFp4WeightsVecKernel, which tiles the output instead:k;threadIdx.xselects the k group,threadIdx.ythe row;kTileN = 64rows is exactly 32 packed bytes, so a block fully consumes every packed sector it touches;The mapping matters more than the vector width. An earlier revision that gave each thread 16 (then 32) consecutive
kof a single row reported ~95% ofMax Bandwidthat only ~26% DRAM throughput in Nsight Compute: bound by memory requests, not DRAM. Widening the per-lane store leaves requests-per-byte unchanged, and measurement confirmed no improvement from 16 -> 32 values per thread.One template covers both codecs:
kE4M3Scaleselects the NVFP4 scale codec (Float8E4M3FN, block 16) over the MXFP4 one (Float8E8M0, block 32).Also in this PR:
DecodeFp4E2M1is made branch-free by assembling the float bits directly. The previous runtime-indexed local table compiles to a constant-bank load that the hardware replays once per distinct address in a warp, and neighbouring weights rarely share a code.DecodeFloat8E4M3FNis moved earlier in the file (no behavior change) so the shared kernel can use it.cudaGetLastErrorcheck after the scalar dequantize launches.Correctness
The tiled kernel reproduces the scalar kernels' indexing exactly (weight nibble selection, block-scale index, output index); the scalar kernels remain the fallback for any shape the tiled mapping cannot cover (odd
n,knot a multiple of 64, or grid dimensions over 65535). Every shape produced by the QMoE quantizers takes the new path, so the existingtest_qmoe_fp4_cuda.pyandtest_qmoe_nvfp4_cuda.pyend-to-end tests cover it.Motivation and Context
Prefill of NVFP4/MXFP4 MoE models spends a significant fraction of time in weight dequantization; this removes it as a bottleneck.