Repository navigation
[ROCm][Kimi-K3][Perf] Fuse MLA decode KV-cache write and Q-prep via AITER - #57452
Closed
rbrugaro-amd wants to merge 7 commits into
Closed
rbrugaro-amd wants to merge 7 commits into
rbrugaro-amd wants to merge 7 commits into
Conversation
KV-concat, and fp8 KV-cache write into the single AITER kernel fused_qk_rope_concat_and_cache_mla, implemented at a model level, no FX-graph pass. Signed-off-by: Xavier Aguilar <xavier.aguilarfruto@amd.com>
Signed-off-by: Xavier Aguilar <xavier.aguilarfruto@amd.com>
Signed-off-by: Xavier Aguilar <xavier.aguilarfruto@amd.com>
… path The new rotary_emb: nn.Module | None on the base MLAAttention broke mypy (3.10-3.13): guard rotary_emb before use in the fused decode branch, narrow forward_context.slot_mapping to dict, ignore the impl attr assignment, and assert rotary_emb in the DeepseekV32 (nvidia) subclass that inherits the type. co-authored by Claude Signed-off-by: Xavier Aguilar <xavier.aguilarfruto@amd.com>
Signed-off-by: Aakif Nawaz <aakif.nawaz@amd.com> Signed-off-by: Aakif Nawaz <aakif.nawaz@amd.com>
Making rotary_emb `nn.Module | None` on the base MLAAttention left three call sites unnarrowed. Assert it before use in the DeepseekV32 AMD executor and in the sparse-MHA branch, and cast the duck-typed impl in the kernel test to ROCMAiterMLASparseImpl. Clean under mypy 1.20.2 on Python 3.10 through 3.13. Signed-off-by: Jin Tao <jin.tao@amd.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…ITER Kimi-K3 MLA decode on ROCm runs three kernels per layer per step: the KV cache write (`concat_and_cache_mla`), the `[ql_nope | q_pe]` concat, and the static fp8 quant of the decode query. Collapse them into the single AITER `fused_qk_rope_concat_and_cache_mla` launch that vllm-project#47757 already wires up for sparse MLA. K3 MLA is NoPE (`mla_use_nope`), while the fused kernel always applies RoPE and takes the cos/sin caches as required arguments. Supply an identity rotation (cos=1, sin=0) so it degenerates to a no-op -- the same approach ROCm/ATOM uses for K3. The cache is a *single row*: the per-head and `_opt` decode kernels clamp `pos` into `[0, cos_cache.size(0))`, so real positions pass through untouched and K3's 1M max_position_embeddings does not cost ~268 MB to express a no-op. The wrapper disables fusion for head counts that would select AITER's general decode kernel, which does not clamp. On top of vllm-project#47757 this adds: - `FusedQKRopeCacheMLAMixin` in `rocm_aiter_mla.py`, extracted from `ROCMAiterMLASparseImpl` so the dense AITER MLA impl used by K3 shares one implementation with the sparse one. - `MLAAttention.can_fuse_qk_rope_cache()` as the single batch-independent predicate, consumed by `_fuses_qk_rope_cache`, the base MLA wrapper and the K3 wrapper. Previously the wrapper kept a second hand-maintained copy of the condition, which is how RoPE ownership could drift between the site that skips the eager rotation and the site that performs the fused call. - Dense MLA fuses only decode-only batches, and only when the rotation is a declared no-op (`is_identity`). A dense impl that fuses some batches but not others would otherwise leave the wrapper applying RoPE eagerly on all of them while the kernel applied it again on some. - DCP, PCP and HiSparse are excluded: the fused call covers neither prefill rows nor DCP padded tokens, and skipping the standalone write would also skip the PCP latent all-gather and the HiSparse cursor advance. Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com> Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Rita Brugarolas Brufau <rita.brugarolasbrufau@amd.com>
rbrugaro-amd
requested review from
AndreasKaratzas,
LucasWilkinson,
MatthewBonanni,
WoosukKwon,
dllehr-amd,
hongxiayang,
mgoin,
tjtanaa,
tlrmchlsmth,
yewentao256 and
zyongye
as code owners
September 17, 2026 22:49
Contributor
|
This pull request has merge conflicts that must be resolved before it can be |
Contributor
Author
|
superseded by #57640 which I implemented only touching amd vendor files |
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.
Purpose
Kimi-K3 MLA decode on ROCm runs three kernels per layer per step:
vllm::concat_and_cache_mla_kernel— the fp8 KV-cache writeat::native::CatArrayBatchedCopy— the[ql_nope | q_pe]decode-query concataiter::scaled_quant_kernel— the static fp8 quant of that queryThis collapses all three into the single AITER
fused_qk_rope_concat_and_cache_mlalaunch that #47757 already wires up for sparse MLA: 15.5 µs → 5.77 µs per MLA
layer per decode step, 2.62× on the fused region, measured on MI355X TP=8.
Before fusion trace:

After fusion trace:

K3 MLA is NoPE (
mla_use_nope), while that kernel always applies RoPE and takesthe cos/sin caches as required arguments. It is fed an identity rotation
(
cos=1, sin=0) so the rotation degenerates to a no-op — the same approachROCm/ATOM uses to run K3 through this kernel.
The identity cache is a single row. AITER's per-head and
_optdecode kernelsclamp
posinto[0, cos_cache.size(0)), so real positions pass throughuntouched; at K3's
max_position_embeddingsof 1 048 576 a full-length constantcache would otherwise cost ~268 MB to express a provable no-op. AITER's general
decode kernel does not clamp (
cache_kernels.cu:2868), so the K3 wrapperdisables fusion for the head counts that would select it (
kv_lora_rank * num_heads < 2048, i.e. ≤3 heads/rank, reachable at TP=32).Dependencies
_aiter_ops.pybinding, theMLAAttention.forward_implhook, and thepositionsplumbing this builds on.When it merges I will rebase and its commits
will drop out of this diff, leaving only the final commit.
sign-offs, purely so CI can run. Please review only the last commit.
Why this is not a duplicate
No open or merged PR fuses the MLA KV-cache write with Q-prep for Kimi-K3.
path, and the NoPE handling is new.
layers/mla.py.graph-rewrite pass. Kimi-K3 is not
@support_torch_compile, so those passesnever fire for it.
What this adds on top of #47757
FusedQKRopeCacheMLAMixininrocm_aiter_mla.py, extracted fromROCMAiterMLASparseImplso the dense AITER MLA impl used by K3 shares oneimplementation with the sparse one instead of duplicating it.
MLAAttention.can_fuse_qk_rope_cache()as the single batch-independentpredicate, consumed by
_fuses_qk_rope_cache, the base MLA wrapper, and the K3wrapper. Previously the wrapper kept a second hand-maintained copy of the
condition; that is how RoPE ownership can drift between the site that skips the
eager rotation and the site that performs the fused call.
declared no-op (
is_identity). A dense impl that fuses some batches but notothers would otherwise leave the wrapper applying RoPE eagerly on all of them
while the kernel applied it again on some.
nor DCP padded tokens, and skipping the standalone write would also skip the
PCP latent all-gather (a collective every rank must reach) and the HiSparse
cursor advance.
The fuse/no-fuse decision is derived only from state that both
update_kv_cacheand
forward_implalready hold, so nothing is carried across the two custom-opboundaries between them.
Test Plan
All numbers below are from the stock upstream nightly
vllm/vllm-openai-rocm:nightly-rocm100-0bfc7a15d095fe83ecc82b50561a93c177fece2d(2026-09-16), 8× MI355X, Kimi-K3 fp4, TP=8.
.pyfiles copied oversite-packages — the only difference between the arms
Every run asserts, from inside the running container, that the fused marker is
present in arm B and absent in arm A, so neither arm can silently A/B against
itself.
Unit tests
E2E serving
--block-sizeis left to vLLM, which derives 1536 to match the KDAmamba page size.
Results
1. Unit tests — PASS
44 passed, including 4 new
test_nope_identity_cos_sin_leaves_pe_unrotatedcases covering both
is_neoxsettings andblock_size1/64, with positions upto 2²⁰ to exercise the clamp. Mutation check: flipping the identity cache from
cos=1tocos=0.5fails all 4, so they are not vacuous.2. Decode kernel census — 3 kernels → 1
Rank-0 torch profile, 4 engine steps, 24 MLA layers,
96 = 24 × 4.vllm::concat_and_cache_mla_kernelat::native::CatArrayBatchedCopyaiter::scaled_quant_kernelaiter::fuse_qk_rope_concat_and_cache_mla_kernelPer MLA layer per decode step: 15.5 µs → 5.77 µs. Launch count 2988 → 2940
per step (−48, −1.6%).
All 8 ranks: 12 941 µs → 4 933 µs = 2.62× (per-rank range 2.58–2.67×,
exactly 96 fused kernels on every rank).
The dispatch lands on AITER's
_optdecode kernel, notper_head—per_headrequires
kv_cache.size(1) == 1, and vLLM derives block size 1536 here. The_optgate iskv_lora_rank × num_heads ≥ 2048; at TP=8 that is 512 × 12 =6144.
_optis also the position-clamping kernel the single-row identity cacherequires.
3. GSM8K — no accuracy change
Full 1319 questions, 5-shot,
temperature=0, no--limit. Three A/B pairs, twoon an earlier nightly and one on the image above:
The delta changes sign across pairs with identical code, swinging ±0.9 pp
against a binomial
se_diffof ~0.7 pp. Read as "no measurable accuracy change,±0.9 pp run-to-run", not as a benefit. All six runs sit far above the 0.90 gate.
4. E2E serving — unchanged within noise
ISL 1024 / OSL 8192 (decode-dominant, where this fusion has its largest share of
wall-clock), 3 repeats per arm with arm order alternating between repeats.
±rngis max−min across repeats, as a percentage.Every ratio is 1.002–1.005×, i.e. ≤0.5%, and at conc 8 and 32 smaller than the
arms' own run-to-run spread. At conc 1 — the quietest point, and the one with
least batching to amortise fixed per-layer cost — the ~0.3% delta does exceed
the spread and is consistent across all six paired repeat comparisons, including
the repeat where the baseline ran first. That is suggestive, but it is a 0.3%
effect measured with 3 repeats and no A-vs-A null control, so this PR does not
claim it.
This is the expected outcome by construction: the fusion removes 0.53% of a
decode step's device time, on 24 MLA layers, in a model that is roughly 1:3
MLA:KDA. For reference,
cross_device_reduce_2stagealone is ~46% of rank-0decode device time in the same trace.
Compatibility / no-effect cases
use_fused_qk_rope_cacheis neverset,
can_fuse_qk_rope_cache()short-circuits on its first guard.refused by the
is_identityguard, so the wrapper keeps applying RoPE eagerlyexactly as today. Without that guard the flag alone would make the wrapper skip
the eager rotation on batches the kernel then declines to fuse.
always_fuses_qk_rope_cacheisTrue there, so
can_fuseand_fusesreduce to [ROCm][MLA][Perf] Fuse decode QK-RoPE + Q-concat + KV-concat + KV-cache write for sparse MLA #47757's original condition onevery batch. The only new refusals are PCP and HiSparse, which [ROCm][MLA][Perf] Fuse decode QK-RoPE + Q-concat + KV-concat + KV-cache write for sparse MLA #47757 did not
exclude and where the fused call would have written un-gathered latents at
un-remapped slots.
I enumerated impl × kv-dtype × rope-kind × pcp × hisparse × batch-mix × dcp
(768 combinations) and checked that RoPE is applied exactly once in every one,
and that
positionsis present whenever the fused branch runs.