Repository navigation
[HIP] fix(custom-ar): gfx1250 fused all-reduce + RMSNorm - #6699
Conversation
🏷️ CI GuideRuns automatically on every PR:
Extended tests (opt-in via labels):
One backend per PR: PR title tags & labels: |
e7c3fed to
e26b419
Compare
fused_ar_rms and its per-token FP8 form only had old-arch kernels, which cast _ptr to the old-arch CustomAllreduce. On gfx1250 _ptr is the gfx1250 class, so those kernels synchronized through the gfx1250 Signal with their own counters and released later gfx1250 barriers early. Add a gfx1250 kernel (one block per token, ranks summed in rank order) with the same numerics as the old-arch kernel, including gemma_norm and the optional bf16 mirror, and dispatch fused_ar_rms to it on gfx1250. The kernel syncs through the barrier, so the communicator uses it above the 4 MiB LL all-reduce range, where it is 1.6-2.4x faster than all-reduce plus RMSNorm on MI455X. Within the LL range, the LL all-reduce plus RMSNorm stays faster. Padded or strided rows have no gfx1250 kernel and take the unfused path. Old-arch dispatch is unchanged.
The per-group and MXFP4 quant forms of fused all-reduce + RMSNorm and the qk-norm all-reduce kernels exist only in the old-arch module and would hit the same Signal mix-up on gfx1250. Skip them there: the communicator takes its unfused path for per-group and MXFP4, and the direct qk-norm entry points raise instead of corrupting later collectives.
… rank skew Speculative decoding alternates small fused all-reduce + RMSNorm calls (the draft's norms) with larger ones (the target), plus plain all-reduces and the barrier-synchronized logits all-gather, all on one custom all-reduce instance while the ranks drift apart. This TP4 test runs that mix with a different GPU-side delay per rank every step and checks every result against an fp32 reference. It fails when gfx1250 runs the old-arch fused kernels (they corrupt the shared Signal and later collectives read stale rows) and on the LL block-epoch race fixed in #6756.
Co-authored-by: Cursor <cursoragent@cursor.com>
eca99ba to
ab59909
Compare
gfx1250 always captures on the copy-in path, so every all-reduce first copies its input into the registered pool. The LL kernel reads the input in place and never touches that pool, so for LL sizes the copy is pure overhead: on MI455X TP4 it costs 1.5-2.5 us per call in CUDA graphs (hidden 6144: 1 row 10.4 -> 8.8 us, 256 rows 28.5 -> 26.1 us).
7648c00 to
c150673
Compare
It guards gfx1250 kernels and dispatch; other archs keep their own fused path, and the CI multi-GPU runners are MI350X.
Two-stage fused AR + RMSNorm wrote each rank's reduced shard at the start of the LL scratch, where peers publish LL lines. An LL reader accepts a line once its two flag words equal the current epoch, so leftover shard data whose words happened to match could be read as a peer's payload. Give the shard its own 16 MiB region after the LL scratch, which covers the communicator's 64 MiB fused limit at TP4. LL never polls it.
The per-token FP8 branch capped fusion at 4 MiB by default, while gfx1250 fuses only above its 4 MiB LL range, so the gfx1250 FP8 kernel was never selected. On gfx1250 bound it at 64 MiB like the unquantized path. Old-arch dispatch and AITER_AR_QUANT_MAX_BYTES are unchanged.
… path The fused per-token FP8 epilogue quantized the fp32 normed value with amax / max and a true divide, while the unfused path quantizes the bf16/fp16 output with dynamic_per_token_scaled_quant: amax * (1 / max), multiply by rcp(scale), clamp, cvt_pk. About 2.4% of the FP8 bytes differed, and enabling the fused path above 4 MiB moved MiniMax-M3 Eagle3 GSM8K from 0.9386 to 0.9303. Use the unfused steps on the rounded output. Both paths now give identical bytes and scales (TP4, two-stage and one-stage, with and without gemma_norm).
Co-authored-by: Cursor <cursoragent@cursor.com>
…PC transport (#7155) * [HIP] feat(custom_ar): read captured gfx1250 inputs in place on the IPC transport During CUDA graph capture on the IPC transport, gfx1250 collectives whose kernels read the peers' inputs (unroll4 all-reduce, reduce-scatter, all-gather, fused all-reduce + RMSNorm) now register their captured input, like the old arch, instead of staging it in the pool with a D2D copy. The LL all-reduce pushes to peer scratch and never stages (since #6699). The VMM and symm_mem transports, including cross-node groups, keep the copy-in path: they cannot export memory from PyTorch's caching allocator. Eager calls still stage. Kernels are unchanged. - Add the gfx1250 IPC graph-buffer export and registration (get_graph_buffer_ipc_meta, register_graph_buffers_ipc), with the old-arch signatures, and wire them into IPCBufferPool.flush_graph_buffers. - Key the capture gate on the transport instead of the arch. - Stop recording the all-reduce output as a graph buffer: the gfx1250 kernel never reads it, and bf16 did not record it anyway. - Add a gfx1250 graph registration regression to --regression, and run test_custom_allreduce_graph_buffer_reuse on gfx1250 above the LL range. Graph replay, TP4 on gfx1250, per call: all-reduce 8 MiB 96.0 -> 92.0 us, all-gather 8 MiB 54.8 -> 49.9 us, reduce-scatter 8 MiB 30.9 -> 26.1 us. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drop the test changes; keep this PR to the capture dispatch logic --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…PC transport (#7155) * [HIP] feat(custom_ar): read captured gfx1250 inputs in place on the IPC transport During CUDA graph capture on the IPC transport, gfx1250 collectives whose kernels read the peers' inputs (unroll4 all-reduce, reduce-scatter, all-gather, fused all-reduce + RMSNorm) now register their captured input, like the old arch, instead of staging it in the pool with a D2D copy. The LL all-reduce pushes to peer scratch and never stages (since #6699). The VMM and symm_mem transports, including cross-node groups, keep the copy-in path: they cannot export memory from PyTorch's caching allocator. Eager calls still stage. Kernels are unchanged. - Add the gfx1250 IPC graph-buffer export and registration (get_graph_buffer_ipc_meta, register_graph_buffers_ipc), with the old-arch signatures, and wire them into IPCBufferPool.flush_graph_buffers. - Key the capture gate on the transport instead of the arch. - Stop recording the all-reduce output as a graph buffer: the gfx1250 kernel never reads it, and bf16 did not record it anyway. - Add a gfx1250 graph registration regression to --regression, and run test_custom_allreduce_graph_buffer_reuse on gfx1250 above the LL range. Graph replay, TP4 on gfx1250, per call: all-reduce 8 MiB 96.0 -> 92.0 us, all-gather 8 MiB 54.8 -> 49.9 us, reduce-scatter 8 MiB 30.9 -> 26.1 us. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drop the test changes; keep this PR to the capture dispatch logic --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Motivation
On gfx1250 (MI455X), MiniMax-M3 TP4 + Eagle3 (K=3) produced wrong tokens. TP ranks returned different results from the same collective, so their speculative state diverged. No-spec decoding was unaffected.
gfx1250 had no fused all-reduce kernels, and the old-arch ones ran with a gfx1250 handle.
fused_allreduce_rmsnorm*(including the per-group and MXFP4 quant forms) andfused_qknorm_allreduce*are built only intomodule_custom_all_reduce, and they cast_fato the old-archCustomAllreduce. On gfx1250,_ptrpoints to the gfx1250 class. These kernels then synchronize through the gfx1250Signalwith their own counters, which releases later gfx1250 barriers early. The next all-gather or all-reduce reads peers' buffers before they are staged, and returns stale rows that differ per rank. The Eagle3 draft model's norms use fused all-reduce + RMSNorm.The other bug found in the same investigation, the LL all-reduce block-epoch race that shows up when small and large all-reduces alternate, is fixed on main by #6756. This PR is rebased on it and only fixes the fused path.
Changes
gemma_norm, per-token FP8 output and the optional bf16 mirror.meta_sizegrows by 16 MiB per rank), so the LL kernel, which accepts a line once its flag words equal the current epoch, never polls data it did not write.dynamic_per_token_scaled_quant, so fused and unfused FP8 bytes and scales are identical. The old-arch default andAITER_AR_QUANT_MAX_BYTESare unchanged.op_tests/multigpu_tests/test_custom_ar_fused_interleave.pymixes fused, small, large and gathered collectives while delaying each rank by a different amount every step. It runs on gfx1250 only; CI has no gfx1250 runner.Non-gfx1250 paths are unchanged:
can_fuse_ar_rms,should_fuse_ar_rmsandhas_old_arch_fused_kernelsare all true there, and the op table maps to the existing ops.Test results
One MI455X node,
rocm/fw-bringup:gfx1250-atom-k3-20261014(ROCm 10.1.0, HIP 7.16.26315, PyTorch 2.11.0+rocm10.1.0a20260811, Triton 3.8.0). TP8 is not validated: no 8-GPU gfx1250 node was available.Kernel check with
op_tests/multigpu_tests/test_fused_ar_rms.py(bf16 and fp16, eager and CUDA graph, fused and per-token FP8; the file is in CI's multi-GPU skip list, so it was run by hand):All 128 runs pass, covering the LL range, one-stage (TP2, hidden 2880/4096), two-stage and FP8 output above 4 MiB. After the staging-region commit, TP4 was re-run at 384, 2800 and 5400 rows × 6144 and 1024×7168 (32 runs), and after the FP8 exact-match commit the per-token FP8 shapes were re-run (16 runs); all pass. Comparing fused FP8 with fused bf16 +
dynamic_per_token_scaled_quant(TP4, 1024×6144, 2800×6144 and 1024×4096, with and withoutgemma_norm) gives identical bytes and scales.Latency, TP4, bf16, hidden 6144, CUDA graph, µs per call, with the script below (
python3 bench_ar_rms.py --rows 1,16,64,256,384,1024,2048,4096):In the LL range the copy-in skip is the gain: with the same script, the LL all-reduce goes from 10.4 to 8.8 µs at 1 row and from 28.5 to 26.1 µs at 256 rows. A barrier-synchronized fused kernel is slower there (19.6 µs at 1 row), and a fused kernel on the LL protocol did not beat LL + RMSNorm below 64 rows, since RMSNorm needs a whole row in one block and that leaves few CUs to poll. Absolute numbers are from one node and vary between nodes.
bench_ar_rms.py
test_custom_ar_fused_interleave.py, 300 steps, alternating 384/1024-row fused calls with LL all-reduce and all-gather:Without #6756, the same test also fails on the plain 16-row all-reduce, independent of this PR.
End to end, MiniMax-M3 MXFP4 TP4 + Eagle3 GQA K3, full GSM8K (1319, 5-shot, c4), default custom all-reduce settings, with ROCm/ATOM#2710 for FlyDSL paged decode (
ATOM_PA_FLYDSL=1):Measured at
eeb90b37a. The later commits move the two-stage staging buffer and route FP8 output above 4 MiB to the fused kernel, which now produces the same bytes as the unfused path it replaces. An earlier version of that kernel quantized the fp32 value instead and moved BF16 GSM8K to 0.9303/0.9310; the exact-match version is what this PR ships. The "before" row used the earlier unified-attention decode stack, which ROCm/ATOM#2710 replaces.