Repository navigation
[Bugfix][Spec Decode] Implement get_top_tokens() on the generic MTP drafter - #56995
ZhengGong-amd wants to merge 2 commits into
Conversation
Why this cannot regress NVIDIAThe measurements in the description are MI355X, so it is worth being explicit about what 1. The default path is untouched on every platform. 2. The models that already had the method on CUDA are not touched. 3. The one NVIDIA-visible change is a configuration that used to be impossible. This is also the case I exercised most heavily: DeepSeek-R1-0528 is a 4. Nothing platform-specific is added. 5. What a CUDA reviewer may want to re-measure. |
…rafter Enabling speculative_config.use_local_argmax_reduction with a draft model built on deepseek_mtp.py fails engine startup rather than falling back: both opt-in sites probe the draft model for get_top_tokens() and raise without it, and DeepSeekMultiTokenPredictor only has compute_logits. DeepSeekMTPModel maps to deepseek_mtp in the registry with no platform dispatch, so DeepSeek-V3 / R1 MTP drafters hit this on every platform; deepseek_v32 and glm_moe_dsa reach it on non-CUDA, where models/deepseek_v32/__init__.py aliases DeepseekV32MTP to this class. Forward to LogitsProcessor.get_top_tokens() from the predictor and the wrapper, mirroring the compute_logits pair already in the file. The predictor applies shared_head first: this draft layer returns (pre-final-norm, post-final-norm) and the proposer consumes the first element, unlike the v3.2 layer which returns the post-norm hidden in both positions and so applies the LM head alone. Getting that backwards returns a different draft token instead of failing, so the added test varies the norm's gain to make a skipped shared_head observable. use_local_argmax_reduction defaults to False and gates every call site, so the default path is unchanged. Generated-by: Hyperloom Co-authored-by: Claude Signed-off-by: Zheng Gong <zgong@amd.com> Co-authored-by: Cursor <cursoragent@cursor.com>
The docstring advertises "reduces communication from O(vocab_size) to O(2 * tp_size)", which reads as unconditional. It is not: the reduction substitutes one small latency-bound collective, whose cost grows with tensor_parallel_size, for a gather that grows with the draft batch, so it only pays above a break-even draft batch size and that break-even rises with TP. Say so, since low-concurrency serving at high TP is the case where enabling it on the strength of the current wording would slow things down. Generated-by: Hyperloom Co-authored-by: Claude Signed-off-by: Zheng Gong <zgong@amd.com> Co-authored-by: Cursor <cursoragent@cursor.com>
bd5eea3 to
3bae736
Compare
|
Full GSM8K and end-to-end results for DeepSeek-R1-0528 (the Setup. MI355X (gfx950), ROCm 7.2.3, torch 2.12.0, aiter 0.1.21.post2. GSM8K
The base-vs-base drift is +0.0030. Patched is +0.0038 from Per-sample agreement (strict-match). Prompt hashes match on all 1319 items in every pair.
Identical text is low even for the same build twice. vLLM's batching makes the reductions batch-shape dependent, so long generations diverge between runs of one build. The patched pairs sit inside the control range on both columns. GSM8K is 5-shot greedy short-answer math, so this says nothing about long context, sampling or multi-turn. End to end
Medians over the three runs (times in ms):
Each arm has one run in a slower mode, with mean TTFT around 1.1 s and throughput around 5.5k (base 1, patched 2). That mode is what the 58 % mean-TTFT spread is. Median TTFT does not move with it, so it is a tail in a few requests, not a slower decode. Reading: with the draft batch at 64, below the TP=4 break-even, the reduction's per-step cost (~70–100 µs in the micro benchmark) is about 0.3 % of TPOT, and nothing moves beyond the within-arm spread. The flag is neutral at this operating point. It does not show the gain, which needs a larger draft batch. |
… V4 MTP drafter Enabling speculative_config.use_local_argmax_reduction with a deepseek_v4 draft model fails engine startup rather than falling back: both opt-in sites probe the draft model for get_top_tokens() and raise without it, and DeepSeekV4MultiTokenPredictor only has compute_logits. model_type "deepseek_v4" is rewritten to DeepSeekV4MTPModel by its own branch in SpeculativeConfig.hf_config_override, which the registry resolves to the per-platform DeepSeekV4MTP, so the generic MTP drafter fix in vllm-project#56995 does not reach it. Forward to LogitsProcessor.get_top_tokens() from the predictor and the wrapper in the ROCm implementation, mirroring the compute_logits pair already in the file. V4 reaches its LM head through hc_head over the (T, hc_mult, D) residual and then the shared head's RMSNorm, unlike V3 (shared_head alone) and V3.2 (an already-normed hidden state); running a different transform returns a different draft token instead of failing, so that transform is factored into _pre_head() and shared by both methods. The added test varies the norm's gain and the hc_head gate parameters to make a skipped stage observable, and runs on ROCm only. The nvidia and xpu drafters are not changed here: this was verified on MI355X only, so they are left to a follow-up that can be tested on that hardware. use_local_argmax_reduction defaults to False and gates every call site, so the default path is unchanged. Generated-by: Hyperloom Co-authored-by: Claude Signed-off-by: BaoYunkai <ybao@amd.com>
Purpose
speculative_config.use_local_argmax_reductionis unusable with the generic MTPdrafter: enabling it does not fall back to a slower path, it fails engine startup.
The reduction itself already lives in core as
LogitsProcessor.get_top_tokens()— eachTP rank argmaxes its own vocab shard and only
(value, index)pairs are gathered. Bothopt-in sites probe the draft model for the method and raise without it:
vllm/v1/worker/gpu/spec_decode/speculator.py(V2 model runner)vllm/v1/spec_decode/llm_base_proposer.pyvllm/model_executor/models/deepseek_mtp.pyhascompute_logitsbut noget_top_tokens, so every checkpoint that resolves to it raises. On currentmainthatis the
deepseek_v3family — DeepSeek-V3 and R1 — on every platform:model_typedeepseek_v3DeepSeekMTPModeldeepseek_mtp.DeepSeekMTPdeepseek_v32,glm_moe_dsaDeepseekV32MTPModeldeepseek_mtp.DeepSeekMTPDeepSeekMTPModelmaps todeepseek_mtpinregistry.pywith no platform dispatch, so aDeepSeek-V3 / R1 MTP drafter is this class on CUDA and ROCm alike.
deepseek_v32andglm_moe_dsaare dispatched invllm/models/deepseek_v32/__init__.py: CUDA usesnvidia/mtp.py, which already implements the method, and since #51915 ROCm usesamd/mtp.py. So for the DSA family only XPU and CPU still fall back here.amd/mtp.py'sown
DeepseekV32MTPhas the same gap, but it is a different file with its own pre-headpath and is out of scope for this PR.
This PR adds the two forwarding methods, mirroring the
compute_logitspair already inthe file. The capability has been rolled out per model since the flag landed in #34049,
and
deepseek_v3was never covered on any platform.Which checkpoints this actually reaches
Resolving real
config.jsons throughhf_config_overrideand the registry onmain@94f4170df3(ROCm), then asking the speculator's own question(
hasattr(cls, "get_top_tokens")). Of the current flagship checkpoints that ship MTPlayers at all:
glm5_next→Glm5NextMTP)amd/mtp.py); DeepSeek-V4-Flash / -Pro, DeepSeek-V4.1-Flash, MiniMax-M3What it does not reach matters as much. On ROCm the DSA family now has its own drafter
class, as do the DeepSeek-V4 line and MiniMax-M3; on CUDA the DSA family already works
through
nvidia/mtp.py. Kimi-K3, Qwen3.5-397B, Qwen3.8-Flash-Next and Qwen3-Next-80B shipno MTP layers in the checkpoints checked.
Scope
Resolving every entry of
_SPECULATIVE_DECODING_MODELSonmain@94f4170df3and askinghasattr, 27 of the 62 speculative architectures satisfy the guard and 34 do not, so thisflag is broadly unavailable rather than uniquely broken here. 24 of the 27 get it from
LocalArgmaxMixin(the EAGLE, DFlash and DSpark families, plus Qwen3.5 MTP); onlyGemma4MTP,InklingMTPandGlm5NextMTPwrite the method themselves (plusDeepseekV32MTPin the CUDA-onlynvidia/mtp.py).This PR fixes one of the 34 rather than sweeping them, because the correct call differs
per family and getting it wrong is silent:
deepseek_mtp.py's draft layer returns(pre-final-norm, post-final-norm)and theproposer consumes the first element, so
compute_logits— and thereforeget_top_tokens— must applyshared_head(the final RMSNorm).deepseek_v32/nvidia/mtp.py's layer returns the post-norm hidden in both tuplepositions, so its
get_top_tokenscorrectly applies the LM head only.Reusing the second form in the first file does not raise; it just returns a different
draft token. The added test is built to fail in that case (see Test Result). Extending
this to the remaining families needs the same per-family check, which is better done in
separate PRs by people who can run those models.
A second commit amends the flag's own docstring. It currently says only "reduces
communication from O(vocab_size) to O(2 * tp_size)", which reads as unconditional; the
measurements below show it is a break-even-dependent trade, and low-concurrency serving
at high TP is the case where the current wording would mislead someone into enabling it.
LocalArgmaxMixininmodels/interfaces.pyis the right tool where it fits, andQwen3_5MTPshows it fits some MTP models: it owns a flatself.lm_headand itscompute_logitsis justself.logits_processor(self.lm_head, hidden_states). This classis not like that. It has no
self.lm_headat all — the head isself.layers[str(mtp_start + spec_step_idx)].shared_head.head, chosen per draft step,and the hidden state has to pass through
shared_head's norm first. The mixin readsself.lm_headand applies no norm, so it cannot serve this class.What this cannot affect
use_local_argmax_reductiondefaults toFalse, and every call site ofget_top_tokensis behind it — no site probes for the method to auto-enable anything.With the flag off, this PR adds two unreachable methods and changes nothing.
Test Plan
pytest tests/v1/spec_decode/test_mtp_local_argmax.py pytest tests/v1/sample/test_head_dtype.py # existing coverage of get_top_tokensNew
tests/v1/spec_decode/test_mtp_local_argmax.pybuilds a realSharedHead(RMSNorm +
ParallelLMHead) andLogitsProcessoron a single vocab shard and assertsget_top_tokens()picks the same token ascompute_logits().argmax(-1), including witha padded vocab. The norm's weight is given a non-uniform gain on purpose: the argmax is
invariant to the norm's per-row rescale, so a default all-ones weight would let a
skipped
shared_headpass unnoticed.End to end, on a
deepseek_v3checkpoint (DeepSeek-R1-0528, official fp8 weights) at TP=4,with a fresh server per run:
The base arm is upstream
mainwith the flag off; with it on,mainraises at engineinit.
Test Result
Hardware for every number below: MI355X (gfx950), ROCm. Correctness and micro numbers
are at real model shapes in bf16 at TP=2 and TP=4; GSM8K and end to end are
DeepSeek-R1-0528 (official fp8 weights) at TP=4 against
main@94f4170df3.Correctness
tests/v1/spec_decode/test_mtp_local_argmax.py: 3 passed. Mutating the patch to dropshared_headfromget_top_tokensturns 2 of the 3 into failures, so the equivalenceassertion is load-bearing rather than decorative.
tests/v1/sample/test_head_dtype.py: identical results with and without this PR(7 passed, 1 pre-existing failure in the
core_modele2e test, which downloadsfacebook/opt-125m).Token-for-token equivalence over a sweep at real model shapes, TP=2, batch sizes 1 to
1024: agreement 1.0000 in all 20 cases. Cross-rank tie-breaking is the part a
single-shard test cannot reach, and it agrees too. The same sweep run against a variant
that skips
shared_headagrees on as little as 0 % of tokens.Before this PR, the
vllm serve/vllm benchinvocation above raises theValueErrorat engine init. After it, both workers log
and generation completes.
Accuracy (GSM8K)
A vocab-parallel argmax is a reduction-order change, so "the unit tests pass" does not
cover it. On DeepSeek-R1-0528, all 1319 items, 5-shot greedy: no measurable accuracy
change on GSM8K, within the base-repeat noise floor. Patched scores 0.9568 against
0.9530 / 0.9560 for two runs of
main— a base-vs-base drift of 0.0030, which thepatched-vs-second-base difference (+0.0008) sits well inside. Per-sample agreement is
inside the same control range. Full table in the comment below.
Micro benchmark
The two providers on a real vLLM process group with a real
SharedHeadandLogitsProcessor, driven through the patched predictor so the only difference betweenarms is the collective:
compute_logits(h).argmax(-1), what the drafter does todayget_top_tokens(h)Five interleaved pairs per point, arm order alternated per pair, 300 iterations after
100 warmup, no other tenant on the box. The
deltacolumn is what actually matters:both arms pay the same per-call launch overhead (~200 µs in this harness, which times
the sampling step in isolation), so the percentage exaggerates the cost at small batch
and the absolute difference does not.
TP=2, GLM-5.2 shapes (
hidden_size6144,vocab_size154880) / DeepSeek-V3 shapes(7168, 129280):
Within-arm spread over the five pairs is ≤ 1 % for 34 of the 40 arms and ≤ 4.7 %
everywhere, and the per-pair deltas are tight (e.g. GLM bs=128: +55.2, +55.4, +55.4,
+55.3, +55.4 %).
TP=4, same shapes, same protocol:
So the trade is a fixed per-step overhead — one extra small collective, ~20 µs at TP=2
and ~95 µs at TP=4 — against a gather that grows with
batch × vocab_size. The patchedarm is nearly batch-independent (382–392 µs across bs=1..512 at TP=4); the baseline is
not. Break-even draft batch is ≈ 16–32 at TP=2 and ≈ 128–256 at TP=4, and it moves
right as
tpgrows because the reduction's own collective is latency-bound while thegather it replaces is bandwidth-bound.
Being explicit about the cost: below break-even this is a regression, up to -41 % of
the sampling step at TP=4 bs=1 (which is +90 µs in absolute terms). A batch-size median
is a bad summary here since it just reflects which batch sizes one tabulates; the
crossover and the absolute delta are the honest statement. The flag is opt-in, so the
decision can be made per deployment, and the numbers above are what it should be made on.
Token agreement was asserted at every point before timing: 1.0000 in all 40 cases
across TP=2 and TP=4. Cross-rank tie-breaking is what a single-shard test cannot
reach, and it agrees at both rank counts.
Communication per draft step, derived from
logits_processor.pyrather than measured:the default path materializes a
[batch, vocab_size]logits tensor on every rank, thereduction gathers
[batch, 2 * tp_size]fp32 instead.vocab_sizeEnd to end
DeepSeek-R1-0528 at TP=4, the invocation in the Test Plan, median of three runs per arm
after one warm-up:
mainFlat, which is what the micro numbers predict for this point. Concurrency 64 puts the
draft batch at 64, below the TP=4 break-even, where the reduction costs ~+70–100 µs per
step — about 0.3 % of a 29.5 ms TPOT, under the ~5 % within-arm spread of throughput.
So at this operating point the flag costs nothing measurable. The gain needs a draft batch
above break-even, i.e. higher concurrency at TP=4, or TP=2. Per-run numbers, including
mean TPOT and TTFT, are in the comment below.
Found by Hyperloom; reviewed and measured by hand.