[Gluon][MLA] Deeper async-copy pipeline in the bh16 stage-1 decode loop - #4758
Draft
amd-ethany wants to merge 3 commits into
Draft
[Gluon][MLA] Deeper async-copy pipeline in the bh16 stage-1 decode loop#4758amd-ethany wants to merge 3 commits into
amd-ethany wants to merge 3 commits into
Conversation
…al waits The loop body retires only the oldest group at the top -- the page-number copy that the local load just below reads -- and defers the rest to the k_c local load that consumes them. The previous trip's K / K_pe copies then stay in flight across the whole issue block, so two KV blocks are resident at once at no extra LDS. This is the granularity the two epilogues already use. The prologue leaves the same set outstanding as a steady-state trip, and the set outstanding at loop exit is unchanged, so both epilogues see what they saw before. Signed-off-by: amd-ethany <ethany@amd.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…es hit in L2 The stage-1 grid is (batch, split, head_block*q_pos) and axis 0 linearizes fastest, so consecutive workgroup ids walk requests at a fixed split and the hardware spreads one split over all XCDs. Swapping axes 0 and 1 keeps a split's workgroups on one XCD, so under prefix caching the requests that share KV pages read them from a shared L2. The swap is taken when NUM_KV_SPLITS is a multiple of the XCD count, which is where the linear id stays congruent mod NUM_XCD for every batch. It relabels which workgroup computes which (batch, split) item: each program covers the same KV range and the reduction order is untouched, so outputs are bit-identical. Signed-off-by: amd-ethany <ethany@amd.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…e grid is thin The reduce grid is batch*nhead*qlen, so plain decode at batch 1 with 12 heads launches 12 workgroups on a 256-CU part. 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 and owns a disjoint slice of o, so slices never communicate. Only slice 0 stores final_lse. D_SPLIT grows while the untiled grid is narrower than the machine and stays at 1 once batch*nhead*qlen already fills it, since every slice repeats the same lse derivation. It also backs off when the head dim does not divide evenly or the slice would fall under 16 elements. num_warps follows D_SPLIT: 8 untiled, 4 once a program moves HEAD_DIM/D_SPLIT elements per row. Worth +8.6% of the decode op at batch 1, +3.0% at batch 2 and +0.6% at batch 4, MI355X device time. Signed-off-by: amd-ethany <ethany@amd.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Contributor
🏷️ CI GuideRuns automatically on every PR:
Extended tests (opt-in via labels):
|
1 task
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.
This is a follow-up PR to PR #4555, #4509, #4507.
On this baseline the PR is worth about +10% of the MLA decode kernel at concurrency 1, 24 and 48 —
commit 3 carries the first, the wait change carries the other two at +10.5% each — and it costs 2.2%
at concurrency 8. Every one of those figures reproduces in every round of every run. The regression is
the more important half of that sentence and is quantified in section 3.
Every number here is kernel-level — one decode op, device time, cold.
Motivation
PR #4555 restored split parallelism and shortened the stage-2 merge, which moved the bottleneck rather
than removing it. Its policy gives each request roughly
256/batchsplits, trading split count againsthow far each stage-1 program has to walk, and that leaves a gap at each end of the trade. One PR covers
both because each gap only appears at its own end of the split range.
High concurrency: few splits, so a long loop, and stage-1 waiting on memory. At 48 requests each gets
5 splits, so one program walks 213 KV blocks — and that loop drained every outstanding copy on every
iteration when only one of them was needed yet. Only one KV block is ever in flight, so a 213-iteration
loop runs latency-bound instead of bandwidth-bound. Commit 1 closes this, the larger of the two gaps.
Batch 1: splits are plentiful, but stage-2 has almost nothing to run them on. Its grid is one
workgroup per request, head and query position — 12 of them on a 256-CU part at nhead 12 — so shortening
the merge chain cannot help when the idle machine is what costs the time. Commit 3 widens that grid
instead, but only while the machine is in fact idle, which is why it is gated: tiling unconditionally
made nhead-64/128 shapes, whose grid already runs into the thousands of programs, 4.2% slower.
Technical Details
1. Two partial waits in the stage-1 loop. The top of the loop needs only one of the four copies the
previous iteration issued, the page number, but it drained all four. Retire just that one there and defer
the rest to the point where their data is actually read. Two KV blocks are then in flight at no extra LDS,
which is the granularity both epilogues already use. The prologue leaves the same copies outstanding as a
steady-state iteration, and the set at loop exit is unchanged, so neither the first iteration nor the
epilogues change behaviour. This is the one change in code that every regime runs,
bh64included.2. Split-major stage-1 grid. Swap two grid axes for
bh16so that all requests' workgroups for agiven split get consecutive ids. They then land on one XCD and share its L2, and under prefix caching
they are reading the same KV rows. Only taken when batch > 1 and the split count is a multiple of the XCD
count, which is what makes that hold for every request rather than some. Pure relabelling — same KV range
per program, same reduction order, bit-identical output.
3. Head-dim-tiled stage-2 reduce. Split the 512-wide head dimension across several programs, each
owning a disjoint slice of the output and re-deriving the same scalar statistics independently, so slices
never communicate and the untiled case is today's kernel. Because each slice repeats that derivation,
tiling buys occupancy by duplicating work, so the launcher tiles only while the grid would otherwise
leave CUs idle — up to 8 ways, and less when the head dimension will not divide evenly. Stage-2's warp
count follows the tiling, with the untiled path keeping
main's 8. That coupling matters: at batch 1 thewarp count is the difference between +33.6% and +18.5% of stage-2, and at concurrency 48 between −4.5%
and −19.1%.
Test Plan
One process, one kernel file. All three commits are switches on a single generated kernel, so every
leg runs against the same allocations — no JIT-cache or allocator drift of the kind that swapping whole
files between processes invites. Both endpoints are checked against the real kernel files every run:
everything off reproduces
mainto within 1 ulp, everything on reproduces #4509 rebased bitwise.Timing. Device time from CUDA events over a captured graph, with a 512 MB cache flush before each cold
sample. 5 rounds, legs rotated between them, each ratio formed inside its own round; the reported value is
the median, and every number quoted had all 5 rounds on the same side of zero.
Workload. 68,000 tokens, a 63,240-token prefix shared across 8 requests, nhead 12 (96 at TP8), page
size 1, and a full-size KV pool so the 64-bit addressing path is taken. Concurrency 1–48 at whatever split
count the policy picks, plus a sweep that sets the split count independently of batch.
aiter's own MLA tests, both configs, against unpatched
mainback to back in the same container:Both pass, 6 of 6 Gluon verdicts on each leg of the second config.
Which regime gets what. Commit 2 applies to
bh16only. Commits 1 and 3 are in code every regimeruns, so the second command above is the one that covers
bh64(nhead 64/128) — and there commit 3's gateleaves stage-2 exactly as
mainhas it, with timings level against unpatchedmain. That gate is thewhole point: tiling unconditionally, as this PR was first written, ran
bh644.2% slower. Commit 1 is notimplicated, since reverting its waits leaves
bh64where it was. Attributed by reverting one commit at atime, with a second
mainprocess as the control for what this test's timing can resolve.Test Result
MI355X (gfx950, 256 CU),
rocm/vllm-dev@sha256:5aa7e626.1. Per-lever, whole decode op, 5-round median
tripsis loop iterations per stage-1 program,SEQ_LEN/splits/BLOCK_N— the variable that governscommit 1. The commit 3 column was measured before its occupancy guard, which keeps the full 8-way tiling
at concurrency 1–4 and reduces it above that, where the column is already ≤ +0.12%.
Two levers, active at opposite ends for unrelated reasons. Commit 3 is worth +8.6% at one request and
is spent by 4, since the split count halves with every doubling of concurrency and there is no merge
cost to attack below ~128 splits. Commit 1 is flat-to-negative low and +10.5% high, and nothing helps
at concurrency 4–8. The two ~10% ends differ 8× in absolute terms: 58.1 → 52.8 µs at concurrency 1
versus 436.8 → 396.0 µs at 48. Commit 1 at concurrency 24 / 48 across three independent runs:
+10.54 / +10.54%, +10.07 / +10.39%, +11.33 / +10.94%; concurrency 8 regressed in all three at
−2.16%, −2.56%, −2.07%.
2. Mechanism: the gain tracks loop depth, not concurrency
Split count forced at fixed batch, commit 1 only — the commit message's claim tested rather than
restated:
The gain appears where the theory says it should and collapses where it says it should, and it is not a
function of batch: at 34 trips batch 8 loses 1.7% while batch 48 gains 7.5%. Batch 8 at 32 splits is
exactly one wave — 8 × 32 = 256 workgroups on 256 CUs — so there is neither trip depth nor a second wave
to hide the pipeline ramp behind, and the production policy happens to place concurrency 8 in that
pocket; the forced sweep reproduces −1.68% where the policy run measures −2.16%. I would rather a
reviewer judge this table than the headline, since a single point estimate could be a scheduling
accident while a dose-response curve with a sign flip where the mechanism predicts one is harder to
explain away.
3. Why commit 2 is in this PR
Commit 2 is here to make commit 1's concurrency-8 regression tolerable, and paired with it that is what
happens: commit 1 alone measures −2.56% there (per-round −2.1 −2.8 −2.7 −1.7 −1.6), with commit 2 −0.33%
(+0.1 −1.0 −0.4 −0.1 +0.2), so commit 2 recovers 2.2 of the 2.6 points. All three together read −0.46%.
The trade is 0.8 points at concurrency 16 (+5.7% → +4.9%), which I took because a regression at a
production point is worth more than an equal gain elsewhere — but it is a judgement call.
AI assistance
This came out of GEAK, AMD's agentic GPU-kernel optimization
framework, running an end-to-end pass against a live Kimi-K3 TP8 server on 8× MI355X. The
measurements above were produced by and reviewed manually.
Submission Checklist