[BugFix][Model Runner V2][Spec Decode] Fix decode instance's multi-layer MTP kv caches during P/D (#55055)
Signed-off-by: Giancarlo Delfin <gdelfin@inferact.ai>
Giancarlo Delfin committed
Sep 15, 2026 at 17:31 UTC
85c1f58d50b80a6668f1f8158f0d8ef7b1d9112a
7 files changed
+69
-46
tests/v1/kv_connector/unit/test_nixl_push_connector.py
+1
-1
@@ -138,7 +138,7 @@ class TestPushScheduler:
138
139
with patch.object(
140
sched,
141
- "_truncate_mamba_request_for_prefill",
141
+ "_truncate_request_for_prefill",
142
side_effect=AssertionError("must not truncate after cache lookup"),
143
):
144
assert sched.get_num_new_matched_tokens(request, 0) == (0, False)
tests/v1/kv_connector/unit/utils.py
+6
-1
@@ -521,6 +521,8 @@ def make_nixl_scheduler(
521
Only sets the flags needed by the tests. When *heartbeat=True* the
522
scheduler-side heartbeat bookkeeping fields are also initialised.
523
"""
524
+ from types import SimpleNamespace
525
+
526
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.scheduler import (
527
NixlConnectorScheduler,
528
)
@@ -532,6 +534,7 @@ def make_nixl_scheduler(
534
block_size=16,
535
mamba_enabled=has_mamba,
536
)
537
+ sched.vllm_config = SimpleNamespace(num_prefill_lookahead_tokens=0)
538
539
if heartbeat:
540
sched._heartbeat_by_engine = {}
@@ -598,9 +601,11 @@ def make_nixl_push_scheduler(
601
mamba_enabled=has_mamba,
602
)
603
601
- # vllm_config is consulted for parallel_config.tensor_parallel_size.
604
+ # vllm_config is consulted for parallel_config.tensor_parallel_size, and by
605
+ # `_prefill_backoff` on both the P and D prefill paths.
606
vllm_config = MagicMock()
607
vllm_config.parallel_config.tensor_parallel_size = 1
608
+ vllm_config.num_prefill_lookahead_tokens = 0
609
sched.vllm_config = vllm_config
610
611
# Push-specific state.
vllm/config/vllm.py
+23
@@ -650,6 +650,29 @@ class VllmConfig:
650
return self.num_speculative_tokens
651
return 0
652
653
+ @property
654
+ def num_prefill_lookahead_tokens(self) -> int:
655
+ """Prefill tokens past the computed range that the drafter reads.
656
+
657
+ Mid-prefill the drafter consumes tokens the target model has not been
658
+ scheduled for yet, so every component that has to keep them available
659
+ must apply this margin: the scheduler, which never ends a chunk within
660
+ it and shifts encoder scheduling by it, and the KV cache manager, which
661
+ treats the trailing `this - 1` tokens as re-prefillable rather than
662
+ finalized. Consumers must read this property rather than re-deriving
663
+ their own per-method lookahead, so those components cannot drift apart.
664
+ """
665
+ speculative_config = self.speculative_config
666
+ if speculative_config is None or not speculative_config.use_eagle():
667
+ return 0
668
+ if speculative_config.use_multi_module_mtp():
669
+ # Each MTP module reads one token further ahead than the one before
670
+ # it, so the chain needs num_speculative_tokens of runway at a
671
+ # chunked-prefill boundary.
672
+ return self.num_speculative_tokens
673
+ # Eagle-family drafters read only the immediate next token.
674
+ return 1
675
+
676
@property
677
def uniform_decode_query_len(self) -> int:
678
"""Query length of every request in a uniform decode batch.
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_scheduler.py
+36
-16
@@ -199,8 +199,8 @@ class NixlBaseConnectorScheduler:
199
def on_new_request(self, request: "Request") -> None:
200
"""Track a request that may need heartbeats."""
201
params = request.kv_transfer_params
202
- if params is not None and params.get("do_remote_decode") and self._has_mamba:
203
- self._truncate_mamba_request_for_prefill(request)
202
+ if params is not None and params.get("do_remote_decode"):
203
+ self._truncate_request_for_prefill(request)
204
205
# NOTE (NickLucche) This excludes request meant for P, ie heartbeats are
206
# effectively disabled for Bidirectional KV transfer.
@@ -382,36 +382,56 @@ class NixlBaseConnectorScheduler:
382
(identity, b"", encoded_data[(target_pp_rank, target_tp_rank)], ts)
383
)
384
385
+ def _prefill_backoff(self) -> int:
386
+ """Trailing prompt tokens the prefiller must not compute; the decoder
387
+ recomputes them locally.
388
+
389
+ Mamba needs h(N-1) so the decoder can derive h(N) itself. Multi-module
390
+ MTP needs to keep its whole lookahead window off of the prefiller, which
391
+ would otherwise embed the unverified drafts in the MTP layer's KV cache. The
392
+ decoder would never rebuild them, because the update is sized by the rejection
393
+ count, which is zero for the first decode.
394
+ """
395
+ return max(
396
+ 1 if self._has_mamba else 0,
397
+ self.vllm_config.num_prefill_lookahead_tokens - 1,
398
+ )
399
+
400
def _get_remote_prefill_token_count(self, num_prompt_tokens: int) -> int:
386
- """D-side only. Returns N-1 for Mamba models since the decoder
387
- always recomputes the last token and must start from h(N-1)."""
388
- if self._has_mamba and num_prompt_tokens > 1:
389
- return num_prompt_tokens - 1
401
+ """D-side only. The number of prompt tokens to load from the prefiller.
402
+ Stops short of the trailing ``_prefill_backoff()`` tokens that the decoder
403
+ will recompute locally."""
404
+ backoff = self._prefill_backoff()
405
+ if backoff and num_prompt_tokens > backoff:
406
+ return num_prompt_tokens - backoff
407
return num_prompt_tokens
408
392
- def _truncate_mamba_request_for_prefill(self, request: "Request") -> None:
393
- """P-side only: drop the last prompt token so the prefiller computes
394
- h(N-1) instead of h(N). The decoder recomputes the last token to
395
- derive h(N) correctly.
409
+ def _truncate_request_for_prefill(self, request: "Request") -> None:
410
+ """P-side only: drop the trailing ``_prefill_backoff()`` prompt tokens
411
+ so the prefiller stops short of what the decoder recomputes locally.
412
+ For Mamba that is the single token needed to yield h(N-1); for
413
+ multi-module MTP it is the drafter's whole lookahead window.
414
415
Guarded by ``_p_side_truncated`` to avoid repeated truncation if the
416
request is preempted and rescheduled."""
417
+ backoff = self._prefill_backoff()
418
params = request.kv_transfer_params
419
if (
401
- params is not None
420
+ backoff
421
+ and params is not None
422
# Guard against repeated truncation after preemption/reschedule.
423
and not params.get("_p_side_truncated")
404
- and request.num_prompt_tokens > 1
424
+ and request.num_prompt_tokens > backoff
425
):
426
if request.prompt_token_ids is not None:
407
- request.prompt_token_ids.pop()
427
+ del request.prompt_token_ids[-backoff:]
428
elif request.prompt_embeds is not None:
409
- request.prompt_embeds = request.prompt_embeds[:-1]
429
+ request.prompt_embeds = request.prompt_embeds[:-backoff]
430
else:
431
return
432
413
- request._all_token_ids.pop()
414
- request.num_prompt_tokens -= 1
433
+ del request._all_token_ids[-backoff:]
434
+ request.num_prompt_tokens -= backoff
435
request.max_tokens = 1
436
params["_p_side_truncated"] = True
437
vllm/v1/core/kv_cache_utils.py
+1
-6
@@ -2678,12 +2678,7 @@ def get_kv_cache_configs(
2678
2679
# When speculating with more than 1 speculative module (e.g. multi-layered MTP)
2680
# tag every SlidingWindowSpec with how many extra tokens to retain in the window.
2681
- extra_retained_tokens = (
2682
- vllm_config.speculative_config.num_speculative_tokens - 1
2683
- if vllm_config.speculative_config is not None
2684
- and vllm_config.speculative_config.use_multi_module_mtp()
2685
- else 0
2686
- )
2681
+ extra_retained_tokens = max(0, vllm_config.num_prefill_lookahead_tokens - 1)
2682
for layer_name, layer_spec in merged_kv_cache_specs.items():
2683
if isinstance(layer_spec, SlidingWindowSpec):
2684
merged_kv_cache_specs[layer_name] = replace(
vllm/v1/core/sched/scheduler.py
+1
-13
@@ -262,13 +262,7 @@ class Scheduler(SchedulerInterface):
262
self.use_eagle_block_drop = False
263
self.num_spec_tokens = vllm_config.num_speculative_tokens
264
self.num_lookahead_tokens = vllm_config.num_lookahead_tokens
265
- # Positions past the computed tokens that the drafter reads mid-prefill.
266
- # Eagle-family drafters read 1 ahead, but multi-module MTP reads
267
- # num_spec_tokens ahead at chunked-prefill boundaries. Determines the
268
- # encoder scheduling shift, the deferred encoder free, the KV cache
269
- # manager's re-prefillable window (this minus 1), and how many tokens to
270
- # reserve between a chunk boundary and the prefill end.
271
- self.num_prefill_lookahead = 0
265
+ self.num_prefill_lookahead = vllm_config.num_prefill_lookahead_tokens
266
self.dynamic_sd_lookup: list[int] | None = None
267
if speculative_config is not None:
268
if speculative_config.num_speculative_tokens_per_batch_size:
@@ -278,12 +272,6 @@ class Scheduler(SchedulerInterface):
272
vllm_num_speculative_tokens=self.num_spec_tokens,
273
)
274
self.use_eagle = speculative_config.use_eagle()
281
- if self.use_eagle:
282
- self.num_prefill_lookahead = (
283
- self.num_spec_tokens
284
- if speculative_config.use_multi_module_mtp()
285
- else 1
286
- )
275
self.use_eagle_block_drop = speculative_config.use_eagle_block_drop()
276
if self.use_eagle and not self.use_eagle_block_drop:
277
logger.warning(
vllm/v1/worker/gpu/model_runner.py
+1
-9
@@ -296,15 +296,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
296
self.is_pooling_model = self.model_config.runner_type == "pooling"
297
self.pooling_runner: PoolingRunner | None = None
298
299
- # Multi-module MTP feeds its modules the next num_speculative_steps prefill
300
- # tokens during chunked prefill. Other speculators only read the immediate
301
- # next one.
302
- num_prefill_lookahead = (
303
- self.num_speculative_steps
304
- if self.speculative_config is not None
305
- and self.speculative_config.use_multi_module_mtp()
306
- else 1
307
- )
299
+ num_prefill_lookahead = max(1, vllm_config.num_prefill_lookahead_tokens)
300
301
self.step_timing = StepTimingCollector()
302