fix(minimax-m3): unblock fp8 KV cache and EAGLE3 spec decode on the vLLM plugin - #2122
Open
PerryZhang01 wants to merge 4 commits into
Open
fix(minimax-m3): unblock fp8 KV cache and EAGLE3 spec decode on the vLLM plugin#2122PerryZhang01 wants to merge 4 commits into
PerryZhang01 wants to merge 4 commits into
Conversation
Contributor
🏷️ CI GuideRuns automatically on every eligible PR before approval:
Heavy model tests:
|
ganyi1996ppo
reviewed
Sep 3, 2026
| self.kv_cache_torch_dtype = kv_cache_dtype_str_to_dtype( | ||
| kv_cache_dtype, vllm_config.model_config | ||
| ) | ||
| if str(kv_cache_dtype).startswith("fp8"): |
Contributor
There was a problem hiding this comment.
Why not handle all of this in kv_cache_dtyoe_str_to_dtype
Contributor
Author
There was a problem hiding this comment.
vllm kv_cache_dtype_str_to_dtype fucn will transfer str 'fp8' to torch.uint8
Serving MiniMax-M3 through the vLLM plugin backend with --kv-cache-dtype fp8
kills EngineCore on the first request:
triton.compiler.errors.CompilationError: at 73:17
qk = tl.dot(q, k, out_dtype=tl.float32) * sm_scale_log2e
Both operands must be same dtype. Got bf16 and uint8
MiniMaxM3SparseIndexerCache takes its KV-cache spec dtype from vLLM's
kv_cache_dtype_str_to_dtype(), and vLLM maps every fp8 kv-cache-dtype to
torch.uint8 -- a byte buffer its own kernels reinterpret. ATOM's
_index_block_score_kernel dispatches on k.dtype.is_fp8() instead, so a
uint8-labelled index cache sends it down the bf16 branch, where it dots bf16
against uint8 and fails to compile.
The bytes in that buffer are already fp8: aiter's fused_qknorm_idxrqknorm
writes them under kv_cache_dtype="fp8", and the native ATOM server allocates
the same cache as dtypes.d_dtypes["fp8"]. Only the torch dtype label was
wrong, so label it aiter.dtypes.fp8 (torch.float8_e4m3fn). The element stays
one byte, so vLLM's page-size accounting is unchanged, and vLLM already
allows a true fp8 dtype in this position -- its own "fp8_inc" entry maps to
torch.float8_e4m3fn.
Scoped to the indexer cache deliberately: the main sparse and dense KV caches
keep their uint8 label because the aiter paged kernels read them as raw bytes.
Verified on 4x MI355X (gfx950), TP=4, amd/MiniMax-M3-MXFP4, vLLM
0.27.2.dev0+g6e448d0ea, --kv-cache-dtype fp8. Before: the first chat request
kills EngineCore. After: the index cache is allocated as
(num_blocks, 128, 128) torch.float8_e4m3fn, chat completions answer normally,
and a 90K-token 90%-prefix-hit aiperf run completes clean (32/32 requests,
TTFT p50 2.28 s, TPOT p50 14.25 ms at concurrency 8).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback: why not handle this in kv_cache_dtype_str_to_dtype. That table is vLLM's, and it is read by every attention layer of every model. Its fp8 -> torch.uint8 entry is deliberate: vLLM's own kernels take an fp8 KV cache as a byte buffer and reinterpret it, and so do the aiter paged kernels behind ATOM's main sparse and dense caches -- which is why _page16_shuffle_cache_for_sparse_kernel does the .view() itself. Relabelling fp8 there would relabel those caches too, on every model ATOM serves through the plugin, to fix one side cache. The exception is layout-specific, so it belongs where the layout is known. vLLM resolves the same way for the same reason: _resolve_dsv4_kv_cache_dtype returns uint8 for the packed fp8_ds_mla layout and torch.float8_e4m3fn for the plain-row one from the same --kv-cache-dtype fp8, and vLLM's own MiniMaxM3IndexerCache skips kv_cache_dtype_str_to_dtype entirely and labels its index cache torch.float8_e4m3fn. So keep the exception here, but collapse the inline branch into one named function with the reasoning attached, and take the fp8 handle from dtypes.d_dtypes -- the same handle the native server resolves this cache through (_resolve_index_cache_dtype). aiter picks that dtype per gfx target, so the two paths stay on one arch-correct fp8 label instead of a hard-coded torch.float8_e4m3fn. No behavior change: dtypes.d_dtypes["fp8"] is dtypes.fp8, and non-fp8 strings still go to vLLM's mapping. Re-checked in the same container the fix was verified in (gfx950, aiter fp8 = torch.float8_e4m3fn): the helper returns float8_e4m3fn for "fp8"/"fp8_e4m3", float16 for "auto", bfloat16 for "bfloat16". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…re using it
MiniMax-M3 with an EAGLE3 draft dies part way into a long run:
atom/model_ops/minimax_m3/index_topk.py minimax_m3_index_topk_decode
AssertionError: total_q 121 not divisible by max_query_len 4
Three ATOM decode paths divide a flat query row by max_query_len to recover
the request it belongs to:
- the M3 index-topk kernels use `row // max_query_len`, and the row's causal
cutoff `seq_len - max_query_len + tok + 1`
- aiter's gluon paged decode reshapes q to
`[q.shape[0] // max_query_len, max_query_len, ...]`
All three only hold when every decode request contributes exactly
max_query_len rows, and none of them checked. Speculative decode breaks the
assumption on both models. On the target, a request that joins the batch
without draft tokens contributes one row while its neighbours contribute
num_spec+1, and vLLM keeps all of them in the decode segment because each
query length is still within the reorder threshold (121 = 30*4 + 1). On the
draft, a request contributes however many tokens the previous step accepted,
so the segment is ragged by construction -- which surfaced as
aiter/ops/triton/gluon/pa_decode_gluon.py
RuntimeError: shape '[27, 4, 1, 16, 128]' is invalid for input of size 225280
(110 rows where 27 requests * 4 = 108 were assumed).
Add `_uniform_decode_query_len()` and consult it in all three builders:
- MinimaxM3SparseAttentionMetadataBuilder.build(): only take the uniform
decode fast path when the segment really is uniform; otherwise hand those
requests to the prefill kernel, which derives causality from
cu_seqlens_q/context_lens and accepts variable query lengths.
- AiterMhaMetadataBuilderForVllm.build(): route a ragged decode segment to
the extend path, which is varlen. Decode requests sort before extends, so
widening the extend segment covers them without reordering.
- AiterMhaMetadataBuilderForVllm.build_for_drafting(): its docstring claimed
"during EAGLE/MTP drafting all requests are uniform decodes" and it only
tested for prefills, so it never reached build(). Fall back to build()
when the batch is ragged.
Both mixed-batch branches also stopped reporting a max_query_len they had not
measured -- the M3 one passed reorder_batch_threshold (always num_spec+1) and
the MHA one a max over the segment. A plain-decode segment under a spec-decode
threshold would take 4 there, and with a row count that happens to divide by 4
the kernels would mis-map every row silently instead of asserting.
The kernel-side assert stays as a backstop, now spelling out the invariant.
Verified on 4x MI355X TP4 with MiniMax-M3-MXFP8 + Inferact/MiniMax-M3-EAGLE3-GQA
(num_speculative_tokens=3), gsm8k 5-shot chat over the full 1319 questions:
before: crashes (total_q 121 not divisible by max_query_len 4)
after: 0.9530 flexible-extract, mean acceptance length 3.27, no errors
no-spec regression, same build: 0.9484 (0.9477 before this change)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…matrix The M3 cell ran with --kv-cache-dtype auto and no speculative config, so it exercised neither fix in this PR: the index cache never reached the fp8 label path, and the decode segment was uniform by construction. Replace it with the combination that did break -- fp8 KV cache plus an EAGLE3 draft (Inferact/MiniMax-M3-EAGLE3-GQA, num_speculative_tokens=3). Everything else about the cell is unchanged. Measured on 4x MI355X TP4 with MiniMax-M3-MXFP8 and the same draft, gsm8k 5-shot chat over the full 1319 questions: 0.9530 flexible-extract with a mean acceptance length of 3.27, against 0.9484 for the same build without spec decode. The threshold stays 0.93; the native accuracy catalog lists 0.9469 for MXFP4 + EAGLE3 at the same threshold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PerryZhang01
force-pushed
the
fix/m3-vllm-plugin-index-cache-fp8-dtype
branch
from
September 8, 2026 11:28
decb4b4 to
8f76794
Compare
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.
Two fixes that together let MiniMax-M3 serve under
--kv-cache-dtype fp8with an EAGLE3 draft on the vLLM plugin backend. Neither one alone is enough: the first unblocks prefill, the second unblocks decode.1. The indexer cache must carry a real fp8 dtype
Serving MiniMax-M3 through the vLLM plugin backend with
--kv-cache-dtype fp8kills EngineCore on the first request:MiniMaxM3SparseIndexerCachetakes its KV-cache spec dtype from vLLM'skv_cache_dtype_str_to_dtype(), and vLLM maps every fp8 kv-cache-dtype totorch.uint8-- a byte buffer its own kernels reinterpret. ATOM's_index_block_score_kerneldispatches onk.dtype.is_fp8()instead, so a uint8-labelled index cache sends it down the bf16 branch, where it dots bf16 against uint8 and fails to compile.The bytes in that buffer are already fp8: aiter's
fused_qknorm_idxrqknormwrites them underkv_cache_dtype="fp8", and the native ATOM server allocates the same cache asdtypes.d_dtypes["fp8"]. Only the torch dtype label was wrong, so label itaiter.dtypes.fp8(torch.float8_e4m3fn). The element stays one byte, so vLLM's page-size accounting is unchanged, and vLLM already allows a true fp8 dtype in this position -- its own"fp8_inc"entry maps totorch.float8_e4m3fn.Scoped to the indexer cache deliberately: the main sparse and dense KV caches keep their uint8 label because the aiter paged kernels read them as raw bytes.
2. A decode segment must be uniform before a decode kernel may have it
With the above fixed, M3 + EAGLE3 still dies part way into a long run:
Three ATOM decode paths recover a request from a flat query row by dividing:
row // max_query_len, and the row's causal cutoffseq_len - max_query_len + tok + 1[q.shape[0] // max_query_len, max_query_len, ...]All three only hold when every decode request contributes exactly
max_query_lenrows, and none of them checked. Speculative decode breaks the assumption on both models. On the target, a request that joins the batch without draft tokens contributes one row while its neighbours contributenum_spec + 1, and vLLM keeps all of them in the decode segment because each query length is still within the reorder threshold (121 = 30*4 + 1). On the draft, a request contributes however many tokens the previous step accepted, so the segment is ragged by construction -- which surfaces as(110 rows where 27 requests * 4 = 108 were assumed).
_uniform_decode_query_len()measures the segment, and all three builders consult it:MinimaxM3SparseAttentionMetadataBuilder.build()takes the uniform decode fast path only when the segment really is uniform; otherwise those requests go to the prefill kernel, which derives causality fromcu_seqlens_q/context_lensand accepts variable query lengths.AiterMhaMetadataBuilderForVllm.build()routes a ragged decode segment to the extend path, which is varlen. Decode requests sort before extends, so widening the extend segment covers them without reordering.AiterMhaMetadataBuilderForVllm.build_for_drafting()-- the one drafting actually goes through. Its docstring claimed "during EAGLE/MTP drafting all requests are uniform decodes" and it only tested for prefills, so it never reachedbuild(). It now falls back tobuild()when the batch is ragged.Both mixed-batch branches also stopped reporting a
max_query_lenthey had not measured: the M3 one passedreorder_batch_threshold(alwaysnum_spec + 1) and the MHA one a max over the segment. A plain-decode segment under a spec-decode threshold takes 4 there, and with a row count that happens to divide by 4 the kernels mis-map every row silently instead of asserting.tests/plugin/test_minimax_m3_decode_uniformity.pypins that case ([4, 4, 6, 2]sums to 16, divides by 4, and is still ragged).The kernel-side assert stays as a backstop, now spelling out the invariant.
3. CI
The M3 accuracy cell ran with
--kv-cache-dtype autoand no speculative config, so it exercised neither fix -- the index cache never reached the fp8 label path and the decode segment was uniform by construction. It now runs fp8 KV plus an EAGLE3 draft (Inferact/MiniMax-M3-EAGLE3-GQA,num_speculative_tokens=3); nothing else about the cell changed. The threshold stays 0.93, and the native accuracy catalog lists 0.9469 for MXFP4 + EAGLE3 at that same threshold.Test Result
Fix 1, on 4x MI355X (gfx950) TP4,
amd/MiniMax-M3-MXFP4, vLLM 0.27.2.dev0+g6e448d0ea,--kv-cache-dtype fp8: before, the first chat request kills EngineCore; after, the index cache is allocated as(num_blocks, 128, 128) torch.float8_e4m3fn, chat completions answer normally, and a 90K-token 90%-prefix-hit aiperf run completes clean (32/32 requests, TTFT p50 2.28 s, TPOT p50 14.25 ms at concurrency 8).Fix 2, on 4x MI355X TP4 with
MiniMax-M3-MXFP8+Inferact/MiniMax-M3-EAGLE3-GQA(num_speculative_tokens=3), gsm8k 5-shot chat, full 1319 questions:total_q 121 not divisible by max_query_len 4The no-spec numbers confirm the uniform fast path still behaves; the spec-decode run keeps its acceptance length, so drafting is doing real work rather than degenerating to plain decode.
tests/plugin/test_minimax_m3_decode_uniformity.py: 13 cases, CPU only.Submission Checklist