Skip to content

[MoE] Port moe_gemm_2stage (stage1+stage2) to the new fx.* pipeline — fp8 (gfx942 + gfx950) - #947

Open
coderfeli wants to merge 14 commits into
mainfrom
worktree-agent-moe-2stage-port
Open

[MoE] Port moe_gemm_2stage (stage1+stage2) to the new fx.* pipeline — fp8 (gfx942 + gfx950)#947
coderfeli wants to merge 14 commits into
mainfrom
worktree-agent-moe-2stage-port

Conversation

@coderfeli

@coderfeli coderfeli commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Ports the MoE 2-stage MFMA kernels (kernels/moe/moe_gemm_2stage/) from the legacy authoring API (SmemAllocator/SmemPtr + raw buffer_ops + raw rocdl.mfma) to the current fx.* surface (make_buffer_tensor + SharedAllocator + tiled copy/MMA + fx.copy_atom_call), and makes the new pipeline the canonical path for fp8 (both stage1 and stage2).

Scope: CDNA3 (gfx94, MI300) + CDNA4 (gfx95, MI350/MI355) — both CI-green.** fp8 routes to the new gemm1/gemm2 builders on both arches. Two CDNA3 accommodations: the packed bf16-output buffer atomic (buffer_atomic_pk_add_bf16, gfx950-only) keeps the atomic+out=bf16 combo on the legacy global-atomic path on gfx942; and gemm2 LDS is packed into one max(2*a_lds, cshuf) region so large f32-output tiles fit CDNA3's 64KB LDS (vs CDNA4's 160KB). Validated: mi325 (gfx942) + mi355 (gfx950) test jobs both pass.

What's on the new pipeline (fp8)

Stage Status
gemm1 (gate-up) correct across all tile configs; perf-aligned (~92% of legacy compute-bound, decode parity)
gemm2 (down-proj) correct for atomic / reduce / both + f32 scalar-atomic output; all tiles incl. tile_n=256; perf parity
  • New builders: _build_moe_gemm1_fp8_gateup (gemm1.py) and _build_moe_gemm2_fp8 (gemm2.py) — B-first MFMA (weight=A-operand, activation=B-operand) with a 2-stage A-LDS ping-pong + B register prefetch + LDS-read hoist. The builders are dtype-parametric (fp8/bf16) but only fp8 is routed (see below).
  • Shared helpers in layout_helpers.py (tiled-MMA/copy factories, sorted gather/scatter _TensorWithIndex, silu_pair, pk-atomic epilogue).
  • Public API unchanged: compile_moe_gemm1, compile_moe_gemm2, compile_moe_gemm2_ex, MoeGemm2Mode, _MoeGemm2ReduceWrapper, compile_moe_reduction.
  • fp16 and all other dtypes stay on the legacy path (unchanged).

Performance (gfx950, median, ~14% clock noise)

  • fp8 gemm1 compute-bound (8192x8192, t=32768, e=16, k=4): ~1193 TFLOPS vs legacy ~1297 (~92%); decode at parity.
  • fp8 gemm2 atomic (tile 256/128): ~1095 vs legacy ~1094 (parity).

Correctness (cache-off)

  • test_moe_gemm_2stage fp8 full matrix (gemm1+gemm2 x atomic/reduce x out f16/bf16/f32 x S/M/L, mask/nomask, eager/graph): 84 passed locally; green on gfx942 + gfx950 in CI.
  • test_moe_stage2_standalone -k fp8 (incl. tile_n=256, decode/prefill): 12 passed.

Notable fixes / gotchas

  • silu output-dtype reinterpret bug: the silu fragment must be produced in the CShuffle store dtype (out_elem); a bf16-vs-f16 mismatch silently reinterprets the bit pattern (0x4480 -> 1024.0 vs 4.5).
  • buffer descriptor sizing: num_records_bytes = M*K*elem_bytes (bytes, not elements).
  • stage2 gather view must be rank-3 [tokens, topk, inter] so the sorted id decodes to (token, slot); a rank-2 view reads slot-0 only (invisible on tame data, wrong on real routing).
  • atomic scatter uses an explicit-index buffer_atomic_add path + a row-guard to drop out-of-tile grid slots (plain stores rely on buffer OOB clamp; atomics don't).

Deferred to follow-ups

  • bf16 on the new pipeline: the dtype-parametric builder already accepts bf16, but routing is gated off. Two issues to resolve first: (1) gfx942/CDNA3 needs the bf16 MFMA(16,16,16) variant (the builder currently emits the gfx950-only MFMA(16,16,32) bf16 op, which aborts compilation on gfx942); (2) a gfx950 crash at in_dtype=bf16 + out_dtype=f32 at large tile_m. bf16 uses the proven legacy path meanwhile.
  • int4 / int4_bf16 (W4A8 / W4A16): blocked on the B-first design — the weight is the MFMA A-operand loaded by a direct same-typed copy, but int4 needs an in-register unpack before the MFMA, and feeding computed values into an A-fragment currently fails to lower (ub.poison after rmem-SSA promotion / LLVM castIsValid). Fix path: an A-first int4 variant (weight as MFMA B-operand, like legacy). int4 stays on legacy.
  • int8 / int8smooth / groupwise int4_bf16 gemm2 and reduction.py remain on legacy.

Notes

  • main has advanced since this branch point; may need a rebase before merge.

Generated with Claude Code

Perf note (gfx950)

fp8 on par with legacy: stage1 compute-bound ~1266 (legacy ~1297) TFLOPS, stage2 atomic ~1100 (parity), reduce ~1520; decode stage1/reduce at parity. Fixed a ~600x atomic-scatter regression on small-tile/decode shapes (out-of-tile lanes were serializing on out[0]); decode-atomic residual ~1.6x vs legacy remains (small-tile issue-slot overhead, decode's preferred reduce mode is on par).

Felix Li and others added 8 commits July 27, 2026 12:57
Add kernels/moe/moe_gemm_2stage/layout_helpers.py, the fxh helper layer the
aiter layout-API reference kernel depends on, adapted to this repo's fx.* surface.
Foundation for migrating gemm1/gemm2 off the legacy SmemAllocator/SmemPtr +
buffer_ops API. Contains only reusable layout/copy-atom/fragment helpers
(FlyObjCache, create_thr_mma, load/store_tiled_mma_frag*, get_tiled_copy_coalesced_mn,
all_copy_atoms, all_elements, atom_tensor, split_works, etc.); aiter package
couplings and host-side torch-view/debug shims dropped.

No behavior change to existing kernels; new file only. Public API of the package
is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the native-fp8 (MFMA 16x16x32) gate-up helpers to layout_helpers.py,
ported faithfully from the aiter reference prefill_1x4 path with compile-time
closures (N,K,TOPK,BLOCK_M,weight_dtype) made explicit args:
- make_1x4_tiled_mma: B-first (4,1,1) tiled_mma; fp8 k_perm (8,4,2):(1,16,8)
- make_gateup_weight_view: shuffle_weight-order preshuffle + gate/up silu grouping
- silu_pair_bf16: silu(gate)*up with optional fp8 weight/act dequant -> bf16
- read_sorted_index / make_tensor_with_index: MoE sorted-row gather + scatter
- atomic_add_bf16: global_atomic_pk_add_bf16 for the down/atomic epilogue

Validated: single-tile native-fp8 GEMM via make_1x4_tiled_mma reaches cos=1.0
cold (the 4x seen in an isolated dense readback is per-wave channel ownership
double-counting, confirmed via per-wave channel-offset dump: waves 0/1/2/3 own
channels 0/16/32/48). No wiring into gemm1.py yet (part 2). black+ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…le configs

New-pipeline (fx.*) MoE stage1 gate-up:
- fp8: fixed silu dtype-reinterpret bug (silu_pair_bf16 out_dtype must match
  CShuffle store dtype); now correct across all tile configs and both out dtypes.
- perf: 2-stage A LDS ping-pong (a_pong wired), B reg prefetch, LDS-read hoist
  one iter ahead -> 883->1193 TFLOPS (~92% of legacy on compute-bound; decode
  already parity).
- tile configs: contiguous_n = max(tile_n//2, 64) supports tile_n=64; tile_m>=16.
- bf16: builder made dtype-parametric (in_dtype param); gfx950 native
  MFMA(16,16,32); no dequant; num_records_bytes fixed to bytes (*elem_bytes).
- fp16/f16 stays on legacy MFMA (unchanged).
- Debug A/B toggles (default-off): MOE_FORCE_LEGACY_FP8 / MOE_FORCE_LEGACY_BF16.

Verified cache-off: 60 passed (fp8/bf16/fp16 x S/M x atomic/reduce x f16/bf16).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/f32)

Add a native-fp8 stage2 down-projection builder `_build_moe_gemm2_fp8`
(gemm2.py) mirroring stage1's B-first layout-API path, and route
`compile_moe_gemm2` to it for in_dtype="fp8" (non-groupwise). Env toggle
MOE_FORCE_LEGACY_G2_FP8=1 restores the legacy path for A/B. All other dtypes,
fp16, groupwise, and the legacy body are untouched; compile_moe_gemm2_ex /
_MoeGemm2ReduceWrapper wrap the new exe unchanged.

New builder: fx.rocdl.make_buffer_tensor (num_records=M*K*1), SharedAllocator
+ @fx.struct/@fx.union LDS, 2-stage A LDS ping-pong (swz 3,4,3) + B register
prefetch, per-token(row) x per-channel(model_dim) fp8 dequant, optional routed
weight, and a CShuffle epilogue with three scatter modes:
  - atomic (accumulate=True), f16/bf16: packed buffer atomic-add into
    out[token, model_dim]
  - atomic, f32: scalar buffer atomic-add
  - reduce (accumulate=False), f16/bf16: plain store into
    out[token*topk+slot, model_dim] for the existing reduction kernel

layout_helpers.py:
  - make_weight_view: plain (no gate/up silu) preshuffle weight view for stage2
  - _TensorWithIndex.copy: add atomic scatter (buffer atomic-add) with an
    out-of-tile grid-slot guard (tall row-guard fake tensor) and value/offset
    width alignment, plus a token row_limit clamp for sentinel rows
  - _buffer_atomic_pk / _buffer_atomic_f32 helpers

Key correctness fix: the A2-gather view is rank-3 [tokens, topk, inter] so the
sorted id decodes to (token, slot); a rank-2 view silently read slot-0 of each
token (masked on tame data, wrong on real routing).

Verify (FLYDSL_RUNTIME_ENABLE_CACHE=0, gfx950):
  - test_moe_stage2_standalone -k fp8: 12 passed (atomic+reduce+torch/flydsl
    reduce+masked, incl tile_n=256, decode/prefill)
  - test_moe_gemm_2stage -k "fp8 and eager and nomask and (S or M)": 30 passed
    (S/M/L, atomic+reduce, f16/bf16/f32)
  - broader fp8 e2e (graph+mask): 56 passed
Perf (dim 8192x8192, t=32768, e=16, k=4, tile 256/128, median-of-5, iters 60):
  new 1095 TFLOPS vs legacy 1094 TFLOPS (0.999x).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generalize the native B-first stage2 down-projection builder
(_build_moe_gemm2_fp8) to also handle bf16, mirroring the fp8->bf16
extension already done on stage1:
- add in_dtype param; derive elem_t (BFloat16 vs Float8E4M3FNUZ),
  elem_bytes (2 vs 1), _val_per_thr (8 vs 16), and A-LDS swizzle
  ((3,3,3) vs (3,4,3))
- size the A LDS buffers in bytes (BM*TILE_K*elem_bytes) so bf16 gets
  2x the byte footprint; num_records stays M*K*elem_bytes
- skip the fp8 per-row/per-channel dequant for bf16 (unscaled inputs);
  sentinel rows still contribute 0 via the OOB-zero A-gather
- keep the atomic (pk/f32) and reduce/f32 epilogues intact (bf16 pk
  atomics go through the existing buffer_atomic_pk_add path on gfx950)

Route in_dtype in {fp8,bf16} (non-groupwise, not forced-legacy) to the
new builder in compile_moe_gemm2, and add a MOE_FORCE_LEGACY_G2_BF16
toggle mirroring MOE_FORCE_LEGACY_G2_FP8. compile_moe_gemm2_ex /
_MoeGemm2ReduceWrapper forward in_dtype unchanged, so reduce mode works
for bf16 too. fp8/f32/fp16/legacy/reduction paths are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove the dev-only MOE_FORCE_LEGACY_* A/B env toggles; fp8/bf16 (non-split-K,
non-groupwise) now route unconditionally to the new-pipeline gemm1/gemm2
builders. Legacy body remains for the un-ported dtypes (int4/int8/int8smooth/
int4_bf16, fp16, fp8 split-K, groupwise). No behavior change with toggles unset.

Verified cache-off: fp8+bf16 gemm1+gemm2 (atomic/reduce) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…peline

CI (full matrix, RUN_TESTS_FULL) surfaced two bf16 new-path bugs:
- gfx942 (CDNA3): compile abort — the builder emits gfx950-only bf16
  MFMA(16,16,32); CDNA3 bf16 is MFMA(16,16,16).
- gfx950: hard crash at in_dtype=bf16 + out_dtype=f32 at large tile_m (FP4-L).

fp8 is green on both gfx942 and gfx950. Route only fp8 to the new gemm1/gemm2
builders; bf16 uses the proven legacy path until the two issues above are fixed.
The dtype-parametric builder still accepts bf16 (kept for the follow-up), it is
just not routed. No behavior change for fp8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderfeli coderfeli changed the title [MoE] Port moe_gemm_2stage (stage1+stage2) to the new fx.* pipeline - fp8 & bf16 [MoE] Port moe_gemm_2stage (stage1+stage2) to the new fx.* pipeline — fp8 Aug 1, 2026
…es legacy

The gfx942 (CDNA3) CI run aborted compiling the new fp8 gemm2 bf16-output
atomic epilogue (op does not lower on CDNA3), and only ~10% of the fp8 matrix
was ever exercised on gfx942 before the abort (reduce/f32/mask/graph untested
there). Rather than ship an unvalidated CDNA3 path, gate the new gemm1/gemm2
builders to CDNA4 (gfx95*). gfx942 falls through to the proven legacy path
entirely; gfx950 continues to use the new, validated pipeline.

gfx950: fp8 still routes to the new path (verified, 24 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
return x // y


def fly_ast_rewrite(member):

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just flyc.jit

r += 1


def _encode_waitcnt(vmcnt=63, expcnt=7, lgkmcnt=63):

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are existing apis in expr

return


def _as_ptr(p, dtype=None):

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also exist

# MLIR values are all SSA which is naturally different from each other and once
# defined stay unchanged for the rest of their lifetime, so they can be used
# safely as a cache key.
class FlyObjCache:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why need this?

# explicit args so the helpers are reusable across tile configs.


def atomic_add_bf16(ptr_base, reg_vec):

@coderfeli coderfeli Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not rocdl wrapper or fx.copy?

Felix Li and others added 5 commits August 1, 2026 05:53
… LOC

Behavior-preserving refactor of the NEW fp8 MoE 2-stage code (gfx950):

- layout_helpers.py: remove confirmed-unused helper surface (FlyObjCache,
  all_elements/collect_nz_modes, split_works, load_fragment, all_copy_atoms,
  atom_tensor, div_up/div_e, get_d1_shape, inner_most_stride, sub_tensor,
  asm_mark, make_1d_coord_tensor) and the now-unused functools/inspect/types/
  TargetAddressSpace imports. Add a tiny `reps(t, i)` helper.
- gemm1/gemm2 fp8 builders: use fxh.reps for the repeated
  fx.size(fx.get_shape(...)[i]).to_py_value() idiom; trim restated comments.

Legacy code paths, routing/arch-gate, and numerics untouched. fp8 2-stage and
stage2-standalone tests pass; perf unchanged (stage1 ~1140 / stage2 reduce
~1524, atomic ~1101 TFLOPS).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…6-atomic carve-out

Drop the CDNA4-only gate: fp8 now routes to the new gemm1/gemm2 builders on
gfx94* (CDNA3) as well as gfx95* (CDNA4). gemm1 (fp8 MFMA + CShuffle store, no
atomics) works on both. gemm2's only CDNA3-incompatible op is the packed
bf16-output buffer atomic (`buffer_atomic_pk_add_bf16` does not exist on gfx942),
so the single combo (atomic + out_dtype=bf16) stays on the legacy body on gfx942
(which uses global bf16 atomics); every other fp8 combo uses the new path. gfx950
is unchanged (all combos new path).

gfx950: verified green (30 fp8 cases, cache-off). gfx942: compile-validated for
the full fp8 matrix locally (this box can cross-compile gfx942 but not execute it,
so correctness is validated by CI on real gfx942 hardware).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…code shapes

The atomic scatter redirected out-of-tile grid lanes to element 0 (adding 0 to
out[0]) instead of dropping them. On small-tile/decode grids (e.g. tile_m=16,
E=128, topk=8) thousands of padding lanes atomic-add 0 to the same cache line,
serializing the kernel: fp8 stage2 atomic on the decode bench shape was 79800us
/ 0.08 TFLOPS vs legacy 134us. Push invalid lanes to an OOB element index
(== output element count) so the buffer resource's hardware bounds check drops
the atomic (matches the plain-store path). Decode atomic 79800us -> 223us;
compute-bound shape unchanged (~1097 TFLOPS). Correctness preserved (also drops
the now-redundant per-lane value zeroing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Profiled the ~1.6x gap between the new B-first fp8 stage2 pipeline and the
legacy body on the decode/sparse-MoE shape (dim=6144x1024, E=128, k=8,
tile_m=16, atomic) on gfx950. Faithful routing (~128 sparse blocks, ~4/16 rows
valid) reproduces new=~220us vs legacy=~135us.

ATT stall taxonomy shows the regime is memory-latency + synchronization bound,
not compute/occupancy/epilogue bound: buffer_load ~45%, s_barrier ~23%,
s_waitcnt ~21%; by source the gemm main loop (B weight load + sparse A gather)
~64% and the per-block sorted-id LDS-seed barrier ~22%; CShuffle epilogue only
~1.3%. Verified that raising occupancy (waves_per_eu=8; a single-buffered
low-VGPR variant), shrinking the epilogue tile, and deepening the B prefetch to
all-tiles-in-flight all leave perf unchanged (<=2%). Closing the gap requires
porting legacy's X-major overlap schedule, a loop-structure rewrite kept off
this shared path to protect the ~1100 TFLOPS compute-bound shape.

Comment-only: records the findings so the ruled-out levers aren't re-tried.
No behavior change; decode ~220us and compute-bound ~1091 TFLOPS unchanged;
fp8 stage2 correctness suites pass (12 standalone + 24 2stage).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ing/pong + CShuffle)

The new gemm2 sized LDS as a_ping=max(a_lds,cshuf) + a_pong=a_lds. For large
f32-output tiles (e.g. out_f32-L: tile_m=64,tile_n=256 -> cshuf=64KB) that is up
to ~72-80KB, which fits CDNA4 (gfx950, 160KB LDS/CU) but overflows CDNA3 (gfx942,
64KB) -> launch failure (exit 1) on gfx942. Use one region sized
max(2*a_lds_bytes, cshuf_bytes) with A ping/pong as offset sub-slices and the
CShuffle epilogue reusing it after the main loop. This strictly reduces total LDS
on both arches and keeps f32-L at exactly 64KB so it fits gfx942.

gfx950: correctness (30 e2e + 12 standalone) and perf (compute-bound ~1093,
decode ~220us) unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderfeli coderfeli changed the title [MoE] Port moe_gemm_2stage (stage1+stage2) to the new fx.* pipeline — fp8 [MoE] Port moe_gemm_2stage (stage1+stage2) to the new fx.* pipeline — fp8 (gfx942 + gfx950) Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant