Repository navigation
feat(cco): example 13 -- MegaMoE Stage1 with the dispatch on SDMA - #904
Draft
yangyuhuiling wants to merge 4 commits into
Draft
yangyuhuiling wants to merge 4 commits into
yangyuhuiling wants to merge 4 commits into
Conversation
A pack kernel lays out this rank's routed rows per (destination, expert) in a cco window; one fused kernel then puts them to the peers (workgroup 0, one signalled put pair per peer and segment, in expert order) while an 8-wave GEMM1 + SwiGLU starts on each expert as soon as its rows land. A few shuffler workgroups put the landed A scales into the contiguous GEMM's layout once per M-tile (4096/8192 buckets); the 2048 bucket stages them per work item instead. MegaMoEStage1Sdma installs into aiter's MegaMoEV2 in place of its Stage1 launch; the layer output is byte-identical to aiter's. Against aiter main f0b96f49e, Stage1 incl. prepare is -0.22 ms at 8192 tokens/rank (2.43 -> 2.21 ms); against main plus the open ROCm/aiter#7001 it is 0.13-0.19 ms slower, since the extra pack copy then costs more than the CU time the copy engines save. Co-Authored-By: Claude <noreply@anthropic.com>
With --fuse-quant (MegaMoEStage1Sdma(fuse_quant=True)), aiter's prepare only plans and two kernels replace pack: - route: walks the production producer's rows as pack does, but only records each row's destination per (token, top-k slot) and writes the route headers to the destination over P2P; - qpack: one block per token quantises the bf16 row once, with aiter's per-1x32 MX fp8 arithmetic, stages it in LDS and stores it to each of the token's slab / landing rows with 16 contiguous bytes per lane. Every bf16 row is read once and every routed row written once, instead of prepare quantising into a buffer and pack copying each routed row from it. At 8192 tokens/rank route + qpack take 0.033 + 0.083 ms where pack took 0.188 ms. Stage1 incl. prepare improves by 0.02 / 0.04 / 0.08 ms at 2048 / 4096 / 8192 tokens/rank, still 0.09-0.16 ms slower than ROCm/aiter#7001. The layer output stays byte-identical to aiter's. Co-Authored-By: Claude <noreply@anthropic.com>
6 tasks done
Ports the per-item fixes of example 14 (#919) to the fused SDMA Stage1 kernel, and the batched row copy of its SDMA dispatch to pack: - tile_find: a ballot + popcount and v_readlane instead of a butterfly max over ds_bpermute (six dependent LDS permutes per work item). - Readiness: a GEMM1 work item reads all shuffler flags (shuffler mode) or all peer signal counts (ldsw mode) in one round trip and spins only on a short one; the shufflers do the same per expert. The peer signal counts are one array indexed by the sender, so its address and stride are looked up once per workgroup (seven lookups per item added VGPR spills). - pack: a wave writes the route headers of 64 rows lane-parallel, then copies 4 rows per step with every load before its stores (vmcnt retires loads and stores in order). - bench: per replay the max over ranks, then the median over 30 replays. Not ported: the SwiGLU v_rcp_f32 (the output must stay byte-identical to aiter's, whose sigmoid divides), and one acquire per loop (this kernel has no per-item acquire; its per-item L1 invalidate is needed). 8x MI355X, v4_pro, aiter f0b96f49e + the files of aiter#7196 and #7001, interleaved runs, SDMA Stage1 ms (incl. prepare), output byte-identical to aiter's: tokens/rank pack before pack after fuse-quant before after 2048 0.945-0.946 0.931-0.937 0.915-0.921 0.901-0.908 4096 1.383-1.386 1.365-1.377 1.324-1.354 1.323-1.326 8192 2.228-2.236 2.210-2.216 2.149-2.161 2.150-2.152 Co-Authored-By: Claude <noreply@anthropic.com>
…ing in the same launch Measured on the same inputs, the 8-wave GEMM1 is the gap to aiter#7001: GEMM1 alone (no dispatch) takes 0.68 / 1.04 / 1.89-1.95 ms at 2048 / 4096 / 8192 tokens/rank against 0.60 / 0.80 / 1.40 ms for #7001's Stage1 tiles. The SDMA dispatch costs the GEMM workgroups less than #7001's CU dispatch (about 0.37 ms at 8192), which is why the two fused kernels took about the same time there. --gemm1 7001 (MegaMoEStage1Sdma(gemm1="7001")) runs gemm1_7001_sdma, one launch built on #7001's compact Stage1 kernel with its CU dispatch replaced: - workgroups 1..32 pack this rank's rows, one (destination, segment) at a time in expert order (group segment first), and flag each segment; - workgroup 0 puts a peer's segment with SDMA as soon as its flag is up; - every workgroup then takes #7001's GEMM1 work items (build_fused_gemm1, its tuned compact route-major FP8 tiles); an item waits until every peer has signalled the group segment and experts 0..e of its M-tile and this rank's own segments of e are packed. So the Stage1 output matches #7001's byte for byte, and no pack / qpack launch runs before the kernel. Needs aiter with aiter#7001's mega_moe/gemm1.py (only imported in this mode) and the prepare's quantisation (not with --fuse-quant). 8x MI355X, v4_pro, aiter f0b96f49e + the files of aiter#7196 and #7001, interleaved runs, Stage1 ms (incl. prepare), output byte-identical: tokens/rank aiter + #7001 --fuse-quant (8-wave) --gemm1 7001 2048 0.813-0.820 0.911-0.915 0.872-0.874 4096 1.167-1.174 1.333-1.335 1.171-1.173 8192 2.022-2.052 2.142-2.149 2.010-2.014 Co-Authored-By: Claude <noreply@anthropic.com>
kawhil-amd
added a commit
to kawhil-amd/mori
that referenced
this pull request
Nov 6, 2026
…he quant stores Two mechanisms, both off by default, both bit-exact against aiter. `--ready-first`: GEMM1 used to gate every tile on the SDMA signals itself, once per tile. Wave 0 of the comm workgroup now reads each source's signal level once per round and publishes m-tiles into a ready queue in the order they become ready; a worker claims a ticket with one atomic and reads the queue, and never touches the signals. Needs `--fuse-layout`, since the scheduler starts when the layout in the same kernel is done. `--quant-lds-store`: the quantised row left each lane as two 16-byte pieces 32 bytes apart, so every store instruction covered half of each 128 B line of an uncached, write-through window. A wave now stages its round of 64 groups in LDS (2304 B per wave) and reads it back so each lane stores one contiguous 16 B -- 1024 contiguous bytes per wave instruction -- with the e8m0 scales packed four per dword. Same bytes, same cache modifier. ROCm#904 measured the same change at 3.1x on its own dispatch; here `prepare` at bs 4096 goes from 114.3 us to 73.4 (quant pass 1: 54.3 -> 29.7). Flash, 8x MI355X, CUDA graph, interleaved A/B, 3 reps, medians (ms): | tokens/rank | base | C1 | C1 + swizzle | aiter compact | |---|---|---|---|---| | 1024 | 0.389 | 0.354 | 0.345 | 0.463 | | 2048 | 0.586 | 0.505 | 0.485 | 0.670 | | 4096 | 0.845 | 0.811 | 0.778 | 0.910 | | 8192 | 1.541 | 1.479 | - | 1.536 | C1 is `--own-tokens --fuse-layout --ready-first`. At bs 256 the scheduler is a small loss (0.222 against 0.218): the first publish waits for the layout and for the slowest peer's first chunk, which no reordering shortens. Under C1 the tile loop is compute-bound -- 354 us of `do_tile` in a 365 us loop at bs 2048, 5 us of queue wait -- and the front end is gated by the slowest peer's first chunk (put to landing is 70-85 us, ~28 of it the wire time for 1.35 MB), so the remaining front-end budget is 20-30 us. Measured and not kept, with numbers, in the README: fusing the prepare into GEMM1 (+-1%), a dynamic arrival-ordered layout (-90 us at bs 2048), local-first and arrival-order row ordering, layout on dedicated blocks or merged into the scheduler block, more quant blocks (511 is net zero, 767 deadlocks), the plan on fewer blocks, unroll, quant-skip, a pre-built doorbell, and `--tile-n 256` (which gives the ~14 us per-tile fixed cost that rules out variable-M tiles). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Nov 6, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
examples/cco/python/13_flydsl_mega_moe_stage1_sdma: aiter's MegaMoEV2 Stage1 (dispatch + GEMM1) with the dispatch moved onto the SDMA copy engines. It is the Stage1 counterpart of example 11, which does the same for Stage2 + combine.This is a draft. With its own 8-wave GEMM1 the SDMA Stage1 is slower than aiter's Stage1 with the open ROCm/aiter#7001. With
--gemm1 7001(#7001's own GEMM1 tiles, the rows packed in the same launch; commit 39f7618) it is even with #7001 at 4096 and 8192 tokens/rank and about 0.05 ms slower at 2048. It is posted as a reference for how cco SDMA can feed a persistent GEMM, and records what bounds SDMA here.How it works
pack.py: one block per dispatch segment.quant_pack.py(--fuse-quant,MegaMoEStage1Sdma(fuse_quant=True)): replaces pack by folding its copy into the quantisation. aiter's prepare then only plans; its quant workgroups are not launched.route: walks the same rows as pack, one lane per row, without moving payload. It records where each token's rows go and writes the route headers as pack does.qpack: one block per token. It quantises the bf16 row once with aiter's per-1x32 MX fp8 arithmetic (same bytes), stages it in LDS, and stores it to each of the token's slab / landing rows with 16 contiguous bytes per lane.gemm1_7001_sdma.py(--gemm1 7001,MegaMoEStage1Sdma(gemm1="7001")): one launch built on #7001's compact Stage1 kernel, its CU dispatch replaced. Workgroups 1..32 pack this rank's rows segment by segment in expert order and flag each one; workgroup 0 puts a peer's segment with SDMA as soon as it is flagged; then every workgroup takes #7001's GEMM1 work items (its tuned compact FP8 tiles), each waiting for the peers' signals and this rank's own segments of its expert. No pack / qpack launch runs before it. See the section below.gemm1_8wave_sdma.py: one persistent fused kernel.shuffler(4096/8192 buckets): a few workgroups put the landed scales into the contiguousshuffle_scalelayout once per M-tile. A work item then waits on their flags only.ldsw(2048 bucket): every work item waits on the 7 peer signals and stages its scales into LDS. Its loads are issued ahead of the prologue's A/B loads.mega_moe_stage1_sdma.py:MegaMoEStage1Sdma.install()swapsMegaMoEV2._s1_mega/_s1_mega_bundle. aiter's prepare, Stage2 and combine are used unchanged.scale_mode="auto"picks the mode from the bucket'ssort_block_m.main.py:test: 4 back-to-back calls with alternating inputs; requires the layer output to match aiter's byte for byte.bench: CUDA Graph timing of Stage1 (incl. prepare) and the whole layer, against aiter.Results
Setup: 8x MI355X, DeepSeek-V4-Pro a8w4 (v4_pro), EP8, MTPR 8192, CUDA Graph, max over ranks. Stage1 includes prepare. All outputs are byte-identical to aiter's.
Against aiter main
f0b96f49e(main.py bench):Against aiter main + ROCm/aiter#7001: same node and container, interleaved #7001 / main / #7001, each aiter tree with its own FlyDSL cache.
With
--fuse-quant(against aiter main + #7001,main.py bench; two interleaved rounds against pack, plus one run of the committed code):At 8192, eager: route + qpack take 0.033 + 0.083 ms where pack took 0.188 ms. The first qpack stored each lane's row piece straight from registers (two 16-byte pieces 32 bytes apart); that ran at 0.26 ms, because the cco window is uncached and strided partial lines write slowly. Staging the row in LDS fixed it.
Why it loses: once the CU dispatch is as cheap as #7001 makes it, the extra pack copy costs more than the CU time the copy engines save. At 8192 that copy is about 0.19 ms, about 360 MB read and written per rank at ~3.8 TB/s. A variant that ran the pack inside the fused kernel to overlap it with GEMM1 was no faster: both compete for HBM bandwidth.
--fuse-quanttakes most of that copy away: the send-side preparation is now about 0.12 ms. The remaining gap is in the fused kernel itself, about 1.85 ms at 8192 against about 2.02 ms for #7001's whole Stage1 including prepare: reading A from the uncached window and the per-work-item waits for landed data. Dropping the pack entirely and putting the token-ordered input as it is would not help either: an M-tile's rows come from almost the whole token range of their source rank, so GEMM1 could not start until nearly all of x had landed.What was measured along the way (fused kernel alone, 8192, no puts)
Reading A from the cco window (uncached VMM) vs a torch copy differed by only 0.02-0.06 ms. An earlier 0.22 ms gap turned out to be a measurement-order artefact.
Per-item fixes from example 14 (commit 1d2b90d)
#919 (example 14, the whole layer in one persistent kernel) cut the per-work-item overhead of the same 8-wave GEMM1 and batched the producers' row copy of its SDMA dispatch. This commit ports what applies here:
tile_find: a ballot + popcount andv_readlaneinstead of a butterfly max overds_bpermute(six dependent LDS permutes per work item).shuffler) or all 7 peer signal counts (ldsw) at once and spins only on a short one; the shufflers do the same per expert. The signal counts are one array indexed by the sender, so its address and stride are looked up once per workgroup. Looking up all seven per item added VGPR spills (1 -> 7 in theldswbuild).pack.py: a wave writes the route headers of 64 rows lane-parallel, then copies 4 rows per step with every load of the step before its stores.vmcntretires loads and stores in order, so one store-ack wait covers 4 rows instead of one.bench: per replay the max over ranks, then the median over 30 replays (was the mean of 20). A host hiccup on one rank stretches the replays it delays; the median ignores those.Not ported:
v_rcp_f32of feat(cco): example 14 -- the whole EP MoE layer in one persistent kernel, Stage2 on SDMA #919: the output must stay byte-identical to aiter's, and aiter's sigmoid uses an IEEE division.shuffler) is needed for correctness.Same node (8x MI355X,
rocm/sgl-devROCm 10 image), aiterf0b96f49e+ the files of ROCm/aiter#7196 and ROCm/aiter#7001, interleaved runs (two each), Stage1 ms including prepare, output byte-identical to aiter's:About 0.01-0.02 ms (1%), much less than in #919. That kernel spilled the
tile_findlane addresses and invalidated L2 at every work item; this one does neither (0 VGPR spills withshuffler, 1 withldsw). The gap to #7001 is unchanged, and it is the GEMM1: measured on the same inputs, see the next section.Benchmark note. On a host whose page cache leaves a NUMA node little free memory, kernel compaction keeps migrating pages. ROCr's system memory (
hipHostMalloc, kernel arguments, signals) is userptr-backed by default, so each migration makes KFD evict every queue of the owning process, and with cross-rank waits one rank's eviction stalls all of them (20-580 ms per call seen with example 14).HSA_USERPTR_FOR_PAGED_MEM=0(GTT buffers instead of userptr) removes it; the numbers in this section were measured with it.aiter#7001's GEMM1 tiles (
--gemm1 7001, commit 39f7618)Where the gap was. Each fused kernel run alone, without its dispatch (#7001's producers skipping the row copies; this kernel's items not waiting), in one run on the same node and inputs, ms:
The 8-wave GEMM1 is 0.08 / 0.25 / 0.42 ms slower than #7001's tiles. #7001 spends about 0.07 / 0.2 / 0.5 ms of its fused kernel on the CU dispatch, which the SDMA dispatch does not take from the GEMM workgroups, so at 8192 the two fused kernels took about the same time and the Stage1 gap was the pack / qpack launch before this one. (The commit message quotes 1.89-1.95 ms and 0.37 ms from an earlier run whose #7001-alone time was 1.56 ms; the values above come from one run, and a second run gave #7001 alone 1.40 ms again.)
What
--gemm1 7001does.gemm1_7001_sdmais #7001's compact Stage1 kernel with the producers' P2P copies replaced:build_fused_gemm1, its tuned compact route-major FP8 tiles); an item waits until every peer has signalled the group segment and experts 0..e of its M-tile and this rank's own segments of e are flagged, where #7001 waits for its producers' tile-ready counters.The Stage1 output matches #7001's byte for byte. Packing inside the launch removes the pack / qpack launch (0.12-0.18 ms at 8192) and starts the copies about that much earlier; the packing workgroups cost the GEMM about 0.08 ms back.
Same node (crsuse2 v3-091,
rocm/sgl-devROCm 10 image), aiterf0b96f49e+ the files of ROCm/aiter#7196 and #7001, interleaved runs (two each), CUDA Graph, ms:--fuse-quant(8-wave)--gemm1 7001--gemm1 7001What bounds it now: the copy engines. At 8192 the puts alone take 1.19 ms and #7001's GEMM1 alone 1.39 ms; running together they share HBM and the fused kernel takes about 1.77 ms. At 2048 / 4096 the puts alone (0.63 / 0.82 ms) already take longer than the GEMM1 alone (0.56 / 0.77 ms). KFD recommends one XGMI SDMA engine per peer link on this node, so each peer's rows go through one engine. Tried and dropped:
CCO_UNCACHED_WINDOW=0): both correct, neither measurably faster.Requirements
MegaMoEV2's compact prepare. Measured with aiterf0b96f49eplus the files of [FlyDSL] MegaMoEV2: opt-in Stage2 + combine over SDMA with an 8-wave GEMM2 aiter#7196; the Stage1 path itself is aiter main's.--gemm1 7001also needs the files of [FlyDSL] [Perf] MegaMoE: faster compact Stage1 producer and FP8 GEMM1 tiles (gfx950) aiter#7001 (mega_moe/gemm1.py), and is compared against that aiter's own Stage1.BUILD_CCO_SDMA=ON, run withMORI_ENABLE_SDMA=1.MORI_ENABLE_SDMA=1 torchrun --standalone --nproc_per_node=8 \ examples/cco/python/13_flydsl_mega_moe_stage1_sdma/main.py test --bs 8192 MORI_ENABLE_SDMA=1 torchrun --standalone --nproc_per_node=8 \ examples/cco/python/13_flydsl_mega_moe_stage1_sdma/main.py bench --bs-list 2048,4096,8192Test plan
test --bs 8192 --scale-mode shuffler,--scale-mode ldsw,test --bs 2048,test --bs 4096: PASS, 0 mismatched rows.bench --bs-list 2048,4096,8192: byte-identical before and after the graph replays.--fuse-quant:test --bs 8192,--bs 4096,--bs 2048: PASS, 0 mismatched rows;bench --bs-list 2048,4096,8192: 0 mismatched rows. Default modetest --bs 8192re-run: PASS.test --bs 8192(shuffler),--bs 4096,--bs 2048(ldsw), and--fuse-quantat 8192 and 4096: PASS, 0 mismatched rows.benchwith and without--fuse-quant(which also checks every token count, 2048 included): two runs each, interleaved with the previous commit, 0 mismatched rows. The committed files re-rantest --bs 8192,--bs 2048,--bs 4096 --fuse-quant: PASS.test --gemm1 7001at 8192 / 4096 / 2048 andtest --bs 8192 --fuse-quant,test --bs 2048(8-wave): PASS, 0 mismatched rows.bench --gemm1 7001andbench --fuse-quanttwo runs each, interleaved, 0 mismatched rows. The committed files are the ones that ran.🤖 Generated with Claude Code