Repository navigation
ag_gemm: all-gather fused into the fp8 GEMM that consumes it - #809
Conversation
…s it Sequence parallelism leaves each rank with its own tokens A_r [m_local, K]; a ColumnParallelLinear after it needs concat_s(A_s) @ B_r.T. mori.ops.ag_gemm does the gather and the GEMM in one kernel over a window that holds the gathered operand: * block 0 is the poster: one lane per peer pushes this rank's rows over SDMA in row bands -- put, quiet, then a system-scope store of the band's arrival flag into the peer. All peers at once rather than vllm#54151's ring: each MI355X peer is its own xGMI link. * tiles over the rank's own rows come first and never wait; remote tiles go (chunk, source, row tile, column tile) and spin on their band's flag before the prologue issues an A load. * a barrier-only kernel closes every call and advances the epoch the flags compare against. Without it the back-to-back test fails deterministically. The poster is part of the GEMM so remote tiles spinning on resident CUs cannot starve it. The GEMM is gemm_ar's 8-wave fp8 kernel, ptpc only for now: its A scale is per row and travels with the row band. Kimi-K3 TP8 column projections (K=7168, N 6400/3840), 8x MI355X, all 92 runs validated at the fp8 floor: fused beats split SDMA by 8.0-21.9% (KDA) and 1.1-26.0% (MLA) for global M 4096-32768, and RCCL + the same GEMM by 1.06-1.47x. Co-Authored-By: Claude <noreply@anthropic.com>
The kernel carries its own copy of gemm_ar's 8-wave fp8 mainloop, written before #785. Apply the same three changes: drain the peeled tail's producers with wait_barrier(0) before it reads the last step's B0/A0, restore the loop's end-of-step count to 2A+B, and make the loop's S0 a read_done_barrier(). The loop and peeled steps now issue the same barrier sequence as gemm_ar. test_ag_gemm.py passes (13), and at K=256 -- where the main loop runs zero times and only the tail drain covers the race -- fused-sdma with --back-to-back 4 (3/3), gemm-only (2/2) and split-sdma validate at relL2 1.66e-3 on 8x MI355X. Co-Authored-By: Claude <noreply@anthropic.com>
test_ag_gemm.py was not run by CI. It goes after the GEMM+AR steps, for the same reason gemm_ag's step does: its 8-rank SDMA process groups leave queues that take seconds to come back, and only the ag_gemm tests settle and retry around that. From /tmp, so the installed package is what gets tested. Co-Authored-By: Claude <noreply@anthropic.com>
One instance per (n, k) weight shape, serving any M up to m_max: it owns the window, compiles each M once, copies this rank's tokens into its slot (or skips the copy when the caller quantised into local_rows()), and returns the gathered GEMM in its own buffer or in out=. Modes fused and split; chunks default to the largest divisor of the row tiles up to 4, the measured winner. The guards follow GemmA2AOp: the constructor refuses a communicator without the SDMA queues it asked for and one spanning more than a node; self_test() pushes known bytes and checks they arrived; operands the launch would read as raw bytes raise up front. The receive slots are laid out for the current M, so one instance switching M moves them. That is safe for the same reason calls can follow each other at all: each ends in a cross-rank barrier, after every rank's GEMM has read them. test_ag_gemm_op.py covers it from one 8-rank spawn -- self_test and its teeth, a padded token count, alternating M, the zero-copy path, split equal to fused byte for byte, back-to-back calls with a held rank, operand checks, close -- and joins the CI step. Co-Authored-By: Claude <noreply@anthropic.com>
N had to be a whole number of 256-column tiles, so Kimi-K3's TP8 shards -- 6288 (KDA) and 3648 (MLA) -- had to be padded to 6400 and 3840: a padded copy of the weight, and a result the caller had to slice, which leaves a strided view the next layer usually copies. Nothing in the kernel needed it. B and its scale go through buffer descriptors sized to the tensors, which return 0 past them, and the lane-transpose store already drops any 8-column group at or past N. So the grid takes ceil(N/256) column tiles and N need only be a multiple of 16, preshuffle_b's band -- which also keeps every store group and 4-column scale load wholly on one side of N. It is not faster: the partial tile still computes all 256 columns, the same work as the padded one. Fused c4 at global M=8192/32768 measures within 0.4% of the padded widths, except MLA at 8192 at -4%; the README records the table. Tested at N=1168 (four whole tiles and 144 columns) through the benchmark back to back, and through AgGemmOp in both modes, split equal to fused byte for byte. Co-Authored-By: Claude <noreply@anthropic.com>
jhchouuu
left a comment
There was a problem hiding this comment.
Tested d3509047 on MI355X at P=4 and P=8 (ROCm 7.14, FlyDSL 0.4.0), merged onto current main (the only conflict is python/mori/ops/__init__.py's _LAZY_SUBMODULES, which needs ag_gemm added next to the new a2a_gemm).
What passes:
test_ag_gemm.pyandtest_ag_gemm_op.py(41 passed on 4 and on 8 GPUs), andbench_ag_gemm.py --back-to-back 8.- At P=4, m_local=4096, N=6400, K=7168: fused c4 runs 720.9 us, against 1118.2 us for split-sdma and 1159.3 us for split-rccl.
- I also checked switching M while a rank is held. With no host sync, M = 512, 1024, 768, 1024, 256, 1024, and a different rank held before each call, every call lands at the fp8 floor in both
fusedandsplit. The claim in the code holds. The op test does not exercise it, though, becauserel()andworst()sync the host between calls; this would be worth adding as a test.
1. self_test can fail on a healthy box when kernels are already compiled
self_test checks the receive slots after its own synchronize(), but nothing stops a faster rank from returning and starting its next op(...). That call's poster writes into the same recv slots of the slower rank. I warmed the op with one call, then ran self_test on every rank with rank 1's host 0.5 s slow after its sync, then made one call. Rank 1 raised:
ag_gemm self-test failed: the slot from rank 0 holds 31, expected 1. Check that mori was built with BUILD_CCO_SDMA=ON ...
On a cold op the first call's JIT compile hides the window, which is why the worker never sees it; a health check on a warm op hits it. Collecting the failures, then comm.barrier(), then raising fixes it. Raising before the barrier would leave the healthy ranks hanging.
2. The epoch wait breaks when the epoch wraps
The band and barrier waits use gemm_ar's _spin_until, an unsigned cur < ep, with ep = epoch + 1. I preset the window's control area to 0xFFFFFFFE and made four validated calls with fresh operands:
- call 1, where
epwraps to 0, comes back at relL2 1.23; - the others are at the floor;
- without the preset all four are at the floor.
It takes 2^32 calls on one instance to get there, but one instance serves every layer with the same (n, k). a2a_gemm on main now has _spin_reach ((int32)(cur - target) < 0) for exactly this; using it in both spins fixes it.
Smaller points
-
Full system fence per remote tile. Each remote tile runs
cco_system_fenceafter its spin (kernels.py:297), whose release half writes back L2. An acquire-only fence, asa2a_gemmdoes, validates (including--back-to-back 8at both world sizes). Two rounds each, us, max over ranks:system fence acquire only change P=4, KDA N=6400, m_local=4096, c4 720.7 / 725.4 712.1 / 711.8 -1.5% P=4, MLA N=3648, m_local=1024, c2 221.8 / 225.2 222.9 / 222.5 ~0 P=8, KDA N=6400, m_local=4096, c4 1292.1 / 1295.5 1237.9 / 1237.2 -4.3% P=8, MLA N=3840, m_local=1024, c2 260.7 / 258.8 253.4 / 253.6 -2.4% -
DEFAULT_CHUNKS=4against the MLA table. The comment says 4 wins from 1024 rows per rank up and 2 at 512, but the README's own MLA table has c1 ahead at global M 4096 and c2 ahead at 8192. I re-measured it at P=8, N=3840 (two rounds, max over ranks, us;chunks_forgives c2 and c4 here):global M split-rccl split-sdma c1 c2 c4 default vs best 4096 200.9 180.2 172.2 182.9 - +6.2% 8192 360.7 332.0 320.5 260.3 282.4 +8.5% At global M=4096 the default is also slower than split-sdma. A shape-aware default, or at least a corrected comment, would help.
-
validate()accepts block_m 384/512. The mainloop cannot serve those: 512 does not fit in LDS, and 384 is the A > B shape behind #857'swait_barrier(2A+B)gap. Restrictingblock_mto the served tiles would close that.
# Conflicts: # python/mori/ops/__init__.py
… default Review follow-up. - The band and barrier waits used gemm_ar's unsigned cur < ep, which passes at once on the call whose epoch + 1 wraps to 0 (relL2 1.23-1.32 on that call). They now use a local _spin_reach, (int32)(cur - target) < 0, as a2a_gemm does. New test: the control area preset to the state 0xFFFFFFFE calls leave, then four validated calls, fused and split; with the old compare the wrapping call fails, with this one all are at the fp8 floor. - A remote tile's wait takes an acquire fence instead of cco_system_fence, whose release half writes back L2 for nothing (1.5-4.3% of a call on the KDA shape, per the review's measurement). - self_test barriers before returning or raising, so a rank that finished checking cannot start its next call and refill the slots a slower rank is still checking. - The default chunk count is the most bands up to 4 that still hold MIN_BAND_TILES = 20 output tiles each. That is the best count at every cell of the README's KDA and MLA tables at P=8 (MLA takes c1 at 512 rows per rank and c2 at 1024, where the old default 4 was 6-8.5% behind). - AgGemmConfig.validate accepts only the served block_m, 256. - The alternating-M case now runs back to back with no host sync and a different rank held before each call, over four M. Co-Authored-By: Claude <noreply@anthropic.com>
|
Thanks. Every point is addressed in bde45d7, on top of a merge with 1. 2. Epoch wrap. Both the band wait and the barrier wait now use a local
Smaller points:
Testing. 8x MI355X, |
jhchouuu
left a comment
There was a problem hiding this comment.
Re-tested bde45d7b, merged onto current main, on MI355X (ROCm 7.14, FlyDSL 0.4.0). Every point is addressed. LGTM.
- Tests.
test_ag_gemm.pyandtest_ag_gemm_op.pypass (49). self_test. My reproducer now passes on every rank, and the call after it lands at the floor. The reproducer warms the op, makes rank 1's host 0.5 s late after its sync, then makes one call.- Epoch wrap. I preset the control area to
0xFFFFFFFE. All four calls land at the floor in bothfusedandsplit; before, the wrapping call came back at 1.23. validate(), the acquire fence and the held-rank alternating-M test are as you describe.
One note on the chunk default, not blocking. MIN_BAND_TILES reproduces the P=8 tables, but it counts a band's tiles without the world size. At P=4 it picks more bands than the best in three of the four cells I measured. Kimi-K3 shapes, P=4, us, median of three rounds (spread about 2% at most):
| N | m_local | c1 | c2 | c4 | split-sdma | default |
|---|---|---|---|---|---|---|
| 3648 (MLA) | 512 | 148.1 | 156.1 | - | 148.4 | c1 |
| 3648 (MLA) | 1024 | 213.6 | 222.4 | 240.6 | 227.9 | c2, +4.1% |
| 6400 (KDA) | 512 | 156.2 | 168.4 | - | 165.1 | c2, +7.8%, no faster than split |
| 6400 (KDA) | 1024 | 298.5 | 233.3 | 253.4 | 305.9 | c4, +8.6% |
A band waits on the rows of P-1 peers. Counting its tiles over those peers fits all twelve cells, the eight in your P=8 tables and these four: (tiles // d) * n_tiles * (world - 1) >= MIN_BAND_TILES * 7. That is exactly today's rule at P=8, and at P=4 it gives c1 / c1 / c1 / c2 here. If you would rather keep the rule as it is, a note in the comment that it was fitted at P=8 would do.
# Conflicts: # .github/workflows/ci_cco.yml # python/mori/ops/__init__.py
Adds
mori.ops.ag_gemm: the all-gather that feeds a ColumnParallelLinear aftersequence parallelism, fused into the fp8 GEMM. Each rank holds its own tokens
A_r [M, K]and its weight shardB_r [N, K], and ends withconcat_s(A_s) @ B_r.T,[world*M, N]. This is the AMD counterpart ofvllm-project/vllm#54151 (Kimi-K3 AG-GEMM).
Draft: ptpc only. blockscale and mxfp8 -- what DeepSeek and Kimi actually
run -- need a push of their own; see the end.
Using it
One instance serves one
(n, k)and any M up tom_max, compiling each M once.Modes
fused(default) andsplit.op.local_rows(m)is this rank's slot inthe window, to quantise into and skip the copy;
out=takes a caller's buffer.Operands the launch would read as raw bytes raise
ValueError; the constructorrefuses a communicator without SDMA queues or spanning more than a node. The
guards follow
GemmA2AOp.N is any multiple of 16: the last 256-column tile may be partial, so Kimi-K3's
TP8 shards (6288, 3648) go in unpadded and come back contiguous.
How it works
The window holds the gathered operand (
recv [world][M][K]fp8 and its per-rowscale); this rank's rows go into
recv[rank]. The GEMM isgemm_ar's 8-wavefp8 kernel reading that one tensor, with three differences:
in row bands: put,
quiet, then a system-scope store of the band's arrivalflag into the peer. All peers at once, unlike #54151's ring -- on MI355X each
peer is its own xGMI link, so a ring would use one of seven at a time.
column tile), the source rotated by rank.
before the prologue issues an A load.
A barrier-only kernel closes every call and advances the epoch the flags are
compared against (nothing is reset, so graph replay works).
Results
Kimi-K3 TP8 column projections, K=7168. fp8 ptpc, 8x MI355X, ROCm 7.14 /
PyTorch 2.12 / FlyDSL 0.3.4.1, graph replay, median of 2 interleaved rounds on an
idle box. All 92 runs validated at the fp8 floor (relL2 1.66e-3) against every
row of C for two operand sets. Times in us;
split-rcclisall_gather_into_tensor+ the same GEMM. Taken before N was relaxed, so at thepadded 6400 / 3840:
The chunks are the mechanism: one chunk barely beats the split path, because
each remote tile then waits for a peer's whole slab. #54151's ratios on GB300
(BF16, ring) are 1.26-1.36x KDA and 1.07-1.54x MLA at global M >= 3072 -- the
same order, on different hardware, precision and GEMM, so only the ratios
compare.
The real shards against the padded widths, fused c4, FlyDSL 0.4.0:
No faster, as expected: the partial tile still computes all 256 columns, the
same work as the padded one. What unpadded N buys is no padded weight copy and a
contiguous result instead of a column slice the next layer would copy. Full
tables in
python/mori/ops/ag_gemm/README.md.Worth a reviewer's attention
grid whose resident blocks all spin on data a not-yet-dispatched push kernel
would send never finishes. Block 0 is dispatched first. Its own tile pays the
push time, and it is a local tile nothing waits on.
rank a call ahead from refilling a slot a slower rank still reads.
--back-to-back 8(no host sync, a different rank held each call) failsdeterministically without it (relL2 0.8-1.3); it does not separate those two
roles.
AgGemmOpswitching M moves the receive slots. They are laid out forthe current M so the GEMM reads one contiguous operand. That is safe for the
same reason consecutive calls are: every rank's GEMM has read the slots before
any rank passes the closing barrier. Covered by an alternating-M test.
its scale go through buffer descriptors sized to the tensors (out of range
reads 0), and the lane-transpose store drops any 8-column group at or past N.
N % 16 keeps every store group and 4-column scale load on one side of N.
gemm_ar's 8-wavepipeline; its barrier sequence matches
gemm_aronmain, tail drainincluded.
CachedWindowcannot be captured by a runtimeif. The kernels take itsbase/strideout before branching and build peer addresses from them, as itsown docstring recommends; using the object inside a branch fails at trace time.
bench_ag_gemm.pyasks for a lazily-createdcuda:ncclgroup alongside gloofor the RCCL baseline.
Test plan
This is the new CI step,
Run CCO AG+GEMM numerics, back to back, placed afterthe GEMM+AR steps because only these tests settle and retry around SDMA queue
reclaim. 41 passed on 8x MI355X from a non-editable install (ROCm 7.14, FlyDSL
0.4.0), run exactly as the step does, and the two CI steps after it pass right
behind it:
test_ag_gemm.py-- layout on the CPU; every mode through the benchmarkagainst an fp32 reference for two operand sets plus
--back-to-back 8; apartial last column tile (N=1168) back to back, fused and split.
test_ag_gemm_op.py-- shape rules on the CPU; from one 8-rank spawn:self_testand its teeth, a padded token count, alternating M, the zero-copypath and
out=, split equal to fused byte for byte, back-to-back calls with aheld rank, operand checks,
close, and a partial column tile in both modes.black and ruff clean.
Not done yet
and needs its own push.
🤖 Generated with Claude Code