Skip to content

perf(rocm): batch sorted mxfp4 gather_qmm prefill by expert - #2164

Merged
inureyes merged 3 commits into
mainfrom
feature/issue-2106-mxfp4-gather-qmm
Oct 6, 2026
Merged

inureyes merged 3 commits into
mainfrom
feature/issue-2106-mxfp4-gather-qmm

Conversation

@inureyes

@inureyes inureyes commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

gpt-oss-20b spent 99.8% of its 512-token prefill on gfx1151 in the per-row non-affine gather_qmv_kernel (about 940 ms per call), because GatherQMM::eval_gpu sent only affine weights to the expert-batched kernel. This gives that kernel an mxfp4 arm.

  • gather_qmv_expert_batched_kernel (patches-rocm/.../quantized/qmm.hip) takes AFFINE=false for mxfp4 (group size 32, E8M0 scales, no biases). Each e2m1 nibble is decoded without a branch through fp16 bits (value times 2^-14), and each word's dot product is rescaled by 2^14 with the group's scale.
  • bf16 mxfp4 reaches it under the existing gate (sorted, transposed, M == 1, B >= 64, E <= 64, B / E >= 4) and the same MLX_ROCM_GATHER_QMV_EXPERT_BATCHED switch, on by default per the perf(rocm): fix expert-batched gather_qmm for bf16 MoE prefill #2066 rule. One new instantiation; f16 mxfp4 and mxfp8 keep the per-row kernel. qmm.hip compile time is unchanged (37.9/37.8 s against 38.1/37.9 s).
  • LOCAL_FIXES.md item 31 records it (not marked for upstreaming, per the 2026-10-06 fork policy); item 10 points to it.
  • Results: docs/benchmark_results/rocm-moe-prefill-mxfp4-gfx1151-2026-10-06.md.

Verification (gfx1151)

  • cargo test --release --features rocm --test rocm_gather_qmm_expert_batched -- --test-threads=1: 2 passed (48 affine cases unchanged, 8 new mxfp4 cases at gpt-oss-20b's expert shape and a partial-column shape, flat and broadcast layouts). The mxfp4 relative errors match the unsorted path to four digits (1.6e-3 to 1.7e-3). With the nibble sign bit dropped the first mxfp4 case fails at 1.377. At K = 2880 the test also requires the sorted call to differ from the same call with the kernel switched off, so it fails if the gate stops reaching the kernel.
  • gpt-oss-20b-MXFP4-Q4, mlxcel-bench-decode --prompt-tokens 512 -n 128 --warmup-tokens 20 --ignore-eos, kernel off/on alternated, six rounds, all under scripts/rocm_gpu_guard.sh: prefill median 7.53 to 526.3 tok/s (69.9x); decode median 8.45 and 8.49 tok/s, ranges overlapping, unchanged (a 16-token control where neither arm reaches the kernel shows about 1% host spread).
  • logit_trace w8 and w256, kernel on against kernel off (no gpt-oss traces exist): 0 / 88 and 0 / 81 decided-position mismatches at --decided 2.0.
  • make verify-versions verify-kernel-dtype-keys verify-kernel-port-dispatch verify-llama-compat verify-fmt verify-rocm-overlay and cargo test --test dead_doc_pointers pass. make verify-rocm (with MLXCEL_ROCM_SMOKE_MODEL=models/mlx/Qwen3-0.6B-4bit) on 826f9d6 plus the review commit's test change: every step through clippy and the smoke passed, and verify-test-rocm gave 11962 passed, 1 failed. The failure was a first version of the dispatch check above, which also ran at K = 512 where both kernels give identical bf16 bytes; it now runs at K = 2880 only, and the file passes (2/2) with clippy clean on the final commit.

Not verified: Metal and CUDA (not available on this host). The change is in the ROCm overlay only; no Metal or CUDA file is touched.

Review: pr-reviewer and pr-security-checker found no CRITICAL, HIGH or MEDIUM issues; the LOW items (dispatch check, SAFETY comments, LOCAL_FIXES wording) are in e87ffa7. Left as is: a sorted index value of E or more leaves its output rows unwritten in this kernel, as it already did for affine; routing never produces one.

Follow-up: decode is now dominated by the same per-row mxfp4 kernel on the unsorted B = 4 path (about 100 ms of a 115 ms step), outside this issue's scope.

Closes #2106

gpt-oss-20b spent 99.8% of a 512-token prefill on gfx1151 in the per-row non-affine gather_qmv_kernel (about 940 ms per call), because GatherQMM::eval_gpu sent only affine weights to the expert-batched kernel, which reads each expert's weights once per four routed rows.

The expert-batched kernel gains an AFFINE=false arm for mxfp4 (group size 32, E8M0 scales, no biases): each e2m1 nibble is decoded without a branch through fp16 bits and each word's dot product is rescaled by 2^14 with the group's scale. bf16 mxfp4 reaches it under the existing gate and MLX_ROCM_GATHER_QMV_EXPERT_BATCHED switch; that is one new instantiation, and qmm.hip compile time is unchanged. LOCAL_FIXES item 31 records it.

tests/rocm_gather_qmm_expert_batched.rs adds mxfp4 cases at gpt-oss-20b's expert shape and a partial-column shape, flat and broadcast layouts, against the unsorted path and a dequantized f32 reference; they fail with the nibble sign dropped. Measured on gfx1151 (guarded, alternated, six rounds): prefill 7.53 to 526.3 tok/s median, decode 8.45 and 8.49 tok/s (unchanged), and no decided-position logit_trace disagreement against the per-row path. Results: docs/benchmark_results/rocm-moe-prefill-mxfp4-gfx1151-2026-10-06.md.

Closes #2106
@inureyes inureyes added status:review Under review type:performance Performance improvements priority:medium Medium priority area:core mlxcel-core: MLX FFI, primitives, KV cache, layers platform:linux Linux (CUDA / packaging) specific labels Oct 6, 2026
…rnel

Review follow-up for #2164. The mxfp4 cases passed even if the gate stopped sending mxfp4 to the expert-batched kernel, since the per-row fallback is also correct. At gpt-oss-20b's K = 2880 the test now also runs the sorted call with the kernel switched off and requires the bytes to differ; at K = 512 both kernels round to identical bf16 outputs, so the narrow shape skips the check. The two unsafe FFI calls the test rewrote get SAFETY comments, and LOCAL_FIXES items 9 and 31 note the mxfp4 scope and the fork policy.

Refs #2106
@inureyes inureyes added status:done Completed and removed status:review Under review labels Oct 6, 2026
@inureyes
inureyes merged commit 08e2269 into main Oct 6, 2026
11 checks passed
@inureyes
inureyes deleted the feature/issue-2106-mxfp4-gather-qmm branch October 6, 2026 16:42
inureyes added a commit that referenced this pull request Oct 6, 2026
## Summary

The shuffle-based HIP ports have only run on 32-lane wavefronts, and their `#error` guards on `__AMDGCN_WAVEFRONT_SIZE` are inert because AMD clang 23 defines neither macro spelling (rechecked here with `hipcc -E -dM` for gfx1151, gfx942 and gfx90a). This PR holds those ports to 32-lane devices at port selection, so a wave64 (CDNA) device takes the graph fallbacks instead of running unvalidated kernels. Nothing changes on gfx1151.

## What changed

- `port_for` (`kernel_port.cpp`) returns a ROCm port only when `rocm_port_allowed(ports.rocm_any_wave_size, rocm_port_warp_size())`, so `has_kernel_port`, every `*_available()` predicate and `select_kernel_port` agree. The refusal message names the width; stderr notes a non-32 wavefront once per process.
- The width is the hardware value from a new overlay call, `mlx::core::rocm::device_warp_size()` (`hipDeviceAttributeWarpSize`, ignores `MLX_ROCM_FORCE_WARP_SIZE`, 0 on error), recorded as LOCAL_FIXES item 32 (new "Additions to the fork's API" section; #2164 took 31) and stubbed in `no_rocm.cpp`. It clears only an HIP error it raised itself.
- `KernelPorts::rocm_any_wave_size` (default false) is set on the five tables whose HIP body has no lane-level operation: RoPE append, paged merge, Gumbel, rejection, and xIELU. xIELU is not in the issue's list, but checking the current code shows its body is purely elementwise; its inert guard is dropped, as the sampler and RoPE bodies already have none. The other ten ROCm tables (bitlinear, ssm, mamba1_scan, moe_gateup, moe_down, moe_fc1_relu2, add3_layer_norm, fused_norm, paged_attention, paged_v2_partial) stay wave32-only. #2107's fused norm is wave32-only (shuffles) and its RoPE port is any-wave.
- `bitlinear_kernel_available` now reads its port table (it returned "any GPU backend" before), so BitNet is refused at load on wave64 rather than failing mid-request.
- `verify-kernel-port-dispatch`: rule 5 pins the any-wave tables with their HIP source, follows each table's `.rocm` getter to its holder and requires exactly that source (no header argument, one raw literal), and fails on lane intrinsics, AMDGCN lane builtins, wavefront macros or 32-lane index arithmetic in it; rule 6 keeps the test seam `set_rocm_port_warp_size_for_tests` out of non-test code, with an exact use count in its definition files. `scripts/ci/check_kernel_port_dispatch_test.sh` (run by the make target and CI job) proves each rule fails on a mutated copy (13 cases).
- `mamba1_selective_scan` is now `-> Result<()>`, since `select_kernel_port` can refuse there; Mamba, Jamba and its parity test `expect` the gated launch. BitLinear's refusal names the BitNet load-time refusal instead of a graph fallback it lacks.
- Parity tests that assert a ROCm port exists (fused MoE, relu2, SSM, fused norm, Mamba1, add3) skip on a wave64 device through `test_support::kernel_ports::skip_for_wave32_only_rocm_port` instead of failing.
- `build.rs` now tracks `kernel_port.*` and `gpu_backend.*`; edits to them did not rebuild the bridge before (found when a negative check passed on a stale library).
- Guard comments now name the host-side check as the protection; `docs/installation.md` gains a "Wave64 devices" row; CONTRIBUTING and the paged-test skip message mention the hold.

## Verification (gfx1151)

- `cargo test --features rocm -p mlxcel-core --lib kernel_port`: 2 passed (`rocm_port_allowed` truth table, runs on every backend).
- `tests/rocm_wave_size.rs`: `device_warp_size() == 32` on gfx1151; with `MLX_ROCM_FORCE_WARP_SIZE=64` in a child (the child's device log confirms the override applied) it still reads 32; all 16 port predicates are true.
- `tests/rocm_wave64_port_refusal.rs`, with the reported width forced to 64 through the test seam: all 11 wave32-only predicates false, the five any-wave ones true; `bitlinear_matmul`, `fused_add_rms_norm` and `fused_moe_expert_kernel` (moe_gateup) refuse naming the width; `layers::fused_add_rms_norm` (fusion on) and `residual_add3_layer_norm` return the graph pair's bytes. With the hold disabled in `port_for`, this test fails listing all 11 tables.
- Existing port tests (debug profile): fused norm, RoPE, MoE, relu2, SSM, Mamba1, Gumbel, rejection, fixed-key, paged v2, MLA, residual add3: 357 passed; 2 `cache::paged_detach` tests fail on a `debug_assert` in the dev profile only (the gate's `test-fast` profile has no debug assertions; unrelated to ports). Apertus, BitNet and Cohere2 tests in the mlxcel lib: 26 passed. `cpu_device_custom_kernel_gates`: passed.
- `make verify-versions verify-kernel-dtype-keys verify-kernel-port-dispatch verify-llama-compat verify-fmt verify-rocm-overlay`: pass.
- `make verify-rocm` on the final head (merged with origin/main 08e2269): OK, 151 test suites, 11969 passed, 0 failed, 381 ignored; clippy, smoke, overlay and script gates pass. The targeted tests above were re-run after the review fixes (29 mlxcel-core, 30 Mamba/Jamba, the three integration files): all pass.
- Meta-Llama-3.1-8B-Instruct-4bit, release binary under `scripts/rocm_gpu_guard.sh` (clean attempt): greedy 64 tokens with `MLXCEL_FUSED_ADD_RMSNORM=0` and `=1` give identical text (33.88 and 33.90 tok/s, single run, not a performance claim); `rocprofv3 --kernel-trace` of a sampled run shows `custom_kernel_mlxcel_fused_add_rms_norm` (wave32-only, 544 launches) and `custom_kernel_mlxcel_gumbel_max_sample` (any-wave) still selected, and no wavefront notice on stderr.

Not verified: no wave64 device is available, so the refusal is proven only through the test seam. Metal and CUDA are not available on this host; the change touches the shared `KernelPorts` struct and `port_for`, whose Metal and CUDA cases are unchanged, and `bitlinear_kernel_available`, which answers the same on Metal and CUDA (both tables have ports).

Closes #2147
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core mlxcel-core: MLX FFI, primitives, KV cache, layers platform:linux Linux (CUDA / packaging) specific priority:medium Medium priority status:done Completed type:performance Performance improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(rocm): add a batched gather_qmm path for mxfp4 MoE prefill

1 participant