[Model Runner V2][Spec Decode] Add KV cache support for multi-layer MTP - #50062
Conversation
fe87670 to
489ca52
Compare
489ca52 to
e680547
Compare
e680547 to
3ce4abb
Compare
|
This pull request has merge conflicts that must be resolved before it can be |
|
✅ Triggered Buildkite CI #83251 for commit |
998f5d8 to
0841fed
Compare
|
/ci run |
|
✅ Triggered Buildkite CI #83401 for commit |
WoosukKwon
left a comment
There was a problem hiding this comment.
LGTM and sorry for all the delays.
I'm probably not the best person to verify the correctness of the PR, but it seems correct to me at the high level, and only affect the behavior on multi-layer MTP. Therefore, I think it is safe to merge. As the last step, can you please run Inkling and add the accuracy & acceptance rates?
End-to-end acceptance-rate evaluation with the Inkling multi-module MTP model
(chunked prefill + prefix caching enabled): [results to be added].
Split out of mrv2-multi-module-mtp to be merged separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Giancarlo Delfin <gdelfin@inferact.ai>
…er-group block size Signed-off-by: Giancarlo Delfin <gdelfin@inferact.ai>
abe7f65 to
ba0d589
Compare
|
/ci run |
|
✅ Triggered Buildkite CI #83778 for commit |
|
@WoosukKwon added eval and benchmarks to the PR summary |
…perty Signed-off-by: Giancarlo Delfin <gdelfin@inferact.ai>
|
/ci run |
|
✅ Triggered Buildkite CI #83794 for commit |
Upstream vllm-project/vllm#50062 added num_prefill_lookahead to get_kv_cache_coordinator and the KV cache coordinator constructors. Mirror the upstream contract in the patched get_kv_cache_coordinator and AscendHybridKVCacheCoordinator: forward the value to the original coordinator on the main lane only (v0.27.1 has no such parameter) and set num_reprefillable_tokens so the inherited cache_blocks excludes the re-prefillable tail during multi-module MTP. Signed-off-by: liaoqidan <1107297340@qq.com>
Upstream vllm-project/vllm#50062 added num_prefill_lookahead to get_kv_cache_coordinator and the KV cache coordinator constructors. Mirror the upstream contract in the patched get_kv_cache_coordinator and AscendHybridKVCacheCoordinator: forward the value to the original coordinator on the main lane only (v0.27.1 has no such parameter) and set num_reprefillable_tokens so the inherited cache_blocks excludes the re-prefillable tail during multi-module MTP. Signed-off-by: liaoqidan <1107297340@qq.com>
Upstream vllm-project/vllm#50062 added num_prefill_lookahead to get_kv_cache_coordinator and the KV cache coordinator constructors. Mirror the upstream contract in the patched get_kv_cache_coordinator and AscendHybridKVCacheCoordinator: forward the value to the original coordinator on the main lane only (v0.27.1 has no such parameter) and set num_reprefillable_tokens so the inherited cache_blocks excludes the re-prefillable tail during multi-module MTP. Signed-off-by: liaoqidan <1107297340@qq.com>
…sh-hit path `HybridKVCacheCoordinator.cache_blocks` decides once how far a request may be registered in the prefix-cache hash map. With fine-grained partial hash hits that bound is the raw token count, because a hit no longer has to land on a `scheduler_block_size` boundary. vllm-project#50062 rewrote the EAGLE branch to re-derive its own bound from `num_finalized_computed_tokens` with an unconditional // self.scheduler_block_size * self.scheduler_block_size so the rounding comes back on the one path that had removed it. Registration is then capped at `floor(n / scheduler_block_size) * scheduler_block_size + manager.block_size` instead of `n`, and everything between the last aligned boundary and the tail stops being registered -- `(n % scheduler_block_size) - manager.block_size` tokens per call, which is most of a segment whenever the group's own block is much smaller than the scheduler block. Only EAGLE-family groups take the branch, so a model without speculative decoding never sees it. Measured on Kimi-K3 MXFP4 (hybrid: 24 MLA + 69 KDA layers) on B300, TP8, with DSpark speculative decoding and an agentic replay workload. TokenSpeed MLA sets `block_size=32` and the attention block is forced to 1536 to cover the mamba page, so the cap drops up to 1,503 tokens of every prefix tail (1,472 when `n` is a multiple of 32): | concurrency | before vllm-project#50062 | with vllm-project#50062 | with this fix | |---|---|---|---| | 8 | 5,146 tok/s/GPU | 4,767 (-7.4%) | 5,183 (+0.7%) | | 16 | 7,669 tok/s/GPU | 6,611 (-13.8%) | 7,654 (-0.2%) | GPU prefix-cache hit rate at concurrency 16: 86.3% before, 77.4% with vllm-project#50062, 86.2% with this fix. The non-speculative arm of the same ladder is unaffected in all three (c1 to c78 within -1.3% to +1.8%). Each number is one 60-minute run; the regressed pair was repeated and agreed to within 0.3%. The fix routes both bounds through one helper, `_align_cacheable`, so the exemption cannot be lost again by re-deriving it in a second place. Reachability upstream: `enable_partial_hash_hits` requires a Mamba "align" group with `block_size > hash_block_size`, prefix caching, and `dcp_world_size == 1`. Our own runs enable it under DCP > 1 via a local carry, which is why we hit it first, but a stock `dcp_world_size == 1` hybrid model with EAGLE-family speculative decoding is affected as written. One thing this change does not address, noted for a reviewer: the soundness guard above (`scheduler_block_size >= num_prefill_lookahead`) is justified in its comment by "hits land on scheduler-block boundaries", which is true only when `enable_partial_hash_hits` is off -- `_cache_hit_alignment_tokens` returns `hash_block_size` when it is on. That guard predates this fix and only matters for multi-module MTP on a hybrid Mamba model, a combination we do not run and cannot measure, so it is left alone rather than adjusted on a guess. Test plan: `tests/v1/core/prefix_cache/test_partial_prefix_cache_hits.py:: test_eagle_group_registers_unaligned_tail_under_partial_hash_hits` fails before the change and passes after. `tests/v1/core/test_prefix_caching.py::test_hybrid_cache_blocks_clamped_to_lcm` still passes: it has no Mamba group, so `enable_partial_hash_hits` is False and the helper returns the rounded value exactly as before. Assisted-by: Claude Opus 5 (Anthropic) Signed-off-by: Mi Sun Park <misunp@nvidia.com>
…TP (vllm-project#50062) Signed-off-by: Giancarlo Delfin <gdelfin@inferact.ai> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Alessandra005 <aurib032@fiu.edu>
…TP (vllm-project#50062) Signed-off-by: Giancarlo Delfin <gdelfin@inferact.ai> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…TP (vllm-project#50062) Signed-off-by: Giancarlo Delfin <gdelfin@inferact.ai> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Wyett <wyettzeng@gmail.com>
Summary
This PR adds the scheduler and KV-cache-manager support required for multi-module MTP
(one MTP module per speculative step, e.g. Inkling's 8-depth checkpoint). It is the
companion to #48892, which introduced the speculator itself to Model Runner V2.
The core property this PR protects: the multi-module drafter reads ahead of the
computed tokens during prefill. MTP module
mcomputes positionp's KV from tokenp + m + 1, so at every chunked-prefill boundary the drafter consumes the nextnum_speculative_tokens(N) known prompt tokens — the "prefill lookahead" — to writeexact KVs into all modules. That lookahead, plus the decode-time rejection re-prefill
(which rewrites up to N−1 trailing positions), creates three hazards that the scheduler
and KV cache layer must handle: chunk boundaries landing where lookahead tokens don't
exist, caching/freeing KV that can still change, and prefix-cache hits serving KV that
encodes another request's continuation.
Changes
Config (
vllm/config/speculative.py)(
n_predict = num_nextn_predict_layersinstead of clamping to 1), and the"exactly one speculative token" restriction is removed. Module
idraftsspeculative token
i.Speculator selection (
vllm/v1/worker/gpu/spec_decode/__init__.py)init_speculatorroutes toMultiModuleMTPSpeculatorwhenuse_multi_module_mtp()(method"mtp"with >1 usable MTP layer).Scheduler (
vllm/v1/core/sched/scheduler.py)num_prefill_lookahead: how many positions past the computedtokens the drafter reads during prefill (N for multi-module MTP, 1 for other
eagle-family drafters, 0 without spec decode). All three consumers below are
projections of it.
_reserve_prefill_lookahead(both scheduling loops, after encodertruncation): never end a prefill chunk with
0 < remaining < Ntokens beforethe prefill end — either finish the prefill or leave ≥ N for the next chunk.
Without this, a boundary near the prompt end has no real lookahead tokens; the
drafter falls back to sampled drafts and the trailing modules' KVs at those
positions are polluted permanently (they fall outside every future query
window, so nothing rewrites them). Vacuous no-op for eagle-family (lookahead 1).
shift_computed_tokensto_try_schedule_encoder_inputsis nownum_prefill_lookahead(previously thehardcoded eagle
+1). MM spans starting inside the lookahead window getencoded one chunk early, so the drafter's future-token embeddings are
available; when the encoder budget can't cover a span, the existing rollback
lands the boundary at
span_start − N, keeping the lookahead window all-text.(The runner-side consumption of these early embeddings lands in a follow-up
PR; until then MM placeholder lookahead tokens use text embeddings.)
num_prefill_lookaheadtoKVCacheManager.KV cache manager / coordinator
(
kv_cache_manager.py,kv_cache_coordinator.py,single_type_kv_cache_manager.py)num_prefill_lookaheadis threaded intoKVCacheCoordinator, which derivesnum_reprefillable_tokens = max(0, lookahead − 1).cache_blocksonly hash-registers tokens up tonum_computed − num_reprefillable_tokens(mirrored in the hybridcoordinator's EAGLE lookahead-block eligibility). During decode, rejection
re-prefill can rewrite the last N−1 tokens' draft KVs; registering them
earlier would expose unverified KV to other requests and mutate blocks after
they are shared.
block_size >= num_prefill_lookahead. The last N slots of any cachedprefix hold draft KVs computed from tokens past the block hash (the writer's
continuation — lookahead tokens at a chunk boundary, or sampled/draft tokens
later). The pre-existing EAGLE last-block drop already recomputes the trailing
block on every hit — with the new request's own lookahead tokens, making the
rewrite exact — but a single dropped block only covers all N polluted slots if
the block size is large enough. Rather than generalizing the drop to multiple
blocks, we assert (real configs use block 16/64 with N ≤ 8).
Sliding-window retention
(
kv_cache_interface.py,kv_cache_utils.py,single_type_kv_cache_manager.py)SlidingWindowSpec.extra_retained_tokens(tagged as N−1 for multi-moduleMTP in
get_kv_cache_configs): the SWA free boundary lags by N−1 tokens, andmax_memory_usage_bytesaccounts for the extra retained blocks. Rejectionre-prefill recomputes positions up to N−1 behind the tip, and each recomputed
position needs its full attention window; without the lag, those windows reach
into already-freed (null) blocks and the corrected KVs would be computed from
garbage. Single source of truth on the spec keeps pool sizing, the admission
cap, and block eviction consistent. Prior spec methods never needed this knob
because no drafter re-processed positions behind the tip (eagle's ±1 is a
token-id shift, not a position shift).
Why this is not duplicating an existing PR
Multi-module MTP is not supported by any open PR; the eagle/single-module paths this
builds on (
drop_eagle_block, encoder shift,use_eagleplumbing) are extendedin place rather than duplicated.
Testing
tests/v1/core/test_scheduler.py— 137 passed (includes the eagle encoder-shiftregression test, chunked-prefill, and preemption paths).
tests/v1/core/test_prefix_caching.py— 89 passed.tests/config/test_speculative_draft_hf_overrides.py— updated for theall-depths Inkling override.
block_size=16, N=8andspec-decode-off; rejects
block_size=4, N=8with a clear message.(chunked prefill + prefix caching enabled): [results to be added].
AI assistance (Claude) was used for implementation and review of this PR; all
changes were human-reviewed.
Evals
GSM8K, 1319 questions, 5-shot, concurrency=64, max_tokens=2048
Benchmarks
Server Config
GSM8K, 1319 questions, concurrency=64, max_tokens=2048