[BugFix] Retain both replay boundaries so an EAGLE resend of a block-aligned prompt still hits (#54713)

Signed-off-by: tobymao <toby.mao@gmail.com>

Toby Mao committed Sep 10, 2026 at 01:39 UTC b28c3e1568bfae930f61d4b24940e47528c85d4a
4 files changed +130 -31
tests/v1/core/test_prefix_caching.py
+98
@@ -3641,6 +3641,104 @@ def test_hybrid_local_kv_retention_mtp_reuses_latest_boundary():
3641 assert [len(blocks) for blocks in computed_blocks.blocks] == [2, 8]
3642
3643
3644 +def test_hybrid_mamba_retention_mtp_resend_of_aligned_prompt():
3645 + """An identical resend and a longer sibling resume at DIFFERENT positions.
3646 +
3647 + How far a lookup matches depends on who is asking. A resend of the same
3648 + prompt caps its lookup at ``num_tokens - 1`` (the last token is recomputed
3649 + for logits), while a sibling whose prompt merely starts with this one caps
3650 + above the prompt. The two coincide unless the prompt length is an exact
3651 + multiple of the alignment -- there they differ by one alignment unit, and
3652 + under the EAGLE drop BOTH are reachable.
3653 +
3654 + Retaining only the higher one leaves the resend with every retained state
3655 + above every candidate its lookup can produce, and the reconciled hit
3656 + collapses to 0 -- the same zero-hit failure sparse retention already fixes
3657 + at unaligned prompt lengths.
3658 + """
3659 + block_size = 32
3660 + num_spec = 3
3661 + kv_cache_config = KVCacheConfig(
3662 + num_blocks=100,
3663 + kv_cache_tensors=[],
3664 + kv_cache_groups=[
3665 + KVCacheGroupSpec(
3666 + ["full"],
3667 + FullAttentionSpec(
3668 + block_size=block_size,
3669 + num_kv_heads=1,
3670 + head_size=1,
3671 + dtype=torch.float16,
3672 + ),
3673 + ),
3674 + KVCacheGroupSpec(
3675 + ["mamba_mtp"],
3676 + MambaSpec(
3677 + block_size=block_size,
3678 + shapes=((1, 1),),
3679 + dtypes=(torch.float32,),
3680 + mamba_cache_mode="align",
3681 + num_speculative_blocks=num_spec,
3682 + ),
3683 + ),
3684 + ],
3685 + )
3686 + manager = make_kv_cache_manager(
3687 + kv_cache_config=kv_cache_config,
3688 + max_model_len=8192,
3689 + enable_caching=True,
3690 + hash_block_size=block_size,
3691 + retention_interval=0,
3692 + use_eagle=True,
3693 + )
3694 +
3695 + # 128 tokens, an exact multiple of the 32-token alignment. A longer sibling
3696 + # matches 128 and drops to 96; this prompt's own resend caps at 127, matches
3697 + # 96 and drops to 64. Both states must survive retention.
3698 + token_ids = [i for i in range(4) for _ in range(block_size)]
3699 + req0 = make_request("0", token_ids, block_size, sha256)
3700 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
3701 + assert num_computed_tokens == 0
3702 + # Prefill in block-aligned chunks the way the align-mode scheduler does: a
3703 + # state only materializes as a chunk's running-state block, so a
3704 + # single-shot prefill could not retain the lower one.
3705 + for chunk_end in (32, 64, 96, 128):
3706 + blocks = manager.allocate_slots(
3707 + req0,
3708 + chunk_end - req0.num_computed_tokens,
3709 + num_computed_tokens,
3710 + computed_blocks,
3711 + num_lookahead_tokens=num_spec,
3712 + )
3713 + assert blocks is not None
3714 + req0.num_computed_tokens = chunk_end
3715 +
3716 + # Block ``i`` ends at token ``(i + 1) * 32``, so positions 64 and 96 are
3717 + # mamba blocks 1 and 2.
3718 + pool = manager.block_pool
3719 + expected_mamba_cached = {1, 2}
3720 + for i in range(4):
3721 + cached = pool.get_cached_block(req0.block_hashes[i], kv_cache_group_ids=[1])
3722 + if i in expected_mamba_cached:
3723 + assert cached is not None, f"mamba hash {i} should be cached"
3724 + else:
3725 + assert cached is None, f"mamba hash {i} should not be cached"
3726 + manager.free(req0)
3727 +
3728 + # The identical resend: full attention matches blocks 0-2 (96 tokens, capped
3729 + # by num_tokens - 1) and the EAGLE drop caps the candidate at 64. Without
3730 + # the lower state retained the reconciled hit would be 0.
3731 + req1 = make_request("1", token_ids, block_size, sha256)
3732 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
3733 + assert num_computed_tokens == 2 * block_size
3734 + assert [len(blocks) for blocks in computed_blocks.blocks] == [2, 2]
3735 +
3736 + # The longer sibling resumes one alignment unit higher, off the same prompt.
3737 + longer = make_request("2", token_ids + [9] * block_size, block_size, sha256)
3738 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(longer)
3739 + assert num_computed_tokens == 3 * block_size
3740 +
3741 +
3742 def test_block_lookup_cache_single_block_per_key():
3743 cache = BlockHashToBlockMap()
3744 key0 = BlockHashWithGroupId(b"hash0")
tests/v1/core/test_single_type_kv_cache_manager.py
+1 -1
@@ -192,7 +192,7 @@ def test_circular_buffer_allocates_one_block_for_the_request_lifetime():
192 )
193 assert manager.allocate_new_blocks(request_id, num_tokens, num_tokens) == []
194
195 - manager.cache_blocks(request_id, 1024, replay_boundary=0)
195 + manager.cache_blocks(request_id, 1024, replay_boundaries=(0,))
196 manager.remove_skipped_blocks(request_id, 1024)
197 assert manager.get_num_common_prefix_blocks(request_id) == 0
198 assert manager.get_num_skipped_tokens(1024) == 0
vllm/v1/core/kv_cache_coordinator.py
+19 -20
@@ -307,27 +307,26 @@ class KVCacheCoordinator(ABC):
307 for manager in self.single_type_managers
308 )
309
310 - def get_replay_boundary(self, request: Request) -> int:
311 - """Return the position a later request replaying this prompt resumes at.
310 + def get_replay_boundaries(self, request: Request) -> tuple[int, ...]:
311 + """Positions a later request replaying this prompt can resume at.
312
313 - A cache hit is the shortest hit across all groups, so this is a
314 - model-level position: every group has to retain state here, whether or
315 - not it is the group that drops. Groups differ only in how much they
316 - keep around it -- EAGLE groups also keep the block above, which they
313 + A hit is the shortest across all groups, so every group retains state
314 + at each position; EAGLE groups also keep the block above, which they
315 match and drop back from (see ``reachable_block_mask``).
316
319 - Under EAGLE that block must exist, so the boundary sits one alignment
320 - unit below the prompt's last aligned position; every group's block size
321 - divides the alignment, so the block above always fits in the prompt.
317 + Two positions are reachable: a resend of the identical prompt is capped
318 + at ``num_tokens - 1`` (its last token is recomputed for logits), a
319 + longer sibling matches the final aligned block. They differ only on a
320 + block-aligned prompt, where retaining just the higher one collapses the
321 + resend's hit to 0. The alignment is the scheduler block size, not the
322 + finer hash granularity, which would over-estimate the reach.
323 """
324 if not self.eagle_group_ids:
324 - return request.num_prompt_tokens - 1
325 - aligned = (
326 - request.num_prompt_tokens
327 - // self.scheduler_block_size
328 - * self.scheduler_block_size
329 - )
330 - return max(aligned - self.scheduler_block_size, 0)
325 + return (request.num_prompt_tokens - 1,)
326 + block = self.scheduler_block_size
327 + resend = (request.num_prompt_tokens - 1) // block * block
328 + extension = request.num_prompt_tokens // block * block
329 + return tuple(sorted({max(resend - block, 0), max(extension - block, 0)}))
330
331 def cache_blocks(self, request: Request, num_computed_tokens: int) -> None:
332 """
@@ -339,7 +338,7 @@ class KVCacheCoordinator(ABC):
338 that need to be cached
339 (including tokens that are already cached).
340 """
342 - replay_boundary = self.get_replay_boundary(request)
341 + boundaries = self.get_replay_boundaries(request)
342 for manager in self.single_type_managers:
343 # Only cache tokens with finalized KV. The last num_reprefillable_tokens
344 # tokens can be re-prefilled during multi-module MTP.
@@ -350,7 +349,7 @@ class KVCacheCoordinator(ABC):
349 request,
350 num_tokens_to_cache,
351 retention_interval=self.retention_interval,
353 - replay_boundary=replay_boundary,
352 + replay_boundaries=boundaries,
353 )
354
355 def free(self, request_id: str) -> None:
@@ -775,7 +774,7 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
774
775 def cache_blocks(self, request: Request, num_computed_tokens: int) -> None:
776 cached_num_computed_tokens = self._align_cacheable(num_computed_tokens)
778 - replay_boundary = self.get_replay_boundary(request)
777 + boundaries = self.get_replay_boundaries(request)
778 for manager in self.single_type_managers:
779 num_tokens_to_cache = cached_num_computed_tokens
780 # EAGLE groups match one block past each aligned boundary and drop
@@ -802,7 +801,7 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
801 request,
802 num_tokens_to_cache,
803 retention_interval=self.retention_interval,
805 - replay_boundary=replay_boundary,
804 + replay_boundaries=boundaries,
805 )
806
807 def find_longest_cache_hit(
vllm/v1/core/single_type_kv_cache_manager.py
+12 -10
@@ -452,7 +452,7 @@ class SingleTypeKVCacheManager(ABC):
452 num_tokens: int,
453 retention_interval: int | None = None,
454 *,
455 - replay_boundary: int,
455 + replay_boundaries: Sequence[int],
456 ) -> None:
457 """
458 Cache the blocks for the request.
@@ -465,6 +465,8 @@ class SingleTypeKVCacheManager(ABC):
465 keeps dense checkpointing; ``0`` keeps only the latest replay
466 boundary; a positive multiple of ``scheduler_block_size`` keeps
467 a tail once per that-sized segment. Only SWA acts on it.
468 + replay_boundaries: Positions a later request replaying this prompt
469 + can resume at, from ``get_replay_boundaries``.
470 """
471 num_cached_blocks = self.num_cached_block.get(request.request_id, 0)
472 num_full_blocks = num_tokens // self.block_size
@@ -473,9 +475,9 @@ class SingleTypeKVCacheManager(ABC):
475 return
476
477 # Token boundaries whose reachable tail must be retained under sparse
476 - # retention: the replay boundary (``num_prompt - 1``, capped by
477 - # ``get_computed_blocks``) and any detected shared-prefix junction.
478 - reachable_boundaries = [replay_boundary]
478 + # retention: every position a replaying sibling can resume at (see
479 + # ``get_replay_boundaries``) and any detected shared-prefix junction.
480 + reachable_boundaries = [*replay_boundaries]
481 if request.shared_prefix_boundary:
482 reachable_boundaries.append(request.shared_prefix_boundary)
483
@@ -818,13 +820,13 @@ class FullAttentionManager(SingleTypeKVCacheManager):
820 num_tokens: int,
821 retention_interval: int | None = None,
822 *,
821 - replay_boundary: int,
823 + replay_boundaries: Sequence[int],
824 ) -> None:
825 super().cache_blocks(
826 request,
827 num_tokens,
828 retention_interval=retention_interval,
827 - replay_boundary=replay_boundary,
829 + replay_boundaries=replay_boundaries,
830 )
831 hash_block_size = self.block_pool.hash_block_size
832 if self.block_size == hash_block_size:
@@ -1228,7 +1230,7 @@ class CircularBufferManager(FullAttentionManager):
1230 num_tokens: int,
1231 retention_interval: int | None = None,
1232 *,
1231 - replay_boundary: int,
1233 + replay_boundaries: Sequence[int],
1234 ) -> None:
1235 return
1236
@@ -1940,14 +1942,14 @@ class MambaManager(SingleTypeKVCacheManager):
1942 num_tokens: int,
1943 retention_interval: int | None = None,
1944 *,
1943 - replay_boundary: int,
1945 + replay_boundaries: Sequence[int],
1946 ) -> None:
1947 num_cached_blocks_before = self.num_cached_block.get(request.request_id, 0)
1948 super().cache_blocks(
1949 request,
1950 num_tokens,
1951 retention_interval=retention_interval,
1950 - replay_boundary=replay_boundary,
1952 + replay_boundaries=replay_boundaries,
1953 )
1954 num_cached_blocks_after = self.num_cached_block.get(request.request_id, 0)
1955 if self.mamba_cache_mode == "align":
@@ -2090,7 +2092,7 @@ class CrossAttentionManager(SingleTypeKVCacheManager):
2092 num_tokens: int,
2093 retention_interval: int | None = None,
2094 *,
2093 - replay_boundary: int,
2095 + replay_boundaries: Sequence[int],
2096 ) -> None:
2097 # We do not cache blocks for cross-attention to be shared between
2098 # requests, so this method is not relevant.