Skip to content

perf(moe): warp-split moe_q4_grouped_mma variant, opt-in (BUTTER_LAGUNA_MOE_NSPLIT=1) - #75

Merged
TheTom merged 9 commits into
devfrom
perf/moe-nsplit-occupancy
Jul 29, 2026
Merged

perf(moe): warp-split moe_q4_grouped_mma variant, opt-in (BUTTER_LAGUNA_MOE_NSPLIT=1)#75
TheTom merged 9 commits into
devfrom
perf/moe-nsplit-occupancy

Conversation

@TheTom

@TheTom TheTom commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

What

Adds moe_q4_grouped_mma_nsplit: doubles the warp count (4→8, 128→256 threads) and splits the NS=16 N-slots across the new warp axis, halving the per-thread f32 accumulator (64→32 regs). BM/BN and the A-tile are unchanged, so A-tile reuse is preserved — this is a restructure, not the tile-parameter shrink that was measured negative earlier in the campaign.

Default OFF, behind BUTTER_LAGUNA_MOE_NSPLIT=1.

Result — real but small, hence opt-in

Registers 64, zero spill under __launch_bounds__(256,4) (verified on the shipped kernel source, not just a scoping copy)
Occupancy 41.7% → 66.1% measured by ncu (66.07-66.33% vs the kernel's own 66.67% ceiling) — the projected jump landed almost exactly
Correctness bit-exact (max_rel = 0.00e0) vs the default kernel across uniform, ragged/non-power-of-two, and zero-count-expert cases, on both host- and device-descriptor paths
Gate byte-identical prefill argmax 290, decode steps 2-17, and the 16-token greedy continuation
Throughput +0.2% to +1.2% isolated-cold across 512/1K/2K/4K/8K (9/10 paired samples favor nsplit)

Why it's small, and why that's the interesting part

Profiling established this kernel is latency-bound with no pipe saturated, at ~30-38% of prefill wall time. A large occupancy gain therefore hides latency better but cannot move the total much — which is what we measure. That is a useful negative result for the campaign: occupancy was not the binding constraint.

Left default-OFF because a ~1% gain sits at the edge of this box's documented 1-3% run-to-run noise; flipping the default deserves a larger measurement budget than one afternoon.

FYI @ekryski.

TheTom added 9 commits July 25, 2026 14:04
sdpa_multi_tc_varlen (and its non-varlen sibling sdpa_multi_tc, which
shares the SDPA_TC_KPREP_SRC kernel) converted the WHOLE causal-prefix
K range into the kh scratch buffer upfront, once per call, and then
dispatched the softmax/merge kernels unconditionally for every 2048-wide
KV block in that range -- even though the QK^T/PV GEMMs were already
correctly skipped via q_cnt==0 for blocks outside a sliding-window
layer's reachable range.

For Laguna-S-2.1 (36 of 48 layers are sliding-window, window=512) this
meant every prefill chunk on every windowed layer paid an O(prefix)
cost (kh sized to base+chunk_len, kprep dispatched over that whole
range, softmax+merge run per block) instead of O(window+chunk_len) --
the cost grows with how deep into the prompt the chunk sits.

Fix: make K-prep per-block (mirroring V-prep/SDPA_TC_VPREP_SRC, which
already worked this way) into a fixed bk-sized rolling buffer instead
of a whole-prefix one, and gate kprep+softmax+merge on the same
q_cnt>0 check the GEMMs already use. A block no row can reach
contributes nothing either way (softmax's own positional mask already
forces bm=-inf/bl=0 for such rows; merge's l_b<=0 early-return already
no-ops on that combination) -- bit-identical output, just without the
wasted dispatches. Side effect: kh's allocation is now a fixed size
across calls instead of growing with prefix depth, which should also
help the CUDA caching allocator reuse pooled buffers instead of
re-allocating on every larger chunk.

Ported from the pre-rename ffai-rust prefill-optimization campaign
(source commit 8f6eb3c); gated there against the full laguna test
suite (10/10) and the prefill-then-decode token-identity oracle
(byte-identical greedy continuation, last-token argmax=290).
The per-block skip in sdpa_multi_tc_varlen omits whole KV blocks a
sliding-window layer's rows can't reach at all (q_cnt==0), but a block
that IS touched still runs the full bk-wide QK^T/PV GEMM + softmax/merge,
even though Laguna's sliding_window (512) needs only a fraction of a
2048-wide block.

Make the KV block size conditional: full-attention layers (win==0) keep
bk=2048 unchanged (bounds the scores buffer, no window to exploit).
Sliding-window layers (win!=0) now use a separate, independently-tunable
BUTTER_LAGUNA_SDPA_WIN_BK (default 1024) -- picked over the window width
itself (512) because at 512 the extra loop/launch overhead from doubling
block count outweighs the reduced overcompute at some sequence lengths,
while 1024 is flat-to-positive everywhere measured.

Ported from the pre-rename ffai-rust campaign (source commit 6fece8b);
gated there against the full laguna suite (10/10) plus the
prefill-then-decode token-identity oracle (argmax=290, byte-identical
greedy continuation).
BUTTER_LAGUNA_PREFILL_CHUNK still overrides explicitly; unset/0 now
resolves through laguna::default_prefill_chunk(total) instead of a
fixed 1024/2048 constant baked into the two test-harness call sites.

A fixed 2048-token chunk (isolated, cold-GPU-per-run measurements) beats
BOTH true monolithic (chunk==n) and the previous chunk=1024 convention
at every n from 2048 through 32768. Monolithic is fine (and equivalent,
since chunk.min(n) collapses either way) up to n=2048, but is unstable
and often much slower above that: a single huge batched activation
tensor stops fitting the working set (single monolithic n=8192 chunk
measured between 191 and 364 tok/s across repeated cold-start runs, vs
a stable ~460-540 tok/s at chunk=2048).

Also adds an argmax cross-check to the BUTTER_LAGUNA_PP throughput bench:
the synthetic token sequence is deterministic, so running the same n at
different chunk sizes (auto vs an explicit override) and comparing the
printed last-token argmax is a quick chunk-invariance sanity check
alongside the separate BUTTER_LAGUNA_PREFILL tokenizer oracle.

Ported from the pre-rename ffai-rust campaign (source commit 6c301a1);
gated there against the full laguna suite (10/10), the tokenizer
argmax=290 oracle (byte-identical greedy continuation -- chunk choice
does not affect correctness by construction), and a chunk=0 (auto) vs
explicit-override argmax cross-check at several prompt lengths.
ncu-confirmed (l1tex bank-conflict vs wavefront counters,
moe_w4a16_marlin128): the dequant STORE into smem_WT was hitting ~18x
overhead vs the conflict-free ideal. Root cause: smem_WT's row stride
was exactly 128 halfwords (256 bytes), a multiple of the 32-bank x
4-byte conflict period, so a warp's 32 threads (which vary the ROW
index while the bank-determining column index stays fixed within an
unrolled dequant iteration) all aliased the SAME bank on every store.

Padded the row stride to 136 halfwords -- the smallest multiple of 8
(WMMA's row-major ldm alignment requirement for __half) that reduces
the conflict; modular arithmetic (row_wt/2 mod 32) shows 8-alignment
makes a fully conflict-free stride impossible for this 32-row/128-bank
shape, so 136 is the best achievable (32-way -> 4-way conflict, an 8x
reduction). smem_X (the ~3.5x load-side offender) is NOT padded this
round: GB10's actual opt-in dynamic-shared-memory cap is 101376 bytes
(queried via CU_DEVICE_ATTRIBUTE_MAX_SHARED_MEMORY_PER_BLOCK_OPTIN --
not the ~228KB the kernel's old doc comment assumed by analogy to a
different GPU), and the padded-WT kernel already uses 99840 bytes,
leaving no room to also pad X without exceeding the cap and failing
cuFuncSetAttribute at launch.

Ported from the pre-rename ffai-rust campaign (source commit 24da67e);
gated there against the full laguna suite (10/10, with
BUTTER_LAGUNA_MARLIN=1 actually set -- the bare default suite doesn't
exercise this kernel), the tokenizer argmax=290 oracle with MARLIN=1
(byte-identical greedy continuation), and PP-bench argmax cross-checks
at several sizes against pre-padding values (all exact matches).
…election sort

moe_build_tiles (single-thread pre-pass kernel building per-tile descriptors
for moe_q4_grouped_mma_dev, used by prefill_layer's two grouped-GEMM calls
per layer under BUTTER_LAGUNA_SCHED=1 default-on desc=true mode) used an
O(n_exp^2) selection sort to order experts by descending token count.
At Laguna's n_expert=256 that's 32768 compares on a SINGLE GPU thread
(the kernel is intentionally single-threaded: only blockIdx.x==0 &&
threadIdx.x==0 runs). ncu measured ~1.34ms/call average at pp2048 --
roughly 96 calls per prefill pass (48 layers x 2 dev-path grouped-GEMM
calls) -- a real cost hidden inside the moe_gateup_gemm/moe_down_gemm
profiling buckets.

Replaced the selection sort with a standard heapsort (build max-heap keyed
by cnt[order[i]], repeated extract-max, reverse for descending), verified
correct via a standalone Python simulation (200+ random cases + edge cases:
n=0, n=1, all-equal, all-zero, both sort directions, n=256) before writing
any CUDA. ncu-confirmed the fix: moe_build_tiles average duration dropped
1,338,549ns -> 245,584ns (5.45x) at pp2048, same launch-skip/count sample.

Correctness of the caller is provably insensitive to the exact tie-break
order this sort produces (tiles write disjoint output regions regardless
of emission order -- moe_q4_grouped_laguna_sched_ab already A/B-tests
desc-mode vs default order on skewed/zero-count-expert groups and requires
bit-exact agreement).

Ported from the pre-rename ffai-rust campaign (source commit c8c76da);
gated there against the full laguna suite (10/10, BUTTER_LAGUNA_MARLIN=1
BUTTER_LAGUNA_MOEFUSE=1), the tokenizer argmax oracle (byte-identical
greedy continuation), and per-size argmax cross-checks matching known
values at n=512/1024/2048/4096/8192/32768.
Prior rounds of profiling (tile-size, launch_bounds, launch-geometry,
smem-budget, pipe-competition) converged on this kernel's ~31%
tensor-pipe utilization being latency/occupancy-bound, with occupancy
pinned at 5 blocks/SM by a 92-95 reg/thread register-file ceiling. This
deepens the software-pipelined cp.async prefetch depth (the remaining
latency-hiding lever) from 2 buffers to 3.

Standalone nvcc -Xptxas -v confirms zero register-count change (95->95
regs, 0 spill) -- only smem grows (12800->19200 bytes), which still fits
5 blocks/SM under the GB10 101376-byte opt-in cap (96000 bytes), so
occupancy is unaffected by construction, not just by measurement.

A live same-session A/B (patched vs reverted baseline, both run
back-to-back to control for this box's substantial run-to-run/day-to-day
throughput drift) measured 3-stage as +6.4% @2048, +2.3% @4096, +1.2%
@8192, and flat (within noise) at 512/1024/16384/32768 -- modest but
real, consistent with occupancy remaining the dominant cost and pipeline
depth only recovering part of the gap.

Ported from the pre-rename ffai-rust campaign (source commit eb51c29);
gated there against the full laguna suite (10/10, byte-identical greedy
continuation, decode-step-1 argmax=290 preserved), moe_grouped_mma_test
(15/21 pass, same 6 known unrelated failures as the documented
baseline), and moe_q4_grouped_laguna_sched_ab (ragged/zero-count-expert
case).
…msnorm_batched_f16out to iron@dev

iron PR #75 landed iron_moe_gather_q4, iron_moe_gather_q4_swiglu,
iron_rms_norm_f16out, and iron_gated_group_rmsnorm_batched_f16out on
thewafflehaus/iron@dev. Replace the always-error stubs and the
rms_norm+cast/gated_group_rmsnorm_batched+cast fallbacks with real
dispatches, mirroring the existing _relu2/_down sibling patterns
(cached_ir + kernel_ir_for). rms_norm_f16out keeps the f32-only,
n%128==0 && n<=4096 fast-path guard (same 16-bit vec4 codegen
restriction as plain rms_norm) and falls back otherwise.

Bump the iron git dependency lock to pick up the merged kernels.
…fix)

Picks up the pool-cap fix landed in thewafflehaus/iron#76 on top of the
#75 kernel port already locked in the prior commit. Confirmed in the
gate below: the single-process comma-list ladder now matches
isolated-per-config within normal run-to-run noise (no more mid-ladder
degradation from the parking-cap pressure #76 fixes).
…A_MOE_NSPLIT=1)

Splits the NS=16 N-slots across an extra warp axis (4->8 warps, 128->256
threads) instead of shrinking BN/the output tile, halving the per-thread
f32 accumulator (oc[16][4]=64 regs -> oc[8][4]=32 regs) while leaving the
A-tile and its reuse unchanged. Under __launch_bounds__(256,4) this
compiles to 64 registers with zero spill, raising the register-limited
occupancy ceiling from 41.7% (5 blocks/SM x 4 warps) to 66.7% (4 blocks/SM
x 8 warps) -- confirmed empirically via ncu (66.07-66.33% achieved vs the
kernel's own 66.67% ceiling), not just via standalone nvcc projection.

Correctness: nsplit vs default max_rel=0.00e0 (bit-exact) across uniform,
ragged/non-power-of-two, and zero-count-expert cases, on both the
host-descriptor (moe_q4_grouped_mma) and on-device-descriptor
(moe_q4_grouped_mma_dev) call paths. Full-model gate byte-identical
(argmax=290, decode steps 2-17, 16-token greedy continuation) with the
same env stack used for benchmarking.

Isolated-cold ladder (2 reps/config, BUTTER_LAGUNA_MARLIN=1
BUTTER_LAGUNA_MOEFUSE=1): nsplit beats default at every size by a small,
consistent margin (+0.2% to +1.2%, mean of 2 reps) --
512: 458.7->464.0, 1024: 593.9->595.2, 2048: 669.9->676.8,
4096: 648.7->654.3, 8192: 619.1->625.9 tok/s. This is a real, directionally
consistent win (9/10 paired samples favor nsplit) but sits at the edge of
this campaign's documented 1-3% run-to-run noise band, well short of the
occupancy jump's theoretical ceiling -- consistent with a latency-bound
kernel that is only ~30-38% of prefill wall time. Gated default-off
pending a longer confirmation run before considering flipping it on.
@TheTom
TheTom requested a review from ekryski July 26, 2026 02:45
@github-actions github-actions Bot added the performance Performance improvement label Jul 26, 2026

@ekryski ekryski left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

@TheTom
TheTom marked this pull request as ready for review July 29, 2026 17:53
@TheTom
TheTom merged commit e37ba2c into dev Jul 29, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance Performance improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants