Repository navigation
[ROCm][Model][MiniMax-M3] Context-parallel sparse lightning indexer (Triton, opt-in) - #57832
Conversation
|
👋 Hi! Thank you for contributing to the vLLM project. 💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment Once the PR is approved or has the If you have any questions, please reach out to us on Slack at https://slack.vllm.ai. Agent GuidelinesIMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban. 🚀 |
|
cc613b2 to
1cde8ac
Compare
… opt-in) Adds context-parallel decode scoring for the AITER fp8 indexer on ROCm (gfx950, fp8 index cache, TP>1, opt-in via VLLM_ROCM_MINIMAX_INDEXER_CP=1). Same distributed algorithm as the Triton path (vllm-project#57832), adapted for the AITER kernel interface: - Each rank scores its 1/world_size shard of KV blocks via pa_sparse_block_score_decode with a shard block table - Scores scattered into global-shape tensor pre-filled with -inf - MAX allreduce reconstructs all positions from their owning rank - pa_sparse_block_topk runs on the complete allreduced scores Guards ported from vllm-project#57832: - is_compiling() guard for cudagraph capture compatibility - max_blocks==0 / rank>=max_blocks guard for short-sequence warmup - Falls through to base AITER impl in all guarded cases Correctness: mathematical equivalence — MAX of non-overlapping partial scores = MAX of full scores. Signed-off-by: Peyman Razaghi <prazaghi@amd.com>
simondanielsson
left a comment
There was a problem hiding this comment.
Thanks for the work!
|
|
||
| # During cudagraph capture, fall through to base impl | ||
| # (dist.all_reduce not compatible with static graph recording). | ||
| if torch.compiler.is_compiling() or max_block == 0 or rank >= max_block: |
There was a problem hiding this comment.
Suggestion: I think we'd need to properly check for graph capture here
There was a problem hiding this comment.
You were right that capture needed attention here, though the failure turned out to be the opposite of a missing guard.
An is_current_stream_capturing() skip is what this had originally, and it is actively unsafe: vLLM v2 records FULL decode graphs with cudagraph_runtime_mode=NONE (see ModelCudaGraphManager.capture), so skipping during capture does not fall back for one warmup step — it bakes the non-CP indexer into the replayed decode graph and CP silently never runs.
The real bug was shape specialization. _capture_cudagraphs goes through _dummy_run, which sets seq_lens to the query length:
elif dcp_dummy_context_len > 0:
seq_lens = max_query_len + dcp_dummy_context_len
else:
seq_lens = max_query_lenso at capture d.max_seq_len is 4 with 3 draft tokens. The score kernel was sized off the batch, which baked LOCAL_BLOCKS = GLOBAL_BLOCKS = CHUNK = 1 and a (batch, 1) grid into every decode graph. On replay at 79k context each rank scored only block rank and the top-k saw one candidate per shard, so the indexer returned the first few blocks plus the forced sink and sliding-window pins regardless of context length. Throughput was down 46%, and output degraded enough to shorten the trace trajectories themselves (ISL 65.7k against the baseline's 79.4k).
Fixed in 9fd6ca3 by taking every kernel shape from max_model_len and making the block counts and strides runtime arguments, so nothing shape-dependent can be specialized into a graph again. The per-request causal bound clips the real work at runtime and chunks past the end return immediately. test_indexer_context_scores_independent_of_shape_bound locks this in: it asserts the scores are identical whether the grid is sized for the batch or for a bound 8x larger.
With that fixed, CP is a real win once it is not hidden behind EAGLE3's 2.78 acceptance length. AgentX on the Weka trace, TP=4 MXFP4, no spec decode, FULL_DECODE_ONLY, baseline and CP back-to-back on the same server config:
| conc | baseline tok/s/GPU | CP tok/s/GPU | throughput | ITL |
|---|---|---|---|---|
| 15 | 12,248 | 13,266 | +8.3% | −20.5% |
| 20 | 13,661 | 16,145 | +18.2% | −20.6% |
| 32 | 17,858 | 21,996 | +23.2% | −28.0% |
On your other suggestion in this file — yes, an all-gather is the right call and that is what it does now. The exchange all-gathers packed (score, block id) int64 keys, so the payload is P * k * 8 bytes per token and does not grow with context.
This investigation and fix were AI-assisted; I have reviewed every changed line and run the benchmarks above myself.
| ) | ||
|
|
||
| if index_md.num_prefills > 0: | ||
| _, prefill_topk = super().forward( |
There was a problem hiding this comment.
Question: Will this re-run the decode part when in mixed batches?
There was a problem hiding this comment.
Good catch on the version you reviewed — at that point prefill went through super().forward(), and the base Triton impl runs both halves, so a mixed batch would have scored the decode tokens twice: once through CP, then again through the non-CP path.
That is no longer the structure. The prefill branch now calls the score and top-k kernels directly:
if index_md.num_prefills > 0:
p = index_md.prefill
score = minimax_m3_index_score(iq[nd:], kv, p.block_table, ...)
prefill_topk = minimax_m3_index_topk(score, p.cu_seqlens_q, ...)so a mixed batch runs CP decode over iq[:nd] and prefill over iq[nd:] exactly once each, writing into disjoint slices of the shared top-k buffer.
The one remaining super().forward() is the decode fallback (Dynamo tracing, or an unbound/empty index cache during the profiling run). It returns immediately, so it also handles both halves in a single call rather than falling through into the prefill branch.
Prefill deliberately stays on the existing non-CP path. Prefill already amortizes the index-cache read across many query tokens, so sharding blocks there would not recover enough traffic to pay for the exchange — CP only targets decode, where each step re-streams the whole cache for a handful of query tokens.
87a6d49 to
5c7aa99
Compare
d605d6b to
1ae2b5d
Compare
|
✅ @peymanr, CI is now available for this PR.
|
|
/amd-ci run |
simondanielsson
left a comment
There was a problem hiding this comment.
I think the implementation looks good, although I didn't read through every single line of triton here. Would be good if someone could also have a look 👍
Suggestion: can we please attach gpqa and niah accuracy results vs main?
dllehr-amd
left a comment
There was a problem hiding this comment.
Thanks @peymanr I think this is good to go in now!
|
I approved, but while Ci is running @peymanr can you confirm accuracy as Simon mentioned? |
|
/ci run |
|
✅ Triggered Buildkite CI #95471 for commit ℹ️ This PR is 1 commit behind upstream |
|
/ci run |
|
✅ Triggered Buildkite CI #95503 for commit |
|
@simondanielsson accuracy vs main, as requested. MI355X, TP=4, MXFP4, bf16 index cache. main = unmodified GPQA-diamond ( NIAH (lm-eval RULER,
Both are within noise of main. The only differences are 1–2 samples on NIAH command (per length L): |
|
/ci run |
|
✅ Triggered Buildkite CI #96351 for commit ℹ️ This PR is 1 commit behind upstream |
shen-shanshan
left a comment
There was a problem hiding this comment.
The acc test results look good.
Unify indexer CP onto attention_config.minimax_m3_indexer_cp, which vllm-project#57832 added for the Triton bf16 path, and admit fp8 there so the same flag selects the AITER MSA CP indexer. Drops the duplicate indexer_cp flag. Signed-off-by: ukannika_amdeng <uma.kannikanti@amd.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Purpose
Adds context-parallel decode scoring for MiniMax-M3's sparse lightning indexer on ROCm (TP>1, bf16 index cache, opt-in via
--attention-config '{"minimax_m3_indexer_cp": true}').Problem
MiniMax-M3 selects a sparse set of KV blocks per query token with a lightweight indexer that scores every block in the request's context. Index heads shard like KV heads (
sparse_num_index_heads == num_key_value_heads == 4), and the index-K cache is a single shared head replicated on every rank. So with TP>1 every rank streams the whole index cache — the sameseq_len/128blocks — once per decode step, just to score its own head(s). That read is duplicatedworld_sizetimes and at long context and large decode batches it is a large term in the step.Solution
Shard the blocks instead of duplicating the read. Because each rank owns different index heads, every rank has to score every head on its shard:
heads * 128bf16 per token (1 KB at M3's 4 heads).1/world_sizeround-robin block shard (block = local * world + rank).kover the shard, pack each candidate into one int64(score, global block id)key, and all-gather the keys —P * heads * k * 8bytes per token, independent of context length.Index-cache traffic per rank drops to
1/P; scoring compute per rank is unchanged (headsheads over1/Pof the blocks). The merge is exact: for a given head, a block in that head's global top-k is necessarily in its own shard's top-k for the same head. Forced init/sink and sliding-window blocks are pinned on both the shard and the merge.Default: OFF (
AttentionConfig.minimax_m3_indexer_cp = False); nothing enables it automatically. Turning it on is a startup error unless the index cache is bf16 (indexer_kv_dtype"auto" or "bf16") — the score kernel reads the cache without a scale; the fp8 path is #57909 — and when the fused decode path (#59653) is also enabled, since that kernel computes indexer top-k itself. It is also a startup error on non-ROCm platforms or withtensor_parallel_size == 1.When to enable: long-context workloads. CP adds a fixed per-layer cost (two small all-gathers, scoring every index head, the merge), so it slows decode on short contexts and speeds it up on long ones. On MI350X at TP=4 (
vllm bench serve, OSL 1024), mean TPOT changes by +15% / +7% at 4k ISL (concurrency 8 / 32), breaks even around 64k / 32k, and improves by 9% / 16–27% at 128k. TTFT is unaffected. Agentic traces (AgentX, ISL ~74k) fall on the winning side.CUDA graph capture
Two properties are required for CP to work under
cudagraph_mode=FULL_DECODE_ONLY.CP must be what gets captured. vLLM v2 records FULL decode graphs with
cudagraph_runtime_mode=NONE(seeModelCudaGraphManager.capture), so aCUDAGraphMode.FULLguard never fires. Guarding ontorch.cuda.is_current_stream_capturing()would record the non-CP indexer into the decode graph that is then replayed. The only guard istorch.compiler.is_compiling(), because Dynamo tracing cannot capture the collectives. Persistent buffers (inIndexerCPDecode) keep the decode path allocation-free so CP is what gets captured.Kernel shapes must be capture-stable. Capture goes through
_dummy_run, which setsseq_lens = max_query_len. Shapes derived from the batch'smax_seq_lenwould specialize a one-block context into every decode graph. All kernel shapes come frommax_model_len, and block counts and strides are runtime arguments rather thantl.constexpr.test_indexer_context_scores_independent_of_shape_boundasserts scores are identical whether the grid is sized for the batch or for a bound 8x larger.--enforce-eageris not required.MiniMax-M3 sparse attention pipeline
bf16indexer_kv_dtype=bf16; no AITER dependencyfp8_e4m3indexer_kv_dtype=fp8SHUFFLE_KV_CACHE_LAYOUT=1+ROCM_AITER_UNIFIED_ATTNChanges
vllm/models/minimax_m3/amd/indexer_context_parallel.pyIndexerCPDecode(Q all-gather, per-head score/exchange/merge) andMiniMaxM3IndexerTritonCPImplvllm/models/minimax_m3/amd/ops/indexer_context_parallel.py_context_scoreshard kernel (bf16 cache only)vllm/models/minimax_m3/amd/ops/indexer_cp_exchange.pyvllm/config/attention.pyminimax_m3_indexer_cp: bool = False; rejects non-bf16 index caches and fused decode at startupvllm/models/minimax_m3/common/indexer.pyselect_indexer_impl_cls(ROCm, TP>1)tests/kernels/attention/test_minimax_m3_indexer_cp.pytests/distributed/test_minimax_m3_indexer_cp_tp.py.buildkite/test-amd.yamlWith
minimax_m3_indexer_cpunset (the default) no existing code path changes.Test Plan
Hardware and image
8x AMD Instinct MI355X (gfx950).
vllm/vllm-openai-rocm:nightly-rocm100-92044241a02f05de51420d654daf669de20b4691with this PR's files applied on top of that exact commit.Correctness
The distributed test runs the production CP decode across real TP ranks and compares each rank's top-k with the non-CP decode kernel (
minimax_m3_index_decode) on the full context, per global index head, using M3's config (4 heads, top-16, init 0, local 1) on 32k–82k token contexts. It is parametrized over TP 2/4/8 (TP=8 covers head replication),query_len1/4 (spec-decode verify), and RCCL vs AITER IPC all-gather. It also asserts the four heads select different blocks on ≥90% of rows, so a merge that mixed heads would fail.Contexts are deliberately long: at top-16 x 128-token blocks plus a forced local block, any prompt under ~2k tokens selects every block, so short-context evals cannot detect a wrong selection.
Server (MXFP4 weights, Triton bf16 indexer)
Baseline is the same command without
"minimax_m3_indexer_cp":true.AgentX benchmark
aiperf 0.12.0, 900 s per point after a 90 s warmup.
Test Result
Correctness
tests/kernels/attention/test_minimax_m3_indexer_cp.py: 16 passed (including config validation).--attention-config '{"indexer_kv_dtype":"bf16","minimax_m3_indexer_cp":true}'selects the CP indexer; the same flag with"indexer_kv_dtype":"fp8"exits at startup with the bf16 error.tests/distributed/test_minimax_m3_indexer_cp_tp.py: 12 passed (TP 2/4/8 xquery_len1/4 x RCCL/AITER).AgentX sweep (MXFP4, TP=4, no speculative decoding)
Same node, same image, same PR code in both arms; only the CP switch differs. (These runs used the earlier environment-variable switch; it has since moved to
attention_config.minimax_m3_indexer_cpwith no change to the CP code path.) CP ran first, baseline second, then CP conc=15 was repeated on a fresh server. Every point has 0 failed requests.The gain grows with decode batch size, as expected when the term being divided by
Pis per-sequence index-cache traffic. At concurrency 15 the throughput delta is about the size of run-to-run variation (baseline conc=15 measured 12,936 and 12,652 in two passes); ITL improves at every point. ISL is within 3% across arms with CP's the higher, so the delta is not a shorter-trajectory artifact.One earlier pass on the same node was discarded: its baseline engine (CP off) exited partway through conc=32 (42% failed requests), and its CP conc=15 point ran at half speed with no errors. Neither reproduced in the pass above, which also logs both servers in full.
Withdrawn: the previous version of this PR scored only each rank's own index head on its shard and merged across ranks, which mixes different heads' candidates at TP=4. The AgentX, EAGLE3 and GSM8K results previously reported here were measured on that code and are removed. EAGLE3 has not been re-measured. GSM8K is not repeated because it cannot distinguish a correct selection from a wrong one at its context lengths (see Test Plan); the per-head equivalence test replaces it.
Hardware note: these are MI355X numbers; absolute tok/s/GPU is not comparable to the MI350X figures in earlier revisions. Every comparison is baseline vs CP on the same node.
AI assistance
This PR was developed with AI assistance, including the per-head CP design, the CUDA-graph capture analysis, the multi-GPU equivalence test, and running the benchmarks above on our hardware, and was prepared with assistance from PR Pundit. I have reviewed every changed line and the results.
This does not duplicate #57909, which implements indexer CP for the AITER fp8 path (
indexer_kv_dtype=fp8). This PR covers the Triton bf16 path. The two select differentMiniMaxM3Indexerimplementations and do not overlap in code.Related