Repository navigation
poc(cco): Mega-MoE GEMM2 fused with an SDMA combine (gfx950) - #859
Conversation
GEMM2 writes its rows into a local send buffer grouped by origin rank
(stable, expert-major inside a run), and cco SDMA ships each rank's run
as contiguous puts, so the transfer does not occupy CUs.
Three kernels on one stream (the P2P-scatter baseline has two):
1. rank_sort_plan: persistent grid, count -> scan -> assign with grid
barriers; a stable send-buffer index per row.
2. gemm2: writes the send buffer; extra comm waves (one per peer) wait
for finished chunks of sort tiles and put them on their own SDMA
queue while the remaining tiles still compute.
3. combine: persistent grid; waits for the peers' flags, builds the
slot -> row table, reduces the top-k rows, and tells the peers their
data has been consumed.
Reuse of the landing buffers across calls is guarded by per-peer
"consumed" flags, so a fast rank cannot overwrite a slow one's data.
main.py checks the result against aiter's MegaMoEV2 stage2+combine on the
same stage1 state; bench.py compares latency against it per token count.
Compile-checked only; not yet run on GPUs.
Co-Authored-By: Claude Code <noreply@anthropic.com>
… 4k tokens
The example was compile-checked only. Running it needed two fixes (aiter here
does not export comm_ops.traced, and mori's SDMA queues are per (src,dst) pair
so --queues 8 asks for 64 per rank and fails), and then it was 0.48x aiter's
stage2+combine at 2048 tokens/rank. It is now 1.09x at 4096 and 1.18x at 8192,
matching aiter's output bit for bit wherever aiter also picks fp8.
Four changes, in order of what they were worth:
fp8 transport. bf16 moved twice the bytes and the transfer stopped fitting
under the GEMM; a truncated-shipping probe showed the comm cost tracked bytes,
not sync. Rows now carry aiter's fp8_blockwise_1x32 in aiter's own layout --
payload then the e8m0 scales, inline -- so one contiguous put still ships a
whole row.
Write-through epilogue stores. The per-unit publish released at system scope,
which on gfx950 is buffer_wbl2: a write-back of the *whole* L2, charged once
per (m_block, n_block) unit, 12288 times a launch. Storing sc0|sc1 instead
makes retiring the stores the release the copy engine needs. 2.4x on the GEMM2
kernel. Same recipe as ops/gemm_ar/kernels_sdma.py. The per-chunk counters are
also a cache line apart now, which is worth 8% on its own.
A per-pair row reservation. `cap` was the whole token-slot space reserved for
every (src,dst) pair, 16x what a pair can use; that put bs=8192 over the 4 GiB
buffer-resource range. It is now the even share times --row-slack, with the
plan kernel refusing to place a row past it, which keeps the handshake-free
write into the peer's landing buffer.
A cheaper plan kernel and completion signal. The plan had two grid barriers
around a prefix sum walked by 8 threads; it is one barrier and a Hillis-Steele
scan every block runs in its own LDS, 0.066 -> 0.030ms. And the comm wave hangs
the copy engine's own trailing ATOMIC on its last put instead of draining the
queue, so it no longer pins the tail of the GEMM2 kernel.
bench.py times eager by default -- prefill does not capture CUDA graphs -- with
--graph for the two-graph slope bench_ep.py uses; the two agree within ~1%.
--no-aligned-pair lets bs=8192 compare, since aiter picks its common-pair
stage2 there and this path has no equivalent (measured 1.5% slower than without
it on uniform routing anyway).
sdmabw.py is new: one cco SDMA channel with no GEMM in the way, which says
t_put = 5.8us + bytes / 60.9 GB/s and sizes the above against the hardware.
stage2+combine vs aiter, glm52 on 8x MI355, eager, --chunk-tiles 16:
tokens/rank 128 512 2048 4096 8192
vs aiter 0.64x 0.72x 0.97x 1.09x 1.18x
whole layer 0.91x 0.90x 1.00x 1.05x 1.08x
Below ~2048 the plan launch and the combine's index inversion outweigh what the
copy engine saves, and aiter's P2P scatter wins.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jhchouuu
left a comment
There was a problem hiding this comment.
Ran this on 8x MI355X (ROCm 7.14, FlyDSL 0.4.0, aiter main d5dc194f) with the branch merged into current main. With uniform routing your numbers reproduce, but with even mildly skewed routing the output is wrong on every rank and nothing reports it.
Reproduced (uniform routing)
-
main.py. Bit-exact against aiter at bs 2048, 4096 and 8192 (8192 run with--no-aligned-pair): 0 mismatched rows on every rank andoverflow0. bs 128 and 512 give 2.67e-2, as you describe. -
bench.py.128 512 2048 4096 8192 stage2+combine 0.65x 0.73x 0.99x 1.11x 1.21x e2e 0.88x 0.85x 1.02x 1.05x 1.10x --graphagrees with eager. -
sdmabw.py. 47.3 GB/s per link at 1.44 MB per put, which fits your 5.8 µs + bytes/60.9 GB/s model. -
Back-to-back calls. I ran 16 calls with no host sync and rank 3 delayed by
torch.cuda._sleep(on every call, and on alternate calls) at bs 2048, 4096 and 8192. Every call is bit-exact.
1. Skewed routing gives wrong results on every rank, with overflow 0 (blocking)
I added a bias to the router scores of rank 0's experts in _make_inputs (scores[:, :32] += b) and ran main.py at bs 4096, otherwise with defaults:
| bias | rank 0's rows vs the even share | mismatched rows per rank (of 4096) | relL2 | overflow |
|---|---|---|---|---|
| 0.05 | +9.8% | 2496-2648 (and it varies from call to call) | 0.36 | 0 |
| 0.1 | +21% | 2972-3091 | 0.40 | 0 |
| 0.3 | +70% | 3497-3583 | 0.48 | 0 |
Rerunning bias 0.3 with --row-slack 8 gives the same result, so the cause is not the per-pair reservation. It is the plan's scan buffer, which is sized from the send side:
TILES_MAX = ceil(slot_cap/SBM) + local_experts + 1(sorted_combine.py:231).- But the scan runs over
num_valid, the rows this rank receives from all peers. That can be up tonpes * slot_cap, and aiter sizes it withnum_valid_max. - At bs 4096, once expert padding is counted, that leaves only about 6% headroom over the even share.
- Nothing checks
tiles <= TILES_MAX, so the scan indexes past its half of the LDS buffer. - Every rank ends up wrong because the hot rank's GEMM2 output feeds all of them.
Real routing is rarely within 10% of even, so I think this needs a fix before anything else. Size the scan from num_valid_max or tile it. At the least, fail when tiles > TILES_MAX.
2. Per-pair overflow is silent and not contained
- Nothing reads
self.overflow. It is allocated and passed to the plan, but neithermain.pynorbench.pychecks it. With--row-slack 1.03, rank 3 overflowed 72 rows and 18 rows of its output were wrong (relL2 2.45e-2), yetmain.pyprinted PASS and SUCCESS. - Only the final per-peer total is clamped to
cap(sorted_combine.py:313-315). The intermediatetile_baseentries are not. - So a chunk that crosses
capships past this rank's slot. The comm wave puts[tile_base[t0], tile_base[t1])(moe_gemm2_localsort.py:596-602), so that chunk:- reads the next peer's run in S;
- writes past this rank's slot in the peer's landing buffer, and past R for the last rank.
- Suggested fix. Clamp
lo/hitocapin the comm wave, and raise whenoverflowis non-zero. - A tighter test would also catch this. At bs >= 2048 the reference is bit-exact, so
main.pycould assert 0 mismatched rows there instead of relL2 < 0.05.
3. Smaller points
- Own outbound puts are never waited on before reuse (remote mode). Nothing waits for this rank's own puts to finish before the next call's plan and GEMM rewrite
idx_sendand S. The consumed handshake runs in the comm waves, after the plan has already written. I could not trigger it in the runs above, but no ordering guarantees it. The same applies toclose()right after a stream sync. tiles == 0reads at a negative index. Whennum_valid == 0,sc(final, (tiles - 1) * npes + tid)(sorted_combine.py:311) reads at a negative index.- The e2e comparison is not like for like.
- The baseline
moe(x)includes aiter's input quantisation, while the new path'sstage1()starts from the pre-quantisedx_q. - At 8192,
base_e2estill uses aiter's aligned-pair stage2 even with--no-aligned-pair.
- The baseline
- The 128/512 baseline is not aiter's own choice.
bench.pybuilds oneMegaMoEV2sized for the largest bs, so at those sizes aiter runs fp8 transport. On its own, aiter picks bf16 there (main.pyshowsp2p_quant=none). - The Communicator is never destroyed.
SortedCombine.close()only callsclose_all_resources();comm.destroy()would also free it.sdmabw.py'scomm.finalize()does not exist, so that line is a no-op. - The encoding comments point to the old location. They reference
mori/ops/gemm_ar/_compat.py. Since #817 the encoding lives inmori.cco.device.flydsl.buffer, andCM_SC0_SC1could replace the literal1 | 16.
…192 tokens/rank Three serial costs in the rank-sort plan kernel, found by a GPU-side phase ladder (rocprof) rather than host timing: - assign re-decoded the whole sort tile for every row (O(SBM) per row, ~12 us). Count and assign now run one block per tile with one thread per row; a wave ballot per pe plus a popcount gives a row's rank among earlier rows for its pe, and per-wave counts cross waves through a few LDS words. - the grid barrier had every block atomic-add one address, which serialises in L2 (~7 us). Each block now bumps its own cache-line slot and polls all slots. - the Hillis-Steele scan ran redundantly in every block (10 barriers, ~15 us). Each block now sums the earlier tiles' counts for the tiles it owns with one parallel pass and a short LDS reduction, and writes tile_base/cursor itself. Outputs (out_row, idx, tile_cnt, tile_base, cursor) are bit-identical to the previous kernel on the same stage1 output at 8192/512/128 tokens per rank, and the bench relL2 stays 0 from 128 to 16384. stage2+combine vs aiter on 8x MI355: 1.20x at 8192 (was 1.17x), 1.31x at 16384, 0.72x at 128 (was 0.65x). plan_bar is now one 128 B slot per block, so a plan kernel and an older one must not share it. Co-Authored-By: Claude Code <noreply@anthropic.com>
…erflow Review findings on ROCm#859: - The plan's prefix was bounded by a tile count sized from the send side (slot_cap), but a rank receives rows from every peer, so under skewed routing the bound was exceeded and the prefix was silently truncated: wrong output on every rank with overflow 0. The prefix now sums up to the tile's own index, so no bound is needed. main.py --hot-bias reproduces it (bias 0.1 failed before, is bit-exact now). - tile_base is clamped to cap, so a chunk that crosses the per-pair reservation is cut at it instead of reading the next peer's run and writing past this rank's slot in the peer's landing buffer. - Nothing read the overflow counter. SortedCombine.check_overflow() raises, and main.py / bench.py call it. main.py also requires zero mismatched rows when both sides move fp8, since relL2 alone let a few wrong rows through. - tiles == 0 left cursor and tile_base stale; they are now zeroed. - The plan waits for every peer's done flag before rewriting idx_send, and close() waits for the same flags, so this rank's own outbound puts have finished reading S before it is reused or freed. Co-Authored-By: Claude Code <noreply@anthropic.com>
|
Thanks for the thorough run, and for the skewed-routing repro in particular. Fixes are in bea7f03 (plus ac0a306 for the plan kernel speed-up); the PR description is updated to match. 1. Skewed routing (blocking) -- fixed. Confirmed. The plan's prefix was bounded by a tile count sized from the send side ( 2. Overflow -- fixed.
3. Smaller points
Re-measured on 8x MI355 after the fixes: stage2+combine vs aiter is 0.66x / 0.75x / 0.98x / 1.07x / 1.20x / 1.31x at 128 / 512 / 2048 / 4096 / 8192 / 16384, bit-exact in every case. Your 1.11x / 1.21x at 4096 / 8192 are in line with mine; the small-batch ratios move by about ±0.05x between runs. |
CI runs the hooks on every file the PR touches, and these two had never been through them: sdmabw.py lacked the license header and was not black-formatted, moe_gemm2_localsort.py defined a constant between imports (E402), and both had an unused local (F841). No behaviour change. Co-Authored-By: Claude Code <noreply@anthropic.com>
…s (+1-3% stage2+combine) The combine used to build the slot -> landing-row table itself and rendezvous the whole grid before reducing; the plan kernel's grid barrier and its remote inv stores were also a large part of its time. Found with a GPU-side phase ladder (rocprof / CUDA-graph replay) rather than host timing. - inv is built by the SENDER and lives in the cco window: the plan kernel fills it for rows that stay on this rank, and the GEMM2 kernel's comm waves push the peers' entries with direct stores while the GEMM computes its first chunk. No idx travels with the data, and the combine loses its build phase and its grid barrier (a 4-byte signal put replaces the idx put). - plan kernel: a two-level tree barrier (no address takes more than 32 atomics, one poll per block) instead of every thread of every block polling every block's flag; plan 43 -> ~17 us at 8192 tokens/rank. - combine runs two blocks per CU now that it has no grid-wide rendezvous: more row loads in flight for the reduce. - the GEMM2 file is renamed moe_gemm2_localsort.py -> moe_gemm2_with_sdma.py. stage2+combine on 8x MI355, same harness, A/B against the previous commit: 2048 0.507 -> 0.492 ms, 4096 0.882 -> 0.855 ms, 8192 1.537 -> 1.490 ms, 16384 2.922 -> 2.840 ms (vs aiter: 1.00x -> 1.03x, 1.10x -> 1.14x, 1.19x -> 1.23x, 1.31x -> 1.35x). Output is bit-identical to aiter at every size and to the previous commit on the same inputs. Co-Authored-By: Claude Code <noreply@anthropic.com>
…its test and benchmark
Layout of examples/cco/python/11_flydsl_mega_moe_sdma/ before -> after:
sorted_combine.py + moe_gemm2_with_sdma.py -> mega_moe_gemm2_with_sdma.py
the host op MegaMoESdma (was SortedCombine) and its three kernels (plan,
GEMM2 with comm waves, combine) with the window layout and the sender side
of the SDMA protocol they share. The two files imported each other; they
no longer do.
main.py + bench.py -> main.py
one harness around aiter's MegaMoEV2 with `test` and `bench` subcommands;
input generation, weight quantisation, the aiter operator, the reference
output and the timing helpers exist once.
sdmabw.py -> sdma_bandwidth.py
Cleanup of what the previous commit left behind or made stale:
- remove grid_barrier (unused), the row count that flags[src] still carried and
_cdone_used's unused argument; flags are one i32 per peer now.
- comm_role: the `for peer ... if peer < npes: if peer != rank:` boilerplate that
was repeated four times is one `sends_to`, and the inv push is `push_inv`.
- use aiter's comm_ops.store_i32_global_system_monotonic for the per-row inv
stores instead of a private helper that reached into comm_ops._llvm_d, which
aiter main no longer has. The example now also runs on aiter main (9b7bb36ad,
FlyDSL 0.4.0) with the same performance and bit-exact output.
- docstrings and comments that still described the idx transfer, two grid
barriers and a combine build phase.
`bench` now compares stage2+combine only: no stage1 timing and no end-to-end
rows, which were not a like-for-like comparison (aiter's included its input
quantisation). README commands, file table and results are updated.
No behaviour change: the SDMA output is bit-identical to the previous commit's
on 8 configurations x 8 ranks (bs 128/2048/4096/8192, two skewed routings, quiet
signalling, bf16 rows), and stage2+combine is unchanged within noise.
Co-Authored-By: Claude Code <noreply@anthropic.com>
|
Thanks for the detailed write-up. I tried a few follow-ups on top of Setup. 8x MI355X, aiter 1. Data puts post a local signal that nothing waits for
sdma.put(peer, win, dst_off, win, src_off, nbytes, queue,
coop=cco.CoopScope.THREAD, local_signal=False)2. The completion signal can ride the last data putUnder
The gain is largest at 2048, where per-put fixed cost is the biggest share of the total. Tried and ruled out
The patch for (1) and (2) is a few lines. Happy to push it as a commit or open a PR against your branch, whichever you prefer. 🤖 Generated with Claude Code |
|
@yangyuhuiling nice idea, i will try it. |
…st put Two packets the copy engines did not need: * Every data put trailed a local signal. A put that names no signal keeps cco's old default -- an ATOMIC into this rank's own signal pool -- and nothing reads that pool: completion is the peer's remote signal, or quiet_queue. Puts now pass local_signal=False. * Under signal_mode="remote" the completion signal rode a separate 4-byte put after the last chunk. It now rides the last chunk's own put; only a peer whose last chunk is empty (or a call with no chunks) still gets the separate put. stage2+combine on glm52, 8x MI355X, fp8, CT=16, median of interleaved rounds, bit-exact against aiter at every size: tokens/rank 2048 4096 8192 16384 no local signal -3.6% -0.6% -0.5% -0.55% both -1.1% -0.5% Co-Authored-By: Claude <noreply@anthropic.com>
|
@kawhil-amd I pushed the two changes from my previous comment as b6e4404 on your branch (maintainer edits were enabled). Please pull it before you push anything else, so your local copy stays in sync. What it changes, all in
Correctness. Re-verified on 8x MI355X at b6e4404 with the same aiter/FlyDSL as before.
Performance.
These gains are a bit smaller than the ones in my previous comment, which came from a quieter run (2048 was −3.6% there). A few 20-call bench means at 8192 came out 6–8% high. To check that this was not a new stall, I timed 400 individual calls, 3 runs per side at 8192:
So the high means were bench noise; the new commit does not stall more often. 🤖 Generated with Claude Code |
…fused path every CU
Two independent costs the fused stage2 was paying, both on top of a GEMM that was
already running at full speed. Measured EP8 on MI355X, fp8, uniform routing,
row_slack 1.5, --no-aligned-pair, chunk_tiles 16; every number below is bit-exact
against aiter.
1. The AMDGPU UnclusteredHighRPReschedule stage.
Fusing the comm role into this kernel cost 8.5% of GEMM2 at 16384 tokens per rank
even when the comm role never ran -- a diagnostic build that compiled it in behind
a runtime-false guard paid the same 0.175 ms. It is not register pressure in the
main loop and not instruction cache: the K loop holds the same opcode multiset
either way (one s_nop and one s_waitcnt apart), the 13 SGPR spills all sit in the
prologue filling s_nop slots, and duplicating the comm role four times to take
.text from 23.2 KB to 45.8 KB moved the cost 2.3%.
What changes is the order. The comm role's live ranges lift the function's register
pressure over the threshold that turns the stage on, and it then breaks the main
loop's clustering: the MFMAs move 23 instructions earlier and the mean load to
s_waitcnt distance falls from 20.9 to 13.6, so the loop hides less latency on every
trip. Compiling with the stage off restores the original schedule.
2. persist_cu on the fused path.
A comm workgroup holds one of its CU's two workgroup slots for the whole kernel.
ktrace at 16384 tokens per rank: those two CUs run 8 GEMM workgroups while the
other 254 run 15 to 18, every workgroup keeps its normal duration (p50 25396 to
25092 ticks), and mean per-CU finish does not move -- only the last CU finishes
3.3% late. The displaced work has to be redistributed and, at the token bucket's
tuned persist_cu of 192, one workgroup is ~13% of the kernel span, so the tail
quantises badly. Handing the fused path every CU makes the workgroups shorter and
more numerous, and the tail absorbs the displacement: the fused kernel's cost over
plain GEMM2 drops a third (V4.1-Flash 0.200 to 0.133 ms, V4-Pro 0.274 to 0.184 ms).
Dropping persist_cu instead, or moving the comm workgroups elsewhere in the grid,
does nothing -- as do poll backoff, comm workgroup count, and XCD alignment.
stage2+combine against aiter MegaMoEV2, tokens per rank:
4096 8192 16384
GLM-5.2 1.15 -> 1.27 1.26 -> 1.38 1.39 -> 1.52
V4.1-Flash 1.03 -> 1.15 1.15 -> 1.27 1.15 -> 1.33
V4-Pro 0.96 -> 1.10 1.07 -> 1.20 1.08 -> 1.21
Also adds the V4.1-Flash shape to the harness networks so those rows reproduce.
Note aiter has no tuned profile for it at 48 experts per rank -- its v41_flash_ep2
profile wants 192 -- so all three networks here run aiter's generic stage2 config.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two fixes on top of this PR: +0.09 to +0.13x on the stage2+combine speedupBoth are costs the fused stage2 was paying on top of a GEMM that was already running at full speed. Measured EP8 on MI355X, fp8, uniform routing, Results — stage2+combine vs aiter MegaMoEV2
1. The AMDGPU
|
| hypothesis | evidence against |
|---|---|
| register pressure in the K loop | same opcode multiset either way — one s_nop and one s_waitcnt apart |
| SGPR spills | 13 spills, all in the prologue, all v_writelane filling s_nop slots; private_segment_fixed_size = 0 |
| L1 instruction cache | .text 12.0 → 23.2 KB; L1I is 64 KB |
| code size | duplicating the comm role 4x took .text to 45.8 KB and moved the cost 2.3% |
What actually changes is the instruction order. The comm role's live ranges lift the function's register pressure over the threshold that turns the stage on, and it then breaks the main loop's clustering:
| K loop (bs=16384, GLM-5.2) | comm role absent | comm role present |
|---|---|---|
| instructions / MFMA / loads / VALU | 155 / 32 / 12 / 81 | 153 / 32 / 12 / 81 |
| position of the first MFMA | 57 | 34 |
| VALU before the first MFMA | 41 | 23 |
mean load → s_waitcnt distance |
20.9 | 13.6 |
Same work, 35% less to hide the load latency with, paid on every trip. Compiling with amdgpu-disable-unclustered-high-rp-reschedule restores the original schedule.
One gotcha: FlyDSL emits code on the first launch, not inside compile_moe_gemm2_with_sdma(), so the llvm_options scope has to cover that launch too.
2. persist_cu on the fused path
A comm workgroup holds one of its CU's two workgroup slots for the whole kernel. ktrace at 16384 tokens/rank (V4.1-Flash):
| comm role absent | comm role resident | |
|---|---|---|
| comm WG residency | 508 / 120 ticks | 194132 / 193152 |
| GEMM WGs on those two CUs | 17 / 18 | 8 / 8 |
| GEMM WG duration, p50 | 25396 | 25092 |
| mean per-CU finish | 185856 | 185798 |
| last CU finish | 188804 | 195072 |
Every workgroup keeps its normal duration and the mean CU finish does not move — only the tail stretches. So it is not interference: the displaced work has to be redistributed, and at the bucket's tuned persist_cu = 192 one workgroup is ~13% of the kernel span, so the tail quantises badly.
Giving the fused path every CU makes workgroups shorter and more numerous, and the tail absorbs the displacement. Fused kernel cost over plain GEMM2 at 16384 tokens/rank:
persist_cu 192 |
persist_cu 256 |
|
|---|---|---|
| V4.1-Flash | 0.200 ms | 0.133 ms |
| V4-Pro | 0.274 ms | 0.184 ms |
Levers that did not help, so they need not be retried: dropping persist_cu, moving the comm workgroups to the end of the grid, making the comm WG count a multiple of the XCD count, poll backoff (1024x slower polling changes nothing), comm_wgs 1 vs 2, push_inv, and cacheline contention on the cdone counter.
Note on the V4.1-Flash rows
The harness gains the V4.1-Flash shape (5120 / 2304 / 384 experts / top-6) so those rows reproduce. aiter has no tuned profile for it at 48 experts per rank — its v41_flash_ep2 profile wants 192 — and _tune_v4_pro returns early unless apply_fp4. So all three networks here run aiter's generic stage2 config; these numbers are not aiter's tuned operating point for Flash.
jhchouuu
left a comment
There was a problem hiding this comment.
Second pass at f5771d11, merged onto current main. Stack: 8x MI355X, ROCm 7.14, torch 2.12, FlyDSL 0.4.0, aiter 9b7bb36ad.
Every first-round point I tested is fixed. I found one new problem: the kernels' JIT cache identity does not include the window layout, and that hangs a later process.
The first-round fixes hold
-
Skewed routing.
main.py test --bs 4096 --hot-bias {0.05, 0.1, 0.3} --row-slack 2is bit-exact (0 mismatched rows). -
Overflow is loud.
--hot-bias 0.3at the default slack fails, and rank 0 reportsrows did not fit the per-pair reservation ... raise row_slack.--row-slack 1.03with uniform routing now fails. In the first round it printed SUCCESS.
-
Uniform routing. Bit-exact at bs 2048 / 4096 / 8192 / 16384, with
--no-aligned-pairfrom 8192 up.--signal-mode quietis bit-exact at 4096. -
Back-to-back calls with data that changes. The existing checks (4 calls in
test, and your 4000-call run) reuse the same inputs. So a combine that read a peer's previous rows, or a sender that overwrote a slow peer's landing buffer, would still produce matching output.I added a variant that doubles the stage1 output scale in place on every odd call (
_s1_osdholds e8m0 bytes, so adding 1 is an exact x2). Call k must then equalref * 2**(k % 2), and a synced sanity pair confirms the check has teeth: the x2 call matches 2*ref exactly and differs from ref on all 4096 rows.I ran 16 calls with no host sync, at bs 4096, with
torch.cuda._sleep(50M)before every call (or every other call) on one rank:remoteandquiet;--hot-bias 0.1with the hot rank as the delayed one.
0 mismatched rows on every rank and every call. It might be worth folding this into
main.py test, since it is the only form that can catch the handshake failing.
1. The kernel cache key misses the layout, so a later process can hang (blocking)
In one container, main.py test --bs 64 followed by main.py test --bs 16 makes the bs-16 run hang in its first sc.run (stuck in the synchronize after it, GPUs busy). Each row below is a fresh container:
| sequence | bs 64 | bs 16 |
|---|---|---|
| bs 16 alone | SUCCESS | |
| bs 64, then bs 16 | SUCCESS | hang (timeout) |
bs 64, then bs 16, FLYDSL_RUNTIME_ENABLE_CACHE=0 |
SUCCESS | SUCCESS |
The cause. FlyDSL's persistent cache identifies a kernel by its source plus its scalar closure values. layout (ArenaLayout), comm (CommSpec) and dec (a dict) are not scalars, so none of what they carry reaches the key:
build_combinereadslayout.off_counter/off_flags/off_doneinside the kernel (mega_moe_gemm2_with_sdma.py:1320-1324, 1418).- The comm role reads
lay.off_counter/off_done(:1084,:218). - The plan kernel reaches
log2_max_tokandrecv_caponly through**dec(:414,:489).
off_sig and every offset after it depend on slot_cap, but the kernel names carry cap and not slot_cap. With row_slack=1.5, max_tok 16, 32 and 64 all give cap=128. The bs-16 process therefore loads the bs-64 binaries and spins on a flag at the wrong offset.
This reaches any process that follows another one with a different max_tok on the same ~/.flydsl cache, a server restart included. Within one process, two MegaMoESdmas with different max_tok may well hit the same thing, but I did not test that.
The fix. Hoist every value a kernel bakes in into a plain int local before the @flyc.kernel, so it becomes a closure scalar and enters the key. That means the offsets, slot_cap, log2_max_tok and recv_cap. Alternatively, put them in the kernel names. ops/gemm_ar/kernels_sdma.py (_phase, phase_specialization) does this for the same reason.
2. The harness hangs on this stack before it reaches the SDMA path
main.py test times the reference with _time(lambda: moe._run_stage2(...)), and bench does the same in base_s2. Both run aiter's _run_stage2 back to back with no host sync and no dispatch in between. Here, two such calls already hang inside aiter, while a synced pair runs fine. So both test and bench hang before the SDMA path runs.
I got through by syncing before each reference call. Which ROCm and torch did you run on? If this is aiter-version dependent, a sync in the reference timing, or a note in the README, would save the next person the hunt.
I did not re-measure performance this round: the shared node picked up another job partway through, so those numbers are discarded.
Smaller points
close()can free the window early._wait_peers_consumedreturns silently when it times out (30 s), andclose()then frees the window anyway. Raising, or at least warning, would turn a stuck peer into an error instead of a use-after-free on the copy engine.- Leftovers from the first round:
- The communicator is still only
close_all_resources()'d, never destroyed. sdma_bandwidth.py'scomm.finalize()is still a no-op behindhasattr.- The
_compat.py/1 | 16comments still point to the old location (mori.cco.device.flydsl.buffer/CM_SC0_SC1).
- The communicator is still only
- The README table predates
f5771d11. The README and the PR body show 1.12x / 1.23x / 1.35x; your latest comment has 1.27x / 1.38x / 1.52x. - Example 11 is also taken by #866. It adds
examples/cco/python/11_flydsl_sdma_allgather_engine_waitand edits the same README rows, so whichever lands second needs renumbering.
…ut, P2P fallback (#889) * feat(cco): example 11 -- 8-wave GEMM2 option and a P2P fallback for rows past the reservation Two additions to examples/cco/python/11_flydsl_mega_moe_sdma (#859): - gemm2_8wave.py: an 8-wave 256x256 persistent A8W4 GEMM2 for the same protocol, selected with MegaMoESdma(gemm2="8wave") / main.py --gemm2 8wave. Workgroup 0 is the comm workgroup (one wave per peer); the others walk the (tile, N-block) work items. Same plan, window and combine as the default GEMM2. It is the kernel of aiter's opt-in MegaMoEV2(stage2_sdma=True) path (ROCm/aiter#7196). - P2P fallback: with fp8 transport a row past the per-pair reservation is no longer dropped. The plan marks it OUT_ROW_P2P, GEMM2 (either kernel) writes it into the origin rank's comb_inp in aiter's P2P layout and sets that rank's inv[slot] to INV_P2P, and the combine reads it from there. bf16 transport keeps the old behaviour (drop + check_overflow raises). stage2+combine vs aiter MegaMoEV2 on 8x MI355X, eager, CT=16, --no-aligned-pair, bit-exact at every size: tokens/rank 512 2048 4096 8192 v4_pro --gemm2 aiter 0.82x 0.98x 1.08x 1.19x v4_pro --gemm2 8wave 0.71x 1.28x 1.40x 1.59x glm52 --gemm2 aiter 0.75x 1.00x 1.26x 1.37x glm52 --gemm2 8wave 0.77x 1.17x 1.40x 1.39x Co-Authored-By: Claude <noreply@anthropic.com> * feat(cco): example 11 -- exact send/landing layout from aiter's gathered route counts The plan kernel reads aiter's all-gathered per-expert route counts (bigcnt) and the dispatch's fanout pair, and derives per destination: the rows it sends there, its run's base in S and where the run lands in the peer's R. No per-pair reservation: R holds at most a rank's own routes and never overflows; S is --send-slack (default 4) x max_tok * topk rows, capped by the 32-bit buffer-resource range, and only rows past it take the P2P fallback (fp8) or are dropped (bf16). The plan checks its own row counts against the gathered ones; take_overflow_rows() raises on a mismatch. --row-slack -> --send-slack. 8x MI355X, v4_pro bs=4096 --hot-bias 1.0: stage2+combine 15.8 -> 5.3 ms (aiter GEMM2), 15.2 -> 4.6 ms (8-wave), aiter 4.35 ms; bit-exact. Uniform: unchanged (bench --gemm2 aiter,8wave 512..8192), bit-exact; glm52 8-wave bit-exact; bf16 transport relL2 unchanged (2.672e-2). Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: xiangch <xiangch@amd.com> Co-authored-by: Claude <noreply@anthropic.com>
Adds
examples/cco/python/11_flydsl_mega_moe_sdma: a MegaMoE stage2 that writesits GEMM2 output into a local, rank-sorted send buffer and has comm waves
inside the GEMM kernel ship finished chunks to the peers with cco SDMA, so the
transfer runs on the copy engines instead of the compute units.
The reference it is measured against is aiter's
MegaMoEV2, whose fused GEMM2scatters every output row straight into the origin rank's
comb_inpwith P2Pstores from the CUs.
Future work: gfx1250 porting, and investigate the cco-sdma use in dispatch + gemm1 fusion (mega-moe stage1).
Files
mega_moe_gemm2_with_sdma.pyMegaMoESdma) and its three kernels: the plan kernel, GEMM2 with comm waves, the combine kernel; the window layout and the sender side of the SDMA protocol they share. The module docstring is the design documentmain.pyMegaMoEV2:main.py test(output vs aiter's) andmain.py bench(stage2+combine latency)sdma_bandwidth.pyResults
stage2+combineon glm52, 8x MI355, eager launches, fp8 transport,--chunk-tiles 16,--no-aligned-pair. Output matches aiter bit for bit(relL2
0.0e+00, 0 mismatched rows) at every size below. Base model config (glm-5)bsis per rank. Single run; the spread is about ±0.05x at 128/512 and ±0.02xfrom 2048 up. Below ~2048 the fixed costs of the extra plan kernel and of the
combine outweigh what the copy engine saves, and aiter's P2P scatter wins.
This is a large-batch (prefill) optimisation, not a general one.
Only
stage2+combineis compared. That is the part this PR replaces; dispatch andstage1 are shared and are not timed.
Where the time goes at 8192 (rocprof kernel times, all ranks): the GEMM2 kernel
is 1255-1280 us against aiter's compact stage2 at 1639-1674 us -- but that is "GEMM plus
its communication", since aiter's kernel carries the P2P scatter and this one hands
the transfer to the copy engines. What is paid back is the plan kernel (~18 us), the
combine (133 us of work, plus waiting for the slowest peer) and a GEMM2 that is
still 100-120 us above its own floor (1.12-1.16 ms with the epilogue stores and the
puts removed). The reduce in the combine reads 6.5e4 rows of 6.3 KB and is close to
the memory-bandwidth limit.
Design
Three kernels on one stream, against the baseline's two:
run, so a range of sort tiles maps to a contiguous row range per peer.
send[out_row]instead of scattering, pluscomm_wgsleading workgroups(one wave per peer) that fill the peer's
invtable and then put finishedchunks while the remaining tiles still compute.
The contiguity in (1) is what makes (2) cheap: a chunk is one ~1.4 MB SDMA put
rather than a scatter of small ones. The receiver needs, for each of its token
slots, the row to read; the sender writes that table (
inv, in the cco window):the plan kernel for rows that stay on its own rank, the comm waves -- direct stores
into the peer's window -- for the rest. So no index travels with the data and the
combine builds no table and needs no grid barrier.
What makes it fast
Four things, in order of what each was worth. Each is backed by an A/B in the
same harness.
fp8 transport (
--quant fp8_blockwise_1x32, default)bf16 moved twice the bytes and the transfer stopped fitting under the GEMM. A
truncated-shipping probe showed that of the +0.219 ms the comm cost at bs=4096,
+0.205 tracked bytes, not sync. Rows now carry aiter's
fp8_blockwise_1x32in aiter's own row layout -- payload, then the e8m0 scales, inline -- so one
contiguous put still ships a whole row. Reuses
_fp8_scale_for_leaderandcvt_scalef32_pk_fp8_f32; the combine dequantises withcvt_pk_f32_fp8.Write-through epilogue stores
The per-unit publish released at system scope. On gfx950 that lowers to
buffer_wbl2 sc0 sc1-- a write-back of the whole L2, not of the rows justwritten -- charged once per
(m_block, n_block)unit, 12288 times per launch.Storing the rows
sc0|sc1instead makes retiring them the release the copyengine needs, and the fence disappears. Confirmed in the ISA: the per-unit
buffer_wbl2is gone and all 8 epilogue stores carrysc0 sc1.2.4x on the GEMM2 kernel. Same recipe as
ops/gemm_ar/kernels_sdma.py(
publish="writethrough"), whose docstring explains it.Note:
buf_copy_store'scache_modifieris a BufferCopy enum (0=cached,2=nt), so sc0|sc1 cannot be reached through it -- the epilogue usesbuffer_store, which passes the value through as the instruction's aux field.The per-chunk completion counters are also a cache line apart, which is worth
another ~8% on its own.
A per-pair row reservation
capwas the whole token-slot space reserved for every(src, dst)pair --16x what a pair can use -- which put bs=8192 over the 4 GiB buffer-resource
range. It is now the even share times
--row-slack(default 1.5), with the plankernel refusing to place a row past it and recording the overflow. This keeps
the handshake-free write into the peer's landing buffer at a statically known
offset.
Window at bs=4096 drops 3.32 GB -> 0.58 GiB; bs=8192 fits at 1.25 GB and bs=16384
at 2.5 GB (fp8).
A plan kernel with no serial chain, and a combine with no build phase
The plan kernel is ~17 us at 8192 tokens/rank (43 us at the start of this round,
66 us at the start of the PR). Each phase was timed on the GPU -- rocprof, or CUDA
graph replay of the kernel alone; timing launches from the host hid them behind the
launch rate:
run one block per tile and one thread per row: each row is decoded once, a wave
ballot per destination pe plus a popcount gives its rank among the earlier rows
for that pe, and the per-wave counts cross waves through a few LDS words.
block only needs the prefix of the tiles it owns: one parallel pass over the
earlier tiles' counts and a short LDS reduction. It runs to the tile's own index,
so there is no bound on the tile count to get wrong (see below).
(the atomics queue in L2), then every thread of every block polled every block's
flag (65k polling loads slowed the stores they waited for). Now it is a two-level
tree: 32 blocks to a counter, the last block of a group bumps a top counter, and
each block polls that one address.
inv(9 us). Moved out of the plan into the GEMM2kernel's comm waves, which are idle until the first chunk is written.
The combine runs two blocks per CU now that it has no grid-wide rendezvous: more
row loads in flight for the reduce (8192: 1.528 -> 1.492 ms; 4x and up was slower).
Outputs of the plan kernel are bit-identical to the version before the rewrite on
the same stage1 output.
The comm wave also hangs the copy engine's own trailing ATOMIC on its last put
(
remote_signal) instead of draining the queue, so it no longer pins the tailof the GEMM2 kernel; the peer's combine -- which was going to wait anyway -- polls
the counter with
wait_signal. Worth 6-12 us.--signal-mode quietkeeps theold drain-and-flag path.
Correctness under skewed routing, overflow and call overlap
Review of the first revision found cases that were wrong without any report. They
are fixed and tested (
main.py test --hot-bias):send side, but a rank receives rows from every peer, so one rank getting more
than ~6% over an even share at bs=4096 made the prefix silently wrong on every
rank. The prefix no longer has such a bound.
main.py test --bs 4096 --hot-bias {0.05,0.1,0.3}(rank 0's experts favoured, up to 70% over the even share) isbit-exact; the previous revision fails at 0.1.
the output wrong, so
MegaMoESdma.check_overflow()raises andmain.pycalls it.tile_baseis also clamped tocap, so a chunk that crosses the reservation iscut at it instead of reading the next peer's run and writing past this rank's slot
in the peer's landing buffer. With the default
--row-slack 1.5,--hot-bias 0.3fails with a message naming
row_slack;--row-slack 2runs and is bit-exact.main.py testis stricter. When both sides move fp8 it requires 0 mismatchedrows, not just relL2 below the tolerance.
GEMM2 that follows rewrites
S, which is what guarantees this rank's own outboundputs of the previous call have finished reading it; the comm waves wait for the same
flag before filling a peer's
invor putting into its landing buffer.close()waits for the flags before freeing the window. The reviewer could not trigger the
unguarded case in 16 back-to-back calls with a delayed rank, and neither did I, so
this is added from the protocol rather than from a failing run.
num_valid == 0writes zeros tocursorandtile_baseinstead of leaving theprevious call's values.
Testing
MORI_ENABLE_SDMA=1 torchrun --standalone --nproc_per_node=8 main.py test --bs 4096 MORI_ENABLE_SDMA=1 torchrun --standalone --nproc_per_node=8 main.py bench \ --bs-list 128,512,2048,4096,8192,16384 --no-aligned-pairmain.py testis bit-exact (0 mismatched rows) at 2048 / 4096 / 8192 / 16384tokens per rank with uniform routing, at 4096 with
--hot-bias 0.05 / 0.1 / 0.3 --row-slack 2, and with--signal-mode quiet. At 128/512 aiter picksp2p_quant=none(bf16) when it chooses for itself, so the2.67e-02there is thispath's fp8 quantisation error, within the 0.05 tolerance.
ranks: bs 128/2048/4096/8192, two skewed routings, quiet signalling, bf16 rows):
identical.
against the first call's: no wrong output. A multi-ms stall shows up every few
thousand calls, with and without this PR's later commits.
2247dec34+ FlyDSL 0.3.0, and aiter main9b7bb36ad(2026-10-28) + FlyDSL 0.4.0(speedup at 2048 / 4096 / 8192 / 16384: 1.05x / 1.14x / 1.24x / 1.34-1.35x).
FLYDSL_DUMP_IR=1) to confirm the write-through stores and theremoval of the per-unit
buffer_wbl2.Ruled out by measurement
Recorded so the next person does not repeat them. All measured in the same
harness, at bs=4096 unless noted:
comm_wgs1 → 8queues > 1, splitting or rotating puts across channelsrecommended_sdma_engine_id_maskhas one bit per link), so extra channels share it and only add descriptors. Splitting a 1.44 MB run in two cost 3%; rotating whole runs across two channels 2%.chunk_tilesfor link efficiencypersist_cu240 → 64, orpersist_strided)stridedmakes the GEMM itself faster (0.615 vs 0.625 ms) but the comm cost jumps 0.139 → 0.383 ms.aligned_pairis 1.5% slower than without it on this routing. Caveat: measured on uniform random routing, where there are no hot expert pairs. Real skewed routing was not tested and could change this.What is left. At 8192 the plan is ~18 us plus a launch, the GEMM2 is 100-120 us above
its own floor (the write-through stores and the SDMA traffic competing with the
GEMM), and the combine waits ~60 us for the slowest peer, of which roughly 40 us
is the last chunk's transfer after the GEMM ends (inferred, not measured on its
own). Folding the plan into the GEMM2 kernel is less attractive than it looked: that
grid is
cu_num * n_blocks(~5760) workgroups, far more than are resident, so a gridsync across all of them would deadlock, and dedicated plan workgroups would occupy
CUs for about as long as the launch they save.
Limitations
MORI_ENABLE_SDMA=1.--queuesmust be 1 for--signal-mode remote(the default), since thesignal rides the channel the data went out on.
bs=16384per rank fits with fp8 rows (2.5 GB),but not with bf16 rows (
--quant none, ~4.8 GB).aligned_pairequivalent, so bucket 8192 needs--no-aligned-pairtocompare like for like.
--row-slackof an even share per (src, dst)pair. Beyond it rows are dropped and
check_overflow()raises; raise--row-slack.Library users must call
check_overflow()themselves --run()does not sync.main.py benchbuilds oneMegaMoEV2sized for the largestbs, so at 128/512aiter runs fp8 transport, where on its own it would pick bf16.
MegaMoESdmareads private attributes of aiter'sMegaMoEV2(_s1_op,_s1_nvm,_g2v2_*, ...), so it follows aiter's internals.🤖 Generated with Claude Code