[Model Runner V2][Spec Decode] Add KV cache support for multi-layer MTP (#50062)
Signed-off-by: Giancarlo Delfin <gdelfin@inferact.ai> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Giancarlo Delfin committed
Aug 13, 2026 at 16:22 UTC
f80b66f548d855a104c9b2a0527e6c0b1a31750c
10 files changed
+196
-32
tests/config/test_speculative_draft_hf_overrides.py
+4
-2
@@ -86,7 +86,7 @@ def test_arch_mapping_applies_before_callable_override():
86
87
88
@pytest.mark.cpu_test
89
-def test_inkling_override_exposes_only_first_mtp_depth():
89
+def test_inkling_override_exposes_all_mtp_depths():
90
text_config = _make_hf_config(
91
architectures=["InklingForCausalLM"],
92
model_type="inkling_model",
@@ -107,7 +107,9 @@ def test_inkling_override_exposes_only_first_mtp_depth():
107
assert out is text_config
108
assert out.model_type == "inkling_mtp"
109
assert out.architectures == ["InklingMTPModel"]
110
- assert out.n_predict == 1
110
+ # Multi-module MTP: every checkpoint depth is exposed (module i drafts
111
+ # speculative token i), no longer clamped to the first depth.
112
+ assert out.n_predict == 8
113
assert out.num_nextn_predict_layers == 8
114
assert out.chain_hidden_post_norm is False
115
assert out.local_layer_ids == [0, 2, 4]
tests/v1/core/test_scheduler.py
+3
-1
@@ -3701,6 +3701,7 @@ def test_mamba_align_eagle_schedules_encoder_at_boundary():
3701
)
3702
scheduler.need_mamba_block_aligned_split = True
3703
scheduler.use_eagle = True
3704
+ scheduler.num_prefill_lookahead = 1
3705
scheduler.max_num_encoder_input_tokens = 2048
3706
scheduler.encoder_cache_manager = EncoderCacheManager(cache_size=2048)
3707
@@ -5341,8 +5342,9 @@ def test_free_encoder_inputs_defers_for_eagle_lookahead():
5342
worker-side token-embedding fallback is only a backstop."""
5343
scheduler = create_scheduler(model="llava-hf/llava-1.5-7b-hf")
5344
# create_scheduler only builds ngram spec configs; force the eagle path that
5344
- # _free_encoder_inputs keys off (self.use_eagle).
5345
+ # _free_encoder_inputs keys off (its read-ahead deferral).
5346
scheduler.use_eagle = True
5347
+ scheduler.num_prefill_lookahead = 1
5348
mm_positions = [[PlaceholderRange(offset=50, length=100)]]
5349
request = create_requests(
5350
num_requests=1,
vllm/config/speculative.py
+1
-10
@@ -630,8 +630,7 @@ class SpeculativeConfig:
630
hf_config.model_type = "inkling_mtp"
631
hf_config.update(
632
{
633
- # Inkling currently exposes only the first checkpoint depth.
634
- "n_predict": 1,
633
+ "n_predict": checkpoint_depths,
634
"num_nextn_predict_layers": checkpoint_depths,
635
"chain_hidden_post_norm": mtp_config.get(
636
"chain_hidden_post_norm", False
@@ -1083,14 +1082,6 @@ class SpeculativeConfig:
1082
"`num_speculative_tokens` was not provided"
1083
)
1084
1086
- if (
1087
- self.draft_model_config.hf_config.model_type == "inkling_mtp"
1088
- and self.num_speculative_tokens != 1
1089
- ):
1090
- raise ValueError(
1091
- "Inkling MTP currently supports exactly one speculative token"
1092
- )
1093
-
1085
if self.dspark_draft_topk is not None and self.method != "dspark":
1086
raise ValueError("dspark_draft_topk is only supported by DSpark")
1087
vllm/v1/core/kv_cache_coordinator.py
+53
-3
@@ -80,6 +80,7 @@ class KVCacheCoordinator(ABC):
80
scheduler_block_size: int,
81
hash_block_size: int,
82
metrics_collector: KVCacheMetricsCollector | None = None,
83
+ num_prefill_lookahead: int = 0,
84
):
85
self.kv_cache_config = kv_cache_config
86
self.max_model_len = max_model_len
@@ -91,6 +92,7 @@ class KVCacheCoordinator(ABC):
92
for g in kv_cache_config.kv_cache_groups
93
)
94
self.scheduler_block_size = scheduler_block_size
95
+ self.num_reprefillable_tokens = max(0, num_prefill_lookahead - 1)
96
97
self.block_pool = BlockPool(
98
num_gpu_blocks=kv_cache_config.num_blocks,
@@ -108,6 +110,28 @@ class KVCacheCoordinator(ABC):
110
if use_eagle and not self.eagle_group_ids:
111
self.eagle_group_ids = set(range(len(kv_cache_config.kv_cache_groups)))
112
113
+ # During chunked prefill with EAGLE, the single next prefill lookahead
114
+ # token past the chunk boundary is combined with the final hidden state
115
+ # and written to the KV cache. Therefore, the final chunk token must be
116
+ # excluded from prefix cache hits to prevent requests from acquiring the
117
+ # KV cache slot polluted with the next prefill token, which may or may not
118
+ # be present after the matching prefix. The last-block drop handles this
119
+ # edge case. During multi-module MTP, the issue generalizes to a prefill
120
+ # lookahead of num_speculative_tokens, so the dropped tail must be large
121
+ # enough to contain them. Hits land on scheduler-block boundaries (see
122
+ # `_cache_hit_alignment_tokens`), so the excluded tail is
123
+ # scheduler_block_size, not the group's own block size.
124
+ if (
125
+ enable_caching
126
+ and self.eagle_group_ids
127
+ and scheduler_block_size < num_prefill_lookahead
128
+ ):
129
+ raise ValueError(
130
+ f"Multi-module MTP with prefix caching requires scheduler_block_size"
131
+ f" (={scheduler_block_size}) >= num_speculative_tokens"
132
+ f" (={num_prefill_lookahead})."
133
+ )
134
+
135
self.single_type_managers = tuple(
136
get_manager_for_kv_cache_spec(
137
kv_cache_spec=kv_cache_group.kv_cache_spec,
@@ -286,9 +310,14 @@ class KVCacheCoordinator(ABC):
310
(including tokens that are already cached).
311
"""
312
for manager in self.single_type_managers:
313
+ # Only cache tokens with finalized KV. The last num_reprefillable_tokens
314
+ # tokens can be re-prefilled during multi-module MTP.
315
+ num_tokens_to_cache = max(
316
+ 0, num_computed_tokens - self.num_reprefillable_tokens
317
+ )
318
manager.cache_blocks(
319
request,
291
- num_computed_tokens,
320
+ num_tokens_to_cache,
321
retention_interval=self.retention_interval,
322
)
323
@@ -407,6 +436,7 @@ class KVCacheCoordinatorNoPrefixCache(KVCacheCoordinator):
436
scheduler_block_size: int,
437
hash_block_size: int,
438
metrics_collector: KVCacheMetricsCollector | None = None,
439
+ num_prefill_lookahead: int = 0,
440
):
441
super().__init__(
442
kv_cache_config,
@@ -420,6 +450,7 @@ class KVCacheCoordinatorNoPrefixCache(KVCacheCoordinator):
450
scheduler_block_size=scheduler_block_size,
451
hash_block_size=hash_block_size,
452
metrics_collector=metrics_collector,
453
+ num_prefill_lookahead=num_prefill_lookahead,
454
)
455
self.num_single_type_manager = len(self.single_type_managers)
456
@@ -457,6 +488,7 @@ class UnitaryKVCacheCoordinator(KVCacheCoordinator):
488
scheduler_block_size: int,
489
hash_block_size: int,
490
metrics_collector: KVCacheMetricsCollector | None = None,
491
+ num_prefill_lookahead: int = 0,
492
):
493
super().__init__(
494
kv_cache_config,
@@ -470,6 +502,7 @@ class UnitaryKVCacheCoordinator(KVCacheCoordinator):
502
scheduler_block_size=scheduler_block_size,
503
hash_block_size=hash_block_size,
504
metrics_collector=metrics_collector,
505
+ num_prefill_lookahead=num_prefill_lookahead,
506
)
507
self.kv_cache_spec = self.kv_cache_config.kv_cache_groups[0].kv_cache_spec
508
self.block_size = self.kv_cache_spec.block_size
@@ -542,6 +575,7 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
575
scheduler_block_size: int,
576
hash_block_size: int,
577
metrics_collector: KVCacheMetricsCollector | None = None,
578
+ num_prefill_lookahead: int = 0,
579
):
580
super().__init__(
581
kv_cache_config,
@@ -555,6 +589,7 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
589
scheduler_block_size=scheduler_block_size,
590
hash_block_size=hash_block_size,
591
metrics_collector=metrics_collector,
592
+ num_prefill_lookahead=num_prefill_lookahead,
593
)
594
# hash_block_size: the block size used to compute block hashes.
595
# The actual block size usually equals hash_block_size, but in cases where
@@ -688,9 +723,20 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
723
# EAGLE groups match one block past each aligned boundary and drop
724
# it, so make that lookahead block eligible to be cached.
725
if manager.use_eagle and aligned_num_computed_tokens > 0:
726
+ # Only cache tokens with finalized KV. The last
727
+ # num_reprefillable_tokens tokens can be re-prefilled during
728
+ # multi-module MTP.
729
+ num_finalized_computed_tokens = max(
730
+ 0, num_computed_tokens - self.num_reprefillable_tokens
731
+ )
732
+ aligned_num_finalized_computed_tokens = (
733
+ num_finalized_computed_tokens
734
+ // self.scheduler_block_size
735
+ * self.scheduler_block_size
736
+ )
737
num_tokens_to_cache = min(
692
- num_computed_tokens,
693
- aligned_num_computed_tokens + manager.block_size,
738
+ num_finalized_computed_tokens,
739
+ aligned_num_finalized_computed_tokens + manager.block_size,
740
)
741
# The manager already knows the fine hit granularity
742
# (``scheduler_block_size``); retention is passed separately so it
@@ -880,6 +926,7 @@ def get_kv_cache_coordinator(
926
scheduler_block_size: int,
927
hash_block_size: int,
928
metrics_collector: KVCacheMetricsCollector | None = None,
929
+ num_prefill_lookahead: int = 0,
930
) -> KVCacheCoordinator:
931
if not enable_caching:
932
return KVCacheCoordinatorNoPrefixCache(
@@ -893,6 +940,7 @@ def get_kv_cache_coordinator(
940
scheduler_block_size=scheduler_block_size,
941
hash_block_size=hash_block_size,
942
metrics_collector=metrics_collector,
943
+ num_prefill_lookahead=num_prefill_lookahead,
944
)
945
if len(kv_cache_config.kv_cache_groups) == 1:
946
return UnitaryKVCacheCoordinator(
@@ -907,6 +955,7 @@ def get_kv_cache_coordinator(
955
scheduler_block_size=scheduler_block_size,
956
hash_block_size=hash_block_size,
957
metrics_collector=metrics_collector,
958
+ num_prefill_lookahead=num_prefill_lookahead,
959
)
960
return HybridKVCacheCoordinator(
961
kv_cache_config,
@@ -920,4 +969,5 @@ def get_kv_cache_coordinator(
969
scheduler_block_size=scheduler_block_size,
970
hash_block_size=hash_block_size,
971
metrics_collector=metrics_collector,
972
+ num_prefill_lookahead=num_prefill_lookahead,
973
)
vllm/v1/core/kv_cache_manager.py
+2
@@ -125,6 +125,7 @@ class KVCacheManager:
125
max_in_flight_tokens: int | None = None,
126
enable_caching: bool = True,
127
use_eagle: bool = False,
128
+ num_prefill_lookahead: int = 0,
129
log_stats: bool = False,
130
enable_kv_cache_events: bool = False,
131
dcp_world_size: int = 1,
@@ -161,6 +162,7 @@ class KVCacheManager:
162
scheduler_block_size=scheduler_block_size,
163
hash_block_size=hash_block_size,
164
metrics_collector=self.metrics_collector,
165
+ num_prefill_lookahead=num_prefill_lookahead,
166
)
167
self.num_kv_cache_groups = len(kv_cache_config.kv_cache_groups)
168
self.block_pool = self.coordinator.block_pool
vllm/v1/core/kv_cache_utils.py
+18
@@ -1468,6 +1468,9 @@ def _promote_local_kv_cache_specs(
1468
promoted_specs[layer_name] = replace_as(
1469
spec,
1470
target_cls,
1471
+ # Promoted specs allocate blocks for all tokens and never free
1472
+ # below the window, so the trailing-edge extension is moot.
1473
+ drop=("extra_retained_tokens",),
1474
block_size=block_size,
1475
page_size_padded=promoted_page_size_padded(spec, block_size),
1476
)
@@ -2128,6 +2131,21 @@ def get_kv_cache_configs(
2131
# Check if the KV cache specs are registered correctly.
2132
# This is to prevent that some layers are initialized with unregistered specs.
2133
KVCacheSpecRegistry.check_kv_cache_spec_registry(merged_kv_cache_specs)
2134
+
2135
+ # When speculating with more than 1 speculative module (e.g. multi-layered MTP)
2136
+ # tag every SlidingWindowSpec with how many extra tokens to retain in the window.
2137
+ extra_retained_tokens = (
2138
+ vllm_config.speculative_config.num_speculative_tokens - 1
2139
+ if vllm_config.speculative_config is not None
2140
+ and vllm_config.speculative_config.use_multi_module_mtp()
2141
+ else 0
2142
+ )
2143
+ for layer_name, layer_spec in merged_kv_cache_specs.items():
2144
+ if isinstance(layer_spec, SlidingWindowSpec):
2145
+ merged_kv_cache_specs[layer_name] = replace(
2146
+ layer_spec, extra_retained_tokens=extra_retained_tokens
2147
+ )
2148
+
2149
# Get global KV cache groups. This also handles spec unification for
2150
# hybrid models when disable_hybrid_kv_cache_manager is enabled.
2151
# After this call, merged_kv_cache_specs may be modified in-place.
vllm/v1/core/sched/scheduler.py
+58
-8
@@ -247,6 +247,13 @@ class Scheduler(SchedulerInterface):
247
self.use_eagle = False
248
self.num_spec_tokens = vllm_config.num_speculative_tokens
249
self.num_lookahead_tokens = vllm_config.num_lookahead_tokens
250
+ # Positions past the computed tokens that the drafter reads mid-prefill.
251
+ # Eagle-family drafters read 1 ahead, but multi-module MTP reads
252
+ # num_spec_tokens ahead at chunked-prefill boundaries. Determines the
253
+ # encoder scheduling shift, the deferred encoder free, the KV cache
254
+ # manager's re-prefillable window (this minus 1), and how many tokens to
255
+ # reserve between a chunk boundary and the prefill end.
256
+ self.num_prefill_lookahead = 0
257
self.dynamic_sd_lookup: list[int] | None = None
258
if speculative_config is not None:
259
if speculative_config.num_speculative_tokens_per_batch_size:
@@ -256,6 +263,12 @@ class Scheduler(SchedulerInterface):
263
vllm_num_speculative_tokens=self.num_spec_tokens,
264
)
265
self.use_eagle = speculative_config.use_eagle()
266
+ if self.use_eagle:
267
+ self.num_prefill_lookahead = (
268
+ self.num_spec_tokens
269
+ if speculative_config.use_multi_module_mtp()
270
+ else 1
271
+ )
272
273
# Create the KV cache manager.
274
if hash_block_size is None:
@@ -267,6 +280,7 @@ class Scheduler(SchedulerInterface):
280
max_in_flight_tokens=vllm_config.max_in_flight_tokens,
281
enable_caching=self.cache_config.enable_prefix_caching,
282
use_eagle=self.use_eagle,
283
+ num_prefill_lookahead=self.num_prefill_lookahead,
284
log_stats=self.log_stats,
285
enable_kv_cache_events=self.enable_kv_cache_events,
286
dcp_world_size=self.dcp_world_size,
@@ -438,6 +452,27 @@ class Scheduler(SchedulerInterface):
452
)
453
return blocks, num_local, shared_prefix_boundary, False
454
455
+ def _reserve_prefill_lookahead(
456
+ self,
457
+ request: Request,
458
+ num_computed_tokens: int,
459
+ num_new_tokens: int,
460
+ ) -> int:
461
+ """Never end a prefill chunk within num_prefill_lookahead of the
462
+ prefill end.
463
+
464
+ At a chunked-prefill boundary, the multi-module MTP drafter consumes
465
+ the next num_prefill_lookahead known prefill tokens as draft inputs. A
466
+ boundary closer to the end than that would make it fall back to
467
+ sampled drafts, permanently polluting the trailing modules' KV caches.
468
+ Either finish the prefill or leave at least num_prefill_lookahead for
469
+ the next chunk. No-op for eagle-family drafters (lookahead 1).
470
+ """
471
+ remaining = request.num_tokens - num_computed_tokens - num_new_tokens
472
+ if 0 < remaining < self.num_prefill_lookahead:
473
+ num_new_tokens -= self.num_prefill_lookahead - remaining
474
+ return max(num_new_tokens, 0)
475
+
476
def schedule(self, throttle_prefills: bool = False) -> SchedulerOutput:
477
self.current_step += 1
478
# NOTE(woosuk) on the scheduling algorithm:
@@ -561,9 +596,15 @@ class Scheduler(SchedulerInterface):
596
request.num_computed_tokens,
597
num_new_tokens,
598
encoder_compute_budget,
564
- shift_computed_tokens=1 if self.use_eagle else 0,
599
+ shift_computed_tokens=self.num_prefill_lookahead,
600
)
601
602
+ # Multi-module MTP: avoid ending a prefill chunk within
603
+ # num_prefill_lookahead of the prefill end.
604
+ num_new_tokens = self._reserve_prefill_lookahead(
605
+ request, request.num_computed_tokens, num_new_tokens
606
+ )
607
+
608
if num_new_tokens == 0:
609
# The request cannot be scheduled because one of the following
610
# reasons:
@@ -576,6 +617,8 @@ class Scheduler(SchedulerInterface):
617
# 3. The encoder cache is exhausted.
618
# 4. Insufficient budget for a block-aligned chunk in hybrid
619
# models with mamba cache mode \"align\".
620
+ # 5. Insufficient budget to keep a multi-module MTP prefill
621
+ # chunk out of the prefill-lookahead window.
622
# NOTE(woosuk): Here, by doing `continue` instead of `break`,
623
# we do not strictly follow the FCFS scheduling policy and
624
# allow the lower-priority requests to be scheduled.
@@ -946,11 +989,18 @@ class Scheduler(SchedulerInterface):
989
num_computed_tokens,
990
num_new_tokens,
991
encoder_compute_budget,
949
- shift_computed_tokens=1 if self.use_eagle else 0,
992
+ shift_computed_tokens=self.num_prefill_lookahead,
993
)
951
- if num_new_tokens == 0:
952
- # The request cannot be scheduled.
953
- break
994
+
995
+ # Multi-module MTP: avoid ending a prefill chunk within
996
+ # num_prefill_lookahead of the prefill end.
997
+ num_new_tokens = self._reserve_prefill_lookahead(
998
+ request, num_computed_tokens, num_new_tokens
999
+ )
1000
+
1001
+ if num_new_tokens == 0:
1002
+ # The request cannot be scheduled.
1003
+ break
1004
1005
# During async KV load, no forward pass is run yet.
1006
# Allocate speculative lookahead slots later to avoid
@@ -2157,9 +2207,9 @@ class Scheduler(SchedulerInterface):
2207
return
2208
2209
# Defer the free by the drafter's look-ahead so an entry stays
2160
- # referenced until the drafter's +1 read has also passed it, mirroring
2161
- # the shift the encoder scheduling path applies.
2162
- spec_lookahead = 1 if self.use_eagle else 0
2210
+ # referenced until the drafter's read-ahead has also passed it,
2211
+ # mirroring the shift the encoder scheduling path applies.
2212
+ spec_lookahead = self.num_prefill_lookahead
2213
2214
# Here, we use list(set) to avoid modifying the set while iterating
2215
# over it.
vllm/v1/core/single_type_kv_cache_manager.py
+19
-1
@@ -561,6 +561,11 @@ class SingleTypeKVCacheManager(ABC):
561
return an empty list.
562
If eagle is enabled, drop the last matched block to force recompute the
563
last block to get the required hidden states for eagle drafting head.
564
+ For multi-module MTP, this recompute also rewrites the dropped block's
565
+ draft-layer KVs, which depend on up to num_speculative_tokens - 1
566
+ tokens past the matched prefix (i.e. on the cache writer's
567
+ continuation, which the block hash does not cover); the coordinator
568
+ asserts the block size covers that window.
569
Need to be customized for each attention type.
570
571
Args:
@@ -877,6 +882,10 @@ class SlidingWindowManager(SingleTypeKVCacheManager):
882
def __init__(self, kv_cache_spec: SlidingWindowSpec, **kwargs) -> None:
883
super().__init__(kv_cache_spec, **kwargs)
884
self.sliding_window = kv_cache_spec.sliding_window
885
+ # Extra trailing tokens to retain below the window (never attended) so a
886
+ # multi-module MTP store-side lag can still reconstruct the window from
887
+ # cached blocks.
888
+ self.extra_retained_tokens = kv_cache_spec.extra_retained_tokens
889
890
@classmethod
891
def _contiguous_blocks_for_hit(
@@ -1072,13 +1081,22 @@ class SlidingWindowManager(SingleTypeKVCacheManager):
1081
attention computation since they are outside the sliding window.
1082
Thus, get_num_skipped_tokens(7) == 4.
1083
1084
+ The trailing edge of the window is extended by ``extra_retained_tokens``
1085
+ so that those extra trailing tokens' blocks are retained (but not
1086
+ attended). This is needed for multi-module spec decoding which can
1087
+ re-prefill the last num_spec_prefill_tokens - 1 tokens from the end
1088
+ of the sequence, and thus needs to delay freeing/caching of blocks.
1089
+
1090
Args:
1091
num_computed_tokens: The number of tokens that have been computed.
1092
1093
Returns:
1094
The number of tokens that will be skipped for attention computation.
1095
"""
1081
- return max(0, num_computed_tokens - self.sliding_window + 1)
1096
+ return max(
1097
+ 0,
1098
+ num_computed_tokens - self.sliding_window + 1 - self.extra_retained_tokens,
1099
+ )
1100
1101
def get_num_common_prefix_blocks(self, running_request_id: str) -> int:
1102
"""
vllm/v1/kv_cache_interface.py
+32
-7
@@ -5,6 +5,7 @@ from __future__ import annotations
5
6
import copy
7
from collections import Counter
8
+from collections.abc import Collection
9
from dataclasses import dataclass, fields, replace
10
from enum import Enum, IntEnum
11
from math import prod
@@ -88,14 +89,24 @@ def is_quantized_kv_cache(kv_cache_dtype: str) -> bool:
89
return get_kv_quant_mode(kv_cache_dtype) != KVQuantMode.NONE
90
91
91
-def replace_as(spec: KVCacheSpec, target_cls: type[_SpecT], **changes) -> _SpecT:
92
+def replace_as(
93
+ spec: KVCacheSpec,
94
+ target_cls: type[_SpecT],
95
+ *,
96
+ drop: Collection[str] = (),
97
+ **changes,
98
+) -> _SpecT:
99
"""``dataclasses.replace``, but rebuilding *spec* as *target_cls*
100
e.g. ``SlidingWindowSpec`` -> ``FullAttentionSpec``
101
95
- Every field of *spec* must exist on *target_cls*; fields only *target_cls* has keep
96
- their default values.
102
+ Every field of *spec* must exist on *target_cls* unless named in *drop*;
103
+ fields only *target_cls* has keep their default values.
104
"""
98
- kwargs = {f.name: getattr(spec, f.name) for f in fields(spec) if f.init}
105
+ kwargs = {
106
+ f.name: getattr(spec, f.name)
107
+ for f in fields(spec)
108
+ if f.init and f.name not in drop
109
+ }
110
kwargs.update(changes)
111
return target_cls(**kwargs)
112
@@ -573,6 +584,12 @@ class ChunkedLocalAttentionSpec(AttentionSpec):
584
class SlidingWindowSpec(AttentionSpec):
585
sliding_window: int
586
head_size_v: int = None # type: ignore[assignment]
587
+ # The trailing edge of the window is extended by ``extra_retained_tokens``
588
+ # so that those extra trailing tokens' blocks are retained (but not
589
+ # attended). This is needed for multi-module spec decoding which can
590
+ # re-prefill the last num_spec_prefill_tokens - 1 tokens from the end
591
+ # of the sequence, and thus needs to delay freeing/caching of blocks.
592
+ extra_retained_tokens: int = 0
593
594
def __post_init__(self):
595
if self.head_size_v is None:
@@ -614,8 +631,13 @@ class SlidingWindowSpec(AttentionSpec):
631
"""
632
# During chunked prefill, we hold KV for the last `sliding_window-1`
633
# computed tokens plus the in-flight tokens (frees happen on the
617
- # processed-token basis); never more than `max_model_len`.
618
- num_tokens = min(self.sliding_window - 1 + max_in_flight_tokens, max_model_len)
634
+ # processed-token basis); never more than `max_model_len`. An additional
635
+ # `extra_retained_tokens` trailing tokens are kept alive below the
636
+ # window for multi-module spec decoding, and must be accounted here too.
637
+ num_tokens = min(
638
+ self.sliding_window - 1 + self.extra_retained_tokens + max_in_flight_tokens,
639
+ max_model_len,
640
+ )
641
# +1 because the sliding window may not start from the beginning of
642
# the block. E.g. block size 4 and num_token 4 needs two blocks
643
# [XXCD][EF] to store the 6-token window [CDEF].
@@ -686,16 +708,18 @@ class SlidingWindowMLASpec(SlidingWindowSpec):
708
model_version_set = set(spec.model_version for spec in specs)
709
sliding_window_set = set(spec.sliding_window for spec in specs)
710
block_stride_set = set(spec.indexes_kv_by_block_stride for spec in specs)
711
+ extra_retained_set = set(spec.extra_retained_tokens for spec in specs)
712
assert (
713
len(cache_dtype_str_set) == 1
714
and len(compress_ratio_set) == 1
715
and len(model_version_set) == 1
716
and len(sliding_window_set) == 1
717
and len(block_stride_set) == 1
718
+ and len(extra_retained_set) == 1
719
), (
720
"All attention layers in the same KV cache group must use the same "
721
"quantization method, compress ratio, model version, sliding "
698
- "window size, and KV block stride indexing."
722
+ "window size, KV block stride indexing, and retained token count."
723
)
724
return cls(
725
block_size=specs[0].block_size,
@@ -705,6 +729,7 @@ class SlidingWindowMLASpec(SlidingWindowSpec):
729
page_size_padded=specs[0].page_size_padded,
730
indexes_kv_by_block_stride=block_stride_set.pop(),
731
sliding_window=sliding_window_set.pop(),
732
+ extra_retained_tokens=extra_retained_set.pop(),
733
cache_dtype_str=cache_dtype_str_set.pop(),
734
compress_ratio=compress_ratio_set.pop(),
735
model_version=model_version_set.pop(),
vllm/v1/worker/gpu/spec_decode/__init__.py
+6
@@ -26,6 +26,12 @@ def init_speculator(vllm_config: VllmConfig, device: torch.device):
26
)
27
28
return Gemma4Speculator(vllm_config, device)
29
+ elif speculative_config.use_multi_module_mtp():
30
+ from vllm.v1.worker.gpu.spec_decode.multi_module_mtp.speculator import (
31
+ MultiModuleMTPSpeculator,
32
+ )
33
+
34
+ return MultiModuleMTPSpeculator(vllm_config, device)
35
elif speculative_config.method == "mtp":
36
from vllm.v1.worker.gpu.spec_decode.mtp.speculator import MTPSpeculator
37