Skip to content

MoE: add a16w mix fused 2-stage kernels (bf16 A × mxfp4/int4/bf16 W) - #948

Open
coderfeli wants to merge 59 commits into
mainfrom
worktree-mxfp-moe-a16w4
Open

MoE: add a16w mix fused 2-stage kernels (bf16 A × mxfp4/int4/bf16 W)#948
coderfeli wants to merge 59 commits into
mainfrom
worktree-mxfp-moe-a16w4

Conversation

@coderfeli

Copy link
Copy Markdown
Collaborator

Summary

Adds a standalone fused 2-stage MoE kernel package kernels/moe/moe_2stage_a16wmix/
for bf16 activations × low-bit weights, aligned with aiter's mixed_moe_gemm_2stage:

  • a16w4 — bf16 A × MXFP4 W (in-kernel cvt_scalef32_pk_bf16_fp4 upconvert, e8m0 folded)
  • a16wi4 — bf16 A × signed-int4 W (groupwise bf16 scale)
  • a16w16 — bf16 A × raw bf16 W

The kernel is bf16 end-to-end: bf16 A staged to LDS (no A-scale), weights upconverted
to bf16 in-kernel, non-scaled mfma_f32_16x16x32_bf16, a bf16 sorted intermediate (no
requant), and a bf16 gemm2. It reuses the standard moe_sorting + shuffle_weight +
e8m0_shuffle contracts.

Correctness

Strict-gated (cos ≈ 0.99999, logits_diff ≈ 1.5e-5) across the Kimi-K3 / Kimi int4 shapes and
full token sweeps (1 → 32768): a16w4 32/32 PASS, a16wi4 94/94 PASS. (For reference,
aiter's own bf16 a16w4 is numerically broken on these shapes in both SEPARATED and INTERLEAVE
gate modes — see the head-to-head notes; our kernel is the correct bf16 a16w4 there.)

Performance (gfx950, balanced routing, vs aiter's tuned kernels)

  • a16w4 (E896): wins the throughput range — 3584×384 tok16→32768 (0.85–0.95×), 3584×512
    tok8→8192 + both shapes' 32768 (0.77–0.85×). Residual >1.0× only at low-M (tok1–8, LDS-wait
    K-loop latency; the tok≤2 4-way slice-K lever brings tok1 to ~1.08×) and 3584×512 tok16384
    (~1.04×, a small kernel-body gap on the largest tuned shape).
  • a16wi4 (E384/385): correct everywhere; competitive after a VALU-lean dequant,
    block_m=64 at the W1-reuse fill point, and a BM=64 occupancy fix. Residual mid-band /
    high-token gap vs aiter's grid split-K (which our nonlinear-SiLU epilogue makes a large
    refactor; prototyped and found not to pay with an fp32-scratch reduction).

Also included

  • python/flydsl/expr/rocdl/__init__.py: cvt_scalef32_pk_bf16_fp4 wrapper (target-specific).
  • tests/kernels/test_moe_gemm.py: a16w4 / a16wi4 / a16w16 e2e + variant coverage, strict gates.
  • docs/issues/mxfp_moe_a4w4_a8w4_broken.md + tools/repro/: documents the pre-existing
    a4w4/a8w4 breakage in kernels/moe/mxfp_moe (left = origin/main; not touched by this PR).

Notes for review

  • a4w4/a8w4 (mxfp_moe) are unchanged / byte-identical to main (documented broken separately).
  • Style: black (120) + ruff clean. Cold-cache verified (FLYDSL_RUNTIME_ENABLE_CACHE=0).

🤖 Generated with Claude Code

Felix Li and others added 30 commits July 30, 2026 09:50
Mirrors cvt_scalef32_pk_f32_fp4; the mxfp4->bf16 upconvert primitive for the
a16w4 (bf16 x mxfp4) MoE path (e8m0 block scale folded into the cvt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New dedicated a16w4 stage1 path in mxfp_moe/gemm1.py (separate from the
a4w4/a8w4 scaled-f8f6f4 body; that code is byte-identical, diff is purely
additive). bf16 activations (no A-scale), in-kernel mxfp4->bf16 W upconvert
via cvt_scalef32_pk_bf16_fp4 (e8m0 scale folded in), non-scaled
MFMA(16,16,32,BFloat16) K=32 via fx.gemm, SiLU epilogue -> bf16 intermediate
[tokens*topk, inter_dim].

A/B/scale addressing ported from aiter compile_mixed_moe_gemm1_a16w4
(load_b_raw_mxfp4_dwordx4 layout_b, make_preshuffle_scale_layout, CK A
sub-lane read). Un-pipelined (perf is M5). A-LDS bank-conflict XOR swizzle
disabled for M1 (linear layout) pending an M5 re-derivation; both the DMA
write and LDS read go through one helper so they stay consistent.

Isolated stage1 vs torch_moe_gemm1 (bf16 A, scale=None; mxfp4 W):
cos=0.999999 on tokens=128/K=2048/INTER=512/NE=8/topk=2/BM=32 and
tokens=64/INTER=256/BM=16 (cold). a4w4/a8w4 variants still green (AC-2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lder

New dedicated a16w4 stage2 path in mxfp_moe/gemm2.py (separate from the
a4w4/a8w4 scaled-f8f6f4 body; that code is byte-identical, diff purely
additive). Reuses the M1-proven a16w4 primitives (shared helpers imported
from gemm1: _a16w4_swizzle_xor16, _e8m0_byte_to_f32): bf16 A intermediate ->
LDS (indexed by sorted position, like the a4w4 path), mxfp4 W2 dwordx4 load +
cvt_scalef32_pk_bf16_fp4 upconvert (N=model_dim, K=inter_dim), non-scaled
MFMA(16,16,32,BFloat16) K=32 via fx.gemm. Output is bf16 [tokens, model_dim]
via the existing atomic (routing-weighted) scatter epilogue -- reused
unchanged; accm[mi][ni] laid out to match its (mchunk, wave-J) indexing
(wave owns TILE_N/4 = 64 cols, num_acc_n=4). Linear A-LDS (XOR swizzle
deferred to M5, as in M1).

Isolated stage2 vs torch_moe_gemm2 (bf16 A intermediate, scale=None; mxfp4
W2, doweight_stage2=True): cos=0.999997 on tokens=128/model_dim=2048/
INTER=512/NE=8/topk=2/BM=32 and tokens=64/model_dim=1024/INTER=256/BM=16
(cold + warm). Mini e2e chain (kernel gemm1 -> host re-sort -> kernel gemm2)
vs full torch e2e: cos=0.999997. a4w4/a8w4 variants still green (AC-2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1. gemm1 a16w4 epilogue now stores the bf16 intermediate by SORTED POSITION
   (row = bx_m + row_in_tile), shape [sorted_size, inter_dim], matching the
   a4w4 fused inter_sorted_* contract so gemm2_a16w4 consumes it drop-in (no
   host gather). Padding rows masked (token >= tokens). out_rsrc sized from
   cumsum0 (padded sorted rows). Localized to _gemm1_body_a16w4 + its call
   site; a4w4/a8w4 gemm1 byte-identical (diff touches only a16w4 lines).

2. host.py: new flydsl_a16w4_gemm1 / flydsl_a16w4_gemm2 arms (+ cached compile
   helpers) mirroring the a4w4 arms: bf16 A (no A-scale), bf16 [sorted_size,
   inter] intermediate (no intermediate scale), reuse sorting/cumsum/m_indices,
   grid, and standard shuffle_weight + e8m0_shuffle W layout. a4w4/a8w4 host
   arms unchanged (only the import line expanded).

Full e2e cold + warm vs torch_moe_gemm1+torch_moe_gemm2 (bf16 A scale=None,
mxfp4 W1/W2, doweight_stage2):
  small  (128, md2048, i512, E8,  k2,  BM32): cos 0.999997
  kimi512(128, md3584, i512, E896,k16, BM32): cos 0.999986
gemm1-alone (sorted-position) still cos 0.999999. test_mxfp_moe_variants
(fp4+fp8) green (AC-2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The a16w4 gemm1 default TILE_N=256 gave num_acc_n=16 -> 2*(2*16) f32[4]
accumulators = 256 VGPR for accs alone, hitting the 256-VGPR cap with a
2764B scratch spill and 1 wave/SIMD occupancy. rocprofv3 kernel-trace:
gemm1_a16w4 tok128 3922us, VGPR=256, Scratch=2764.

TILE_N=64 (matching aiter's tuned t32x64x256 gemm1 tile for Kimi-K3
3584x512) drops accumulators 4x: VGPR 256->128, Scratch 2764->0 (spill
gone), occupancy doubles. Measured s1 (median-of-3, GPU5 gfx950, warm,
Kimi-K3 3584x512 E896 k16): tok1 408->47, tok16 1106->210, tok128
3955->680, tok1024 4449->836, tok8192 20512->4051 us. Wins every token.

a16w4 e2e cos unchanged (kimi512 0.999986 cold); a4w4/a8w4 byte-identical
off-path (test_mxfp_moe_variants 6/6 green); no spill (Scratch=0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…el balance)

a16w4 gemm2 grid was plain m-major linear (no XCD remap), unlike the a4w4 gemm2
path which already round-robins the launch index across the 8 XCDs. flyprof on
tok16 (bundle runs/g2_tok16, mapped 99.9%) shows the kernel is HBM-bandwidth-bound
(L2 hit 14.9%, achieved 2705/8000 GB/s, MFMA only 4.6% of instrs, total_stall
82.5%) with the m-major grid clustering consecutive tiles onto the same XCD/HBM
channels.

Port the same bijective 8-XCD round-robin (+ optional M-group swizzle) into
compile_gemm2_a16w4_port, gated by xcd_swizzle (default 1). a16w4-only; the
a4w4/a8w4 gemm2 code is byte-identical (36 ins, 1 del, all inside the a16w4
builder). host.py needs no change (the default flows through).

Perf (s2 median-of-5, iters=40, GPU6, Kimi-K3 3584x512 E896 k16):
  tok1  7.59->7.29 (-4%), tok16 49.70->44.78 (-10%), tok128 160.61->140.80 (-12%),
  tok1024 200.92->182.83 (-9%), tok4096 555.44->503.26 (-9%). Now WIN tok1/1024/
  4096 vs aiter, tok128 1.07x.

AC-P1 cos 0.99921 held (== base). AC-P2 test_mxfp_moe_variants 6/6 green.
AC-P3 VGPR 141->144, spill=0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… variant)

The a16w4 gemm2 A-LDS was LINEAR: M1 deferred the XOR swizzle because flipping
enable=True on the old scheme (swizzled LDS *dest* of the buffer_load_lds DMA)
NaN'd -- the DMA hardware does not honor an arbitrary swizzled per-lane LDS dest.

Root-caused and fixed by matching kernels/gemm/mxfp4_preshuffle.py: keep the LDS
dest LINEAR and swizzle the GMEM *source* column instead, so linear LDS slot
[row][col] holds A[row][swz(row,col)]; the LDS read applies the SAME swizzle to
its offset to recover the right logical K. Both sides go through
_a16w4_swizzle_xor16(..., enable=True). a16w4-gemm2-only (gemm1 and the shared
helper default stay enable=False -> byte-identical off-path).

flyprof tok16 (bundle runs/g2_tok16_swz vs runs/g2_tok16):
  SQ_LDS_BANK_CONFLICT 36.77M -> 7.35M (-80%); L2 hit 14.9% -> 32.8%;
  lds stall 19.45% -> 3.6%; lgkmcnt 12.1% -> 6.3%; total_stall 82.5% -> 76.2%.

Perf (s2 median, iters=40, GPU6, Kimi-K3 3584x512 E896 k16), R1(XCD) -> R2(+swz):
  tok16 44.78->38.66 (-14%, gap 1.31x->1.13x), tok128 140.80->125.59 (-11%, now
  0.95x WIN), tok1024 182.83->168.47 (-8% WIN), tok4096 503.26->472.97 (-6% WIN),
  tok1 ~7.3->7.7 (noise, WIN). Now BEAT aiter on 4 of 5 tokens.

AC-P1 cold cos 0.99960/0.99959 (== base, NaN gate cleared). AC-P2
test_mxfp_moe_variants 6/6 green. AC-P3 VGPR 144->156, spill=0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds aiter's SiTUv2 activation as an opt-in epilogue for the a16w4 gemm1
so the perf-parity run against aiter (which uses SiTUv2 for Kimi-K3) is
apples-to-apples. Default stays "silu" so the a16w4 correctness tests
(torch ref = SiLU) and all existing callers are byte-identical.

  situ(g)    = beta * tanh(g/beta) * sigmoid(g)         (beta=1)
  situ_up(u) = linear_beta * tanh(u/linear_beta)         (linear_beta=1)
  out        = situ(clamp(g, max=+7)) * situ_up(clamp(u, [-7,+7]))

New _situ_mul_batch / _tanh_f32 / _sigmoid_f32 in mxfp4_gemm_common.py
(tanh via the exp2/rcp expansion, sign-restored, matching aiter
mixed_moe tanh_elem). act= threads through compile_gemm1_a16w4_port
(baked into the kernel name for cache keying) and flydsl_a16w4_gemm1.

Verified: SiTUv2 stage1 vs a torch SiTUv2 reference cos=0.999999
(max_abs 0.0020, small shape, cold). SiLU default e2e cos 0.999986
(kimi512) unchanged. test_mxfp_moe_variants 6/6 green. Both act paths
VGPR=128, Scratch=0 (no spill). a4w4/a8w4 byte-identical off-path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t small M)

gemm2's B (mxfp4 weight) load used cache_modifier=2 (SLC / non-temporal stream)
for all tokens. At small M the whole expert's B is reused across few M-blocks, so
CACHED loads (cache_modifier=0, nt off) win on L2 reuse -- matching aiter's
bnt0-at-small-M vs bnt2 gemm2 selection.

Thread b_cache_mod through _gemm2_body_a16w4 / compile_gemm2_a16w4_port (default
2 = prior behavior, name-tagged only when != 2 -> byte-identical off-path). Host
flydsl_a16w4_gemm2 picks 0 (cached) for M_logical<=16, else 2 (nt). a16w4-only;
a4w4/a8w4 gemm2 bodies + host arms untouched.

Same-session median-of-5 A/B (3584x512, GPU6), nt(bcm2) vs cached(bcm0):
  tok8 23.5->22.0 (-6%), tok16 43.0->38.6 (-7..-10%, cached selected); crossover
  at tok32 (nt +8%), so threshold=16. tok128/1024 keep nt (unchanged from R2).
  tok1 is a wash (both ~7, already beats aiter). Timing is ground truth; aggregate
  flyprof L2 counters at tok16 are capture-noisy.

AC-P1 cold cos 0.99960/0.99959 (tok16 cached + tok128 nt paths). AC-P2
test_mxfp_moe_variants 6/6 green. AC-P3 VGPR 156, spill=0 (aux bit only).

gemm2 now beats aiter on 4/5 tokens; tok16 ~1.08x (down from 1.45x at base).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
flyprof tok16384 (bundle runs/g2_tok16384): the a16w4 gemm2 is NOT HBM-bound at
large M (L2 hit 65.1%, HBM only 603/8000 GB/s) -- it is atomic-epilogue-bound
(vmcnt bubble 45%, top hotspots the shared _atomic_bf16_epilog global_atomic_pk_
add_bf16 + stids/sweights loads) plus K-loop B-load vmcnt. The atomic epilog is
shared with a4w4/a8w4 and already matches aiter, so it stays untouched.

The one gated lever: B cache modifier is a U-shape in M. Cached loads
(cache_modifier=0) win at BOTH ends -- small M (B reused across few M-blocks) and
large M (>=2048; L2 hit ~65%, so nt streaming would bypass a reusable cache) --
while nt (cache_modifier=2) wins the middle band (32..1024). A/B median-of-5:
tok2048 -5%, tok4096 -5.4%, tok8192 -2.7%, tok16384 -1.9%; tok1024 keeps nt.

Host threshold M<=16 -> (M<=16 or M>=2048) cached, else nt. Host-only change;
gemm2.py kernel unchanged (bcm0 already supported, spill=0 verified in R3).
a16w4 gemm2 host arm only -- a4w4/a8w4 unaffected.

Final large-M s2 (median iters=40, GPU6, 3584x512): tok8192 800->774 (0.965x WIN
vs aiter 802.1), tok16384 1535->1508 (1.061x, down from 1.08x, vs aiter 1421.0).

AC-P1 cold cos 0.99959 (tok4096 cached + tok128 nt). AC-P2 test_mxfp_moe_variants
6/6 green. AC-P3 no new kernel variant (bcm0 spill=0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gemm1's B (mxfp4 W1 gate+up) load used cache_modifier=2 (nt / stream) for
all tokens. A/B (median-of-3, GPU5, Kimi-K3 3584x512, TILE_N=64) shows the
CACHED load (cache_modifier=0, nt off) wins across the ENTIRE token range
-- unlike gemm2 (where nt won large M), gemm1's B has enough cross-M-block
L2 reuse (E896, 2*inter cols) that streaming pessimizes it everywhere:

  s1 nt(2) -> cached(0):  tok1 46.7->42.0 (-10%), tok2 48.3->44.1,
  tok4 57.6->54.0, tok16 219.8->214.9 (-2%), tok128 681.8->653.4 (-4%),
  tok1024 815.8->773.8 (-5%), tok4096 2269->2076 (-9%), tok8192 4032->3695,
  tok16384 7648->6939 (-9%).

flyprof (bubbles, tok128) confirms the mechanism: vmem_load 23.4%->17.4%
(less global-load work from L2 reuse), occupancy mean_waves 2.04->2.19.
(The aggregate PMC L2-hit counter is capture-noisy per gemm2's ledger note;
median-of-3 timing + the vmem_load bubble are the reliable evidence.)

Thread b_cache_mod through _gemm1_body_a16w4 / compile_gemm1_a16w4_port
(name-tagged only when != 2 -> off-path byte-identical) and
flydsl_a16w4_gemm1 (default now 0 = cached, the measured optimum).
a16w4-only; a4w4/a8w4 bodies + host arms untouched.

AC-P1 cold cos 0.999986 (kimi512) / 0.999997 (small) unchanged. AC-P2
test_mxfp_moe_variants 6/6 green. AC-P3 VGPR=128, Scratch=0 (no spill).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ISA diff (isa-diff-gemm1.md) proved our un-pipelined hot loop ran
load-all -> s_waitcnt vmcnt(0) expcnt(0) lgkmcnt(0) -> s_barrier ->
compute-all, 14x (full VMEM drain + 2 barriers/tile, max 3 loads in
flight) vs aiter's software pipeline (partial lgkmcnt waits, 1 barrier,
~9 loads in flight). The earlier R2/R3 prefetch attempts FAILED because
they kept the full vmcnt(0) drain -- THE key miss the ISA diff identified.

Fix in _gemm1_body_a16w4:
- A-LDS double-buffered (A_LDS_STAGES=2): tile K+1's DMA writes the pong
  slot while tile K reads ping.
- Prologue loads tile-0 A(->LDS)+B+B-scale; each iter issues tile K+1's
  A-DMA + B/B-scale AFTER the wait/barrier but BEFORE tile K's MFMA, so
  they stay in flight across the MFMA cluster.
- Replace the full s_waitcnt(0) drain with a PARTIAL s_waitcnt(lgkmcnt=0)
  (the buffer_load..lds A-DMA completes on lgkmcnt; B vmem is NOT drained)
  + exactly ONE barrier per tile. lgkmcnt(0) targets tile K's A-DMA
  exactly because K+1's DMA is issued only after the barrier.

s1 median-of-3 (GPU5, iters=50, TILE_N=64, 3584x512), base 3afb739 -> pipe:
  tok16 209.1->197.8 (-5.4%), tok128 656.8->605.5 (-7.8%),
  tok1024 791.6->766.5 (-3.2%), tok4096 2073.5->2022.5 (-2.5%),
  tok16384 6856->6685 (-2.5%). Wins every token.

AC-P1 cold cos 0.999986 (kimi512) / 0.999997 (small) unchanged. AC-P2
test_mxfp_moe_variants 6/6 green (a4w4/a8w4 byte-identical off-path).
AC-P3 VGPR=128, AGPR=0, Scratch=0 (no spill); LDS 16KB->32KB (2 slots).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… main

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_gemm1_body_a16w4 never used `wave`: all 4 waves of the 256-thread
workgroup redundantly computed the FULL TILE_N (num_acc_n=TILE_N/16) for
both gate and up, loaded the same B, upconverted the same mxfp4, ran the
same MFMA, and wrote the same results to the same global rows (idempotent,
so correctness held but ~4x wasted work). This is the 128-MFMA/tile vs
aiter's 32-MFMA/tile gap.

Fix mirrors _gemm2_body_a16w4's proven wave-split: _n_per_wave = TILE_N/4,
num_acc_n = _n_per_wave/16, and n_tile_base = wave*_n_per_wave shifts each
wave's gate/up N addressing (col_g / scale ng) so each wave owns a distinct
TILE_N/4 column slice. The epilogue already writes col_g_list[ni], now
per-wave-distinct -> full non-overlapping coverage.

Verified (TILE_N=64, K=3584, cold cache): MFMA 128->32/tile (448 total =
14*32, == aiter), VGPR 256->94 (no spill, AGPR/scratch 0). s1 median-of-3
~2x faster (tok16 201.8->119.8us, 128 606.2->345.2, 1024 772.1->410.6,
4096 2024->1013, 16384 6735->3341). Cold e2e cos kimi512 0.999986 /
small 0.999997 (== fp4 bar). a4w4/a8w4 untouched (variants test green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wires the a16w4 (bf16 A x mxfp4 W1/W2) fused MoE into the pytest matrices:

- Export flydsl_a16w4_gemm1/2 from kernels.moe.mxfp_moe (__init__).
- _run_a16w4_moe_e2e: dedicated e2e harness -- A stays raw bf16 (no quant,
  x_scale=None), W1/W2 mxfp4 (standard shuffle + e8m0), bf16 [sorted_size,inter]
  intermediate (no ascaleout), routes flydsl_a16w4_gemm1/2 (atomic gemm2), ref =
  torch_moe_gemm1(bf16 A, scale=None) -> ref1.to(bf16) -> torch_moe_gemm2 (no fp4
  re-quant). _run_mxfp_moe_e2e gains an a_dtype=="a16" branch that delegates to it.
- test_moe_gemm_2stage: add "a16w4" to the in_dtype parametrize + CLI --in_dtype
  choices + "all" expansion; encode a16w4 constraints (gemm1 inter%64==0 &
  2*inter%256==0; gemm2 model_dim%256==0 & inter%256==0; atomic-only -> skip reduce
  & tile_m must be in {16,32,64}); route a16w4 -> a_dtype="a16".
- test_mxfp_moe_variants: add "a16w4" a_dtype; only bm32_atomic applies (bf16 A has
  no inline_quant/interleave gemm1 variant -> other two skip).
- New test_a16w4_moe_e2e: small (128x1024x256 E8 k2) + Kimi-K3 (3584x512 E896 k16,
  large_shape) shapes.

Cold (FLYDSL_RUNTIME_ENABLE_CACHE=0, gfx950): a16w4 e2e small + kimi512 + variants
bm32_atomic + matrix FP4-M all PASS at the fp4 verify bar. a4w4/a8w4 not regressed
(fp4/a8w4/fp8/bf16 rows 23 passed / 0 failed; variants 6/6). Style gate clean.

Shape constraints encoded: a16w4 needs tile_k>=256, model_dim>512, inter%256==0,
model_dim%256==0, tile_m in {16,32,64}, atomic epilog only (no reduce).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-diff at HEAD (isa-diff-gemm1-f019.md) confirmed the wave-split (f019ad1)
already brought MFMA to 32/tile and VGPR to 94 (below aiter's 134), and
measured occupancy at ~3.8-4.0 waves/CU -- so occupancy was NOT the residual
1.5x. The dominant remaining lever is N-tile geometry: aiter's tuned CSV
(kimik3 abf16_wfp4) uses t32x128x256 for the mid/large tokens while gemm1 was
pinned at tile_n=64.

Swept tile_n 64/128/256 (median-of-3, dev7, cold): tile_n=256 wins or ties at
every token except a ~3% wash at tok128 (within gfx950 clock noise), and beats
aiter at tok16. With the wave-split N-split, tile_n=256 -> num_acc_n=4, VGPR
256 (no spill, AGPR/scratch 0), LDS 32 KB unchanged (A-LDS only).

s1 median-of-3 (us), before(tn64)/after(tn256) vs aiter s1:
  tok    tn64    tn256   aiter    new gap
   16    117.1    89.1    92.6    0.96x (BEAT)
  128    345.7   289.3   228.6    1.27x
 1024    406.8   341.0   276.6    1.23x
 4096   1005.4   770.9      -     -
16384   3334.5  1953.5  1313.3    1.49x   (was 2.53x)

tok16384 gap 2.53x -> 1.49x; mid-band ~1.5x -> ~1.25x. Cold e2e cos kimi512
0.999986 / small 0.999997 (== fp4 bar). variants test green. a4w4/a8w4 host
arms + builders untouched (byte-identical off-path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-diff (isa-diff-gemm1-f019.md) found ours interleaved the next tile's
buffer_load..lds (A DMA, a VMEM op) among the current tile's ds_read cluster,
forcing per-read s_waitcnt vmcnt(0) drains (2/iter avg, 3 on the pong iter) that
also flushed the in-flight B mxfp4-weight loads. Aiter instead reads the whole
resident A-LDS buffer FIRST, THEN issues the next A-DMA, keeping one vmcnt(0)
only at the buffer-swap and reaching vmcnt(8) in-flight depth.

Mirror it: hoist all m_repeat*k_unroll ds_read_b128 into preload_a() before the
kt+1 A-DMA + B loads; compute_tile now consumes preloaded fragments. ISA at
tn256: vmcnt(0) 24->23, vmcnt(N>0) 44->65 (deeper overlap), VGPR 256 (no spill,
AGPR/scratch 0), MFMA 1792 unchanged.

Marginal at tn256 (the fat N tile already amortizes the drains) but a consistent
small win at large M with no downside. s1 median (dev7, cold, same-session
head-to-head), Lever1-only/+phase-sep:
  tok1024   326.4 -> 324.7  (-0.5%)
  tok4096   772.1 -> 774.8  (+0.3%, wash)
  tok16384 1957.3 -> 1917.5 (-2.0%, seen twice)

Cold e2e cos kimi512 0.999986 / small 0.999997 (== fp4 bar). variants green.
a4w4/a8w4 use _gemm1_body (untouched); this only edits _gemm1_body_a16w4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…main

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ratch ISA doc

Keeps the tn256 win on inter%256==0 shapes (kimi512, small) while preserving
support for inter%256!=0 shapes (e.g. 3584x384) instead of a hard TILE_N=256.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…shape s1 win

Refactor flydsl_a16w4_gemm1/gemm2 to aiter's tile-config param interface
(tile_m/tile_n/tile_k, waves_per_eu, k_batch, b_nt, xcd_swizzle, gate_mode,
act), removing the home-grown adaptive TILE_N. tile_m->BM, tile_n->TILE_N,
tile_k->TILE_K, b_nt->W-load cache modifier.

Kernel builders: add xcd_swizzle + waves_per_eu to compile_gemm1_a16w4_port
(xcd = bijective XCD/HBM-channel grid remap, same _xcd pattern as a4w4 gemm1 /
a16w4 gemm2; waves_per_eu = rocdl.waves_per_eu min-occupancy value_attr) and
waves_per_eu to compile_gemm2_a16w4_port (xcd already present). a4w4/a8w4
builders + host untouched (byte-identical off-path).

CSV loader: pick_a16w4_config()/_load_a16w4_csv()/_decode_a16w4_kname() parse
aiter's kimik3_fp4_tuned_fmoe.csv abf16_wfp4 rows -> per-token tile-config
(tile_m/n/k, waves_per_eu, xcd, b_nt, k_wave) as a candidate-geometry source.

Perf (the one portable lever): gemm1 b_nt default is now the measured per-M
W-load U-shape (nt streaming b_nt=2 for 16<=tok<=1024, cached b_nt=0 at the
ends), same mechanism as gemm2. Measured s1 (median-of-3, 3584x512 E896 k16,
gfx950): tok16 90.3->82.3 (-8.8%), tok128 287.0->254.7 (-11.2%), tok1024
338.0->313.7 (-7.2%); tok4096/16384 unchanged (cached). cos held (kimi512
0.999986, small 0.999997), no VGPR/SGPR/scratch spill.

test_a16w4_moe_e2e updated to the new interface (waves_per_eu left None: our
fixed 4-wave tile_n=256 body is 256-VGPR-bound, so aiter's tuned waves_per_eu
3/4 spills and regresses ~4-5x here -- that knob is for aiter's tile_n=64 body).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…_nt win

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(unmix from mxfp_moe)

Move the a16w4 (bf16 A x mxfp4 W) fused 2-stage MoE kernel out of
kernels/moe/mxfp_moe/ into a new self-contained package
kernels/moe/moe_2stage_a16wmix/ so it is no longer mixed with the
a4w4/a8w4 (mxfp4-activation) code.

New package (gemm1/gemm2/host/common/__init__): moves _gemm1_body_a16w4,
compile_gemm1_a16w4_port, gemm1_a16w4_grid, _gemm2_body_a16w4,
compile_gemm2_a16w4_port, gemm2_a16w4_grid, the a16w4 host glue + CSV
loader, and the a16w4-only helpers (_a16w4_swizzle_xor16,
_e8m0_byte_to_f32). common.py is a curated copy of the subset of shared
helpers a16w4 uses (raw/udiv/umod, buffer_rsrc, global_i32 helpers, gep/
lds ptr helpers, silu/situ activation, kmchunks/lds_acc), plus a private
copy of _atomic_bf16_epilog, so the package has no import back into
mxfp_moe.

mxfp_moe: pure deletion of the a16w4 additions -- gemm1.py/gemm2.py have
zero added lines (a4w4/a8w4 execution paths byte-identical); removed the
now-unused _situ_mul_batch/_tanh_f32/_sigmoid_f32 from mxfp4_gemm_common
and trimmed the a16w4-only imports/exports.

tests: import flydsl_a16w4_gemm1/2 from the new package;
_run_a16w4_moe_e2e behavior unchanged. cvt_scalef32_pk_bf16_fp4 stays in
expr/rocdl (shared primitive).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add compare_interleave_sweep.py: a combined-dispatch comparison harness
mirroring aiter's gfx950 per-1x32 (mxfp4-weight) INTERLEAVE/Swiglu
production path. Routes token<256 -> our a16w4 (bf16 A x mxfp4 W,
moe_2stage_a16wmix) and token>=256 -> our a8w4 (fp8 A x mxfp4 W,
mxfp_moe interleave). Both regimes consume the identical shuffle_weight +
e8m0_shuffle W layout (interleave is purely an internal kernel N-column
addressing choice, so no boundary re-shuffle).

Reports per-token s1/s2/total (median-of-3) and, alongside the loose
shipped e2e gate, a TRUSTWORTHY cos vs a bf16-dense MoE reference
(aiter's yardstick). Additive only; no kernel changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… to it

Extend the standalone a16wmix MoE package with a second weight format,
a16wi4 (bf16 A x signed-int4 W, groupwise scale, group_size=32), alongside
the existing a16w4 (mxfp4). int4 W reuses the a16w4 mxfp4 body verbatim:
packed 2 nibbles/byte in the SAME preshuffle byte layout, dequant via
v_cvt_off_f32_i4 (folding the x16 correction into the per-group scale) ->
the identical non-scaled MFMA(16,16,32,bf16). Adds a w_dtype="mxfp4"|"int4"
param to compile_gemm1/2_a16w4_port, the bodies, and the host wrappers;
w_dtype="mxfp4" is byte-identical to the original a16w4 (kernel names and
const_expr-gated paths unchanged).

Groupwise bf16 scale uses (E, N, G//2, 2) packed layout via
a16wi4_scale_to_kernel_layout; the K->group map (adj_ku) matches the a16w4
e8m0 addressing and is verified against the weight preshuffle.

Route the legacy moe_gemm_2stage int4_bf16 test entry (run_moe_stage1/2) to
the new path: stage1 always, stage2 for bf16 output (the a16w4 atomic epilog
is bf16-only; f16-out stage2 keeps the legacy builder). Per-row (group_size=-1)
is expressed as all-equal groups.

Correctness: cold cos ~0.99999 vs a torch int4-groupwise reference across
shapes; the legacy int4_bf16 groupwise + 2stage atomic/reduce tests pass via
the new path. No VGPR/SGPR spill (gemm1/gemm2 spill_count=0). a16w4 mxfp4
e2e/variants and a4w4/a8w4 regression green. Style gate clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…os); xfail broken a4w4/a8w4

The two fused-MoE e2e gates (_run_a16w4_moe_e2e, _run_mxfp_moe_e2e) called
verify_output() with the return value discarded (never asserted) AND with
logits_diff_threshold=1, so they validated nothing. Even the logits threshold
was dead: verify_output early-returns True when <5% of elements exceed the
allclose tol, so the loose rtol/atol=0.5 masked the check regardless.

Make the gates honest: assert verify_output(..., rtol=2e-3, atol=2e-3,
logits_diff_threshold=2e-3).

Independent strict-cosine verification (real cosine of e2e output vs the torch
reference, cold) confirms:
  a16w4 e2e cos ~0.9999 (control, faithful)  -> strict-asserted, passes
  a4w4  e2e cos ~0.12  (broken)              -> xfail
  a8w4  e2e cos ~0.07  (broken)              -> xfail
Stage split localizes it: a4w4 stage1 cos = 1.0 but gemm2-isolated cos = 0.12
(shared down-proj broken); a8w4 stage1 cos = 0.16 (fp8-gemm1 A-path broken too).

The broken kernels are also memory-unsafe: launching them and unwinding an xfail
under pytest teardown cascades an illegal-address crash into other tests, so the
a4w4/a8w4 xfails use run=False (variants) / pytest.xfail() before launch (2stage)
to document the expected failure without executing the kernel. a16w4/a16wi4 stay
strict-asserted-and-passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…8w4 breakage

The fused MX-FP4 MoE kernels in kernels/moe/mxfp_moe/ are numerically broken:
a4w4 e2e cos ~0.12, a8w4 ~0.07, vs the a16w4 control (separate kernels) at
~0.9999. The shared flydsl_mxfp4_gemm2 down-proj is broken and, for a8w4, the
fp8 gemm1 A-path additionally. The kernels are also memory-unsafe (illegal
address under teardown). This is documentation/tracking only -- no kernel fix.

- tools/repro/repro_mxfp_moe_a4w4_a8w4_broken.py: deterministic, cold-cache,
  self-validating (a16w4 control) strict-cosine reproducer with PASS/FAIL.
- docs/issues/mxfp_moe_a4w4_a8w4_broken.md: ready-to-file ROCm/FlyDSL issue
  draft (gh CLI absent) with evidence table, localization, and repro command.
- tests/kernels/test_moe_gemm.py: point the a4w4/a8w4 xfail reasons at the
  issue doc + repro, with a TODO(issue #NNN) placeholder. xfail(run=False)
  retained for CI stability.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add aiter-style intra-block slice-K (k_wave) to the a16w4/a16wi4 gemm1:
repartition the 4 waves into (4/k_wave) N-waves x k_wave K-waves, each K-wave
computes a K-slice (klen=K/k_wave) of a wider N-slice, and the k_wave partial
f32 accumulators are reduced in LDS (gate/up in separate rounds to bound peak
scratch) before the SiLU epilogue. Only the primary k-group (wave_k_id==0)
writes the intermediate; peers hold the identical reduced sum.

Everything is const_expr(k_wave>1)-gated: the k_wave=1 path is byte-identical
to before (verified: identical final ISA, 260 VGPR / 0 spill). Threaded through
compile_gemm1_a16w4_port and the host flydsl_a16w4_gemm1 (the CSV loader already
decodes _kw{N}); k_wave in {1,2,4} with 4%k_wave==0 and K%(k_wave*TILE_K)==0.

Verdict: slice-K does NOT help this kernel's fixed-256-thread / TILE_N=256
geometry. Because the workgroup stays at 4 waves, k_wave>1 widens each wave's
N-tile by k_wave x (num_acc_n 4 -> 8 -> 16), which blows the register file:
kw2 = 512 VGPR / 282 spill (~2x slower), kw4 = 512 VGPR / 969 spill (~3x
slower) vs kw1 = 260 VGPR / 0 spill. aiter benefits because its kw configs use
tile_n=64 and add real waves (num_waves_total up to 8 = 512 threads); a fixed
256-thread block cannot add parallelism, so slice-K only redistributes the same
MFMA work worse plus LDS-reduce overhead. Real cosine ~0.999999 for kw1/kw2/kw4
(kw4 needs tile_k=128 on Kimi K=3584: klen=896 not divisible by 4*256). The
param is landed for aiter-interface parity, not as a small-M win.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The a16wi4 (bf16 A x signed-int4 W groupwise) path that now serves int4_bf16
inherited the a16w4-mxfp4 fat-wave tile_n=256 geometry, which is a consistent
loss for the int4 (bf16-A) kernel: measured median-of-3 on gfx950, tile_n=128
beats 256 on BOTH stages across every int4 MoE shape/token tested (model_dim/
inter in {256,512,1024,2048}, tokens 16..1024) -- e.g. gemm1 inter=1024 tok=256
15.8 vs 30.7 us; gemm2 similar. int4 is bandwidth/grid-fill bound, so the wide
256-col tile just halves the N-tiles per expert (fewer waves to hide latency)
with no MFMA-density payoff, unlike the fp4 path where 256 is the tuned tile.

- host: add _default_tile_n(N, w_dtype); int4 -> 128 (fallback when N%128!=0),
  mxfp4 unchanged (256 when N%256==0). gemm1/gemm2 tile_n=None now delegate to
  it (gemm2 previously had no None handling).
- gemm2 _atomic_bf16_epilog: derive num_acc_n / _n_per_wave / s-count from BN so
  the tile_n=128 geometry lowers (was hardcoded to the tile_n=256 4-way split).
  Byte-identical for mxfp4 tile_n=256.
- test: int4_bf16 new-path stages pass tile_n=None (host default = 128) instead
  of hardcoding 256; add inert, documented FLYDSL_INT4_FORCE_LEGACY /
  FLYDSL_A16WI4_TILE_N perf-measurement escape hatches.

Correctness (cold, FLYDSL_RUNTIME_ENABLE_CACHE=0): int4_bf16 new-path + legacy-
routed (reduce/f16-out) tests pass vs torch int4-groupwise reference. a16w4-mxfp4
non-regressed (test_a16w4_moe_e2e + FP4-M cosine pass). Residual vs legacy int4
at the caller's BM=32 is a stage2 tile_m effect (the a16wi4 kernel prefers BM=16,
which reaches parity/win) controlled by the caller, not the host tile-selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Felix Li and others added 29 commits July 31, 2026 06:34
…tical)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… draft

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a16w16 (bf16 A x raw bf16 W, no scale, no upconvert) as a third weight
format alongside a16w4 (mxfp4) and a16wi4 (int4). B is loaded straight into
the bf16 MFMA(16,16,32,bf16) fragment: each dwordx4 (8 bf16) IS one K32 ku
fragment. No dequant, no scale buffer. The bf16 W preshuffle (shuffle_weight
layout=(16,16), elem_bytes=2) is K-reindexed so the (klane,ku)->K map matches
the fp4 path. mxfp4/int4 paths stay byte-identical via const_expr gates.

Cold real cos (bf16-dense MoE ref) ~= 1.0 (0.999997, unquantized). a16w4/
a16wi4 non-regression green. a16w16 is 4x the W bytes of a16w4 (bf16 2B vs
fp4 0.5B) so ~4.8x slower on the small BW-bound shape (reported, expected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-launch investigation)

Add an opt-in persistent, CU-limited launch grid to the shared a16w4/a16wi4
gemm2 (down-proj), mirroring aiter's persist path: cap the launch to
min(total_work, NUM_CU=256) CTAs and have each CTA round-robin over the REAL
work-tiles [0, bound) (bound from the cumsum, so padded empty m-blocks are never
executed), with the existing XCD swizzle applied per visited tile so each real
tile is computed exactly once. Threaded through gemm2_a16w4_grid,
compile_gemm2_a16w4_port, and flydsl_a16w4_gemm2.

Default is OFF and byte-identical to the prior grid (persist tag not appended,
no loop emitted). Correctness verified bit-exact (maxdiff=0.0) between persist-on
and persist-off across tok 1/16/128/1024 at E896, and the strict-cos e2e
(test_a16w4_moe_e2e, incl. kimi 3584x512 E896 k16) + a16wi4 int4_bf16 tests pass.

Finding: the persistent grid does NOT close the E896 perf gap and is a small
(~1-7%) regression, so it is left off by default. Measured on gfx950: launching
only the real tiles (zero empty CTAs) is the same latency as the padded launch
(477us both), i.e. empty-CTA launch overhead is ~0 and is not the bottleneck.
The E896 cost is real-tile compute -- the sort emits ~1 padded m-block per
distinct expert (234 blocks for 256 active slots at tok16), each computing a full
BM x TILE_N x K tile for ~1 token. aiter's fast a16w4 gemm2 uses a tile_k=128
REDUCE epilogue (not atomic); the persistent grid cannot address that.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire aiter's tuned kimik3_fp4_tuned_fmoe.csv per-token tile config into the
a16w4 (bf16 A x mxfp4 W) dispatch. resolve_a16w4_gemm{1,2}_config parse
(tile_m/n/k, waves_per_eu, xcd_swizzle, b_nt, k_wave) verbatim from the CSV row
for (model_dim, inter_dim, E, topk, tokens) and pass them to the builders.
Default-enabled (use_csv_config) for mxfp4; falls back to the adaptive default
when no CSV row matches (non-kimi shapes, missing CSV). a16wi4/a16w16/a4w4/a8w4
untouched (guarded on w_dtype=="mxfp4").

At small M the CSV yields tile_n=64 + k_wave in {2,4}, which -- unlike a wide
tile_n=256+kw (spills to 512 VGPR) -- keeps per-wave num_acc_n = tile_n*kw/64 at
or below the no-spill default footprint (<=4) while adding intra-block K-slice
parallelism. Measured (gfx950, 3584x512, median-of-3): s1 speedup at tok
1/2/4/8 = 1.86/1.46/1.21/1.12x; all configs 0 spill (tn64 kw1=122, kw2=175,
kw4=236 VGPR vs default tn256=260). e2e real cos 0.99999 (>= fp4 bar).

kw4 divisibility: the builder requires K % (k_wave*tile_k)==0; aiter names kw4
rows tile_k=256 but relies on K-padding we lack (K=3584: 3584 % 1024 != 0).
_kw_tile_k_for keeps the CSV k_wave and picks the largest tile_k satisfying the
constraint (kw4 -> tile_k=128), else drops to k_wave=1. gemm2 CSV rows violating
D_INTER%tile_k or model_dim%tile_n (e.g. inter=384, tile_k=256) return None ->
adaptive default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…o mid-M regress)

aiter's CSV tile_n=64 configs are tuned for aiter's thin-wave body and regress
our fat-wave gemm1 at mid tokens (16-128). Default to our tuned tile_n; CSV
params remain available (use_csv_config=True / aiter head-to-head) and win at
small M (tok1-8). No custom tile tables added; CSV is parsed verbatim when used.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The a16w16 (bf16 A x raw bf16 W) path produced garbage at large expert
count (kimi512 E896: e2e logits_diff 0.35, stage1 cos 0.66) while small-E
passed and mxfp4/int4 passed at E896.

Root cause: the bf16 W buffer offset overflowed 32 bits at large E. The bf16
W [E, N_OUT, K] is 2 B/elem (4x the fp4 0.5 B/elem), and the kernel folded the
expert offset into the load index (n_blk = (e*N_OUT + col)//16) against a
whole-tensor buffer resource. buffer_load computes its byte offset as
offset*elem_bytes in i32, so at E896 the byte offset reached ~6.6 GB (gemm1,
K=3584) / ~3.3 GB (gemm2), overflowing i32; the whole-tensor num_records
(6.6 GB) also exceeded the 32-bit buffer num_records field (clamped to 4 GB,
making high experts read OOB->0). fp4/int4 escape both (~4x smaller bytes).

Fix (bf16 path only, const_expr-gated): fold the per-expert base into the i64
buffer resource address (base = arg_bq + e*N_OUT*K*2) and index W WITHIN the
expert (n_blk carries no expert_off). Per-expert byte offset stays < 8 MB and
per-expert num_records < 8 MB, both well under 2^31. mxfp4/int4 keep the
whole-tensor resource + expert_off-in-index path byte-identical.

a16w16 kimi512 e2e cos 0.657 -> 0.99999, stage1 cos 0.658 -> 1.0; small-E
still passes. a16w4/a16wi4 non-regression green (byte-identical, const_expr
gates). No spill. Gate: existing strict kimi512-a16w16 e2e assert (E896).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…uilding block)

Kept as an opt-in (persist=True) building block; default-off is byte-identical.
Does not close the E896 small-M gap (that's atomic-vs-reduce+tile_k, not grid),
but a correct/tested option for grid-capped launches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

# Conflicts:
#	kernels/moe/moe_2stage_a16wmix/host.py
…-preserving)

Reduce the incremental-build/ledger-style comment bloat accumulated over many
tuning rounds to the density of the neighboring mxfp_moe kernels, while keeping
the load-bearing ABI/layout notes (fp4/int4->bf16 fragment mapping, the bf16
expert-offset-in-i64 overflow fix, the LDS buffer_load_lds swizzle hazard) and
all intentional opt-in features (k_wave, persist, CSV config, mxfp4/int4/bf16).

Strictly comment/docstring-only: the executable AST is byte-identical modulo
docstrings across all five files. Verified behavior-preserving on gfx950:
a16w4/a16w16/a16wi4 tests unchanged (7 passed / 2 skipped / 6 xfailed), real
cos ~0.999995 for a16w4 and a16w16 at the kimi512 shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the clean, behavior-preserving raw-arith call-sites with the current
fx.* operator/method surface, matching the mxfp_moe/attention idiom:
  - arith.cmpi(eq)+arith.select -> (a == b).select(t, f)   (B-scale lo/hi, x4)
  - arith.cmpi(ult) -> (token < i32_ntok); arith.andi -> &  (epilogue store mask)
  - arith.TruncFOp(T.bf16, y) -> y.to(fx.BFloat16)          (epilogue + int4 upconvert)

Kept as raw (layout-API ceiling / reference-aligned, noted in code): the
preshuffled-W buffer_ops.buffer_load (crd2idx layout) and per-lane scalar
B-scale loads, the masked buffer_store output, and the llvm.AtomicRMWOp bf16
scatter epilogue -- all match mxfp_moe's justified raw machinery. XCD-swizzle
arith.minsi kept (no fx signed-int-min operator). The A-DMA already uses
make_buffer_tensor+fx.copy and the MMA already uses make_mma_atom+fx.gemm.

Verified COLD (FLYDSL_RUNTIME_ENABLE_CACHE=0, gfx950): full gate 7 passed /
2 skipped / 6 xfailed (unchanged); real cos a16w4/a16w16 = 0.999995 (unchanged);
s1/s2 median-of-3 within noise (tok128 s1 332/s2 1662us, tok1024 s1 343/s2 965us
vs baseline 333/1665, 342/964); no new spill (gemm1 260 VGPR/0 spill, gemm2
156 VGPR/0 spill, 0 scratch mem-ops); style clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Convert the hot-loop W dwordx4 weight load from raw buffer_ops.buffer_load over a
create_buffer_resource_from_addr V# to the current BufferCopy128b copy-atom idiom
used by the mxfp_moe reference:
  - add _global_i32_buffer_tiles helper (curated from mxfp4_gemm_common) to common
  - W load: fx.copy(BufferCopy128b(b_cache_mod) atom, slice(w_tiles, tile_idx), r)
    replacing buffer_load(w_rsrc, ..., cache_modifier=b_cache_mod); the runtime cache
    modifier is carried in the atom aux field. Both load_b_raw (mxfp4/int4) and
    load_b_raw_bf16 (a16w16) in gemm1 and gemm2.

Kept raw (layout-API ceiling, noted in code): the per-lane scalar (vec_width=1)
e8m0 B-scale buffer_load (no layout form, dict-cached), and the masked predicated
bf16 output buffer_store + create_buffer_resource_from_addr (no copy-atom form for
a masked scatter-store). The llvm.AtomicRMWOp bf16 scatter epilogue and XCD-swizzle
arith.minsi also stay (match mxfp_moe's justified raw machinery).

Verified COLD (FLYDSL_RUNTIME_ENABLE_CACHE=0, gfx950): full gate 7 passed /
2 skipped / 6 xfailed (unchanged); real cos a16w4/a16w16 = 0.999995 (unchanged);
byte-equivalent ISA (gemm1 260 VGPR/0 spill, 280 buffer_load_dwordx4; gemm2 156
VGPR/0 spill; 0 scratch); s1/s2 median-of-3 within noise (tok128 s1 335/s2 1663us,
tok1024 s1 341/s2 965us vs baseline 333/1665, 342/964); style clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ilog)

The moe_2stage_a16wmix extraction (678ad90) dropped the @flyc.jit decorator
from _atomic_bf16_epilog. Without it the ``if token_id < i32_M`` bound check is
no longer AST-rewritten into an scf.if guard; it runs as a plain Python if that
is dropped at trace time, so the atomic-fadd scatter fires unconditionally on
padded/OOB sorted rows. On the Kimi-K3 E896 shape this issues ~10x extra atomic
HBM transactions, regressing gemm2 stage2 from ~39us to ~490us at tok16.

Re-adding @flyc.jit restores the scf.if guard (byte-identical to the pre-
extraction kernel). gemm2 s2 (3584x512 E896 k16, cold, median-of-3):
tok16 496->38us, tok128 1669->145us, tok1024 951->176us. No VGPR spill (156,
0 spill). a16w4/a16wi4/a16w16 correctness unchanged (170+9 tests pass);
a4w4/a8w4 (mxfp_moe) untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…o k_wave

The tuned-CSV loader previously filtered to abf16_wfp4 only, so a16wi4 (int4)
rows were skipped and the caller fell back to the default tile config. Accept
abf16_w{fp4,int4,bf16} rows, and parse the _kb{N} grid-split-K token: since we
have no grid split-K, approximate it with our intra-block slice-K lever k_wave
(kb==2 -> kw2, kb>2 -> kw4). int4 rows (kb2/4/7/14) now resolve to k_wave 2/4;
K=7168 divides k_wave*tile_k cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…h tokens

The mxfp4 gemm1 default tile_n=256 ("fat wave") compiles to 260 VGPR, which on
gfx950 (512 VGPR/SIMD, alloc granularity 8 -> 264) allows only 1 wave/SIMD --
the occupancy cliff. Weight-load latency is then fully exposed, and because the
whole E896 grid is capped at 1 wave/SIMD regardless of M, the FlyDSL/aiter s1
gap grows with token count (~1.96x @tok8192, ~2.06x @tok16384).

tile_n=128 splits each block into 2 n-blocks with thinner waves (num_acc_n 4->2),
dropping VGPR 260->178 -> 2 waves/SIMD. rocprofv3 confirms ~2x SQ_WAVES and
~10% fewer total GPU cycles (GRBM_COUNT 185M->167M). No VGPR/AGPR spill.

Measured (3584x512 E896 k16, balanced routing, isolated gemm1 s1, median-of-5
cold, GPU4/gfx950):
  tok     tn256(before)  tn128(after)  delta
  1024      320.6us        299.5us     -6.6%
  2048      553.5us        536.9us     -3.0%
  4096      806.9us        759.6us     -5.9%
  8192     1284.9us       1189.3us     -7.4%
 16384     2449.7us       2208.2us     -9.9%
Also faster at small tokens (tok1 31.9->23.7us, tok64/256 ~-4%); no regression.

Scope: mxfp4 gemm1 only (D_INTER % 128 == 0). bf16 (a16w16, VGPR>=448 regardless
of tile_n) and int4 (already defaults to 128) keep _default_tile_n. gemm2 default
unchanged. The opt-in use_csv_config (aiter-compare) path is preserved -- it sets
tile_n before this default and wins. Parity: test_a16w4_moe_e2e[small,kimi512]
(mxfp4+bf16) pass cold.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…OOB safety)

The gemm1 A (activation) global->LDS DMA built its buffer resource with
num_records = 0xFFFFFFFF (~4GB). moe_sorting padding rows carry a sentinel
sorted_token_id >= n_tokens; the A-load path decodes it (t_i32 = fused &
0x00FFFFFF -> x_row_base_div4 = t_i32 * c_k_div4) into a byte offset PAST the
real [n_tokens, K] bf16 allocation. With the oversized resource the hardware OOB
mask never clamps those padding-row loads, so the direct-to-LDS DMA can read
unmapped memory and fault at high token counts (allocation-dependent: the read
lands 1+ rows past the buffer). The epilogue already guards the STORE
(token < i32_ntok) but nothing guarded this READ.

Size the resource to the true allocation (i32_ntok * c_k_div4 * 4 bytes ==
n_tokens * K * 2). The HW mask then returns 0 for padding-row A-loads (harmless
-- their store is dropped), matching aiter. Resource-descriptor only; the hot
loop, VGPR (174, no spill), and s1 timing are unchanged, and valid-row output is
byte-identical (verified: out.sum identical at tok 2048/8192/16384). Shared body
-> covers mxfp4 / int4 / bf16 alike.

Parity: test_a16w4_moe_e2e[small,kimi512] (mxfp4+bf16) pass cold; gemm1 runs
clean at tok 2048/4096/8192/16384 under balanced routing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
At n_tokens>=16 the leaner TILE_K=128 (more, shorter K-tiles) plus xcd_swizzle=1
(bijective XCD/HBM-channel round-robin) is measured faster across the whole M>=16
range on both Kimi-K3 shapes (3584x384 / 3584x512): gemm1-s1 ~0.83-0.94x on the
high-M tail (2048..16384), closing the residual aiter gap (3584x512 s1 was ~1.06
-1.11x aiter at tok2048..16384, now 0.96x/0.98x/1.01x/1.05x). Gated at >=16 because
TILE_K=128 regresses the launch-latency-bound tok 1..8 (~+5-10%), which keep
TILE_K=256. Only fires when the caller left tile_k/xcd at defaults and not
use_csv_config; mxfp4 only (int4/bf16 share the body but are separately tuned).
Parity held (e2e ld~1.5e-5 cold at all tokens both shapes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…l fix)

At tok 1..2 the E896 launch has only ~topk m-blocks of real work so the GPU is
badly under-filled. The narrower TILE_N=64 doubles the CTA count per m-block for
better latency hiding across the CUs: gemm1-s1 ~0.86-0.89x at tok 1..2 on both
Kimi-K3 shapes (3584x512 tok1 s1 25.0->21.2us, tok2 27.2->24.3; total 1.44x->1.25x
/ 1.29x->1.21x vs aiter). Gated to tok<=2 (regresses ~+18% from tok4 up) and to the
default path (not use_csv_config, needs D_INTER%64==0). Residual tok1-2 gap vs aiter
(~1.4x on s1) is fixed kernel/launch latency at M=1, not empty-CTA grid-fill: a
grid-size probe showed capping the launch to cover only the real work-tiles gives no
speedup, so a persist/CU-capped gemm1 grid does not help. Parity held (ld~1.5e-5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rewrite _int4_nibble_to_bf16x8 to match aiter's tuned int4 dequant path:
use v_cvt_off_f32_i4_sdwa byte_sel to read each nibble without a per-nibble
shift (even nibbles 0,2,4,6 = byte_sel 0..3 on raw; odd 1,3,5,7 = byte_sel 0..3
on raw>>4 -> ONE shift total instead of seven), and pack f32->bf16 pairs with
v_cvt_pk_bf16_f32 (4 packed converts instead of 8 scalar .to(bf16)).

Measured on gfx950/dev4 (rocprofv3, 7168x512 E384/k8 tok4096, realistic harness
memory): SQ_INSTS_VALU 470.4M -> 418.9M (-11%) and SQ_WAVES 55324 -> 45056 (now
matches aiter's 45024). No VGPR spill (Scratch=0; VGPR 52->60). Parity held: cos
0.999987 (logits_diff 1.27e-5), strict kernel-mode gate PASS cold at all tokens.
Same v8bf16 output layout; int4-only (mxfp4/bf16 paths untouched).

Wall-clock is ~neutral at the tested MoE shapes (gemm1 is memory-bound there;
the dequant VALU was already latency-hidden), but the op-count reduction matches
aiter and helps compute-bound / L2-resident cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
int4 gemm1 stage1 is W1-load bound. With all experts active, each expert's W1
tile is re-fetched from HBM once per padded 32-row m-block. At the fill point
where every expert has exactly two half-full 32-row m-blocks (avg tokens/expert
in (32,64]), doubling block_m to 64 collapses them into ONE full 64-row block,
halving the W1 HBM re-reads. Measured on 7168x512 E384/k8 at tok2048:
TCC_MISS 3.34e7 -> 1.47e7 (2.27x), TCC_READ 5.72e7 -> 3.31e7, stage1
802 -> 749 us (-6.7%); tok3072 831 -> 769 (-7.4%); 7168x256 tok2048 -11%.
No VGPR spill (92->144 VGPR, scratch 0, LDS 32->64KB).

Outside that narrow band block_m=64 either wastes padding (<=1 block/expert) or
halves grid parallelism with no reuse gain (>=3 blocks/expert), so it is not
applied -- verified block_m stays 32 (byte-identical) at tok 512/1024/1536/
4096/8192/16384. Add a16wi4_recommend_block_m() (mirrors aiter's
estimated_m_per_expert heuristic) for the dispatcher to size routing + gemm1
block_m together (block_m sizes moe_sorting padding, so caller must couple them).
int4-only; mxfp4/bf16 codegen untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rewrite _int4_nibble_to_bf16x8 to the VALU-lean gfx950 path: read each nibble
with v_cvt_off_f32_i4_sdwa byte_sel (even nibbles from raw, odd from raw>>4 ->
ONE shift total instead of seven) and pack f32->bf16 pairs with v_cvt_pk_bf16_f32
(4 packed converts instead of 8 scalar .to(bf16)).

The prior attempt (e75f0f9, reverted in 712c28f) produced garbage (cos=0,
output magnitude ~1e23) at the Kimi 7168xN shapes for tokens<=256. Root-caused:
the byte_sel dequant was correct all along; the bug was the STATELESS
rocdl.cvt_pk_bf16_f32 (has_side_effects=False). In the gemm1 hot loop the 4
identical-shaped packed converts per K-step get CSE-merged / reordered across K
iterations, corrupting the v8bf16 MMA operand. Isolated-kernel tests of both
primitives were correct; only the full hot-loop context miscompiled. Fix: emit
the pack via a side-effecting inline-asm helper (_cvt_pk_bf16_f32_se) that pins
each convert to its K-step.

Bound classification (rocprofv3, gfx950/dev4, 7168x512 E384/k8): int4 gemm1 is
VALU-BOUND, not memory-bound -- VALUBusy 62.7%, MfmaUtil 17.6%, MemUnitStalled
0.01%, VALU/VMEM inst ratio 43.5x, occupancy 21.7% (LDS-limited). After fix:
SQ_INSTS_VALU 157.0M -> 138.2M (-12%), VALUBusy 62.7% -> 58.8%; occupancy and
Scratch(=0, no spill) unchanged (VGPR 92->120, still LDS-bound).

gemm1 stage1 before->after (median-of-7 cold, 7168x512 E384/k8):
  tok128  456.0->424.8 us (1.073x)   tok1024 481.7->462.0 (1.043x)
  tok256  461.8->429.3 us (1.076x)   tok4096 1201->1144 (1.050x)
  tok512  469.8->443.9 us (1.058x)   tok8192 2145->2091 (1.026x)

Accuracy: cos>=0.9994 at the previously-broken Kimi 7168x256 tok{16,32,64,128,
256}; output fingerprints byte-match the scalar path at every token. Official
tests pass cold: test_moe_gemm_w4a16_groupwise_scale (2/2),
test_moe_gemm_2stage int4_bf16 S/M/L (12/12), test_a16w4_moe_e2e mxfp4+bf16
(4/4, no regression -- int4-only change). int4-only: _int4_nibble_to_bf16x8 is
called only on the _is_int4 branch; mxfp4/bf16 paths untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
int4 gemm1 at the a16wi4_recommend_block_m BM=64 W1-reuse fill point (tok
~1600..3072 on 7168x512 E384/k8) was VGPR/AGPR-occupancy-crippled: TILE_N=128
compiles to VGPR=349 + AGPR=93 -> only 1 wave/SIMD (OccupancyPercent 12.3%),
fully exposing the W-load + int4-dequant + A-LDS-read latency. Halving to
TILE_N=64 drops the footprint to VGPR=244/AGPR=0 (no spill) -> 2 waves/SIMD
(OccupancyPercent 23.0%), cutting gemm1-s1 ~23% across the whole BM=64 band
(tok 1600/2048/2560/3072: 955/966/979/997us -> 734/745/764/773us), narrowing
the tok2048 gap vs aiter from 1.49x to 1.15x.

Scoped to w_dtype=int4 AND BM==64: the BM=32 mid band (tok 128..1024) is
already 2 waves/SIMD (VGPR=234) and NOT occupancy-bound -- measured TILE_N=64
there is a net wash-to-loss (5x more SQ_WAIT_INST_LDS at equal active cycles),
so it keeps TILE_N=128. mxfp4/bf16 never reach this branch (byte-identical
codegen). Accuracy cos=0.999999 cold at BM=64 and tok<=256; no VGPR spill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Not imported/exported; a measurement scratch that should not ship in the package.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
At M=1 the a16w4 gemm1 K-loop is LDS-wait-bound (~90% of SQ_BUSY cycles are
SQ_WAIT_INST_LDS): the ds_read A-operand fetch cannot be hidden behind the
shallow 7-tile K-loop when there is ~1 real token per 32-row block. Splitting K
4 ways (each wave computes a klen=896 slice, then an LDS-reduce) halves the
per-wave K-loop depth, cutting the exposed LDS latency. Measured gemm1-s1 ~0.83x
at tok 1..2 on both Kimi-K3 shapes (3584x384 / 3584x512), matching aiter's own
tok1 config (t32x64x256_kw4). tok1 total 3584x512 1.27x -> 1.06x, 3584x384
1.27x -> 1.20x vs aiter. K=3584 % (4*128)==0 requires TILE_K=128 (kw4 with
TILE_K=256 would need K % 1024, which 3584 fails). Regresses from tok4 up, so
gated with the existing TILE_N=64 lever to tok<=2, mxfp4 only, defaults only.
Parity preserved (cos ~0.99999, ld 1.5e-5); mid/high tokens and a16wi4/a16w16
untouched (byte-identical config path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n gfx942)

The moe_2stage_a16wmix a16wi4 kernel uses the gfx950 (CDNA4) K=32 bf16 MFMA and
fp4/int4 convert ops, so it aborts at launch on gfx942 (CDNA3, K=16 only) -- the
CI mi325 runner hit 'Fatal Python error: Aborted' at host.py:300. Route int4_bf16
to the new kernel only when 'gfx95' in ARCH; on gfx942 fall back to the legacy
moe_gemm_2stage builder (which supports gfx942 incl. groupwise, verified via
FLYDSL_INT4_FORCE_LEGACY). a16w4/a16w16 tests were already gfx950-gated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…equant

The moe_2stage_a16wmix a16wi4 (int4) and a16w16 (bf16) kernels aborted at launch
on gfx942 because they hardcoded MFMA(16,16,32,bf16) (CDNA4-only) and the int4
dequant used v_cvt_pk_bf16_f32 (gfx950-only). Arch-gate both:

  * MMA: gfx950 keeps the K=32 16x16x32 bf16 MFMA (one per K-step); gfx942 uses the
    K=16 16x16x16bf16_1k MFMA, splitting each v8bf16 K-step into two v4bf16 halves
    and issuing TWO MFMAs into the same 16x16 f32 accumulator. Load/dequant still
    produce v8bf16; only the MMA emission changes.
  * int4 dequant: gfx950 keeps the VALU-lean v_cvt_pk_bf16_f32 pack; gfx942 falls
    back to scalar .to(bf16) truncation per nibble (arch-agnostic).

a16w4 (mxfp4) stays gfx950-only (cvt_scalef32_pk_bf16_fp4 is CDNA4-only).

The path is selected host-side by a16wmix_use_k16() from get_rocm_arch(); the
gfx950 default (K=32) is byte-identical to before (verified: all 44 IR stages of
gemm1+gemm2 identical for mxfp4/int4/bf16). FLYDSL_A16WMIX_FORCE_K16=1 forces the
gfx942 (K=16 + scalar) codepath on any arch, to validate it on a gfx950 box.

Relax the test int4_bf16 routing gate (_A16WMIX_GFX) to include gfx94*, since the
new path now runs on gfx942.

Verified on gfx950 (GPU4): default K=32 path byte-identical + passes cold;
FORCE_K16 path correct cold for int4 + bf16 + mxfp4 incl tile_m=16/tok=64, zero
VGPR spill (only 16x16x16 MFMAs emitted). Perf: FORCE_K16 int4 is ~7-9% FASTER
than legacy int4 (moe_gemm_2stage) at 3 Kimi-like shapes on gfx950.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ly, IR byte-identical)

Collapse multi-paragraph rationale/measurement-log comments to 1-2 tight lines
across gemm1/gemm2/host/common/__init__, keeping the essential "why" and all
load-bearing warnings terse (atomic epilog @flyc.jit, A-load OOB clamp,
side-effecting bf16 convert, K16/K32 + mxfp4 arch-gates).

Comment/whitespace-only: all IR stages (00_origin .. 21_final_isa) for a16w4 +
int4 + bf16 gemm1+gemm2 are byte-identical to HEAD (diff -rq, cold, COMPILE_ONLY).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… on HW)

The gfx942 K=16 codepath passes the gfx950 FORCE_K16 proxy but ABORTS at launch on
real gfx942 (mi325 CI): the CDNA3 mfma_f32_16x16x16bf16_1k variant differs from the
CDNA4 K=16 the proxy emits, so the proxy did not validate the actual gfx942 ISA.
Re-gate int4_bf16 new-kernel routing to gfx950 only; gfx942 uses the legacy
moe_gemm_2stage path (verified working). The arch-gated K=16 kernel code stays
(dormant on gfx942) for later real-hardware validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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