[Bugfix][Core] Fix mamba prefill checkpoint block reservation and prompt-end eviction in align mode (#59175)
Signed-off-by: kliuae <kuanfu.liu@embeddedllm.com> Co-authored-by: Nick Hill <nickhill123@gmail.com>
kliuae committed
Oct 1, 2026 at 05:26 UTC
7e583e615c20ee4ff0cd82aac592c7d6310aa7c3
5 files changed
+182
tests/v1/core/prefix_cache/test_partial_prefix_cache_hits.py
+147
@@ -13,6 +13,9 @@ import torch
13
14
from tests.v1.core.test_prefix_caching import make_kv_cache_manager, make_request
15
from vllm.distributed.kv_transfer.kv_connector.v1.base import SupportsHMA
16
+from vllm.model_executor.layers.mamba.checkpoint import (
17
+ compute_mamba_prefill_checkpoints,
18
+)
19
from vllm.utils.hashing import sha256
20
from vllm.v1.core.kv_cache_utils import (
21
KVCacheBlockCopy,
@@ -28,6 +31,7 @@ from vllm.v1.kv_cache_interface import (
31
KVCacheGroupSpec,
32
MambaSpec,
33
SlidingWindowSpec,
34
+ get_mamba_prefill_checkpoint_position,
35
)
36
37
@@ -686,6 +690,81 @@ def test_transient_checkpoint_evicts_retained_boundary_hash():
690
assert manager.block_pool.get_cached_block(checkpoint_hash, [1]) is None
691
692
693
+@pytest.mark.parametrize("prompt_len", [120, 104, 96])
694
+def test_prompt_end_checkpoint_survives_block_aligned_cache_count(prompt_len):
695
+ """At the default match unit the hybrid coordinator hands ``cache_blocks``
696
+ a block-aligned token count, which is below the prompt length whenever the
697
+ prompt is not block-aligned. The prompt-end checkpoint is the replay
698
+ boundary and must stay published under retention_interval=0; only a
699
+ mid-prompt checkpoint is transient.
700
+ """
701
+ hash_block_size = mamba_block_size = 32
702
+ manager = make_full_mamba_manager(
703
+ dcp_world_size=1,
704
+ hash_block_size=hash_block_size,
705
+ full_block_size=hash_block_size,
706
+ mamba_block_size=mamba_block_size,
707
+ num_prefill_checkpoint_blocks=1,
708
+ )
709
+ manager.coordinator.retention_interval = 0
710
+ assert not manager.coordinator.enable_partial_hash_hits
711
+
712
+ request = make_request("producer", list(range(prompt_len)), hash_block_size, sha256)
713
+ assert manager.allocate_slots(request, request.num_tokens) is not None
714
+ checkpoint_position = get_mamba_prefill_checkpoint_position(
715
+ prompt_len, hash_block_size, drop_eagle_block=False
716
+ )
717
+ checkpoint_hash = request.block_hashes[checkpoint_position // hash_block_size - 1]
718
+ hit = manager.block_pool.get_cached_block(checkpoint_hash, [1])
719
+ assert hit is not None
720
+ assert hit[0].block_hash_num_tokens == checkpoint_position
721
+
722
+ manager.free(request)
723
+ replay = make_request("replay", list(range(prompt_len)), hash_block_size, sha256)
724
+ _, num_computed, _ = manager.get_computed_blocks(replay)
725
+ assert num_computed == checkpoint_position
726
+
727
+
728
+@pytest.mark.parametrize("retention_interval,reserved", [(None, True), (0, False)])
729
+def test_intermediate_chunk_checkpoint_reserved_only_under_dense_retention(
730
+ retention_interval, reserved
731
+):
732
+ """A checkpoint inside a chunk that does not finish the prefill is only
733
+ publishable under dense retention. With retention_interval=0 it would be
734
+ evicted as transient, so the block is not reserved in the first place;
735
+ the prefill-end checkpoint is reserved either way.
736
+ """
737
+ hash_block_size = 16
738
+ manager = make_full_mamba_manager(
739
+ dcp_world_size=1,
740
+ hash_block_size=hash_block_size,
741
+ full_block_size=hash_block_size,
742
+ mamba_block_size=32,
743
+ num_prefill_checkpoint_blocks=1,
744
+ )
745
+ manager.coordinator.retention_interval = retention_interval
746
+ mamba_manager = manager.coordinator.single_type_managers[1]
747
+
748
+ request = make_request("producer", list(range(240)), hash_block_size, sha256)
749
+ computed_blocks, num_computed, _ = manager.get_computed_blocks(request)
750
+ free_before = manager.block_pool.get_num_free_blocks()
751
+
752
+ # Mid-prompt chunk: the chunk-keyed position (112) is inside it. Blocks:
753
+ # the attention blocks, the running mamba block, plus the checkpoint.
754
+ assert manager.allocate_slots(request, 128, num_computed, computed_blocks)
755
+ assert (request.request_id in mamba_manager._checkpoints) == reserved
756
+ allocated = free_before - manager.block_pool.get_num_free_blocks()
757
+ assert allocated == 128 // hash_block_size + 1 + int(reserved)
758
+
759
+ request.num_computed_tokens = 128
760
+ manager.new_step_starts()
761
+ # Final chunk: the prefill-end checkpoint at 224 is reserved either way.
762
+ assert manager.allocate_slots(request, 112) is not None
763
+ assert mamba_manager._checkpoints[request.request_id][0] == 224
764
+ end_hash = request.block_hashes[224 // hash_block_size - 1]
765
+ assert manager.block_pool.get_cached_block(end_hash, [1]) is not None
766
+
767
+
768
def test_eagle_block_aligned_checkpoint_replaces_newer_hash():
769
hash_block_size = mamba_block_size = 32
770
manager = make_full_mamba_manager(
@@ -2565,3 +2644,71 @@ def test_prompt_end_checkpoint_survives_sparse_retention_with_coarse_hash():
2644
follower = make_request("follower", list(range(240)) + [7] * 40, block_size, sha256)
2645
_, num_hit, _ = manager.get_computed_blocks(follower)
2646
assert num_hit == 224
2647
+
2648
+
2649
+@pytest.mark.parametrize(
2650
+ "hash_block_size,mamba_block_size,use_eagle",
2651
+ [(32, 32, False), (16, 32, False), (32, 32, True)],
2652
+)
2653
+@pytest.mark.parametrize("retention_interval", [0, None])
2654
+@pytest.mark.parametrize(
2655
+ "num_prompt_tokens,num_output_tokens",
2656
+ [(104, 0), (200, 0), (40, 24), (40, 25), (150, 43)],
2657
+)
2658
+@pytest.mark.parametrize("chunk_blocks", [2, 100])
2659
+def test_checkpoint_reservation_matches_worker(
2660
+ hash_block_size,
2661
+ mamba_block_size,
2662
+ use_eagle,
2663
+ retention_interval,
2664
+ num_prompt_tokens,
2665
+ num_output_tokens,
2666
+ chunk_blocks,
2667
+):
2668
+ """The worker exports a checkpoint wherever `compute_mamba_prefill_checkpoints`
2669
+ says, into whatever block sits at its column. Each chunk, the manager must
2670
+ either reserve exactly that position and column, or leave the column null so
2671
+ the export is masked; anything else publishes a hash over the wrong state.
2672
+ Under sparse retention, only the prefill-end chunk may reserve one.
2673
+ """
2674
+ manager = make_full_mamba_manager(
2675
+ dcp_world_size=1,
2676
+ hash_block_size=hash_block_size,
2677
+ full_block_size=hash_block_size,
2678
+ mamba_block_size=mamba_block_size,
2679
+ num_blocks=64,
2680
+ use_eagle=use_eagle,
2681
+ num_speculative_blocks=3 if use_eagle else 0,
2682
+ num_prefill_checkpoint_blocks=1,
2683
+ )
2684
+ manager.coordinator.retention_interval = retention_interval
2685
+ mamba_manager = manager.coordinator.single_type_managers[1]
2686
+
2687
+ request = make_request(
2688
+ "req", list(range(num_prompt_tokens)), hash_block_size, sha256
2689
+ )
2690
+ request.append_output_token_ids(list(range(1000, 1000 + num_output_tokens)))
2691
+ num_tokens = request.num_tokens
2692
+ prefill_end = max(num_prompt_tokens, num_tokens - 1)
2693
+
2694
+ start = 0
2695
+ while start < num_tokens:
2696
+ end = min(start + chunk_blocks * mamba_block_size, num_tokens)
2697
+ assert manager.allocate_slots(request, end - start) is not None
2698
+ offsets, cols = compute_mamba_prefill_checkpoints(
2699
+ [end],
2700
+ [end - start],
2701
+ hash_block_size=hash_block_size,
2702
+ mamba_block_size=mamba_block_size,
2703
+ checkpoint_alignment=16,
2704
+ drop_eagle_block=use_eagle,
2705
+ )
2706
+ reserved = mamba_manager._checkpoints.get(request.request_id)
2707
+ if reserved is not None:
2708
+ assert reserved == (start + offsets[0], cols[0])
2709
+ assert retention_interval is None or end >= prefill_end
2710
+ elif offsets[0]:
2711
+ assert mamba_manager.req_to_blocks[request.request_id][cols[0]].is_null
2712
+ request.num_computed_tokens = end
2713
+ manager.new_step_starts()
2714
+ start = end
vllm/v1/core/kv_cache_coordinator.py
+9
@@ -174,6 +174,7 @@ class KVCacheCoordinator(ABC):
174
num_local_computed_tokens: int,
175
num_tokens_main_model: int,
176
apply_admission_cap: bool = False,
177
+ prefill_end: int = 0,
178
) -> int:
179
"""Get the number of device blocks needed to be allocated for the request.
180
@@ -195,12 +196,19 @@ class KVCacheCoordinator(ABC):
196
per-request admission cap (SWA / chunked-local). Set only by
197
the full-sequence admission gate; per-step allocation must
198
leave it False so the predictor matches `allocate_new_blocks`.
199
+ prefill_end: The token index the request's prefill ends at, the
200
+ same value the scheduler splits chunks against. Under sparse
201
+ retention Mamba reserves a prefill checkpoint only on the chunk
202
+ reaching it; under dense retention every chunk publishes a
203
+ state, so it is ignored (0).
204
205
Returns:
206
The number of blocks to allocate.
207
208
"""
209
num_blocks_to_allocate = 0
210
+ if self.retention_interval != 0:
211
+ prefill_end = 0
212
for i, manager in enumerate(self.single_type_managers):
213
if isinstance(manager, CrossAttentionManager):
214
# For cross-attention, we issue a single static allocation
@@ -223,6 +231,7 @@ class KVCacheCoordinator(ABC):
231
num_local_computed_tokens,
232
num_tokens_main_model,
233
apply_admission_cap=apply_admission_cap,
234
+ prefill_end=prefill_end,
235
)
236
return num_blocks_to_allocate
237
vllm/v1/core/kv_cache_manager.py
+6
@@ -512,6 +512,10 @@ class KVCacheManager:
512
):
513
watermark_blocks = self.watermark_blocks
514
515
+ # Matches the scheduler's own prefill boundary: `num_tokens - 1`
516
+ # extends it to resumed requests replaying their output tokens.
517
+ prefill_end = max(request.num_prompt_tokens, request.num_tokens - 1)
518
+
519
if full_sequence_must_fit:
520
# First check and fail if the full request sequence won't fit.
521
full_num_tokens = min(request.num_tokens, self.max_model_len)
@@ -525,6 +529,7 @@ class KVCacheManager:
529
num_local_computed_tokens=num_local_computed_tokens,
530
num_tokens_main_model=full_num_tokens,
531
apply_admission_cap=True,
532
+ prefill_end=prefill_end,
533
)
534
required_blocks = num_blocks_to_allocate + watermark_blocks
535
if required_blocks > self.block_pool.get_num_free_blocks():
@@ -559,6 +564,7 @@ class KVCacheManager:
564
+ num_external_computed_tokens,
565
num_local_computed_tokens=num_local_computed_tokens,
566
num_tokens_main_model=num_tokens_main_model,
567
+ prefill_end=prefill_end,
568
)
569
570
# Keep `reserved_blocks` free for other in-flight sequences, and an
vllm/v1/core/sched/scheduler.py
+1
@@ -3003,6 +3003,7 @@ class Scheduler(SchedulerInterface):
3003
num_local_computed_tokens=request.num_computed_tokens,
3004
num_tokens_main_model=full_num_tokens,
3005
apply_admission_cap=True,
3006
+ prefill_end=max(request.num_prompt_tokens, request.num_tokens - 1),
3007
)
3008
return num_blocks + self._spec_decode_step_blocks()
3009
vllm/v1/core/single_type_kv_cache_manager.py
+19
@@ -184,6 +184,7 @@ class SingleTypeKVCacheManager(ABC):
184
num_local_computed_tokens: int,
185
num_tokens_main_model: int,
186
apply_admission_cap: bool = False,
187
+ prefill_end: int = 0,
188
) -> int:
189
"""Get the number of blocks needed to be allocated for the request.
190
@@ -203,6 +204,10 @@ class SingleTypeKVCacheManager(ABC):
204
apply_admission_cap: If True, clamp by `num_required_blocks` by
205
`_max_admission_blocks_per_request`for recycling-aware specs
206
(SWA, chunked-local).
207
+ prefill_end: The token index the request's prefill ends at, the
208
+ same value the scheduler splits chunks against. Mamba reserves
209
+ a prefill checkpoint only on the chunk reaching it; 0 (dense
210
+ retention) reserves one on every chunk.
211
212
Returns:
213
The number of blocks to allocate.
@@ -1215,6 +1220,7 @@ class CircularBufferManager(FullAttentionManager):
1220
num_local_computed_tokens: int,
1221
num_tokens_main_model: int,
1222
apply_admission_cap: bool = False,
1223
+ prefill_end: int = 0,
1224
) -> int:
1225
return 0 if self.req_to_blocks.get(request_id) else 1
1226
@@ -1678,12 +1684,14 @@ class MambaManager(SingleTypeKVCacheManager):
1684
query_start: int,
1685
query_end: int,
1686
checkpoint_position: int,
1687
+ prefill_end: int,
1688
) -> bool:
1689
assert isinstance(self.kv_cache_spec, MambaSpec)
1690
checkpoint_idx = cdiv(query_end, self.block_size) - 2
1691
blocks = self.req_to_blocks[request_id]
1692
return (
1693
self.has_prefill_checkpoint_blocks
1694
+ and query_end >= prefill_end
1695
and is_mamba_prefill_checkpoint_valid(
1696
query_start=query_start,
1697
query_end=query_end,
@@ -1712,6 +1720,7 @@ class MambaManager(SingleTypeKVCacheManager):
1720
num_local_computed_tokens: int,
1721
num_tokens_main_model: int,
1722
apply_admission_cap: bool = False,
1723
+ prefill_end: int = 0,
1724
) -> int:
1725
assert isinstance(self.kv_cache_spec, MambaSpec)
1726
if (
@@ -1738,6 +1747,7 @@ class MambaManager(SingleTypeKVCacheManager):
1747
num_local_computed_tokens,
1748
num_tokens_main_model,
1749
apply_admission_cap=apply_admission_cap,
1750
+ prefill_end=prefill_end,
1751
)
1752
else:
1753
# We don't allocate blocks for lookahead tokens in align mode, because if
@@ -1765,6 +1775,11 @@ class MambaManager(SingleTypeKVCacheManager):
1775
)
1776
if has_partial_hit:
1777
num_new_blocks = max(num_new_blocks, 0) + 1
1778
+ # Keyed on the chunk end like the worker's
1779
+ # `compute_mamba_prefill_checkpoints`, which writes the state there.
1780
+ # The helper returns a boundary strictly below its input, so every
1781
+ # chunk has one; under sparse retention only the prefill-end chunk's
1782
+ # is kept, so `prefill_end` skips reserving blocks for the others.
1783
checkpoint_position = get_mamba_prefill_checkpoint_position(
1784
num_tokens,
1785
self.block_pool.hash_block_size,
@@ -1775,6 +1790,7 @@ class MambaManager(SingleTypeKVCacheManager):
1790
total_computed_tokens,
1791
num_tokens,
1792
checkpoint_position,
1793
+ prefill_end,
1794
):
1795
checkpoint_position = 0
1796
checkpoint_block = int(checkpoint_position > 0)
@@ -2272,6 +2288,7 @@ class HiSparseSourceManager(FullAttentionManager):
2288
num_local_computed_tokens: int,
2289
num_tokens_main_model: int,
2290
apply_admission_cap: bool = False,
2291
+ prefill_end: int = 0,
2292
) -> int:
2293
required = super().get_num_blocks_to_allocate(
2294
request_id,
@@ -2407,6 +2424,7 @@ class HiSparseHotManager(_HiSparseAuxiliaryManager):
2424
num_local_computed_tokens: int,
2425
num_tokens_main_model: int,
2426
apply_admission_cap: bool = False,
2427
+ prefill_end: int = 0,
2428
) -> int:
2429
assert not new_computed_blocks
2430
# A hot region is needed to read host-backed history: an external
@@ -2479,6 +2497,7 @@ class HiSparseResidentManager(_HiSparseAuxiliaryManager):
2497
num_local_computed_tokens: int,
2498
num_tokens_main_model: int,
2499
apply_admission_cap: bool = False,
2500
+ prefill_end: int = 0,
2501
) -> int:
2502
del num_tokens_main_model
2503
assert not new_computed_blocks