Skip to content

[Gluon][MLA] Size NUM_KV_SPLITS from the page table, not the optional min_kv_seq_len hint - #4507

Closed
amd-ethany wants to merge 1 commit into
ROCm:mainfrom
amd-ethany:fix/mla-gluon-splitkv-sizing-from-page-table
Closed

[Gluon][MLA] Size NUM_KV_SPLITS from the page table, not the optional min_kv_seq_len hint#4507
amd-ethany wants to merge 1 commit into
ROCm:mainfrom
amd-ethany:fix/mla-gluon-splitkv-sizing-from-page-table

Conversation

@amd-ethany

@amd-ethany amd-ethany commented Aug 2, 2026

Copy link
Copy Markdown

mla_gluon() sizes its split-KV parallelism in the bh16bn64 regime from min_kv_seq_len, a
keyword argument that defaults to 1. Any caller that does not populate it silently gets
NUM_KV_SPLITS == 1 and a launch of batch_size workgroups, regardless of context length. This PR
derives the split count from the page table instead, which is host-known, capture-safe, and cannot
be defeated by an unset argument.

On a Kimi-K3 TP8 serving workload this is worth 2.63x down to 1.31x inter-token latency across
concurrency 1–48
at 68k context, and 4.80x end-to-end at 327,600-token context, with GSM8K
unchanged. Host-side wrapper only; both @gluon.jit kernels are byte-identical.

Motivation

The split count is bounded by triton.cdiv(min_kv_seq_len, BLOCK_N). min_kv_seq_len is optional
and defaults to 1, so a caller that does not set it gets cdiv(1, 64) == 1 and therefore
NUM_KV_SPLITS == 1. Stage-1 then launches only batch_size workgroups, each walking its entire
sequence.

At Kimi-K3 decode on MI355X (TP8, 12 heads/rank, batch 8, 327,600-token context) that is 8
workgroups on a 256-CU part: 3.1% occupancy, 363 GB/s effective KV bandwidth against 5.60–5.69 TB/s
attainable, and _mla_gluon holding 85.9% of decode GPU time.

The failure is silent. There is no warning and no assertion, and because NUM_KV_SPLITS == 1 takes a
fast path that writes o directly, the stage-2 reduce disappears from the profile entirely — so the
usual signal that split-KV is misconfigured is absent precisely when it is misconfigured. vLLM's ROCm
aiter-MLA backend is such a caller today: it declares the field on its decode metadata and never
assigns it.

Technical Details

Take the per-request context from the page table. Its shape is the allocated context width, which is
what stage-1 actually walks, and it is host-known and shape-stable — no .item() / .cpu() /
.max() / synchronize, and no Python branch on a device value — so the constexpr is identical at
HIP-graph capture time and on every replay.

else:  # bh16bn64
    if use_2d_view:
        pt_tokens_per_req = int(page_table.shape[-1])
    else:
        pt_tokens_per_req = max(
            1, int(page_table.numel()) // max(1, batch_size)
        )
    NUM_KV_SPLITS = max(
        1,
        min(
            get_num_sms() // max(1, batch_size * qlen * NUM_M_BLOCKS),
            triton.cdiv(pt_tokens_per_req, BLOCK_N),
        ),
    )

min_kv_seq_len remains accepted and is still honoured by the bh64 regime.

This also replaces the hardcoded 256-workgroup budget with the existing get_num_sms() helper from
aiter.ops.triton.utils.device_info — the module this file already imports from. It honours the
CU_NUM override and is the same value the tuning dispatch keys are built from. On a 256-CU part the
term is unchanged.

Correctness with ragged batches. Over-splitting a short request is safe on the kernel's existing
floor arithmetic, which this patch does not touch: splits that come out empty early-return at the
split_kv_start >= split_kv_end guard without writing their partials, and the stage-2 reduce
re-derives the same bound from the same seq_info, so uninitialised slots are never read. The guard
is evaluated per request, so a batch mixing seq_len=1 with seq_len=327600 is safe.

Determinism. Splitting changes the order of the KV reduction, so output is no longer
bit-identical to the unsplit path. This is float association, not a different computation: the
split-K online-softmax merge is the same reduction, and accuracy is gated — the frozen fp32 oracle
passes at tol 2e-2 and GSM8K is unchanged at 0.9250. A fixed launch shape stays deterministic
run-to-run. What it does cost is batch invariance: NUM_KV_SPLITS depends on batch_size, so the
same request can differ in the last bits at a different batch size, where bh16bn64 previously always
took one split. Note that mla_gluon() has no num_kv_splits argument to pin it — that knob is on
aiter.mla's non-Gluon entry point, which is where VLLM_BATCH_INVARIANT pins it. Happy to add an
override here if maintainers want one.

Scope. bh16bn64 only. bh16bn128 (batch_size == 1, fp8 KV) and bh64 keep their existing
min_kv_seq_len bounds; bh64 in particular asserts on min_kv_seq_len for its
gl.assume(num_iter > 3) invariant. No GPU code changes — both @gluon.jit kernels are
byte-identical and the diff is entirely in the host-side wrapper.

Test Plan

aiter's own MLA tests. Both configs run on the rebased branch and pass — every checkAllclose
verdict green, 1 on the first and 10 on the second:

# the small-nhead long-context corner this patch is about
python op_tests/test_mla.py -c 327600 -b 8 -n 12,1 -d bf16 -kvd bf16
# the large-nhead / large-batch configs the kernel was originally designed for
python op_tests/test_mla.py -c 16384 -b 64 128 -n 64,1 128,1 -d bf16 -kvd bf16

Worth being precise about what this does and does not show. test_absorb_decode_gluon_bh16 calls
mla_gluon with min_kv_seq_len=ctx_lens, i.e. it populates the hint correctly, so it does not
reproduce the defect
— it is a regression check rather than a demonstration. That makes the
informative comparison the one against the branch's own pre-patch parent, run back to back in the
same container on the same GPU:

config pre-patch parent this patch NUM_KV_SPLITS both verdicts
-c 327600 -b 8 -n 12,1 463.32 µs 465.53 µs 32 1/1 pass on both
-c 16384 -b 64 128 -n 64,1 128,1 pass pass 10/10 pass on both

The two agree to within 0.5%, which is the expected and desired result: when a caller supplies
min_kv_seq_len correctly, cdiv(pt_tokens_per_req, BLOCK_N) resolves to the same bound, so the
patch selects the identical split count and nothing moves. What it establishes is that the
page-table-derived value agrees with a correctly-supplied hint, and that the large-nhead /
large-batch shapes are unaffected.

The verdict counts differ because the second command enumerates four configurations (two batch sizes
× two head counts) and checks several implementations in each. Only 3 of the 11 verdicts exercise
bh16bn64, the one kernel this patch affects — the small-nhead config and the two nhead=64 ones;
nhead=128 dispatches elsewhere. The remaining verdicts cover aiter_asm and gluon_mla and show
only that nothing adjacent broke.

Isolated kernel. Device-event timing inside the deployment graph context, fresh subprocess per
case, 3 random draws, harness spread ≤ 0.53%. Correctness gated on every run against a frozen,
implementation-independent fp32 online-softmax reference at tol 2e-2, plus a parity check against the
unmodified production kernel and a capture-once / replay-many check over boundary shapes.

Two adversarial ragged cases under replay:

  • seq_lens = [1, 63, 64, 65, 4096, 4097, 65536, 327600] — straddles BLOCK_N=64, bottoms out at 1
  • seq_lens = [308736, 308737, 312831, 312832, 312833, 320000, 327599, 327600] — straddles the
    shared-prefix and block boundaries

End-to-end, concurrency sweep (primary). 8x MI355X, Kimi-K3 TP8, bf16 KV, aiperf against a
live vLLM server. Agentic-proxy workload: 8 shared prefixes of 63,240 tokens plus 4,760 unique input
tokens per request (68,089-token mean measured ISL), 350 output tokens pinned with ignore_eos and
min/max_tokens, prefix caching on, cudagraph_mode=FULL_AND_PIECEWISE, seed 42. Concurrency 1, 8,
16, 24, 48 with 5, 40, 80, 120, 240 requests respectively. Each leg is a fresh server on the same
node in the same container; 4 aiperf profile runs per point with a 15 s cooldown, 100 trial records
total. Candidate legs were confirmed by server-log grep to have loaded the patched module and the
baseline leg confirmed not to have.

Ratios are formed within a trial index and then averaged, so prefix-cache history is matched on
both sides of every ratio.

End-to-end, long context (secondary). vLLM serving benchmark: ISL 18,264 plus a 308,736-token
shared random prefix (327,600-token prompts), OSL 1,200, concurrency 8, 16 prompts, seed 42, prefix
caching on. Reference and candidate legs on the same node in the same container, serialized on a GPU
lock, both to completion. A separate locked baseline measured 34.897 tok/s over 3 timed rounds with
0.06% spread.

Accuracy. GSM8K, 200 problems, run on both legs of the same paired session.

Test Result

1. Concurrency sweep at 68k context — this patch alone

Inter-token latency, and the input throughput that follows from it. Brackets are the min–max over
trial indices. NUM_KV_SPLITS is what this patch selects; stock is always 1.

conc NUM_KV_SPLITS stock ITL P50 this patch speedup stock tok/s/GPU this patch speedup
1 256 69.05 ms 26.26 ms 2.63x [2.62–2.63] 302 656 2.16x [2.07–2.42]
8 32 81.46 ms 39.73 ms 2.07x [1.95–2.35] 1,969 3,701 1.83x [1.67–2.29]
16 16 94.47 ms 54.84 ms 1.72x [1.68–1.77] 3,167 4,866 1.54x [1.52–1.55]
24 10 103.66 ms 65.53 ms 1.58x [1.55–1.63] 4,373 6,338 1.45x [1.42–1.48]
48 5 132.73 ms 101.11 ms 1.31x [1.29–1.33] 7,063 9,096 1.29x [1.27–1.30]

Every baseline trial was worse than every candidate trial at every concurrency — the ITL P50
distributions are disjoint, Welch p<1e-12 throughout. Work parity was checked rather than assumed:
completed requests, mean ISL and mean OSL are identical across legs at every point, with zero errors
anywhere.

The speedups above are trial-index-paired ratios, but they do not depend on that choice: taking the
ratio of means instead agrees to three decimals at every point except concurrency 8 — the
prefix-cache boundary point — where it differs by 0.02x on ITL and 0.05x on throughput.

2. Long context, single point — batch 8 / ctx 327,600

launch grid       (8, 1) = 8 WGs  ->  (8, 32) = 256 WGs on 256 CUs
isolated kernel   8.31 ms      ->  0.4474 ms     (18.6x)
isolated b1       8.98 ms      ->  0.213 ms      (42x)
effective KV BW   363 GB/s     ->  6.75 TB/s
in-situ per call  8241.6 us    ->  270.7 us      (30.4x) *
decode GPU share  85.9%        ->  16.7%         *
end-to-end        32.48        ->  155.85 tok/s  (4.80x)
median TPOT       234.9 ms     ->  43.0 ms
GSM8K             unchanged at 0.9250

* the two in-situ rows come from a profile that also carried a follow-up grid/epilogue change, so
they are not attributable to this patch alone. Every other row is this patch by itself.

Correctness: frozen fp32 oracle pass, parity vs the unmodified kernel pass, cudagraph
replay over boundary shapes pass, both ragged cases above pass. 1 of 12 greedy probes matched
byte-for-byte, as expected from the reduction-order change.

On the bandwidth row: 6.75 TB/s sits above the 5.60–5.69 TB/s this box actually attains (two
independent 8 GB read/copy probes past the LLC; the 8.0 TB/s nameplate is ~40% optimistic). That is
not an error — the 256 MiB LLC is already servicing part of the shared prefix, so the kernel moves
fewer distinct bytes than the naive 3.019 GB per call.

Caveats — both measurements

Shared-prefix warm-up is controlled for in both — the sweep pairs on trial index, the long-context
point matches cold-to-cold and warm-to-warm.

AI assistance

This was found by 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

@amd-ethany
amd-ethany requested a review from a team August 2, 2026 02:01
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

🏷️ CI Guide

Runs automatically on every PR:

  • ✅ Pre-checks (submodule verification, code formatting)
  • ✅ Aiter op tests (gfx942 + gfx950)
  • ✅ Triton tests on MI35X (only when aiter/ops/triton/** or related paths are changed)

Extended tests (opt-in via labels):

Label Tests
ci:triton-300x Run an additional Triton test job on MI300X in PRs; main branch always runs both MI35X and MI300X
ci:sglang SGLang integration tests: DeepSeek-R1-MXFP4 accuracy, Qwen 3.5 accuracy
ci:atom ATOM benchmark: DeepSeek-R1-0528, GPT-OSS-120B
ci:atom_full ATOM accuracy suite for PR and main models from ATOM models_accuracy.json
ci:vllm vLLM benchmark: GPT-OSS-120B, DeepSeek-R1-0528, Kimi-K2.5
ci:all All standard extended tests (excludes ci:atom_full)

Only add ci:atom_full for FlyDSL or Triton upgrades.
Add labels via the sidebar or gh pr edit 4507 --add-label <label>

The bh16bn64 split count is bounded by triton.cdiv(min_kv_seq_len, BLOCK_N).
min_kv_seq_len is an optional wrapper argument that defaults to 1, so any
caller that does not populate it gets cdiv(1, 64) == 1 and therefore
NUM_KV_SPLITS == 1. Stage-1 then launches only batch_size workgroups, each
walking its entire sequence.

At Kimi-K3 decode on MI355X (TP8, 12 heads/rank, batch 8, 327,600-token
context) that is 8 workgroups on a 256-CU part: 3.1% occupancy, 363 GB/s
effective KV bandwidth against 5.60-5.69 TB/s attainable, and _mla_gluon
holding 85.9% of decode GPU time.

Take the per-request context from the page table instead. Its shape is the
allocated context width, which is what stage-1 actually walks, and it is
host-known and shape-stable: no .item()/.cpu()/.max()/synchronize and no
Python branch on a device value, so the constexpr is identical at HIP-graph
capture time and on every replay. min_kv_seq_len remains accepted and is
still honoured by the bh64 regime.

Also replace the hardcoded 256-workgroup budget with the existing
get_num_sms() helper from aiter.ops.triton.utils.device_info, which honours
the CU_NUM override and is the same value the tuning dispatch keys are built
from. On a 256-CU part this term is unchanged.

Over-splitting a short request stays correct by the kernel's existing floor
arithmetic: seq_len // NUM_KV_SPLITS is 0 for the leading splits, which
early-return at the `split_kv_start >= split_kv_end` guard without writing
partials; the last split absorbs the whole request and the stage-2 reduce
re-derives the same division.

Measured on 8x MI355X, Kimi-K3 TP8, bf16 KV, batch 8 / ctx 327,600:

  isolated kernel   8.31 ms -> 0.4474 ms   (18.6x)
  in-situ per call  8241.6 us -> 270.7 us  (30.4x)
  decode GPU share  85.9% -> 16.7%
  end-to-end        32.48 -> 155.85 tok/s  (4.80x)
  median TPOT       234.9 -> 43.0 ms
  GSM8K             unchanged at 0.9250

Scope: bh16bn64 only. bh16bn128 (batch_size == 1, fp8 KV) and bh64 keep
their existing min_kv_seq_len bounds; bh64 in particular asserts on
min_kv_seq_len for its gl.assume(num_iter > 3) invariant.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: amd-ethany <ethany@amd.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@amd-ethany
amd-ethany force-pushed the fix/mla-gluon-splitkv-sizing-from-page-table branch from 5f71d07 to db0e270 Compare August 3, 2026 23:58
@amd-ethany
amd-ethany marked this pull request as ready for review August 4, 2026 00:16
@zufayu
zufayu requested a review from vgokhale August 5, 2026 02:48
@vgokhale

vgokhale commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

How do I reproduce these perf numbers?

@vgokhale
vgokhale requested a review from k50112113 August 6, 2026 15:55
@amd-ethany

amd-ethany commented Aug 7, 2026

Copy link
Copy Markdown
Author

How do I reproduce these perf numbers?

Hi @vgokhale

Here is the recipe we were given:

docker run --entrypoint vllm --device=/dev/kfd --device=/dev/dri
--security-opt seccomp=unconfined --group-add video
--privileged --ipc=host -p 8000:8000
-v ~/.cache/huggingface:/root/.cache/huggingface
-e VLLM_ROCM_USE_AITER=1
-e SAFETENSORS_FAST_GPU=1
-e AITER_SITUV2_A8W4=1
-e AITER_BF16_FP8_MOE_BOUND=0
-e VLLM_USE_BREAKABLE_CUDAGRAPH=0
vllm/vllm-openai-rocm:kimi-k3 serve moonshotai/Kimi-K3
--trust-remote-code
--moe-backend auto
--tensor-parallel-size 8
--load-format auto
--gpu-memory-utilization 0.95
--mm-encoder-tp-mode data
--max-num-batched-tokens 4096
--enable-auto-tool-choice
--tool-call-parser kimi_k3
--reasoning-parser kimi_k3
--max-num-seqs 128 --enable-prefix-caching

and on the benchmark:

aiperf profile --model moonshotai/Kimi-K3 --tokenizer moonshotai/Kimi-K3
--tokenizer-trust-remote-code --url http://127.0.0.1:8000 --api-key EMPTY
--endpoint-type chat --streaming --use-server-token-count
--num-prefix-prompts 8 --prompt-prefix-length 63240
--synthetic-input-tokens-mean 4760 --synthetic-input-tokens-stddev 0
--output-tokens-mean 350 --output-tokens-stddev 0
--extra-inputs ignore_eos:true --extra-inputs min_tokens:350 --extra-inputs max_tokens:350
--warmup-request-count 3 --sweep-type zip
--concurrency 1,2,4,8,12,16,24,32,48
--request-count 5,10,20,40,60,80,120,160,240
--random-seed 42 --ui simple

You could reproduce the baseline with the above cmd, then patch the code in this PR additionally, PR#4509 is also optional.
Then you will see the number on mi355. I suggest you pick some concurrency setup and request-count, as this complete tests require few hours.

@Fangzhou-Ai

Copy link
Copy Markdown
Contributor

Can you refer to #4555 ? Seems that one supersedes this one?

@Dewei-Wang-sh

Copy link
Copy Markdown
Contributor

Can you refer to #4555 ? Seems that one supersedes this one?

yes, we have deprecated using kv_seq_len for num_kv_split.
also your current host side seqlen max is kind of expensive.

@amd-ethany

Copy link
Copy Markdown
Author

@Fangzhou-Ai @Dewei-Wang-sh
Thanks for pointing that out. I see and have validated that the difference between #4507, #4509, and #4555 is a design choice rather than a performance-related one. I’ll therefore close this PR and reference #4555.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants