Repository navigation
feat(rocm/quant): add native NVFP4 qmm and gather_qmm kernels - #2629
Conversation
ROCm had no NVFP4 kernel, so the capability table converted ModelOpt NVFP4 checkpoints to affine 4-bit and refused MLX-native NVFP4 planes (#1806). A new overlay file, quantized/nvfp4_qmv.hip, adds nvfp4_qmv_kernel (eight rows per block, reloading a weight group only when the expert changes, per-expert global scale applied as global_scale / (448 * 6)) and an exact NVFP4 dequantize kernel. QuantizedMatmul runs the qmv kernel below the dequantize + GEMM crossover and admits NVFP4 to that route above it; GatherQMM runs the qmv kernel for every NVFP4 call. qdequant.hpp gains a branch-free E4M3 decode beside the moved fp4 decode. The kernels use 16-lane column groups and never read WARP_SIZE, so they are correct on wave64 too. quant_mode_support(Rocm, Nvfp4) is now Native; the hardware table test, the sanitize route tests, the layers route-line tests and the ROCm docs follow. tests/rocm_nvfp4_quant.rs compares every route with MLX's CPU dequantize. LOCAL_FIXES item 63. Refs #2617
validate_native_global_scale refused a per-expert NVFP4 global_scale plane on any backend but Metal and CUDA, so with NVFP4 native on ROCm the formerly gated switch_layers::global_scale_tests::native_global_scale_matches_dense_dequantized_reference and gemma_unified's unified_nvfp4_fused_experts_keep_their_native_global_scale still failed at load. ROCm's GatherQMM now applies the scale, so the gate admits ROCm; supported-models.md says so. Both tests pass on gfx1151. Refs #2617
QuantizedMatmul checked x's K against the NVFP4 group size for every layout, but a non-transposed weight is quantized along N, so a valid x [1, 17] against a [17, 32] weight was refused before it could reach the dequantize + GEMM route. The check now takes K for a transposed weight and N otherwise. nvfp4_qmv capped grid x at INT32_MAX, while HIP limits blocks times threads per grid dimension to UINT32_MAX, so the cap is now UINT32_MAX / 16 and an oversized launch gets the clear error instead of a HIP configuration failure. Tests: an output width that leaves a partial 16-column block, an untransposed K of 17, a gather with expert indices at and past the expert count (rows must be exactly zero, sorted and unsorted, with the global scale), and a check that each dequantized reference reproduces the original weights within NVFP4 rounding, which ties the global_scale / (448 * 6) convention to the source weights rather than to the kernel's formula. Validated on gfx1151: rocm_nvfp4_quant (6 tests), rocm_mxfp4_quant, rocm_hip_grid_clamp gather_qmv, and the NVFP4 and global-scale lib tests of mlxcel and mlxcel-core pass; clippy and the overlay contract checks pass. Refs #2617
Implementation Review SummaryIntent
Findings Addressed
Remaining Items
Verification
The global-scale convention matches MLX: |
MLX's validate_quantized_input compares only the weight's batch prefix and last axis with the scales', so a malformed checkpoint can hand the backend a scale plane with a short row axis or an extra leading axis. The NVFP4 kernels index scales as [..., N, K / 16], so the qmv and gather kernels read past the scale buffer, and nvfp4_dequantize_dense sized its grid from scales.size(), so an oversized plane made the dequantize kernel write past the dequantized weight (the affine dequantize sizes from its output and has no such write). check_nvfp4_planes now requires scales.shape == w.shape[:-1] + [w.shape[-1] / 2] with one-byte elements in both the QuantizedMatmul and GatherQMM NVFP4 arms, and nvfp4_dequantize_dense derives its group count from the weight and checks the scales and output against it. A new rocm_nvfp4_quant test feeds a short-row and an extra-axis plane through both routes and expects the refusal. Recorded in LOCAL_FIXES item 63. Validated on gfx1151: rocm_nvfp4_quant 7/7, rocm_mxfp4_quant 5/5, the five contract verify targets, clippy on the test target. Refs #2617
Security and performance reviewFixed in f2acc66:
Not fixed (below the auto-fix threshold):
Checked without findings: global-scale length vs expert count, rhs bounds (zeroed rows), partial row and column tiles, 64-bit index math, grid limits, and the dequantize + GEMM plane (one weight matrix, same bound and LRU cap as affine). Affine, mxfp4 and fp8 routing in Verification on gfx1151: rocm_nvfp4_quant 7/7, rocm_mxfp4_quant 5/5, the five contract verify targets, clippy on the test target. |
Summary
ROCm had no NVFP4 kernel, so ModelOpt NVFP4 checkpoints were converted to affine 4-bit at load and MLX-native NVFP4 planes were refused (#1806). This adds native NVFP4 (E2M1 values, E4M3 scales per 16, optional float32 global scale per expert) to
QuantizedMatmulandGatherQMMand marks(Rocm, Nvfp4)Native.quantized/nvfp4_qmv.hip(included byqmm.hip):nvfp4_qmv_kernelcomputes eight rows per block (one for M = 1 and unsorted gather decode) and reloads a weight group only when the expert changes, so dense multi-row calls (speculative-decode verify widths) and sorted gather prefill read each group once per eight rows. It servesQuantizedMatmulbelow the dequantize + GEMM crossover and every NVFP4GatherQMMcall, applyingglobal_scale[expert] / (448 * 6)once per output.nvfp4_dequantize_kernelexpands a plane exactly;select_qmm_routeadmits NVFP4 to the dequantize + hipBLASLt/rocBLAS route, which covers prefill-size and non-transposed GEMMs.qdequant.hpp: branch-free E4M3 decode beside the moved fp4 decode (both exact, power-of-two offsets folded into the final multiply).WARP_SIZE, so they are lane-free and need no wavefront gate (not testable on this host).QuantizedMatmulnow refuses an unexpected fourth input in a non-affine mode instead of ignoring it.validate_native_global_scale(switch_layers) admitted only Metal and CUDA; it now admits ROCm. Sanitize route tests, layers route-line tests and docs (installation ROCm table, env vars, supported models) follow the table change.LOCAL_FIXES.mditem 63 (rebased onto fix(rocm): split per-row softmax and norm grids past the HIP limit #2627, which took 62).Numerical results (gfx1151, vs f32
x @ dequantize(w).Ton CPU)Max abs error / error relative to max |reference|, worst case per path:
f16/bf16 errors are output rounding; f32 matches to accumulation order.
Test plan
tests/rocm_nvfp4_quant.rsandtests/rocm_nvfp4_quant/gather.rs(new): 7 tests pass on gfx1151 under the GPU guard after the post-rebase overlay rebuild (worst relative error f32 2.1e-6, f16 4.9e-4, bf16 5.4e-3)switch_layers::global_scale_testscases,gemma_unified::...unified_nvfp4_fused_experts_keep_their_native_global_scale,mistral4::modelopt_stack_tests::...,phimoe::sanitize_tests::...; also the ROCm-skippedsanitize_testsNVFP4 loads andffi_testsnative-NVFP4 global-scale tests (post-rebase rerun: mlxcel lib filters 56 passed, mlxcel-core 11 passed)rocm_mxfp4_quant,rocm_hip_grid_clamp gather_qmv,rocm_gather_qmm_expert_batchedmake verify-llama-compat verify-env-var-docs verify-kernel-port-dispatch verify-kernel-dtype-keys verify-rocm-overlaycargo clippy --profile test-fast --features rocm -p mlxcel -p mlxcel-core --lib --tests -- -D warningsChanges during review
transpose_ ? K : N), so a non-transposed weight with K = 17 reaches the dequantize + GEMM route instead of being refused.nvfp4_qmvcaps grid x at UINT32_MAX / 16 (blockDim.x is 16) with a clear error.check_nvfp4_planesrequiresscales.shape == w.shape[:-1] + [w.shape[-1] / 2]with one-byte scales in both theQuantizedMatmulandGatherQMMarms, and the dequantize sizes its grid from the weight. Before this, a malformed scale plane (which MLX'svalidate_quantized_inputlets through) could make the dequantize kernel write past its output and the kernels read past the scales.assert_reconstructs, which ties the global-scale reference to the source weights rather than to the kernel's constant.global_scale / (448 * 6)was checked against MLX'sfp_quantize/fp_dequantizeinmlx/ops.cppand the CUDAfp_gather_qmv.Follow-ups: an expert-batched or grouped dequantize + GEMM path for NVFP4 gather prefill (it currently reads each expert's weights once per eight sorted rows); wave64 not measured;
nvfp4_qmv_kerneldoes not boundlhs_indicesagainst the x batch (same as most existing gather kernels; mlxcel generates them); sorted NVFP4 gather prefill may be slower than the former affine conversion with its expert-batched routes (not measured, no checkpoint);GatherQMM::eval_gpudivides by M*N before an empty-output check (pre-existing).Closes #2617