[Gluon][MLA] Split-major grid and blocked stage-2 reduce for the small-nhead decode regime for kimi-k3 - #4509
Closed
amd-ethany wants to merge 3 commits into
Closed
Conversation
Contributor
🏷️ CI GuideRuns automatically on every PR:
Extended tests (opt-in via labels):
|
13 tasks
…es hit in L2 The bh16 stage-1 grid is (batch, split, head_block*q_pos). Triton linearizes axis 0 fastest, so the workgroup id is `batch + batch_size*split + ...` and the hardware round-robins that id over the XCDs. Consecutive ids therefore walk different REQUESTS at a fixed split, which spreads one split's workgroups across all 8 XCDs. Every XCD then pulls its own copy of the same KV pages from HBM. Swapping axes 0 and 1 gives `split + NUM_KV_SPLITS*batch + ...` instead, so all requests' workgroups for a given split share an XCD and its L2. The swap is only taken when NUM_KV_SPLITS is a multiple of the XCD count, which is exactly the condition under which the linear id stays congruent mod NUM_XCD for every batch; otherwise the ordering is left alone. This matters under prefix caching, where the requests in a decode batch share most of their KV pages: co-scheduled workgroups then read identical rows and hit in L2 rather than each XCD streaming its own copy. The change is a relabeling of which workgroup computes which (batch, split) item. Each program covers the same KV range as before and the reduction order is untouched, so outputs are bit-identical. It is also a no-op until the split count is larger than 1 for a multi-request batch, which on the decode path requires ROCm#4507 (or a caller that passes min_kv_seq_len). Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: amd-ethany <ethany@amd.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…ver the head dim
_mla_softmax_reducev_kernel merges the per-split partials one split at a time.
Each step's rescale depends on the previous e_max and moves only head_dim_ckv
elements, so with a large split count the kernel is a hundreds-deep chain of
1 KB dependent loads running at memory latency rather than bandwidth. Its
(batch, nhead, qlen) grid is also small: at batch 1 with 12 heads that is 12
workgroups on a 256-CU part.
Two changes, both re-associations of the same online-softmax merge:
* merge BLOCK_S splits per iteration as one [BLOCK_S, BLOCK_D] tile, which
shortens the serial chain by BLOCK_S and gives the memory system BLOCK_S
independent loads in flight;
* tile the 512-wide head dim across D_SPLIT programs packed into grid axis 2
alongside q_pos. Each slice re-derives the same scalar lse statistics from
the same Mid_lse vector (a few hundred duplicated bytes) and owns a disjoint
slice of `o`, so the slices never communicate.
BLOCK_S=1, D_SPLIT=1 is exactly the previous kernel. D_SPLIT backs off to 1 when
the head dim does not divide evenly or the resulting slice would be under 16
elements. The empty-split guard keeps its semantics: the loop starts on the
block boundary at or below LOOP_START and masks the uninitialised slots off
inside the block, so stage-1's untouched slots are still never read. The MTP
NaN guard is applied per row of the tile.
On Kimi-K3 TP8 / MI355X with 256 splits at batch 1, stage-2 goes from 87.8 us to
4.0 us per call. This is only reachable once the split count is larger than 1,
which on the decode path requires ROCm#4507 (or a caller that passes
min_kv_seq_len); below that the NUM_KV_SPLITS == 1 fast path skips stage-2
entirely.
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: amd-ethany <ethany@amd.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…al waits
The stage-1 loop body opened with a full drain, wait_group(0). At that point the
outstanding groups, in issue order, are exactly the four committed by the
previous trip:
G1 page -> bufs_page[async_idx] (needed at the local load just below)
G2 kv0 -> bufs_kv[buf_idx] \
G3 kpe -> bufs_kpe[buf_idx] } (not needed until the MFMA block)
G4 kv1 -> bufs_kv[buf_idx] /
Only G1 is required at the top. Draining all four serialized the page-number
prefetch chain behind the whole KV stream and collapsed the pipeline to one KV
block in flight. Waiting for just the oldest group (leave three pending, two
without PE) keeps the previous trip's K / K_pe copies in flight across the
entire issue block, so two KV blocks are resident at once at no extra LDS, and
moving the rest of the wait down to the k_c local load retires exactly the three
groups that load actually depends on.
This is the same granularity the two epilogues already use --
wait_group(3 if HAS_PE else 2) at the page load and
wait_group(2 if HAS_PE else 1) at the MFMA. The loop body was simply the
conservative one.
The prologue leaves the same set outstanding as a steady-state trip (page for
buffer 1, then kv0 / kpe / kv1 for buffer 0), so trip 0 retires its page group
under the same accounting, and the set outstanding at loop exit is unchanged, so
epilogue 1 is unaffected.
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: amd-ethany <ethany@amd.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
amd-ethany
force-pushed
the
perf/mla-gluon-split-major-grid-blocked-reduce
branch
from
August 4, 2026 09:19
035020c to
813b9e7
Compare
amd-ethany
marked this pull request as ready for review
August 4, 2026 09:22
5 tasks
Contributor
|
Can you benchmark higher concurrencies? Can you benchmark the other models to confirm they don't regress? |
Closed
1 task
Contributor
|
hi can you check #4555, the stage2 reduce is optimized and deprecated kv_seq_len for split. |
Dewei-Wang-sh
self-requested a review
August 13, 2026 02:28
1 task
Author
|
This PR is out-dated, plz check the follow-up PR in #4758 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
On top of #4507, this PR is worth 6.22% of inter-token latency at batch 1 and decays to nothing by
batch 48, because what it shortens is a serial chain whose depth is the split count — and the
split count is
num_CUs / batch. A small, split-count-dependent win on the stage that becomes thenext bottleneck, not a second large one.
Motivation
Once split parallelism is restored by #4507, the stage-2 reduce becomes the next constraint in the small-batch
corner.
_mla_softmax_reducev_kernelwalks the splits serially, each step's rescale depending on theprevious
e_max, on a(batch, nhead, qlen)grid that is only 12 workgroups atbatch=1, nhead=12.Its cost therefore grows with the split count, reaching a third of the batch-1 decode budget at 256
splits — which is what made raising
NUM_KV_SPLITSlook like a bad trade before this.Stage-1 has two smaller issues in the same regime. Its grid is
(batch, split, head_block*q_pos)andTriton linearizes axis 0 fastest, so one split's workgroups scatter across all 8 XCDs, each pulling
its own copy of KV pages that a prefix-cached decode batch shares ~94% of. Separately, the loop body
opened with a full
wait_group(0)drain, serializing the page-number prefetch behind the KV stream.Technical Details
1. Split-major stage-1 grid. Swap grid axes 0 and 1 for the
bh16regimes, so the linearworkgroup id is
split + NUM_KV_SPLITS*batch + .... All requests' workgroups for a given split thenland on the same XCD and share its L2, and under prefix caching they are reading the same rows. The
swap is taken only when
NUM_KV_SPLITS % NUM_XCD == 0, which is exactly the condition under whichthe id stays congruent mod
NUM_XCDfor every batch; otherwise the ordering is left alone. It is arelabeling of which workgroup computes which
(batch, split)item — same KV range per program, samereduction order, bit-identical output.
2. Blocked, head-dim-tiled stage-2 reduce. Two re-associations of the same online-softmax merge:
merge
BLOCK_Ssplits per iteration as one[BLOCK_S, BLOCK_D]tile (shortens the serial chain byBLOCK_S, gives the memory systemBLOCK_Sindependent loads), and tile the 512-wide head dimacross
D_SPLITprograms packed into grid axis 2 alongsideq_pos. Each head-dim slice re-derivesthe same scalar lse statistics from the same
Mid_lsevector — a few hundred duplicated bytes — andowns a disjoint slice of
o, so slices never communicate.BLOCK_S=1, D_SPLIT=1is exactly thekernel as it stands today; the launcher picks
BLOCK_S = min(256, next_pow2(NUM_KV_SPLITS))andD_SPLIT = 8, backingD_SPLIToff when the head dim does not divide evenly or the slice would beunder 16 elements.
Two invariants carry over. The empty-split guard keeps its semantics: the loop starts on the block
boundary at or below
LOOP_STARTand masks the uninitialised slots off inside the block, sostage-1's untouched slots are never read. The MTP NaN guard is applied per row of the tile, so a
fully causal-masked split (NaN logits with
lse=-inf) contributes exactly zero.3. Two partial waits in the stage-1 loop. At the top of the loop body the outstanding groups are
exactly the four the previous trip committed — page, kv0, kpe, kv1 — and only the page copy is
needed there. Retire just the oldest (
wait_group(3 if HAS_PE else 2)) and move the rest of the waitdown to the
k_clocal load, which retires exactly the three groups it depends on. Two KV blocks arethen resident at once at no extra LDS. This is the same granularity the two epilogues already use.
The prologue leaves the same set outstanding as a steady-state trip, so trip 0 accounts identically,
and the set outstanding at loop exit is unchanged, so epilogue 1 is unaffected.
Test Plan
End-to-end, concurrency sweep (primary). 8x MI355X (gfx950), Kimi-K3 TP8, bf16 KV, 12 heads/rank.
aiperf, mean measured ISL 68,088.7 / OSL 350.0, concurrency 1/8/16/24/48 at 5/40/80/120/240 requests,
4 profile runs per point with a 15 s cooldown, seed 42, prefix caching on,
cudagraph_mode=FULL_AND_PIECEWISE. Three legs — stock, #4507, #4507 + this PR — each a fresh serveron the same node in the same container, each confirmed by server-log grep to have actually loaded (or
not loaded) the patched module.
End-to-end, long context (secondary). Same hardware, 327,600-token context, batch 8. vLLM serving
benchmark: ISL 18,264 plus a 308,736-token shared random prefix, OSL 1,200, concurrency 8, 16 prompts,
seed 42. Reference leg is #4507 alone; candidate is #4507 plus this change. Both legs serialized on a
GPU lock, both to completion, cudagraph captured on both (est. graph memory 1.22 GiB each), engagement
8/8 workers on both.
Kernel-level. Frozen, implementation-independent fp32 online-softmax reference at tol 2e-2 under
capture-once / replay-many, including ragged batches
(
seq_lens = [1, 63, 64, 65, 4096, 4097, 65536, 327600]) and prefix-boundary batches(
[308736, 308737, 312831, 312832, 312833, 320000, 327599, 327600]).aiter's own MLA tests. Both configs pass on the rebased branch, with identical verdicts to
unpatched
mainrun back to back in the same container:All three changes are exercised, 1/1 verdict on both legs. The
second config lands in the
bh64regime, the regression surface for the wait change since thestage-1 loop is shared by every regime: 10/10 verdicts on both legs.
Isolation. The stage-2 knob flip described above, to separate the two levers.
Accuracy. GSM8K, 200 problems, on both legs of the same paired session.
Style.
ruff check(0.16.0, the pinned version) andblack --checkpass on the changed file.Test Result
1. Concurrency sweep at 68k context — this PR's increment over #4507
Reference leg is #4507 alone, candidate is #4507 plus this PR, 4 trials each, ratios formed within a
trial index so prefix-cache history is matched on both sides.
splitsis what #4507 selects(
num_CUs / batch) and is also the depth of the chain this PR shortens.The shape is what the mechanism predicts: the win tracks the split count, and by 5 splits there is no
chain left to shorten. Only the top two rows have every trial on the same side of zero, and at 32
splits one trial is just +0.4%, so I would defend +6.22% at 256 splits and treat everything from 16
splits down as within noise.
Work parity: completed requests, mean ISL and mean OSL identical across all three legs at every
point, zero errors. The candidate range is disjoint from stock at every concurrency.
The grid order is gated off at batch 1, so it was checked on its own rather than through the sweep:
flipping only that axis at batch 8, 32 splits and 327,600 tokens moves the decode kernel 0.4568 →
0.3655 ms, with L2 hit 0.71% → 82.18% and HBM traffic 3.033 → 0.544 GB (
rocprofv3, 5 dispatches).L2→CU request volume is identical either way; only the order changes. The analytic ceiling for that
page table is 82.46% hit, leaving 0.28 pp on the axis.
2. Long context, single point — 327,600 tokens
The original measurement, at 4.8x the context and 2 repeats per leg:
The larger number here is consistent with the sweep rather than in tension with it: this point runs
at batch 8 over a 327,600-token context, so stage-2 is a far larger share of a much longer decode
than it is at 68k. The binary it was measured on also carried a stage-1 tail-mask peel that is held
back from this PR, since MTP on
mainmakes that mask live forQLEN > 1, so some of the +10.86%belongs to the peel. It also rests on 2 repeats against the sweep's 4, which is why the sweep leads.
The reduction order changes relative to the reference leg, so bitwise equality is not expected;
GSM8K moved 0.905 → 0.910, inside the 0.05 tolerance.
AI assistance
This came out of GEAK, AMD's agentic GPU-kernel optimization
framework, running an end-to-end optimization pass against a live Kimi-K3 TP8 server on 8x MI355X.
Everything in this PR was verified manually before submission.
Submission Checklist