perf(rocm): profile gfx1151 decode per kernel and rank the #1814 ports - #2086
Conversation
Implementation Review SummaryIntent
Findings Addressed
Checked, no change neededEvery figure in the doc's tables matches Verification
|
Security and performance reviewNo CRITICAL or HIGH findings, so nothing was changed on the branch. The scripts are local operator tooling: every input (model paths, flags, Checked and clean: commands are passed as argv arrays ( MEDIUM
LOW
|
The three grouped_gemm_numeric_tests gated on Metal or CUDA, so on ROCm they returned before touching the GPU and MLX's ROCm GatherMM was never checked. Run by exact name on gfx1151 with the gate widened, all three pass against the f64 host reference, and a rocprofv3 kernel trace shows the overlay's gather_batched_gemm_kernel (f32, bf16, f16) and a hipBLASLt GEMM for the sorted single-row case, so the pass is not vacuous. With the reference pointed at the wrong expert, all three fail at the value assertion. The gates now read gpu_backend_available(), the file leaves BACKEND_ENUMERATION_TODO in check_kernel_port_dispatch.py, and the checker reports 0 awaiting a predicate. Metal and CUDA still run the tests (not run here). Refs #2061
ROCm had end-to-end tok/s only, so the #1814 kernel ports had no measured order. This adds what a per-kernel decode profile needs, as reusable tooling rather than a one-off: - scripts/rocm_gpu_guard.sh: the idle-GPU guard the #2056 baseline described, as a script (90 s with /sys/class/kfd/kfd/proc empty and no compiler, a 1 Hz monitor that ignores the command's own GPU processes, rerun on contention, every sample logged). - mlxcel-bench-decode: --temperature and --top-p (default greedy, unchanged), and MLXCEL_BENCH_PHASE_MARKS=1, which prints the warmup, measured, decode-start and end times on CLOCK_MONOTONIC and CLOCK_BOOTTIME so a trace can be cut to the measured decode by timestamp. - scripts/rocm_decode_profile.sh runs a plain and a rocprofv3 --kernel-trace --hip-graph-trace --stats run per model under the guard; scripts/rocm_decode_profile.py cuts the decode window, reports GPU time and host gap per token, profiler cost, top kernels, and attributes dispatches to mlxcel ops and #1814 port units by dispatch order, with which ports mlxcel actually reaches per model. - tests/test_rocm_decode_profile.py covers the guard against a fake KFD directory and the cut and attribution rules on synthetic steps; docs/benchmarks.md documents the harness. Refs #2061
Profiles greedy and sampled decode (pp512/tg128) of Llama-3.1-8B, Qwen3-30B-A3B, granite-4.0-h-tiny and Nemotron-3-Nano-30B-A3B on gfx1151 with rocprofv3, every run under the idle-GPU guard (16 accepted runs; one rejected by the guard, one discarded and rerun by hand), and attributes decode GPU time to the port units split from #1814. Shipped-settings share of decode GPU time: #2067 SSM update 29.8% (granite) and 20.0% (Nemotron) plus more than half their dispatches; #2065 fused MoE 46.8% of Qwen3, mostly GEMVs already at about 181 GB/s; #2064 samplers 0 greedy, 0.4 to 3.8% sampled; #2063 zero (both fusions ship off) and #2068 zero (no paged path in single-stream decode). Implied order: 7, 5, 4, 8, 3. The rocprofv3 stats CSVs, per-kernel decode tables, summaries, bench logs and the guard log are under benchmarks/rocm_profiles/gfx1151_929c80ab/. The report also records the gather_mm test outcome. Refs #2061
The decode window holds 127 forward passes (the first of 128 tokens comes from the prefill), while per-token figures divide by 128 to match the bench's tok/s. The attribution check quoted 46.6 and 49.6 SSD dispatches per Mamba2 layer and claimed whole multiples "per token"; per step they are exactly 47 and 50, which is the check that actually holds. The Method section now states the 127/128 relation. Also: the #2063 opt-in ceiling is 0.83 to 0.89% (the top-p run reads 0.89), the #2064 reached share counts the whole sampler tail including the --ignore-eos logit bias, so it is slightly high rather than net of it, and a guard-rejected attempt stays in the run's _bench.log (granite greedy) while only the last, accepted attempt is read. Numbers recomputed from the committed summaries and decode CSVs; no GPU run. Refs #2061
rocm_decode_profile.sh now scrubs local paths from the published guard.log from an EXIT trap, so an early exit (guard 75, rocprofv3 or cp failure) no longer leaves them in, and the replacement is literal so '#' and regex characters in the paths are harmless. rocm_decode_profile.py makes Dispatch slotted to bound the memory of a full kernel trace; the report output for benchmarks/rocm_profiles/gfx1151_929c80ab is byte-identical. rocm_gpu_guard.sh reads the parent pid after the last ')' of /proc/<pid>/stat, avoids glob expansion when splitting holders, rejects non-integer --idle-secs, --max-attempts and --max-wait (and a missing option value) with exit 2, and stops the command and monitor on INT or TERM (exit 130 or 143). Tests cover the validation, octal-looking values, the SIGTERM path and slots. Validation: python3 -m unittest tests/test_rocm_decode_profile.py (17 pass), bash -n on both scripts, make verify-fmt verify-kernel-port-dispatch. No GPU workload was run. Refs #2061
c9d6811 to
0d5d6db
Compare
Closes #2065 The fused two-kernel decode MoE path had Metal and CUDA ports only, and both Rust gates in front of it read the backend-wide `custom_kernels_available()`, so ROCm decode ran `gather_qmm` for every routed expert. ## Changes - `MOE_GATEUP_HIP_SOURCE` and `MOE_DOWN_HIP_SOURCE` in `mlx_cxx_kernels.cpp`, ported from the CUDA sources through `fast::hip_kernel`: same inputs, grid and template args, `__shfl_down(v, o, 32)`. The down kernel keeps its 6-bit branch, f32 partials and single final rounding (#886), and serves all three callers. Both tables' `.rocm` slots are filled. - Predicates `fused_moe_kernels_available()` and `moe_down_kernel_available()` read the tables. `switch_layers.rs` (every SwitchGLU family, qwen3_next and gemma4 included) and `nemotron_h.rs` gate on them; neither calls `custom_kernels_available()` any more. Nemotron-H on ROCm now runs `fused_moe_forward` (still `gather_qmm` experts) instead of `forward_nonfused`. - `MLXCEL_FUSED_MOE_RELU2` also requires the fc1_relu2 and down ports, so on ROCm and CUDA it declines to `gather_qmm` instead of refusing. On CUDA that replaces a panic at the `.expect` in `nemotron_h.rs`; Metal is unchanged. - ROCm builds default `MLXCEL_FUSED_MOE_SGY` to 2 (Metal and CUDA keep 8): at 8 the port was slower than `gather_qmm` on gfx1151 (59.42 against 61.64 tok/s). SGY only shapes the threadgroup; the output does not depend on it. - Test gates: `gpu_backend_or_skip()` fails rather than skips on a GPU backend whose predicate is false; the qwen3_moe and qwen3_vl_moe positive controls read the predicate. New SwiGLU case at 4/4, 8/8 and 4/6 bits. Tolerances unchanged. - `scripts/rocm_gpu_guard.sh`: a KFD proc entry outlives its reaped process for a moment, so the guard counted the command's own exited children as foreign and rejected most attempts (here and in the parallel #2067 unit). It now skips holders with no `/proc` entry; new test in `tests/test_rocm_decode_profile.py` fails on the old guard. - The wave32 `#error` guard is inert with AMD clang 23, which defines neither `__AMDGCN_WAVEFRONT_SIZE` spelling (checked with `hipcc -E -dM` for gfx942 and gfx1151). Comments and docs now say the explicit width 32 is what keeps each fold inside one row on wave64; wave64 is untested. - Technical report (en, ko) under `TECHNICAL_REPORTS/2098-*`. Docs: results page `docs/benchmark_results/rocm-fused-moe-gfx1151-2026-10-05.md`, raw rows under `benchmarks/`, traces under `benchmarks/logit_traces/rocm_gfx1151_77afbb1d/`; stale "ROCm aborts" text removed from `README.md`, `docs/installation.md`, `docs/environment-variables.md`. ## Measured (gfx1151) Decode, `bench_decode.sh` pp512/tg128, before (`main` 57d8ed2) and after alternated run by run, every run through the guard: | Model | Before tok/s (median) | After tok/s (median) | Change | |---|---|---|---| | Qwen3-30B-A3B-4bit | 61.29 / 61.05 / 61.83 (61.29) | 62.51 / 62.68 / 62.16 (62.51) | +2.0% | | Nemotron-3-Nano-30B-A3B-4bit | 74.30 / 74.62 / 74.37 (74.37) | 75.11 / 75.09 / 74.88 (75.09) | +1.0%, near noise | | Mixtral-8x7B-4bit (same path, Dff above the cap) | 9.42 / 9.08 / 10.91 (9.42) | 9.51 / 9.57 (two runs) | noise | The Qwen3 gain is consistent (every after run above every before run) and sits inside the #2086 estimate: the expert GEMVs were already near bandwidth, so the pair recovers dispatches and the activation and combine work only. Logits (`compare_logit_traces.py --decided 2.0`): 0 decided-position mismatches in every row. Qwen3 `w1` (the only window that reaches the single-token kernels) against Metal's fused kernel: 1/128 top-1 disagreements, against 5/128 for ROCm `gather_qmm`. Nemotron-H `w8` and `w1ctx512` against ROCm main, `c5fe9a16` and Metal M5: 0 decided mismatches. Mixtral `w1` and Qwen3 `fused0` `w1` are byte-identical to `rocm_gfx1151_bec64748`; Qwen3 `fused0` `w8` is byte-identical to `main`. ## Verification (gfx1151) - `make verify-rocm` on the rebased head: 146 suites, 11857 passed, 0 failed, 378 ignored (head `eade24a1`, rebased on `57d8ed29`). Before the rebase, on `beb8f77c`: 11852 passed, 0 failed. - `fused_moe_parity_tests`: 5 passed (fused vs all-f32 reference nrms at most 3.0e-6; fused vs `gather_qmm` about 3.7e-3, `gather_qmm` vs reference about 3.3e-3). Starting the down kernel's fold at 8 fails both reference tests (nrms 0.63 and 0.70). - `qwen3_moe::tests` and `qwen3_vl_moe::tests`: 12 passed, positive controls on the kernel. Nemotron-3-Nano generates the same greedy text with and without `MLXCEL_FUSED_MOE_RELU2=1`. Not verified: Metal and CUDA (not available on this host). Their `.metal` and `.cuda` entries and sources are untouched and both predicates answer true there; the CUDA-visible changes are the `MLXCEL_FUSED_MOE_RELU2` decline and none other (the SGY default is chosen at compile time for ROCm builds only). Gemma 4 and Qwen3-Next reach the same kernels but have no checkpoint here. Wave64 (CDNA) is untested.
Measures where ROCm decode time goes per kernel on gfx1151 and ranks the port issues split from #1814 by it; settles MLX's ROCm
gather_mm.What changed
docs/benchmark_results/rocm-decode-profile-gfx1151-2026-09-30.md: decode GPU time and host gap per token, top kernels, share per port unit for Llama-3.1-8B, Qwen3-30B-A3B, granite-4.0-h-tiny and Nemotron-3-Nano (greedy, plus temperature and top-p runs), the attribution method, and the ranked order. Raw rocprofv3 stats CSVs, per-kernel decode tables, summaries and the guard log are underbenchmarks/rocm_profiles/gfx1151_929c80ab/.scripts/rocm_gpu_guard.sh(the perf(bench): ROCm support in the benchmark harness and a gfx1151 baseline #2056 idle-GPU guard as a script),scripts/rocm_decode_profile.sh/.py(profile, decode cut, attribution, report),mlxcel-bench-decode --temperature/--top-pandMLXCEL_BENCH_PHASE_MARKS=1; documented indocs/benchmarks.md.grouped_gemm_numeric_tests.rsgates readgpu_backend_available(); the file leftBACKEND_ENUMERATION_TODO.Result: implied order #2067, #2065, #2064, #2068, #2063 (items 7, 5, 4, 8, 3). Every measured run passed the guard (90 s idle, 1 Hz monitor); one attempt was rejected by the guard, one rerun by hand. Profiler cost scales with dispatch count: within noise on Llama, 5% on Qwen3, 20 to 23% on the hybrids, so the doc reports shares.
Verification (gfx1151)
cargo test --release --features rocm -p mlxcel-core --lib grouped_gemm_numeric_tests -- --test-threads=1 --nocapture: 3 passed; each also run by exact name under rocprofv3, trace showsgather_batched_gemm_kerneland a hipBLASLt GEMM; all three fail with a wrong-expert referencepython3 scripts/ci/check_kernel_port_dispatch.py:0 awaiting a predicate;make verify-kernel-port-dispatchpassesmake verify-versions verify-kernel-dtype-keys verify-kernel-port-dispatch verify-llama-compat verify-fmt: passcargo clippy -p mlxcel --features rocm --bin mlxcel-bench-decode -- -D warningsandcargo clippy -p mlxcel-core --features rocm --lib --tests -- -D warnings: clean (narrow scope instead ofmake verify-clippy-rocm, which is workspace-wide)cargo test --features rocm --test dead_doc_pointers: passpython3 -m unittest tests/test_rocm_decode_profile.py: 17 passed;bash -non all three scripts (shellcheck is not installed on this host)Changes during review
rocm_decode_profile.shscrubs local paths fromguard.login an EXIT trap with a literal replacement;rocm_gpu_guard.shparses the parent PID after the last), avoids glob expansion, validates its integer options, and stops the command and monitor on INT/TERM;Dispatchinrocm_decode_profile.pyis slotted.rocm_decode_profile.py reportoutput on the committed data is byte-identical before and after.Not verified: Metal and CUDA (not available here). The
gather_mmgate change means the three tests still run there, unchanged.cargo test --test dead_doc_pointerswithout--features rocmfails to link on this host (copy_gpu_inplaceundefined fromkv_inplace_write.cpp), unrelated to this change.Closes #2061