Skip to content

perf(laguna): prefill optimization campaign — sliding-window SDPA, marlin smem padding, moe heapsort + pipeline depth - #74

Merged
TheTom merged 8 commits into
devfrom
perf/laguna-prefill-campaign
Jul 25, 2026
Merged

perf(laguna): prefill optimization campaign — sliding-window SDPA, marlin smem padding, moe heapsort + pipeline depth#74
TheTom merged 8 commits into
devfrom
perf/laguna-prefill-campaign

Conversation

@TheTom

@TheTom TheTom commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Ports 6 commits from an overnight Laguna prefill-optimization campaign (run on a pre-rename ffai-rust working tree) onto this repo's post-rename dev branch. Each source commit was individually located, re-derived against this tree's current structure (crate names, BUTTER_LAGUNA_* env convention, wh-butter-*/wh-iron-* paths), and re-verified to build here — see the per-commit list below.

Verified ladder (isolated-per-config methodology — one process per size, cold GPU each run — measured on the source tree at HEAD eb51c29):

n tok/s
512 445
1024 596
2048 667
4096 642
8192 612

vs. the campaign's starting point (559 @2048 / 504 @8192 / 317 @32768) and the reference C++ fork's stock numbers (663 / 660 / 631 @2048/8192/32768) — we now beat fork-stock at 2048.

⚠️ Methodology note: numbers from this box vary substantially between isolated-per-config runs and single-process sweeps — the same ladder measured as one process reads 10-32% lower at mid sizes (thermal/allocator contamination across configs in one process). Any benchmark quoted from this box must state which methodology was used; only isolated-per-config numbers are comparable to the ladder above.

Every change was gated (on the source tree, GB10) against byte-identical greedy continuation and the last-token argmax=290 oracle vs. the reference implementation.

The 6 fixes

  1. fix(sdpa) 8f6eb3cf7c0ab2 — sliding-window attention layers were K-prepping and running softmax/merge over the entire causal prefix instead of just the touched window; fixed to per-block K-prep gated on the same q_cnt>0 check the QK/PV GEMMs already used (bit-identical output, removes wasted dispatches — the root cause of prefill throughput decaying with sequence length).
  2. perf(sdpa) 6fece8b0425f8b — windowed-layer KV block granularity shrunk 2048→1024 (new BUTTER_LAGUNA_SDPA_WIN_BK env, full-attention layers unaffected) — a touched block was still 4x wider than the 512 sliding window even after fix Release v0.1.0 #1.
  3. perf(prefill) 6c301a1f0106ab — chunk size now resolves to min(total, 2048) when unset (chunk==0 sentinel), replacing a fixed 1024 default; monolithic chunking is unstable and often much slower above ~2048 tokens (single monolithic n=8192 chunk measured 191-364 tok/s across cold-start runs vs. a stable 460-540 at chunk=2048). BUTTER_LAGUNA_PREFILL_CHUNK still overrides explicitly.
  4. perf(marlin) 24da67ebb548cc — padded moe_w4a16_marlin128's smem_WT row stride 128→136 halfwords; ncu showed the unpadded stride caused a 32-way shared-memory bank conflict on every dequant store (~18x overhead), 136 is the best achievable under WMMA's 8-halfword alignment requirement and the device's 101376-byte opt-in smem cap.
  5. perf(moe) c8c76dab2ffe9d — replaced an O(n²) single-thread selection sort in moe_build_tiles with an O(n log n) heapsort; at n_expert=256 this was ~1.34ms/call (ncu), dropping to ~0.246ms (5.45x). Correctness is insensitive to tie-break order (tiles write disjoint regions regardless of emission order).
  6. perf(moe) eb51c292ffc2b6 — deepened moe_q4_grouped_mma's cp.async software pipeline from 2 to 3 stages; register count is unchanged (95→95, 0 spill) so occupancy (5 blocks/SM) is unaffected by construction, only smem grows (12800→19200 bytes, still under cap). Modest but real gain from deeper latency-hiding; occupancy remains the kernel's dominant cost.

⚠️ Hardware validation status

This port has NOT yet been re-validated on the GB10 — the box is in use by another job concurrently with this port. Every source commit was individually gated on the GB10 (full laguna test suite 10/10, byte-identical greedy continuation, argmax oracles) on the pre-rename tree, but this re-derived port (adapted to the post-rename crate/env names and this tree's current state, which has diverged ~900 lines from what was previously merged) needs its own confirming run — full laguna suite + the prefill/decode argmax oracle — before merge.

Gates run (Mac, no CUDA hardware — this is the ceiling available in this environment)

  • cargo build --workspace — clean (pre-existing warnings only, no errors)
  • cargo check -p wh-butter-cuda --features cuda — clean, typechecks the CUDA backend
  • rustfmt --check on the 3 touched files — fails, but this is pre-existing/repo-wide: origin/dev's versions of these same files already fail rustfmt --check before this port (no rustfmt.toml, no CI fmt job; wh-butter-ops/src/lib.rs uses a dense hand-formatted convention for CUDA dispatch calls throughout). This port's diff follows the same established convention as the surrounding code.
  • cargo clippy on the 3 touched crates — no new errors; one new trivial warning (clippy::manual_clamp on default_prefill_chunk's total.min(2048).max(1), kept literal to match the ported source commit) alongside ~46 pre-existing warnings elsewhere in wh-butter-modeltests unrelated to this change.
  • grep for leftover ffai/metaltile/FFAI_ naming across the full diff — zero matches.

Files touched

  • rust/crates/wh-butter-ops/src/lib.rs (commits 1, 2, 4, 5, 6)
  • rust/crates/wh-butter-models/src/laguna.rs (commit 3)
  • rust/crates/wh-butter-modeltests/src/lib.rs (commit 3)

Test plan

  • Full laguna test suite (10/10) on GB10 against this branch
  • BUTTER_LAGUNA_PREFILL=1 argmax=290 oracle, byte-identical greedy continuation
  • BUTTER_LAGUNA_MARLIN=1 variant of the same oracle (exercises fix ci: PRs run unit only; release runs unit + integration #4)
  • moe_grouped_mma_test (expect the same 6 known-unrelated failures as documented baseline)
  • Re-run the isolated-per-config ladder on this branch and confirm it still beats fork-stock at 2048

TheTom added 6 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).
@TheTom
TheTom requested a review from ekryski July 25, 2026 19:13
@github-actions github-actions Bot added the performance Performance improvement label Jul 25, 2026
TheTom added 2 commits July 25, 2026 22:56
…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).
@TheTom
TheTom marked this pull request as ready for review July 25, 2026 23:27
@TheTom

TheTom commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

Now fully gated end-to-end on GB10 — decode included.

Two commits added: the four TODO(rebrand-merge) stubs (moe_gather_q4, moe_gather_q4_swiglu, rms_norm_f16out, gated_group_rmsnorm_batched_f16out) are wired to the real kernels now that iron #75 merged, plus an iron lock bump to dev#131b199 picking up both #75 and #76.

Decode was previously blocked at step 2 on the moe_gather_q4 stub. It now runs to completion and is byte-identical to the pre-rebrand oracle logs — prefill argmax 290, then decode steps 2-17 matching every value exactly, continuation "3D Printing Enthusiasts!...". This is the first byte-identical decode proof since the rename. Full ladder argmaxes (512/1K/2K/4K/8K) match every prior round, and iron's cuda_line_port_correctness suite passes 5/5 including ragged and zero-count-expert MoE cases.

Ladder (isolated-per-config): 461 / 594 / 668 / 650 / 622 tok/s @ 512/1K/2K/4K/8K.

Notably, single-process now measures the same within ~1% — previously it read 10-32% lower at mid sizes. That's iron #76 (allocator pool cap 4→8 GiB) confirmed in the wild, not just in an A/B.

@TheTom
TheTom merged commit e6adc48 into dev Jul 25, 2026
6 checks passed
@TheTom
TheTom deleted the perf/laguna-prefill-campaign branch July 25, 2026 23:58
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.

1 participant