[ROCm][MLA] Use saturated Gluon split bucket for CUDA graphs - #51119
LiuYinfeng01 wants to merge 3 commits into
Conversation
Capture small-head MLA decode with the 16K split bucket so full CUDA graph replay avoids the single-split fallback without adding runtime device-to-host synchronization. Signed-off-by: Liuyinfeng01 <yinfeliu@amd.com>
|
@hongxiayang @Fangzhou-Ai @tjtanaa @AndreasKaratzas Hi, could you please review this PR when you have a chance? I’d really appreciate your feedback. Thank you! |
Use runtime 1/16/48/64/128 split buckets so short decode avoids the regression from forcing the saturated split count while long-context decode retains its speedup. Replay MLA eagerly within the breakable graph to eliminate per-layer child-graph launches.
|
@Fangzhou-Ai Hi. Yesterday you raised the question of whether forcing I therefore updated the proposal to runtime-select five Gluon split buckets: Decode TPOT comparison (Kimi-K3, TP8, concurrency=1, 1K output)
The one-shot 64-token dynamic point is 0.88% slower than fixed-1, within the original <=1% TPOT gate; I am calling this out rather than claiming a strict speedup at every point. Every other final dynamic point is faster than fixed-1, reaching 3.937x at 100K. Validation: the 10 newly added bucket-selection and eager-dispatch tests pass; syntax, whitespace, and IDE lint checks also pass. |
|
This pull request has merge conflicts that must be resolved before it can be |
Preserve both runtime MLA bucket selection and the upstream asynchronous NaN accounting helpers while resolving the GPU model runner overlap. Signed-off-by: LiuYinfeng01 <yinfeliu@amd.com>
|
Already fixed in aiter pr 4555 ROCm/aiter#4555. So closed this PR. |
Summary
This PR changes the default
AiterMLADecodeMetadata.min_kv_seq_lenfrom1to16384.The change is intentionally small, but the reason is specific to the interaction between vLLM FULL CUDA Graph capture and AITER's host-side Gluon split selection:
min_kv_seq_lento choose the split-KV launch topology in its Python wrapper.This PR only fixes that vLLM integration issue. Split-policy tuning and stage-1/stage-2 kernel optimization remain AITER responsibilities.
How the issue was found
I first found this from Kimi-K3 TP8 end-to-end serving performance on gfx950. The 100K-input / 1K-output, concurrency-1 workload measured roughly 87–90 ms TPOT, while an isolated 12-head Gluon configuration with sufficient split parallelism indicated that the decode path should be much faster.
The initial investigation focused on AITER. ROCm/aiter#4450 tuned the 12-head BF16 Gluon split policy and tested context buckets for the Kimi-K3 TP8 shape. Reducing the split count also reduced the cost of the existing serial stage-2 reduction, so the first apparent solution was an AITER split-policy change.
During the review of ROCm/aiter#4450, @Dewei-Wang-sh and I spent an afternoon tracing this behavior from the AITER wrapper back into vLLM. His key review direction was to pass the split-sizing information from the caller. He explained that the existing AITER interface follows the Moonshot integration: Moonshot obtains a meaningful minimum KV length in its runtime and passes it to AITER. In that contract,
1is a fallback default; it is not intended to describe every real long-context decode request.This exposed the integration mismatch. vLLM was leaving the fallback at
1. More importantly, when FULL CUDA Graph was enabled, vLLM performed warmup/capture with a dummy sequence length of 1. Dewei helped me follow the execution path and confirm that real decode replay did not run the host wrapper again. Therefore, even an attempted runtime assignment could not reach the Python code that selects the launch topology. This discussion and the subsequent call-chain tracing moved the fix from AITER to the vLLM caller. Thank you to Dewei for the detailed review and for helping identify the actual boundary of the problem.The relevant AITER host calculation is in
mla_gluon.py:For the
BLOCK_N=64path, capture withmin_kv_seq_len=1givescdiv(1, 64)=1, so the host calculation collapses to one KV split. The captured graph then permanently records that one-split launch. With the vLLM capture hint set to16384, the same term becomescdiv(16384, 64)=256; it no longer forces the policy to one split, and AITER can apply its workgroup budget and small-head policy to capture a saturated, non-one-split topology. The real sequence length is still read from device metadata during replay;16384is only the host-side capture hint.In vLLM, the field was declared with a default of
1, and_build_decode()constructedAiterMLADecodeMetadatawithout assigning it.forward_mqa()then passed that unchanged value tomla_gluon():min_kv_seq_lenmetadata default_build_decode()metadata constructionforward_mqa()passing the host hint to AITERTherefore, even a real 10K or 100K request reached the AITER wrapper with
min_kv_seq_len=1in normal execution.Why assigning the real runtime length did not fix FULL CUDA Graph
I next tested assigning the real value while constructing decode metadata:
For an actual request this computes the expected value, for example approximately 10K or 100K. It works conceptually in eager execution because the AITER Python wrapper runs for every call and can select a new split count.
It does not work for FULL CUDA Graph capture.
For pure decode, vLLM's dummy run has query length 1. The capture path consequently builds dummy sequence lengths from
max_query_len, which is also 1:The resulting sequence is:
Instrumented FULL CUDA Graph trace
To verify this rather than infer it from performance, I instrumented an isolated vLLM/AITER copy and ran one Kimi-K3 TP8 request with 100K input tokens and 4 output tokens. Logging was restricted to TP rank 0. The complete capture call chain was:
The following excerpts are sanitized to retain only the events relevant to MLA graph capture and replay.
Eager warmup
FULL graph capture
At this point capture has fixed all of the host-selected topology:
NUM_KV_SPLITS=1(1, 1, 1)(1, 1, 12, 1, 512)mid_lse=NoneReal 100K decode replay
The capture-time
seq_info_ptrand all three runtimestatic_seq_info_ptrvalues are the same:137855956567552. This proves that the real metadata was copied into the same static GPU buffer used by the graph. However, there were noforward_mqa_call,mla_wrapper_split, or Python stage-launch events between the three replay steps.Why the wrapper is not re-entered
This is not a JIT-cache effect. During capture,
BreakableCUDAGraphWrapperexecutes the model and records the graph. On later calls, its capture-versus-replay dispatch selects_replay(), which calls onlyentry.capture.replay(). The capture artifact then replays its saved segments.This run reported
graph_segments=1, eager_breaks=0, so the only saved segment was GPU graph replay; it was not a Python model, attention, or AITER-wrapper invocation. A PIECEWISE graph can re-enter Python when attention is registered as an eager break, but this FULL graph had no such break. JIT kernel caching also does not skip the Python wrapper and should not be confused with graph replay.The distinction is therefore:
seq_infoGPU buffer.NUM_KV_SPLITS, workspace shape, stage-1 grid,mid_lseallocation, and whether stage 2 exists, because those are host-side decisions made before the graph is recorded.This directly explains why a host-only split policy still regressed to approximately 87–88 ms: FULL capture recorded the
split=1topology and replay could not change it.The runtime assignment also requires a device-to-host synchronization for
.min()/.item(). It increased the measured TPOT from approximately 87–88 ms to approximately 89–90 ms while still replaying the one-split graph. It was therefore both insufficient and slower.Why use 16,384 for capture
Changing vLLM to capture a separate graph for every context bucket would be a much larger architectural change. Moving split activation back to the device is an AITER policy decision and conflicts with the reviewer-requested host-side interface. Per-request device-to-host synchronization is not viable on the decode path.
The smallest vLLM-side integration is therefore to provide one representative, saturated capture hint.
16384was selected so capture does not fall into the one-split topology, while avoiding a context-length graph ladder or runtime synchronization.The value is a capture hint, not an assertion that every request has a 16K context. The kernel still reads the real per-request sequence length from
seq_infoduring replay. The hint determines only the host-selected split topology that CUDA Graph records.The target workload matrix includes 8K/1K, 60K/1K, and 100K/1K cases. The fixed-hint end-to-end rerun completed so far is the 100K/1K case; the shorter fixed-hint cases have not yet been rerun end to end.
Runtime-owner feedback and related AITER work
The AITER runtime discussion clarified that two separate problems had been mixed together:
The related AITER PRs explore different kernel-side solutions:
The runtime-owner measurement makes the separation important:
This shows that reducing splits helped partly because it avoided an inefficient stage-2 implementation. ROCm/aiter#4555 is the better kernel optimization: it preserves enough stage-1 workgroups to fill the CUs and fixes stage-2 itself. The AITER runtime team will own that optimization.
Accordingly, this PR does not claim that 48 or 96 splits is the final AITER policy. It only ensures that vLLM does not silently force every host-side policy to the pathological one-split capture.
End-to-end validation
Kimi-K3 TP8 on 8x MI355X/gfx950, BF16 KV, FULL CUDA Graph, 100K input / 1K output, concurrency 1:
1min()metadata experimentThe fixed capture hint removes the regression without changing graph dispatch or adding decode-path synchronization.
Scope question for maintainers
The remaining concern is short-context behavior. This change assumes that the small-head Gluon path is primarily valuable for the long-context workloads for which split parallelism is required. A fixed 16K hint may over-split very short requests.
AITER has empty-split guards, and ROCm/aiter#4450 validated explicit split counts larger than sequence lengths at boundaries including sequence length 1, so this is expected to be correct. However, the short-context performance trade-off still deserves explicit confirmation.
Would maintainers prefer:
Given the runtime-owner direction toward ROCm/aiter#4555 for kernel performance, my preference is to keep this PR narrowly focused on preventing
min_kv_seq_len=1from being frozen by vLLM FULL CUDA Graph capture.Change
No graph-dispatch, replay-path, or AITER kernel code is changed.
Test plan
python3 -m py_compile vllm/v1/attention/backends/mla/rocm_aiter_mla.pyforward_mqa()/mla_gluon()once and runtime decode uses graph replay without re-entering either wrapperseq_infobuffer used by the captured graph