[Core] Preserve Marconi caching with selective hybrid cache retention (#47782)

Signed-off-by: Nick Hill <nickhill123@gmail.com>

Nick Hill committed Jul 13, 2026 at 21:24 UTC 8ac8375270c1c86b0a8eaba7e738908a9a7303e1
9 files changed +502 -236
tests/v1/core/prefix_cache/test_partial_prefix_cache_hits.py
+16 -16
@@ -110,7 +110,7 @@ def test_hybrid_mamba_align_partial_hash_hit():
110 )
111
112 req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
113 - computed_blocks, num_computed = manager.get_computed_blocks(req0)
113 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req0)
114 assert num_computed == 0
115 blocks = manager.allocate_slots(req0, 6, num_computed, computed_blocks)
116 assert blocks is not None
@@ -125,7 +125,7 @@ def test_hybrid_mamba_align_partial_hash_hit():
125 assert partial_mamba_block[0].block_hash_num_tokens == 6
126
127 req1 = make_request("1", [0, 0, 1, 1, 2, 2, 3, 3], hash_block_size, sha256)
128 - computed_blocks, num_computed = manager.get_computed_blocks(req1)
128 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req1)
129 assert num_computed == 6
130 assert [len(group) for group in computed_blocks.blocks] == [3, 2]
131
@@ -185,7 +185,7 @@ def test_hybrid_mamba_partial_tail_owner_uses_cow_on_continue():
185 )
186
187 req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
188 - computed_blocks, num_computed = manager.get_computed_blocks(req0)
188 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req0)
189 assert num_computed == 0
190 assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
191
@@ -258,7 +258,7 @@ def test_hybrid_mamba_partial_tail_owner_continue_preserves_later_hit():
258 )
259
260 req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
261 - computed_blocks, num_computed = manager.get_computed_blocks(req0)
261 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req0)
262 assert num_computed == 0
263 assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
264
@@ -279,7 +279,7 @@ def test_hybrid_mamba_partial_tail_owner_continue_preserves_later_hit():
279 manager.new_step_starts()
280
281 req1 = make_request("1", [0, 0, 1, 1, 2, 2, 4, 4], hash_block_size, sha256)
282 - computed_blocks, num_computed = manager.get_computed_blocks(req1)
282 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req1)
283 assert num_computed == 6
284 # The later request hits the moved (private-copy) entry, not the source.
285 assert computed_blocks.get_block_ids()[1][1] == moved_block_id
@@ -338,7 +338,7 @@ def test_hybrid_mamba_moved_partial_entry_defers_same_step_hit():
338 )
339
340 req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
341 - computed_blocks, num_computed = manager.get_computed_blocks(req0)
341 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req0)
342 assert num_computed == 0
343 assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
344 manager.new_step_starts()
@@ -351,13 +351,13 @@ def test_hybrid_mamba_moved_partial_entry_defers_same_step_hit():
351
352 # A request hitting the moved entry in the SAME step must be deferred.
353 req1 = make_request("1", [0, 0, 1, 1, 2, 2, 4, 4], hash_block_size, sha256)
354 - computed_blocks, num_computed = manager.get_computed_blocks(req1)
354 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req1)
355 assert num_computed == 6
356 assert manager.allocate_slots(req1, 2, num_computed, computed_blocks) is None
357
358 # Next step the moved entry is consumable.
359 manager.new_step_starts()
360 - computed_blocks, num_computed = manager.get_computed_blocks(req1)
360 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req1)
361 assert num_computed == 6
362 assert manager.allocate_slots(req1, 2, num_computed, computed_blocks) is not None
363
@@ -397,7 +397,7 @@ def test_hybrid_full_attention_partial_hash_hit_uses_cow():
397 )
398
399 req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
400 - computed_blocks, num_computed = manager.get_computed_blocks(req0)
400 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req0)
401 assert num_computed == 0
402 assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
403 manager.free(req0)
@@ -410,7 +410,7 @@ def test_hybrid_full_attention_partial_hash_hit_uses_cow():
410 assert partial_full_block is not None
411
412 req1 = make_request("1", [0, 0, 1, 1, 2, 2, 3, 3], hash_block_size, sha256)
413 - computed_blocks, num_computed = manager.get_computed_blocks(req1)
413 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req1)
414 assert num_computed == 6
415 assert [len(group) for group in computed_blocks.blocks] == [2, 2]
416
@@ -471,7 +471,7 @@ def test_hybrid_partial_hit_cow_target_starts_uncached():
471 )
472
473 req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
474 - computed_blocks, num_computed = manager.get_computed_blocks(req0)
474 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req0)
475 assert num_computed == 0
476 assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
477 manager.free(req0)
@@ -488,7 +488,7 @@ def test_hybrid_partial_hit_cow_target_starts_uncached():
488 assert partial_mamba_block is not None
489
490 req1 = make_request("1", [0, 0, 1, 1, 2, 2, 3, 3], hash_block_size, sha256)
491 - computed_blocks, num_computed = manager.get_computed_blocks(req1)
491 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req1)
492 assert num_computed == 6
493
494 new_blocks = manager.allocate_slots(
@@ -586,7 +586,7 @@ def test_hybrid_partial_hash_truncates_full_attention_hit_length():
586 block_size=block_size,
587 )
588
589 - computed_blocks, num_computed = manager.get_computed_blocks(req)
589 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req)
590 assert num_computed == 6
591 assert [len(group) for group in computed_blocks.blocks] == [2, 2]
592
@@ -627,7 +627,7 @@ def test_cow_retained_blocks_returned_for_release():
627 hash_block_size=hash_block_size,
628 )
629 req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
630 - computed_blocks, num_computed = manager.get_computed_blocks(req0)
630 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req0)
631 assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
632
633 # The owner's move queues a copy and retains both endpoints.
@@ -797,7 +797,7 @@ def test_hybrid_partial_hit_with_eagle_stays_within_group_blocks():
797 # The owner prefills in scheduler-split style: stop at the block boundary
798 # (4), then at the prompt's last hash boundary (6, partial entries).
799 req0 = make_request("0", [7] * 6, hash_block_size, sha256)
800 - computed_blocks, num_computed = manager.get_computed_blocks(req0)
800 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req0)
801 assert manager.allocate_slots(req0, 4, num_computed, computed_blocks) is not None
802 req0.num_computed_tokens = 4
803 manager.new_step_starts()
@@ -808,7 +808,7 @@ def test_hybrid_partial_hit_with_eagle_stays_within_group_blocks():
808 # A longer request with eagle: full attention drops the partial tail, so
809 # the joint hit must fall back to the block boundary the FA blocks cover.
810 req1 = make_request("1", [7] * 6 + [9] * 2, hash_block_size, sha256)
811 - computed_blocks, num_computed = manager.get_computed_blocks(req1)
811 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req1)
812 assert num_computed == 4
813 assert all(
814 len(group) * block_size >= num_computed for group in computed_blocks.blocks
tests/v1/core/test_prefix_caching.py
+330 -86
@@ -244,7 +244,7 @@ def test_prefill(hash_fn):
244 unique_token_ids = [3] * 7
245 all_token_ids = common_token_ids + unique_token_ids
246 req0 = make_request("0", all_token_ids, block_size, hash_fn)
247 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req0)
247 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
248 assert len(req0.block_hashes) == 3
249 assert not computed_blocks.blocks[0]
250 assert num_computed_tokens == 0
@@ -274,7 +274,7 @@ def test_prefill(hash_fn):
274 # Incomplete 1 block (5 tokens)
275 unique_token_ids = [3] * 5
276 req1 = make_request("1", common_token_ids + unique_token_ids, block_size, hash_fn)
277 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req1)
277 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
278 assert len(req1.block_hashes) == 3
279 assert computed_blocks.get_block_ids() == ([1, 2, 3],)
280 assert num_computed_tokens == 3 * 16
@@ -307,7 +307,7 @@ def test_prefill(hash_fn):
307 # Incomplete 1 block (6 tokens)
308 unique_token_ids = [3] * 6
309 req2 = make_request("2", common_token_ids + unique_token_ids, block_size, hash_fn)
310 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req2)
310 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req2)
311 assert len(req2.block_hashes) == 3
312 assert computed_blocks.get_block_ids() == ([1, 2, 3],)
313 assert num_computed_tokens == 3 * 16
@@ -327,7 +327,7 @@ def test_prefill(hash_fn):
327
328 # Cache miss and eviction.
329 req3 = make_request("3", [99] * (16 * 10), block_size, hash_fn)
330 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req3)
330 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req3)
331 assert not computed_blocks.blocks[0]
332 assert num_computed_tokens == 0
333 blocks = manager.allocate_slots(
@@ -369,7 +369,7 @@ def test_prefill_hybrid_model():
369 unique_token_ids = [3] * 7
370 all_token_ids = common_token_ids + unique_token_ids
371 req0 = make_request("0", all_token_ids, block_size, hash_fn)
372 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req0)
372 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
373 assert len(req0.block_hashes) == 3
374 assert not computed_blocks.blocks[0]
375 assert num_computed_tokens == 0
@@ -405,7 +405,7 @@ def test_prefill_hybrid_model():
405 unique_token_ids = [3] * 5
406 all_token_ids = common_token_ids + unique_token_ids
407 req1 = make_request("1", common_token_ids + unique_token_ids, block_size, hash_fn)
408 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req1)
408 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
409 assert len(req1.block_hashes) == 3
410 assert computed_blocks.get_block_ids() == ([1, 2, 3], [0, 6, 7], [0, 10, 11])
411 assert num_computed_tokens == 3 * 16
@@ -539,7 +539,7 @@ def test_prefill_hybrid_model_eagle():
539 unique_token_ids = [6] * 7
540 all_token_ids = common_token_ids + unique_token_ids
541 req0 = make_request("0", all_token_ids, block_size, hash_fn)
542 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req0)
542 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
543 assert len(req0.block_hashes) == len(all_token_ids) // block_size
544 assert not computed_blocks.blocks[0]
545 assert num_computed_tokens == 0
@@ -576,7 +576,7 @@ def test_prefill_hybrid_model_eagle():
576 unique_token_ids = [6] * 5
577 all_token_ids = common_token_ids + unique_token_ids
578 req1 = make_request("1", all_token_ids, block_size, hash_fn)
579 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req1)
579 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
580 assert len(req1.block_hashes) == num_full_blocks
581 assert computed_blocks.get_block_ids() == (
582 [1, 2, 3, 4, 5],
@@ -712,7 +712,7 @@ def _test_partial_request_hit(
712 req = make_request(request_id, prompt_token_ids, block_size, sha256)
713 for hash_with_group_id in hash_to_evict:
714 manager.block_pool.cached_block_hash_to_block._cache.pop(hash_with_group_id)
715 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req)
715 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req)
716 assert len(req.block_hashes) == num_full_blocks
717 assert num_computed_tokens == expect_hit_length * block_size
718 for block_per_group in computed_blocks.blocks:
@@ -873,7 +873,7 @@ def test_prefill_hybrid_model_combinations(spec_types: list[str]):
873
874 # First request: no cache hit initially
875 req0 = make_request("0", all_token_ids, block_size, hash_fn)
876 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req0)
876 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
877
878 assert len(req0.block_hashes) == 3
879 assert not computed_blocks.blocks[0] # No cache hit initially
@@ -890,7 +890,7 @@ def test_prefill_hybrid_model_combinations(spec_types: list[str]):
890
891 # Second request: should hit cached blocks for common prefix
892 req1 = make_request("1", common_token_ids + [4] * 5, block_size, hash_fn)
893 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req1)
893 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
894
895 # Should hit cached blocks for all groups
896 assert num_computed_tokens == 3 * block_size
@@ -950,7 +950,7 @@ def test_prefill_hybrid_model_combinations_eagle(
950
951 # First request: no cache hit initially
952 req0 = make_request("0", all_token_ids, block_size, hash_fn)
953 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req0)
953 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
954
955 assert len(req0.block_hashes) == num_full_blocks
956 assert not computed_blocks.blocks[0] # No cache hit initially
@@ -966,7 +966,7 @@ def test_prefill_hybrid_model_combinations_eagle(
966 # Second request: should hit cached blocks for common prefix
967 all_token_ids = common_token_ids + [6] * 5
968 req1 = make_request("1", all_token_ids, block_size, hash_fn)
969 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req1)
969 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
970
971 # Should hit cached blocks for all groups
972 assert num_computed_tokens == expect_hit_length * block_size
@@ -1019,7 +1019,7 @@ def test_prefill_hybrid_model_mamba_align():
1019 # First request: allocate_slots should not crash with the assertion error
1020 # in MambaManager.cache_blocks() when null blocks are present.
1021 req0 = make_request("0", all_token_ids, block_size, hash_fn)
1022 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req0)
1022 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
1023 assert num_computed_tokens == 0
1024
1025 blocks = manager.allocate_slots(req0, 55, num_computed_tokens, computed_blocks)
@@ -1048,28 +1048,33 @@ def test_hybrid_cache_mamba_align_shared_prefix_detection():
1048 # Request: 3 blocks
1049 prefix = [i for i in range(3) for _ in range(block_size)]
1050 req_0 = make_request("0", prefix, block_size, hash_fn)
1051 - computed_blocks, num_computed = manager.get_computed_blocks(req_0)
1052 - num_uncached_common = manager.coordinator.num_uncached_common_prefix_tokens
1051 + computed_blocks, num_computed, shared_prefix_boundary = manager.get_computed_blocks(
1052 + req_0
1053 + )
1054 assert num_computed == 0 # nothing cached yet
1054 - assert num_uncached_common == 0
1055 + assert shared_prefix_boundary == 0
1056 manager.allocate_slots(req_0, 3 * block_size, 0, computed_blocks)
1057
1058 # Request: 3 blocks (shared with above) + 7 different tokens
1059 req_1 = make_request("1", prefix + [100] * 7, block_size, hash_fn)
1059 - computed_blocks, num_computed = manager.get_computed_blocks(req_1)
1060 - num_uncached_common = manager.coordinator.num_uncached_common_prefix_tokens
1060 + computed_blocks, num_computed, shared_prefix_boundary = manager.get_computed_blocks(
1061 + req_1
1062 + )
1063 assert num_computed == 3 * block_size # we should observe a 3-block cache hit
1062 - assert num_uncached_common == 0
1064 + assert shared_prefix_boundary == 0
1065 manager.allocate_slots(req_1, 7, 3 * block_size, computed_blocks)
1066
1067 # Request: 3 blocks, but only 2 blocks shared (replace the last token in 3rd block):
1068 req_2 = make_request("2", prefix[:-1] + [101], block_size, hash_fn)
1067 - computed_blocks, num_computed = manager.get_computed_blocks(req_2)
1068 - num_uncached_common = manager.coordinator.num_uncached_common_prefix_tokens
1069 + computed_blocks, num_computed, shared_prefix_boundary = manager.get_computed_blocks(
1070 + req_2
1071 + )
1072 assert num_computed == 0 # mamba_align doesn't cache intermediate blocks
1070 - assert num_uncached_common == 2 * block_size # heuristic detects a shared prefix
1073 + # boundary == junction: 2 shared blocks (num_computed 0 + 2 uncached blocks).
1074 + assert shared_prefix_boundary == 2 * block_size
1075
1072 - # Next, validate scheduler logic for num_uncached_common_prefix_tokens > 0
1076 + # Next, validate the align-mode Marconi split. It takes the shared-prefix
1077 + # junction (absolute) and stops the chunk there (req_2 has num_computed 0).
1078 # Create minimal mock with just the needed attributes
1079 mock = SimpleNamespace(
1080 cache_config=SimpleNamespace(block_size=block_size),
@@ -1077,11 +1082,11 @@ def test_hybrid_cache_mamba_align_shared_prefix_detection():
1082 hash_block_size=block_size,
1083 mamba_partial_cache_hit=False,
1084 )
1085 + req_2.shared_prefix_boundary = shared_prefix_boundary
1086 num_new_tokens_adjusted = Scheduler._mamba_block_aligned_split(
1087 self=mock,
1088 request=req_2,
1089 num_new_tokens=3 * block_size,
1084 - num_uncached_common_prefix_tokens=num_uncached_common,
1090 )
1091 assert num_new_tokens_adjusted == 2 * block_size # adjust to the common prefix
1092
@@ -1118,7 +1123,7 @@ def test_hybrid_model_mamba_align_with_dynamic_draft_tokens():
1123
1124 all_token_ids = [i for i in range(3) for _ in range(block_size)] + [3] * 7
1125 req0 = make_request("0", all_token_ids, block_size, hash_fn)
1121 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req0)
1126 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
1127 assert num_computed_tokens == 0
1128 blocks = manager.allocate_slots(
1129 req0, len(all_token_ids), num_computed_tokens, computed_blocks
@@ -1170,7 +1175,7 @@ def test_prefill_plp():
1175 unique_token_ids = [3] * 7
1176 all_token_ids = common_token_ids + unique_token_ids
1177 req0 = make_request("0", all_token_ids, block_size, hash_fn, prompt_logprobs=5)
1173 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req0)
1178 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
1179 assert len(req0.block_hashes) == 3
1180 assert not computed_blocks.blocks[0]
1181 assert num_computed_tokens == 0
@@ -1202,7 +1207,7 @@ def test_prefill_plp():
1207 # Incomplete 1 block (5 tokens)
1208 unique_token_ids = [3] * 5
1209 req1 = make_request("1", common_token_ids + unique_token_ids, block_size, hash_fn)
1205 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req1)
1210 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
1211 assert len(req1.block_hashes) == 3
1212 assert computed_blocks.get_block_ids() == ([1, 2, 3],)
1213 assert num_computed_tokens == 3 * 16
@@ -1236,7 +1241,7 @@ def test_prefill_plp():
1241 req2 = make_request(
1242 "2", common_token_ids + unique_token_ids, block_size, hash_fn, prompt_logprobs=5
1243 )
1239 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req2)
1244 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req2)
1245 assert len(req2.block_hashes) == 3
1246 assert not computed_blocks.blocks[0]
1247 assert num_computed_tokens == 0
@@ -1273,7 +1278,7 @@ def test_decode():
1278 # Incomplete 1 block (7 tokens)
1279 unique_token_ids = [3] * 7
1280 req0 = make_request("0", common_token_ids + unique_token_ids, block_size, sha256)
1276 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req0)
1281 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
1282 assert not computed_blocks.blocks[0]
1283 assert num_computed_tokens == 0
1284 blocks = manager.allocate_slots(
@@ -1331,7 +1336,7 @@ def test_evict():
1336
1337 last_token_id = 5 * 16 + 7
1338 req0 = make_request("0", list(range(last_token_id)), block_size, sha256)
1334 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req0)
1339 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
1340 assert not computed_blocks.blocks[0]
1341 assert num_computed_tokens == 0
1342 blocks = manager.allocate_slots(
@@ -1344,7 +1349,7 @@ def test_evict():
1349 req1 = make_request(
1350 "1", list(range(last_token_id, last_token_id + 3 * 16)), block_size, sha256
1351 )
1347 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req1)
1352 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
1353 assert not computed_blocks.blocks[0]
1354 assert num_computed_tokens == 0
1355 blocks = manager.allocate_slots(
@@ -1369,7 +1374,7 @@ def test_evict():
1374
1375 # Touch the first 2 blocks.
1376 req2 = make_request("2", list(range(2 * 16 + 3)), block_size, sha256)
1372 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req2)
1377 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req2)
1378 assert computed_blocks.get_block_ids() == ([1, 2],)
1379 assert num_computed_tokens == 2 * 16
1380 blocks = manager.allocate_slots(
@@ -1395,7 +1400,7 @@ def test_hash_block_correct_reuse():
1400 # Allocate 1 block and cache it.
1401 num_tokens = block_size * 1
1402 req = make_request("0", list(range(num_tokens)), block_size, sha256)
1398 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req)
1403 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req)
1404 assert not computed_blocks.blocks[0]
1405 assert num_computed_tokens == 0
1406 blocks = manager.allocate_slots(
@@ -1409,7 +1414,7 @@ def test_hash_block_correct_reuse():
1414 # Allocate a new block that's not full, make sure hash info on the
1415 # block is cleared.
1416 req = make_request("1", list(range(num_tokens - 1)), block_size, sha256)
1412 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req)
1417 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req)
1418 assert not computed_blocks.blocks[0]
1419 assert num_computed_tokens == 0
1420 blocks = manager.allocate_slots(
@@ -1436,7 +1441,7 @@ def test_computed_blocks_not_evicted():
1441 # Allocate a block and cache it.
1442 num_tokens = block_size * 1
1443 req0 = make_request("0", list(range(num_tokens)), block_size, sha256)
1439 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req0)
1444 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
1445 assert not computed_blocks.blocks[0]
1446 assert num_computed_tokens == 0
1447 blocks = manager.allocate_slots(
@@ -1449,7 +1454,7 @@ def test_computed_blocks_not_evicted():
1454 req1 = make_request(
1455 "1", list(range(num_tokens, num_tokens * 2)), block_size, sha256
1456 )
1452 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req1)
1457 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
1458 assert not computed_blocks.blocks[0]
1459 assert num_computed_tokens == 0
1460 blocks = manager.allocate_slots(
@@ -1465,7 +1470,7 @@ def test_computed_blocks_not_evicted():
1470 # Now if we have a cache hit on the first block, we should evict the second
1471 # cached block rather than the first one.
1472 req2 = make_request("2", list(range(num_tokens * 2)), block_size, sha256)
1468 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req2)
1473 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req2)
1474 assert len(computed_blocks.blocks[0]) == 1
1475 assert computed_blocks.blocks[0][0].block_id == 1
1476 assert num_computed_tokens == block_size
@@ -1496,7 +1501,7 @@ def test_basic_prefix_caching_disabled():
1501 "1", list(range(10)), block_size, sha256
1502 ) # 2 blocks and some more
1503
1499 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req1)
1504 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
1505 assert not computed_blocks.blocks[0]
1506 assert num_computed_tokens == 0
1507 blocks = manager.allocate_slots(
@@ -1509,7 +1514,7 @@ def test_basic_prefix_caching_disabled():
1514
1515 # No caching.
1516 req2 = make_request("2", list(range(16)), block_size, sha256) # shared prefix
1512 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req2)
1517 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req2)
1518 assert not computed_blocks.blocks[0]
1519 assert num_computed_tokens == 0
1520 blocks = manager.allocate_slots(
@@ -1519,7 +1524,7 @@ def test_basic_prefix_caching_disabled():
1524
1525 # New requests should not have any blocks.
1526 req3 = make_request("3", list(range(4)), block_size, sha256)
1522 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req3)
1527 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req3)
1528 assert not computed_blocks.blocks[0]
1529 assert num_computed_tokens == 0
1530 blocks = manager.allocate_slots(
@@ -1701,7 +1706,7 @@ def test_mm_prefix_caching():
1706 mm_positions=mm_positions,
1707 mm_hashes=mm_hashes,
1708 )
1704 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req0)
1709 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
1710
1711 # Completed block should have hashes
1712 assert not computed_blocks.blocks[0]
@@ -1766,7 +1771,7 @@ def test_mm_prefix_caching():
1771 mm_positions=mm_positions,
1772 mm_hashes=mm_hashes,
1773 )
1769 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req1)
1774 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
1775 assert len(computed_blocks.blocks[0]) == 3
1776 assert num_computed_tokens == 3 * 16
1777
@@ -1788,7 +1793,7 @@ def test_cache_key_salting():
1793 common_token_ids = [i for i in range(3) for _ in range(block_size)]
1794 token_ids = common_token_ids + [3] * 11
1795 req0 = make_request("0", token_ids, block_size, sha256, cache_salt="salt1")
1791 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req0)
1796 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
1797
1798 # Completed block should have hashes
1799 assert not computed_blocks.blocks[0]
@@ -1827,7 +1832,7 @@ def test_cache_key_salting():
1832 # Test cache hit with a new request that has the same salt.
1833 token_ids = common_token_ids + [4] * 11
1834 req1 = make_request("1", token_ids, block_size, sha256, cache_salt="salt1")
1830 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req1)
1835 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
1836 # Should match only a prefix of 3 blocks.
1837 assert len(computed_blocks.blocks[0]) == 3
1838 assert num_computed_tokens == 3 * block_size
@@ -1835,7 +1840,7 @@ def test_cache_key_salting():
1840 # Test cache miss with same content but different salt.
1841 token_ids = common_token_ids + [4] * 11
1842 req2 = make_request("2", token_ids, block_size, sha256, cache_salt="salt2")
1838 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req2)
1843 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req2)
1844 assert len(computed_blocks.blocks[0]) == 0
1845 assert num_computed_tokens == 0
1846 block_hashes = req2.block_hashes
@@ -1869,7 +1874,7 @@ def test_prefill_not_enough_free_blocks_with_computed_blocks():
1874 # | Common-0 | Common-1 | Common-2 | ... |
1875 common_token_ids = [i for i in range(3) for _ in range(16)]
1876 req0 = make_request("0", common_token_ids, block_size, sha256)
1872 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req0)
1877 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
1878 assert not computed_blocks.blocks[0]
1879 assert num_computed_tokens == 0
1880 manager.allocate_slots(
@@ -1881,7 +1886,7 @@ def test_prefill_not_enough_free_blocks_with_computed_blocks():
1886
1887 # | Common-0 | Common-1 | Common-2 | Req1-3 | Req1-4 | Req1-5 | ... |
1888 req1 = make_request("1", common_token_ids * 2, block_size, sha256)
1884 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req1)
1889 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
1890 assert computed_blocks.blocks[0] == block_part0
1891 assert num_computed_tokens == 3 * 16
1892 manager.allocate_slots(
@@ -1899,7 +1904,7 @@ def test_prefill_not_enough_free_blocks_with_computed_blocks():
1904 # | Common-0 | Common-1 | Common-2 | Req1-3 (F) | Req1-4 (F) |
1905 # | Req1-5(F)| Req2-0 | Req2-1 | ... |
1906 req2 = make_request("2", [7] * block_size * 2, block_size, sha256)
1902 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req2)
1907 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req2)
1908 assert not computed_blocks.blocks[0]
1909 assert num_computed_tokens == 0
1910 manager.allocate_slots(
@@ -1914,7 +1919,7 @@ def test_prefill_not_enough_free_blocks_with_computed_blocks():
1919 # In this case, the ref_cnt of the computed blocks should not be changed.
1920 assert manager.block_pool.free_block_queue.num_free_blocks == 5
1921 req3 = make_request("3", common_token_ids * 3, block_size, sha256)
1917 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req3)
1922 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req3)
1923 assert computed_blocks.blocks[0] == block_part1
1924 assert num_computed_tokens == 6 * 16
1925 # Req3 cannot be allocated.
@@ -1949,7 +1954,7 @@ def test_reset_prefix_cache():
1954 unique_token_ids = [4] * 7
1955 all_token_ids = full_block_token_ids + unique_token_ids
1956 req1 = make_request("1", all_token_ids, block_size, sha256)
1952 - computed_blocks, _ = manager.get_computed_blocks(req1)
1957 + computed_blocks, _, _ = manager.get_computed_blocks(req1)
1958 assert len(req1.block_hashes) == 3
1959 assert len(computed_blocks.blocks[0]) == 3
1960 blocks = manager.allocate_slots(
@@ -1984,7 +1989,7 @@ def test_prefix_cache_stats_disabled():
1989
1990 # Call all functions that check whether log_stats is disabled.
1991 req = make_request("0", list(range(16)), block_size, sha256)
1987 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req)
1992 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req)
1993 assert not computed_blocks.blocks[0]
1994 assert num_computed_tokens == 0
1995 manager.allocate_slots(
@@ -2591,7 +2596,7 @@ def test_eagle_enabled_removes_last_block():
2596 req = make_request("divisible_request", token_ids, block_size, sha256)
2597
2598 # Prime the cache
2594 - computed_blocks, _ = manager.get_computed_blocks(req)
2599 + computed_blocks, _, _ = manager.get_computed_blocks(req)
2600 manager.allocate_slots(
2601 req, len(token_ids), len(computed_blocks.blocks[0]) * 16, computed_blocks
2602 )
@@ -2599,7 +2604,7 @@ def test_eagle_enabled_removes_last_block():
2604
2605 # New request with same tokens + Eagle enabled
2606 req_eagle = make_request("eagle_divisible", token_ids, block_size, sha256)
2602 - computed_blocks, num_tokens = manager.get_computed_blocks(req_eagle)
2607 + computed_blocks, num_tokens, _ = manager.get_computed_blocks(req_eagle)
2608
2609 # Should retain 1 block:
2610 # 1. Original 3 blocks → pop last hash → 2 matched blocks
@@ -2623,7 +2628,7 @@ def test_eagle_with_partial_blocks():
2628 req = make_request("partial_block_test", token_ids, block_size, sha256)
2629
2630 # Prime the cache
2626 - computed_blocks, _ = manager.get_computed_blocks(req)
2631 + computed_blocks, _, _ = manager.get_computed_blocks(req)
2632 manager.allocate_slots(
2633 req, len(token_ids), len(computed_blocks.blocks[0]) * 16, computed_blocks
2634 )
@@ -2631,7 +2636,7 @@ def test_eagle_with_partial_blocks():
2636
2637 # New request with Eagle enabled
2638 req_eagle = make_request("partial_eagle", token_ids, block_size, sha256)
2634 - computed_blocks, num_tokens = manager.get_computed_blocks(req_eagle)
2639 + computed_blocks, num_tokens, _ = manager.get_computed_blocks(req_eagle)
2640 # Original match: 2 full blocks → Eagle removes 1 → 1 remaining
2641 assert len(computed_blocks.blocks[0]) == 1
2642 assert num_tokens == 1 * block_size
@@ -2664,7 +2669,7 @@ def test_eagle_with_sliding_window():
2669 req = make_request("partial_block_test", token_ids, block_size, sha256)
2670
2671 # Prime the cache
2667 - computed_blocks, _ = manager.get_computed_blocks(req)
2672 + computed_blocks, _, _ = manager.get_computed_blocks(req)
2673 manager.allocate_slots(
2674 req, len(token_ids), len(computed_blocks.blocks[0]) * 16, computed_blocks
2675 )
@@ -2675,7 +2680,7 @@ def test_eagle_with_sliding_window():
2680
2681 # New request with Eagle enabled
2682 req_eagle = make_request("partial_eagle", token_ids, block_size, sha256)
2678 - computed_blocks, num_tokens = manager.get_computed_blocks(req_eagle)
2683 + computed_blocks, num_tokens, _ = manager.get_computed_blocks(req_eagle)
2684 # Original match: 2 full blocks → Eagle removes 1 → 1 remaining
2685 assert len(computed_blocks.blocks[0]) == 1
2686 assert num_tokens == 1 * block_size
@@ -2695,7 +2700,7 @@ def test_eagle_with_sliding_window():
2700 req_after_evict = make_request(
2701 "partial_eagle_after_evict", token_ids, block_size, sha256
2702 )
2698 - computed_blocks, num_tokens = manager.get_computed_blocks(req_after_evict)
2703 + computed_blocks, num_tokens, _ = manager.get_computed_blocks(req_after_evict)
2704 # Cache miss. The only hit prefix is [NULL_BLOCK, BLOCK_2] if eagle is
2705 # not considered. But after dropping the last matched block due to eagle,
2706 # there will be no matched prefix.
@@ -2754,7 +2759,7 @@ def test_eagle_swa_alignment_caches_extra_block():
2759 # Prime the cache with a long prompt (16 swa blocks = 4 aligned segments).
2760 token_ids = [i for i in range(16) for _ in range(block_size)]
2761 req0 = make_request("0", token_ids, block_size, sha256)
2757 - computed_blocks, _ = manager.get_computed_blocks(req0)
2762 + computed_blocks, _, _ = manager.get_computed_blocks(req0)
2763 blocks = manager.allocate_slots(
2764 req0,
2765 len(token_ids),
@@ -2768,7 +2773,7 @@ def test_eagle_swa_alignment_caches_extra_block():
2773 # Without the fix, ``num_computed_tokens`` is 0; with the fix, it lands at
2774 # an alignment boundary (multiple of 32 tokens, minus the EAGLE drop).
2775 req1 = make_request("1", token_ids, block_size, sha256)
2771 - _, num_computed_tokens = manager.get_computed_blocks(req1)
2776 + _, num_computed_tokens, _ = manager.get_computed_blocks(req1)
2777 assert num_computed_tokens > 0, (
2778 "EAGLE + SWA with sliding_window <= alignment failed to find any "
2779 "cache hit; the +1 block past each segment boundary must be cached."
@@ -2823,7 +2828,7 @@ def test_eagle_swa_boundary_caches_post_boundary_block():
2828
2829 token_ids = [i for i in range(5) for _ in range(block_size)]
2830 req0 = make_request("0", token_ids, block_size, sha256)
2826 - computed_blocks, _ = manager.get_computed_blocks(req0)
2831 + computed_blocks, _, _ = manager.get_computed_blocks(req0)
2832 blocks = manager.allocate_slots(
2833 req0,
2834 len(token_ids),
@@ -2838,7 +2843,7 @@ def test_eagle_swa_boundary_caches_post_boundary_block():
2843 manager.free(req0)
2844
2845 req1 = make_request("1", token_ids + [999], block_size, sha256)
2841 - _, num_computed_tokens = manager.get_computed_blocks(req1)
2846 + _, num_computed_tokens, _ = manager.get_computed_blocks(req1)
2847 assert num_computed_tokens == 4 * block_size
2848
2849
@@ -2879,7 +2884,7 @@ def test_eagle_grouped_swa_siblings_use_same_cache_mask():
2884
2885 token_ids = [i for i in range(9) for _ in range(block_size)]
2886 req0 = make_request("0", token_ids, block_size, sha256)
2882 - computed_blocks, _ = manager.get_computed_blocks(req0)
2887 + computed_blocks, _, _ = manager.get_computed_blocks(req0)
2888 blocks = manager.allocate_slots(
2889 req0,
2890 len(token_ids),
@@ -2894,7 +2899,7 @@ def test_eagle_grouped_swa_siblings_use_same_cache_mask():
2899 manager.free(req0)
2900
2901 req1 = make_request("1", token_ids + [999], block_size, sha256)
2897 - _, num_computed_tokens = manager.get_computed_blocks(req1)
2902 + _, num_computed_tokens, _ = manager.get_computed_blocks(req1)
2903 assert num_computed_tokens == 8 * block_size
2904
2905
@@ -2938,7 +2943,7 @@ def test_different_block_size():
2943 common_token_ids = [i for i in range(10) for _ in range(block_size)]
2944
2945 req0 = make_request("0", common_token_ids, block_size, sha256)
2941 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req0)
2946 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
2947 assert not computed_blocks.blocks[0]
2948 assert not computed_blocks.blocks[1]
2949 assert num_computed_tokens == 0
@@ -2947,13 +2952,13 @@ def test_different_block_size():
2952 )
2953 assert blocks.get_block_ids() == ([1, 2, 3, 4], [5, 6, 7, 8, 9, 10, 11])
2954 req1 = make_request("1", common_token_ids[: 7 * block_size + 1], block_size, sha256)
2950 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req1)
2955 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
2956 assert len(computed_blocks.blocks[0]) == 3
2957 assert len(computed_blocks.blocks[1]) == 6
2958 assert num_computed_tokens == 6 * 16
2959
2960 req2 = make_request("2", common_token_ids[: 6 * block_size + 1], block_size, sha256)
2956 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req2)
2961 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req2)
2962 assert len(computed_blocks.blocks[0]) == 3
2963 assert len(computed_blocks.blocks[1]) == 6
2964 assert num_computed_tokens == 6 * 16
@@ -2967,7 +2972,7 @@ def test_different_block_size():
2972 manager.block_pool.cached_block_hash_to_block.pop(
2973 make_block_hash_with_group_id(req1.block_hashes[5], 1), 10
2974 )
2970 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req1)
2975 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
2976 assert len(computed_blocks.blocks[0]) == 2
2977 assert len(computed_blocks.blocks[1]) == 4
2978 assert num_computed_tokens == 4 * 16
@@ -3020,7 +3025,7 @@ def test_hybrid_cache_blocks_swa_tail_window_only():
3025 # 8 hash-blocks of 8 tokens (64 tokens, two lcm-aligned segments).
3026 token_ids = [i for i in range(8) for _ in range(block_size)]
3027 req = make_request("0", token_ids, block_size, sha256)
3023 - computed_blocks, _ = manager.get_computed_blocks(req)
3028 + computed_blocks, _, _ = manager.get_computed_blocks(req)
3029 blocks = manager.allocate_slots(
3030 req,
3031 8 * block_size,
@@ -3089,7 +3094,7 @@ def test_hybrid_cache_blocks_clamped_to_lcm():
3094 # to 96 tokens — SWA caches 6 hashes, full-attn caches 3.
3095 token_ids = [i for i in range(7) for _ in range(block_size)]
3096 req = make_request("0", token_ids, block_size, sha256)
3092 - computed_blocks, _ = manager.get_computed_blocks(req)
3097 + computed_blocks, _, _ = manager.get_computed_blocks(req)
3098 blocks = manager.allocate_slots(
3099 req,
3100 7 * block_size,
@@ -3154,7 +3159,7 @@ def test_hybrid_local_kv_retention_interval_aligns_in_manager(monkeypatch):
3159 # 96-token replay boundary, and the 128-token interval boundary.
3160 token_ids = [i for i in range(16) for _ in range(block_size)]
3161 req = make_request("0", token_ids, block_size, sha256)
3157 - computed_blocks, _ = manager.get_computed_blocks(req)
3162 + computed_blocks, _, _ = manager.get_computed_blocks(req)
3163 blocks = manager.allocate_slots(
3164 req,
3165 len(token_ids),
@@ -3296,13 +3301,13 @@ def test_hybrid_local_kv_retention_interval_survives_recycling(monkeypatch):
3301
3302 token_ids = fill_request("fill_0", 0)
3303 replay_req = make_request("replay", token_ids[:1800], hash_block_size, sha256)
3299 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(replay_req)
3304 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(replay_req)
3305 assert num_computed_tokens == 1024
3306 assert [len(blocks) for blocks in computed_blocks.blocks] == [4, 16, 128, 256]
3307
3308 fill_request("fill_1", 100_000)
3309 replay_req = make_request("replay_again", token_ids[:1800], hash_block_size, sha256)
3305 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(replay_req)
3310 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(replay_req)
3311 assert num_computed_tokens == 1024
3312 assert [len(blocks) for blocks in computed_blocks.blocks] == [4, 16, 128, 256]
3313
@@ -3345,7 +3350,7 @@ def test_hybrid_local_kv_retention_latest_only_reuses_replay_boundary(monkeypatc
3350
3351 token_ids = [i for i in range(16) for _ in range(block_size)]
3352 req0 = make_request("0", token_ids, block_size, sha256)
3348 - computed_blocks, _ = manager.get_computed_blocks(req0)
3353 + computed_blocks, _, _ = manager.get_computed_blocks(req0)
3354 blocks = manager.allocate_slots(
3355 req0,
3356 len(token_ids),
@@ -3369,14 +3374,14 @@ def test_hybrid_local_kv_retention_latest_only_reuses_replay_boundary(monkeypatc
3374 assert retained_swa_block[0].ref_cnt == 0
3375
3376 req1 = make_request("1", token_ids, block_size, sha256)
3372 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req1)
3377 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
3378 # Full prompt hits intentionally recompute the final block for logits, so
3379 # the longest usable hit is the previous LCM boundary: 96 tokens.
3380 assert num_computed_tokens == 12 * block_size
3381 assert len(computed_blocks.blocks[1]) == 12
3382
3383 shorter_req = make_request("2", token_ids[: 12 * block_size], block_size, sha256)
3379 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(shorter_req)
3384 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(shorter_req)
3385 assert num_computed_tokens == 0
3386 assert len(computed_blocks.blocks[1]) == 0
3387
@@ -3429,7 +3434,7 @@ def test_hybrid_local_kv_retention_mtp_reuses_latest_boundary(monkeypatch):
3434 # 104 tokens, and that tail is two 8-token blocks wide: hashes 11 and 12.
3435 token_ids = [i for i in range(15) for _ in range(block_size)] + [15] * 7
3436 req0 = make_request("0", token_ids, block_size, sha256)
3432 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req0)
3437 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req0)
3438 assert num_computed_tokens == 0
3439 blocks = manager.allocate_slots(
3440 req0,
@@ -3451,7 +3456,7 @@ def test_hybrid_local_kv_retention_mtp_reuses_latest_boundary(monkeypatch):
3456 manager.free(req0)
3457
3458 req1 = make_request("1", token_ids, block_size, sha256)
3454 - computed_blocks, num_computed_tokens = manager.get_computed_blocks(req1)
3459 + computed_blocks, num_computed_tokens, _ = manager.get_computed_blocks(req1)
3460 assert num_computed_tokens == 12 * block_size
3461 assert [len(blocks) for blocks in computed_blocks.blocks] == [3, 12]
3462
@@ -3858,7 +3863,7 @@ def test_swa_free_split_keeps_cached_tail_ahead_of_scratch(monkeypatch):
3863
3864 token_ids = [i for i in range(16) for _ in range(block_size)]
3865 req = make_request("0", token_ids, block_size, sha256)
3861 - computed_blocks, _ = manager.get_computed_blocks(req)
3866 + computed_blocks, _, _ = manager.get_computed_blocks(req)
3867 blocks = manager.allocate_slots(
3868 req,
3869 len(token_ids),
@@ -3941,7 +3946,7 @@ def test_pure_swa_retention_interval_caches_sparse_tails(monkeypatch):
3946
3947 token_ids = [i for i in range(16) for _ in range(block_size)]
3948 req = make_request("0", token_ids, block_size, sha256)
3944 - computed_blocks, _ = manager.get_computed_blocks(req)
3949 + computed_blocks, _, _ = manager.get_computed_blocks(req)
3950 blocks = manager.allocate_slots(
3951 req,
3952 len(token_ids),
@@ -3965,7 +3970,7 @@ def test_pure_swa_retention_interval_caches_sparse_tails(monkeypatch):
3970
3971 # A replay of the same prompt hits the latest replayable boundary (240).
3972 replay = make_request("1", token_ids, block_size, sha256)
3968 - _, num_computed = manager.get_computed_blocks(replay)
3973 + _, num_computed, _ = manager.get_computed_blocks(replay)
3974 assert num_computed == 240
3975
3976
@@ -3977,7 +3982,7 @@ def test_pure_swa_retention_latest_only(monkeypatch):
3982
3983 token_ids = [i for i in range(16) for _ in range(block_size)]
3984 req = make_request("0", token_ids, block_size, sha256)
3980 - computed_blocks, _ = manager.get_computed_blocks(req)
3985 + computed_blocks, _, _ = manager.get_computed_blocks(req)
3986 blocks = manager.allocate_slots(
3987 req,
3988 len(token_ids),
@@ -3997,7 +4002,7 @@ def test_pure_swa_retention_latest_only(monkeypatch):
4002 assert cached == {14}
4003
4004 replay = make_request("1", token_ids, block_size, sha256)
4000 - _, num_computed = manager.get_computed_blocks(replay)
4005 + _, num_computed, _ = manager.get_computed_blocks(replay)
4006 assert num_computed == 240
4007
4008
@@ -4010,7 +4015,7 @@ def test_pure_swa_retention_dense_default_caches_all(monkeypatch):
4015
4016 token_ids = [i for i in range(16) for _ in range(block_size)]
4017 req = make_request("0", token_ids, block_size, sha256)
4013 - computed_blocks, _ = manager.get_computed_blocks(req)
4018 + computed_blocks, _, _ = manager.get_computed_blocks(req)
4019 blocks = manager.allocate_slots(
4020 req,
4021 len(token_ids),
@@ -4052,7 +4057,7 @@ def test_mamba_reachable_block_mask_sparsifies_retention():
4057 kv_cache_spec=spec,
4058 use_eagle=False,
4059 retention_interval=retention_interval,
4055 - num_prompt_tokens=num_prompt_tokens,
4060 + reachable_boundaries=(num_prompt_tokens - 1,),
4061 )
4062 return None if m is None else {i for i, v in enumerate(m) if v}
4063
@@ -4065,3 +4070,242 @@ def test_mamba_reachable_block_mask_sparsifies_retention():
4070 assert retained(64) == {3, 7, 11, 14, 15}
4071 # interval 0 -> only the latest replay boundary (block 14).
4072 assert retained(0) == {14}
4073 +
4074 +
4075 +def test_mamba_reachable_block_mask_pins_shared_prefix():
4076 + """A Marconi-detected shared prefix (``shared_prefix_boundary``) lands before
4077 + ``num_prompt`` so the replay-boundary rule alone would drop it. The mask must
4078 + pin the single state block ending on that boundary so sparse retention does
4079 + not defeat cross-request shared-prefix reuse."""
4080 + from vllm.v1.core.single_type_kv_cache_manager import MambaManager
4081 +
4082 + block_size = 16
4083 + spec = MambaSpec(
4084 + block_size=block_size,
4085 + shapes=(1, 1),
4086 + dtypes=(torch.float32,),
4087 + mamba_cache_mode="align",
4088 + )
4089 +
4090 + def retained(retention_interval, shared_prefix_boundary, end_block=16):
4091 + boundaries = [255] # replay boundary (num_prompt 256 - 1)
4092 + if shared_prefix_boundary:
4093 + boundaries.append(shared_prefix_boundary)
4094 + m = MambaManager.reachable_block_mask(
4095 + start_block=0,
4096 + end_block=end_block,
4097 + alignment_tokens=block_size,
4098 + kv_cache_spec=spec,
4099 + use_eagle=False,
4100 + retention_interval=retention_interval,
4101 + reachable_boundaries=boundaries,
4102 + )
4103 + return None if m is None else {i for i, v in enumerate(m) if v}
4104 +
4105 + # interval 0 keeps only replay boundary 14; the shared prefix at token 96
4106 + # (state block 96//16 - 1 = 5) is now pinned too.
4107 + assert retained(0, 96) == {5, 14}
4108 + # A non-aligned boundary floors to the enclosing aligned boundary.
4109 + assert retained(0, 100) == {5, 14}
4110 + # Coexists with segment tails (interval 64 -> {3,7,11,15} + replay 14).
4111 + assert retained(64, 96) == {3, 5, 7, 11, 14, 15}
4112 + # Dense default ignores the hint (nothing to sparsify).
4113 + assert retained(None, 96) is None
4114 + # Out-of-range boundary is a no-op (only replay 14 remains).
4115 + assert retained(0, 16 * block_size * 2) == {14}
4116 + # No boundary given -> unchanged replay-only behavior.
4117 + assert retained(0, 0) == {14}
4118 + assert retained(0, None) == {14}
4119 +
4120 +
4121 +def test_mamba_shared_prefix_survives_zero_retention(monkeypatch):
4122 + """Manager-level check of the full wiring: a pinned shared-prefix boundary
4123 + (``Request.shared_prefix_boundary``, set by the scheduler on Marconi-style
4124 + detection) keeps its Mamba state block cached under
4125 + ``VLLM_PREFIX_CACHE_RETENTION_INTERVAL=0``, which otherwise retains only the
4126 + end-of-prompt replay boundary. Without this, a shared prefix (junction
4127 + before ``num_prompt``) would be recomputed by every sharing request."""
4128 + monkeypatch.setenv("VLLM_PREFIX_CACHE_RETENTION_INTERVAL", "0")
4129 + block_size = 16
4130 +
4131 + # 16-block (256-token) prompt; replay boundary is block 240 // 16 - 1 = 14.
4132 + token_ids = [i for i in range(16) for _ in range(block_size)]
4133 +
4134 + def cached_mamba_blocks(shared_prefix_boundary):
4135 + # Fresh manager per scenario so cached blocks don't leak between runs.
4136 + manager = make_kv_cache_manager(
4137 + _make_hybrid_kv_cache_config(block_size, 100, ["full", "mamba"]),
4138 + max_model_len=8192,
4139 + enable_caching=True,
4140 + hash_block_size=block_size,
4141 + )
4142 + req = make_request("r", token_ids, block_size, sha256)
4143 + req.shared_prefix_boundary = shared_prefix_boundary
4144 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req)
4145 + blocks = manager.allocate_slots(
4146 + req, len(token_ids), num_computed, computed_blocks
4147 + )
4148 + assert blocks is not None
4149 + pool = manager.block_pool
4150 + return {
4151 + i
4152 + for i in range(16)
4153 + if pool.get_cached_block(req.block_hashes[i], kv_cache_group_ids=[1])
4154 + is not None
4155 + }
4156 +
4157 + # Without a pinned boundary, retention=0 keeps only the replay boundary (14).
4158 + assert cached_mamba_blocks(0) == {14}
4159 + # Pinning the shared prefix at token 96 (state block 5) retains it too, so a
4160 + # later request sharing that prefix can hit the Mamba state.
4161 + assert cached_mamba_blocks(96) == {5, 14}
4162 +
4163 +
4164 +def test_mamba_shared_prefix_reuse_under_zero_retention(monkeypatch):
4165 + """Full cross-request Marconi flow: a partial shared prefix cached by the
4166 + detecting request must stay reusable by a later request under
4167 + ``VLLM_PREFIX_CACHE_RETENTION_INTERVAL=0``. Without the pin the junction is
4168 + masked out and the later request misses; with it (and under dense) the reuse
4169 + is preserved."""
4170 + block_size = 16
4171 +
4172 + def last_req_hit(retention, pin):
4173 + if retention is None:
4174 + monkeypatch.delenv("VLLM_PREFIX_CACHE_RETENTION_INTERVAL", raising=False)
4175 + else:
4176 + monkeypatch.setenv("VLLM_PREFIX_CACHE_RETENTION_INTERVAL", str(retention))
4177 + manager = make_kv_cache_manager(
4178 + _make_hybrid_kv_cache_config(block_size, 200, ["full", "mamba_align"]),
4179 + max_model_len=8192,
4180 + enable_caching=True,
4181 + hash_block_size=block_size,
4182 + )
4183 + shared = [7 for _ in range(2 * block_size)] # 2-block shared prefix
4184 +
4185 + def distinct(v):
4186 + return [v for _ in range(2 * block_size)]
4187 +
4188 + # req0 primes the shared prefix's (dense) attention cache; align-mode
4189 + # Mamba keeps only its own tail, not the shared-prefix junction.
4190 + req0 = make_request("0", shared + distinct(50), block_size, sha256)
4191 + cb, nc, _ = manager.get_computed_blocks(req0)
4192 + manager.allocate_slots(req0, len(req0.all_token_ids), nc, cb)
4193 +
4194 + # req1 detects the shared prefix (attn hit, Mamba lag) and, via the
4195 + # Marconi chunk, caches the junction's Mamba state.
4196 + req1 = make_request("1", shared + distinct(60), block_size, sha256)
4197 + cb, nc, boundary = manager.get_computed_blocks(req1)
4198 + assert boundary == 2 * block_size # junction detected at 2 shared blocks
4199 + if pin:
4200 + req1.shared_prefix_boundary = boundary
4201 + # Simulate the Marconi chunk: schedule up to the junction (boundary - nc).
4202 + manager.allocate_slots(req1, boundary - nc, nc, cb)
4203 +
4204 + # req2 shares the same prefix: does it reuse the cached junction?
4205 + req2 = make_request("2", shared + distinct(70), block_size, sha256)
4206 + _, nc2, _ = manager.get_computed_blocks(req2)
4207 + return nc2
4208 +
4209 + # Dense retains the junction -> reuse works (baseline ceiling).
4210 + assert last_req_hit(retention=None, pin=False) == 2 * block_size
4211 + # retention=0 without the pin masks the junction out -> reuse lost (the bug).
4212 + assert last_req_hit(retention=0, pin=False) == 0
4213 + # retention=0 with the pin keeps the junction -> reuse restored.
4214 + assert last_req_hit(retention=0, pin=True) == 2 * block_size
4215 +
4216 +
4217 +def test_swa_reachable_block_mask_pins_shared_prefix():
4218 + """SWA analog of the Mamba pin: the shared-prefix junction must keep the
4219 + ``need``-block sliding-window tail ending on that boundary (not a single
4220 + block), so a windowed hit can land there under sparse retention."""
4221 + from vllm.v1.core.single_type_kv_cache_manager import SlidingWindowManager
4222 +
4223 + block_size = 16
4224 +
4225 + def retained(retention, boundary, window, end_block=16):
4226 + spec = SlidingWindowSpec(
4227 + block_size=block_size,
4228 + num_kv_heads=1,
4229 + head_size=1,
4230 + dtype=torch.float32,
4231 + sliding_window=window,
4232 + )
4233 + boundaries = [255] # replay boundary (num_prompt 256 - 1)
4234 + if boundary:
4235 + boundaries.append(boundary)
4236 + m = SlidingWindowManager.reachable_block_mask(
4237 + start_block=0,
4238 + end_block=end_block,
4239 + alignment_tokens=block_size,
4240 + kv_cache_spec=spec,
4241 + use_eagle=False,
4242 + retention_interval=retention,
4243 + reachable_boundaries=boundaries,
4244 + )
4245 + return None if m is None else {i for i, v in enumerate(m) if v}
4246 +
4247 + # window == block_size -> need = cdiv(15, 16) = 1: single-block tail (like
4248 + # Mamba). Junction at token 96 -> block 5; replay boundary -> block 14.
4249 + assert retained(0, 96, block_size) == {5, 14}
4250 + # window == 3 * block_size -> need = cdiv(47, 16) = 3: the junction keeps a
4251 + # 3-block WINDOW {3,4,5} (the SWA distinction), plus replay window {12,13,14}.
4252 + assert retained(0, 96, 3 * block_size) == {3, 4, 5, 12, 13, 14}
4253 + # Coexists with segment tails (interval 64, need=1): {3,7,11,15} + replay 14
4254 + # + junction 5.
4255 + assert retained(64, 96, block_size) == {3, 5, 7, 11, 14, 15}
4256 + # Dense (all blocks reachable) ignores the hint.
4257 + assert retained(None, 96, block_size) is None
4258 + # No boundary -> unchanged replay-only behavior.
4259 + assert retained(0, 0, block_size) == {14}
4260 +
4261 +
4262 +def test_swa_shared_prefix_reuse_under_zero_retention(monkeypatch):
4263 + """SWA cross-request analog: a partial shared prefix's sliding-window tail
4264 + must stay reusable under ``VLLM_PREFIX_CACHE_RETENTION_INTERVAL=0``. Without
4265 + the pin the junction window is masked out and a later request misses; with
4266 + it (and under dense) reuse is preserved."""
4267 + block_size = 16
4268 +
4269 + def last_req_hit(retention, pin):
4270 + if retention is None:
4271 + monkeypatch.delenv("VLLM_PREFIX_CACHE_RETENTION_INTERVAL", raising=False)
4272 + else:
4273 + monkeypatch.setenv("VLLM_PREFIX_CACHE_RETENTION_INTERVAL", str(retention))
4274 + manager = make_kv_cache_manager(
4275 + _make_hybrid_kv_cache_config(block_size, 200, ["full", "sliding_window"]),
4276 + max_model_len=8192,
4277 + enable_caching=True,
4278 + hash_block_size=block_size,
4279 + )
4280 + shared = [7 for _ in range(4 * block_size)] # 4-block shared prefix
4281 +
4282 + def distinct(v):
4283 + return [v for _ in range(2 * block_size)]
4284 +
4285 + # req0 primes the (dense) full-attention cache; SWA under RET=0 keeps
4286 + # only its own replay window, not the shared-prefix boundary.
4287 + req0 = make_request("0", shared + distinct(50), block_size, sha256)
4288 + cb, nc, _ = manager.get_computed_blocks(req0)
4289 + manager.allocate_slots(req0, len(req0.all_token_ids), nc, cb)
4290 +
4291 + # req1 detects the shared prefix (full-attn hit, SWA lag). No chunk for
4292 + # SWA -- the pin fires during normal prefill.
4293 + req1 = make_request("1", shared + distinct(60), block_size, sha256)
4294 + cb, nc, boundary = manager.get_computed_blocks(req1)
4295 + if retention == 0:
4296 + assert boundary == 4 * block_size # junction detected when SWA lags
4297 + if pin and boundary:
4298 + req1.shared_prefix_boundary = boundary
4299 + manager.allocate_slots(req1, len(req1.all_token_ids), nc, cb)
4300 +
4301 + # req2 shares the same prefix: does its SWA window reuse the junction?
4302 + req2 = make_request("2", shared + distinct(70), block_size, sha256)
4303 + _, nc2, _ = manager.get_computed_blocks(req2)
4304 + return nc2
4305 +
4306 + # Dense keeps every SWA tail -> reuse works (ceiling).
4307 + assert last_req_hit(retention=None, pin=False) == 4 * block_size
4308 + # retention=0 without the pin drops the junction window -> reuse lost (bug).
4309 + assert last_req_hit(retention=0, pin=False) == 0
4310 + # retention=0 with the pin keeps the junction window -> reuse restored.
4311 + assert last_req_hit(retention=0, pin=True) == 4 * block_size
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/coordinator.py
+4 -1
@@ -235,6 +235,9 @@ class MooncakeStoreCoordinator:
235 manager_cls = KVCacheSpecRegistry.get_manager_class(spec)
236 assert manager_cls is not None
237 use_eagle = g_idx in self.eagle_group_ids
238 + reachable_boundaries = (
239 + () if num_prompt_tokens is None else (num_prompt_tokens - 1,)
240 + )
241 mask = manager_cls.reachable_block_mask(
242 start_block=start_chunk,
243 end_block=end_chunk,
@@ -242,7 +245,7 @@ class MooncakeStoreCoordinator:
245 kv_cache_spec=spec,
246 use_eagle=use_eagle,
247 retention_interval=retention_interval,
245 - num_prompt_tokens=num_prompt_tokens,
248 + reachable_boundaries=reachable_boundaries,
249 )
250 if mask is not None:
251 assert len(mask) == end_chunk - start_chunk
vllm/v1/core/kv_cache_coordinator.py
+20 -12
@@ -370,7 +370,10 @@ class KVCacheCoordinator(ABC):
370 self,
371 block_hashes: list[BlockHash],
372 max_cache_hit_length: int,
373 - ) -> tuple[tuple[list[KVCacheBlock], ...], int]:
373 + ) -> tuple[tuple[list[KVCacheBlock], ...], int, int]:
374 + """Returns the per-group hit blocks, the hit length, and the number of
375 + ``num_uncached_common_prefix_tokens`` (a shared prefix that a
376 + sparse-retention group has not cached yet; 0 unless hybrid)."""
377 pass
378
379 def new_step_starts(self) -> None:
@@ -422,11 +425,11 @@ class KVCacheCoordinatorNoPrefixCache(KVCacheCoordinator):
425 self,
426 block_hashes: list[BlockHash],
427 max_cache_hit_length: int,
425 - ) -> tuple[tuple[list[KVCacheBlock], ...], int]:
428 + ) -> tuple[tuple[list[KVCacheBlock], ...], int, int]:
429 blocks: tuple[list[KVCacheBlock], ...] = tuple(
430 [] for _ in range(self.num_single_type_manager)
431 )
429 - return blocks, 0
432 + return blocks, 0, 0
433
434
435 class UnitaryKVCacheCoordinator(KVCacheCoordinator):
@@ -486,7 +489,7 @@ class UnitaryKVCacheCoordinator(KVCacheCoordinator):
489 self,
490 block_hashes: list[BlockHash],
491 max_cache_hit_length: int,
489 - ) -> tuple[tuple[list[KVCacheBlock], ...], int]:
492 + ) -> tuple[tuple[list[KVCacheBlock], ...], int, int]:
493 hit_blocks, hit_length = self.single_type_managers[0].find_longest_cache_hit(
494 block_hashes=block_hashes,
495 max_length=max_cache_hit_length,
@@ -498,7 +501,8 @@ class UnitaryKVCacheCoordinator(KVCacheCoordinator):
501 dcp_world_size=self.dcp_world_size,
502 pcp_world_size=self.pcp_world_size,
503 )
501 - return hit_blocks, hit_length
504 + # Single group: nothing "uncached common" -- no other group to lag it.
505 + return hit_blocks, hit_length, 0
506
507
508 class SpecGroup(NamedTuple):
@@ -675,7 +679,7 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
679 self,
680 block_hashes: list[BlockHash],
681 max_cache_hit_length: int,
678 - ) -> tuple[tuple[list[KVCacheBlock], ...], int]:
682 + ) -> tuple[tuple[list[KVCacheBlock], ...], int, int]:
683 """
684 Find the longest cache hit using an iterative fixed-point algorithm.
685
@@ -691,7 +695,9 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
695 Returns:
696 A tuple containing:
697 - A tuple of the cache hit blocks for each single type manager.
694 - - The number of tokens of the longest cache hit.
698 + - The number of tokens of the reconciled (combined) cache hit.
699 + - ``num_uncached_common_prefix_tokens``: a shared prefix that a
700 + sparse-retention group has not cached yet (0 unless hybrid).
701 """
702
703 num_groups = len(self.kv_cache_config.kv_cache_groups)
@@ -794,12 +800,14 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
800 del blks[num_blocks:]
801 hit_length_by_group[group_id] = hit_length
802
797 - # Uncached shared prefix detection: If any attn. group cached a longer prefix
798 - # than the current prefix, it is an uncached common prefix across requests:
799 - self.num_uncached_common_prefix_tokens = longest_hit_length - hit_length
800 - return tuple(
803 + # Uncached shared prefix detection: if any attn. group cached a longer
804 + # prefix than the reconciled hit, it is an uncached common prefix across
805 + # requests that a sparse-retention group hasn't cached yet.
806 + num_uncached_common_prefix_tokens = longest_hit_length - hit_length
807 + cache_hit_blocks = tuple(
808 blocks if blocks is not None else [] for blocks in hit_blocks_by_group
802 - ), hit_length
809 + )
810 + return cache_hit_blocks, hit_length, num_uncached_common_prefix_tokens
811
812 def find_longest_cache_hit_per_group(
813 self,
vllm/v1/core/kv_cache_manager.py
+18 -4
@@ -204,7 +204,7 @@ class KVCacheManager:
204 self.prefix_cache_stats = PrefixCacheStats()
205 return stats
206
207 - def get_computed_blocks(self, request: Request) -> tuple[KVCacheBlocks, int]:
207 + def get_computed_blocks(self, request: Request) -> tuple[KVCacheBlocks, int, int]:
208 """Get the computed (cached) blocks for the request.
209 Note that the computed blocks must be full.
210
@@ -215,13 +215,18 @@ class KVCacheManager:
215 A tuple containing:
216 - A list of blocks that are computed for the request.
217 - The number of computed tokens.
218 + - ``shared_prefix_boundary``: the block-aligned token position of
219 + a shared prefix that a sparse-retention group (Mamba / sliding
220 + window) has not cached yet (Marconi-style APC), or 0 if none.
221 + Pinned so ``VLLM_PREFIX_CACHE_RETENTION_INTERVAL`` does not drop
222 + the junction and defeat cross-request reuse.
223 """
224 # We skip finding the prefix cache hit when prefix caching is
225 # disabled or the request is marked as skipping kv cache read
226 # (which happens when the request requires prompt logprobs
227 # or calls a pooling model with all pooling).
228 if not self.enable_caching or request.skip_reading_prefix_cache:
224 - return self.empty_kv_cache_blocks, 0
229 + return self.empty_kv_cache_blocks, 0, 0
230
231 # NOTE: When all tokens hit the cache, we must recompute the last token
232 # to obtain logits. Thus, set max_cache_hit_length to prompt_length - 1.
@@ -230,7 +235,7 @@ class KVCacheManager:
235 # num_computed_tokens to be block-size aligned. Removing this limitation
236 # could slightly improve performance in the future.
237 max_cache_hit_length = request.num_tokens - 1
233 - computed_blocks, num_new_computed_tokens = (
238 + computed_blocks, num_new_computed_tokens, num_uncached = (
239 self.coordinator.find_longest_cache_hit(
240 request.block_hashes, max_cache_hit_length
241 )
@@ -256,6 +261,14 @@ class KVCacheManager:
261 group_idx,
262 )
263
264 + # The junction to pin is where the lagging sparse-retention group stops
265 + # (``num_new_computed_tokens``) plus the uncached shared prefix -- i.e.
266 + # the longest single-group hit. Sub-block gaps are left to the mask,
267 + # which floors to the alignment boundary (a no-op there).
268 + shared_prefix_boundary = (
269 + num_new_computed_tokens + num_uncached if num_uncached else 0
270 + )
271 +
272 if self.log_stats:
273 assert self.prefix_cache_stats is not None
274 self.prefix_cache_stats.record(
@@ -264,7 +277,8 @@ class KVCacheManager:
277 preempted=request.num_preemptions > 0,
278 )
279
267 - return self.create_kv_cache_blocks(computed_blocks), num_new_computed_tokens
280 + blocks = self.create_kv_cache_blocks(computed_blocks)
281 + return blocks, num_new_computed_tokens, shared_prefix_boundary
282
283 def allocate_slots(
284 self,
vllm/v1/core/sched/scheduler.py
+71 -89
@@ -350,85 +350,69 @@ class Scheduler(SchedulerInterface):
350 num_new_tokens: int,
351 num_new_local_computed_tokens: int = 0,
352 num_external_computed_tokens: int = 0,
353 - num_uncached_common_prefix_tokens: int = 0,
353 ) -> int:
355 - num_computed_tokens = (
354 + """Clip a prefill chunk so it ends where Mamba state must be cached.
355 +
356 + In "align" cache mode the SSM state is only materialized at chunk
357 + ends, so chunk ends are steered onto cacheable positions: block
358 + boundaries by default, plus mandatory early stops (the prompt's
359 + partial-tail hash boundary, a detected shared-prefix junction).
360 + """
361 + start = (
362 request.num_computed_tokens
363 + num_new_local_computed_tokens
364 + num_external_computed_tokens
365 )
360 - # Perform block-aligned splitting at prefill phase, including:
361 - # * non-resumed requests: num_computed_tokens < num_prompt_tokens + 0
362 - # * resumed requests: num_computed_tokens < (
363 - # num_prompt_tokens + num_output_tokens
364 - # )
365 - # NOTE: Use `request.num_tokens - 1` to bypass normal decoding.
366 - if num_computed_tokens < max(request.num_prompt_tokens, request.num_tokens - 1):
367 - # To enable block-aligned caching of the Mamba state, `num_new_tokens`
368 - # must be a multiple of `block_size`.
369 - # As an exception, if `num_new_tokens` is less than `block_size`, the
370 - # state is simply not cached, requiring no special handling.
371 - # Additionally, when Eagle mode is enabled, FullAttn prunes the last
372 - # matching block. To prevent this from causing a Mamba cache miss, the
373 - # last chunk must be not smaller than `block_size`.
374 - block_size = self.cache_config.block_size
375 - last_cache_position = request.num_tokens - request.num_tokens % block_size
376 - # eagle prune
377 - if self.use_eagle:
378 - last_cache_position = max(last_cache_position - block_size, 0)
379 - num_computed_tokens_after_sched = num_computed_tokens + num_new_tokens
380 - next_boundary = (num_computed_tokens // block_size + 1) * block_size
381 - if (
382 - num_computed_tokens % block_size != 0
383 - and next_boundary <= last_cache_position
384 - and num_computed_tokens_after_sched > next_boundary
385 - ):
386 - # Resumed mid-block (partial hash hit): stop the first chunk
387 - # at the next block boundary so chunk ends re-align to blocks
388 - # and the boundary state is materialized before running past.
389 - num_new_tokens = next_boundary - num_computed_tokens
390 - elif num_computed_tokens_after_sched < last_cache_position:
391 - # Align the chunk END (not its length) to block_size;
392 - # identical to flooring the length when the start is
393 - # block-aligned. May yield 0 (insufficient budget to reach
394 - # the next boundary); the caller then skips the request.
395 - aligned_end = num_computed_tokens_after_sched // block_size * block_size
396 - num_new_tokens = max(aligned_end - num_computed_tokens, 0)
397 - elif (
398 - num_computed_tokens
399 - < last_cache_position
400 - < num_computed_tokens_after_sched
401 - ):
402 - # force to cache the last chunk
403 - num_new_tokens = last_cache_position - num_computed_tokens
404 - elif self.mamba_partial_cache_hit:
405 - # Prefill of the final partial block: stop once at the
406 - # prompt's last hash boundary so the mamba partial tail entry
407 - # can be registered.
408 - tail_boundary = (
409 - request.num_prompt_tokens
410 - // self.hash_block_size
411 - * self.hash_block_size
412 - )
413 - if (
414 - num_computed_tokens
415 - < tail_boundary
416 - < num_computed_tokens_after_sched
417 - and tail_boundary < request.num_prompt_tokens
418 - and tail_boundary > last_cache_position
419 - ):
420 - num_new_tokens = tail_boundary - num_computed_tokens
421 -
422 - # Marconi cache admission optimization:
423 - # cache common prefixes by scheduling num_new_tokens = common prefix length
424 - if (
425 - num_uncached_common_prefix_tokens >= block_size
426 - and num_new_tokens > num_uncached_common_prefix_tokens
427 - ):
428 - num_new_tokens = num_uncached_common_prefix_tokens
429 - # keep alignment to block_size
430 - num_new_tokens = num_new_tokens // block_size * block_size
431 - return num_new_tokens
366 + # Split only during prefill: `request.num_tokens - 1` extends this to
367 + # resumed requests replaying their output tokens.
368 + if start >= max(request.num_prompt_tokens, request.num_tokens - 1):
369 + return num_new_tokens
370 +
371 + block_size = self.cache_config.block_size
372 + # The last block-aligned position whose state can be cached. With
373 + # Eagle, FullAttn prunes the last matching block, so back off one
374 + # block to avoid a Mamba cache miss.
375 + last_cache_position = request.num_tokens - request.num_tokens % block_size
376 + if self.use_eagle:
377 + last_cache_position = max(last_cache_position - block_size, 0)
378 +
379 + end = start + num_new_tokens
380 + # Until `last_cache_position`, chunk ends must land on block
381 + # boundaries. May yield an empty chunk (budget cannot reach the next
382 + # boundary); the caller then skips the request.
383 + if end < last_cache_position:
384 + end = end // block_size * block_size
385 +
386 + next_block_boundary = (start // block_size + 1) * block_size
387 + tail_boundary = (
388 + request.num_prompt_tokens // self.hash_block_size * self.hash_block_size
389 + if self.mamba_partial_cache_hit
390 + else 0
391 + )
392 + stops = (
393 + # Resumed mid-block (fine-grained partial hash hit): re-align to
394 + # the block grid before running on, so the crossed boundary's
395 + # state is materialized (unless it is past the cacheable range).
396 + next_block_boundary
397 + if start % block_size != 0 and next_block_boundary <= last_cache_position
398 + else 0,
399 + # Never run past the last cacheable block boundary mid-chunk.
400 + last_cache_position,
401 + # Fine-grained hits: the prompt's partial-tail entry can only be
402 + # registered by a chunk ending exactly at its last hash boundary.
403 + tail_boundary
404 + if last_cache_position < tail_boundary < request.num_prompt_tokens
405 + else 0,
406 + # Marconi shared-prefix junction, block-floored (a sub-block
407 + # junction's state is not separately cacheable): cache its state
408 + # so sibling requests sharing the prefix can reuse it.
409 + start + (request.shared_prefix_boundary - start) // block_size * block_size
410 + if start < request.shared_prefix_boundary < end
411 + else 0,
412 + )
413 + # Stop at the earliest mandatory position strictly inside the chunk.
414 + end = min((s for s in stops if start < s < end), default=end)
415 + return max(end - start, 0)
416
417 def schedule(self, throttle_prefills: bool = False) -> SchedulerOutput:
418 self.current_step += 1
@@ -718,7 +702,6 @@ class Scheduler(SchedulerInterface):
702 num_external_computed_tokens = 0
703 load_kv_async = False
704 connector_prefix_cache_queries, connector_prefix_cache_hits = 0, 0
721 - num_uncached_common_prefix_tokens = 0
705
706 # Get already-cached tokens.
707 if request.num_computed_tokens == 0:
@@ -733,8 +716,7 @@ class Scheduler(SchedulerInterface):
716 ):
717 computed, per_group_hits = (
718 self.kv_cache_manager.coordinator.find_longest_cache_hit_per_group(
736 - request.block_hashes,
737 - request.num_tokens - 1,
719 + request.block_hashes, request.num_tokens - 1
720 )
721 )
722 new_computed_blocks = (
@@ -750,6 +732,9 @@ class Scheduler(SchedulerInterface):
732 # the last block) is transferred unconditionally by
733 # _apply_prefix_caching in nixl/worker.py.
734 num_new_local_computed_tokens = max(per_group_hits)
735 + # The per-group lookup does not detect an uncached shared
736 + # prefix, so there is no junction to pin in this path.
737 + request.shared_prefix_boundary = 0
738 if self.kv_cache_manager.log_stats:
739 assert self.kv_cache_manager.prefix_cache_stats is not None
740 self.kv_cache_manager.prefix_cache_stats.record(
@@ -758,17 +743,15 @@ class Scheduler(SchedulerInterface):
743 preempted=request.num_preemptions > 0,
744 )
745 else:
761 - new_computed_blocks, num_new_local_computed_tokens = (
762 - self.kv_cache_manager.get_computed_blocks(request)
763 - )
764 -
765 - # In case of hybrid models, obtain hint for Marconi-style APC logic
766 - if self.has_mamba_layers:
767 - num_uncached_common_prefix_tokens = getattr(
768 - self.kv_cache_manager.coordinator,
769 - "num_uncached_common_prefix_tokens",
770 - 0,
771 - )
746 + (
747 + new_computed_blocks,
748 + num_new_local_computed_tokens,
749 + # Junction to pin (Marconi-style APC) so its
750 + # sparse-retention state (Mamba block / sliding-window
751 + # tail) survives retention and serves a later hit; 0
752 + # if no uncached shared prefix was detected.
753 + request.shared_prefix_boundary,
754 + ) = self.kv_cache_manager.get_computed_blocks(request)
755
756 # Get externally-cached tokens if using a KVConnector.
757 if self.connector is not None:
@@ -906,7 +889,6 @@ class Scheduler(SchedulerInterface):
889 num_new_tokens,
890 num_new_local_computed_tokens,
891 num_external_computed_tokens,
909 - num_uncached_common_prefix_tokens,
892 )
893 if num_new_tokens == 0:
894 break
vllm/v1/core/single_type_kv_cache_manager.py
+37 -27
@@ -419,6 +419,13 @@ class SingleTypeKVCacheManager(ABC):
419 if num_cached_blocks >= num_full_blocks:
420 return
421
422 + # Token boundaries whose reachable tail must be retained under sparse
423 + # retention: the replay boundary (``num_prompt - 1``, capped by
424 + # ``get_computed_blocks``) and any detected shared-prefix junction.
425 + reachable_boundaries = [request.num_prompt_tokens - 1]
426 + if request.shared_prefix_boundary:
427 + reachable_boundaries.append(request.shared_prefix_boundary)
428 +
429 block_mask = self.reachable_block_mask(
430 start_block=num_cached_blocks,
431 end_block=num_full_blocks,
@@ -426,7 +433,7 @@ class SingleTypeKVCacheManager(ABC):
433 kv_cache_spec=self.kv_cache_spec,
434 use_eagle=self.use_eagle,
435 retention_interval=retention_interval,
429 - num_prompt_tokens=request.num_prompt_tokens,
436 + reachable_boundaries=reachable_boundaries,
437 )
438 self.block_pool.cache_full_blocks(
439 request=request,
@@ -449,14 +456,15 @@ class SingleTypeKVCacheManager(ABC):
456 kv_cache_spec: KVCacheSpec,
457 use_eagle: bool,
458 retention_interval: int | None = None,
452 - num_prompt_tokens: int | None = None,
459 + reachable_boundaries: Sequence[int] = (),
460 ) -> list[bool] | None:
461 """Per-block mask for ``cache_full_blocks``. ``None`` means cache
462 every (non-null) block — the default for full attention.
463
457 - Subclasses with sparse hit semantics (SWA) override this to skip
458 - blocks that can never serve a hit at any alignment-aligned prefix
459 - length.
464 + Subclasses with sparse hit semantics (SWA / Mamba) override this to skip
465 + blocks that can never serve a hit at any alignment-aligned prefix length.
466 + ``reachable_boundaries`` are token positions whose reachable tail must be
467 + retained; the base (dense) policy ignores them.
468 """
469 return None
470
@@ -962,7 +970,7 @@ class SlidingWindowManager(SingleTypeKVCacheManager):
970 kv_cache_spec: KVCacheSpec,
971 use_eagle: bool,
972 retention_interval: int | None = None,
965 - num_prompt_tokens: int | None = None,
973 + reachable_boundaries: Sequence[int] = (),
974 ) -> list[bool] | None:
975 assert isinstance(kv_cache_spec, SlidingWindowSpec)
976 if alignment_tokens is None:
@@ -1002,18 +1010,16 @@ class SlidingWindowManager(SingleTypeKVCacheManager):
1010 if i >= shift and (i - shift) % per_segment >= per_segment - need:
1011 mask[i - start_block] = True
1012
1005 - # (2) Replay-boundary tail. ``get_computed_blocks`` caps hits at
1006 - # ``num_prompt - 1`` (to recompute the last token's logits), so an exact
1007 - # prompt replay can only land on the latest *fine*-aligned boundary.
1008 - # Sparse retention would otherwise skip it, so keep its tail explicitly.
1009 - if retention_interval is not None and num_prompt_tokens is not None:
1010 - latest = (num_prompt_tokens - 1) // alignment_tokens * alignment_tokens
1011 - prompt_end_block = latest // block_size + shift
1012 - for i in range(
1013 - max(start_block, prompt_end_block - need),
1014 - min(end_block, prompt_end_block),
1015 - ):
1016 - mask[i - start_block] = True
1013 + # (2) Reachable-boundary tails: the replay boundary (``num_prompt - 1``,
1014 + # capped by ``get_computed_blocks``) and any shared-prefix junction. Both
1015 + # land before segments would cover them under sparse retention, so keep
1016 + # the ``need``-block tail ending on each boundary explicitly.
1017 + if retention_interval is not None:
1018 + for boundary_tokens in reachable_boundaries:
1019 + aligned = boundary_tokens // alignment_tokens * alignment_tokens
1020 + end = aligned // block_size + shift
1021 + for j in range(max(start_block, end - need), min(end_block, end)):
1022 + mask[j - start_block] = True
1023
1024 return mask
1025
@@ -1321,15 +1327,19 @@ class MambaManager(SingleTypeKVCacheManager):
1327 kv_cache_spec: KVCacheSpec,
1328 use_eagle: bool,
1329 retention_interval: int | None = None,
1324 - num_prompt_tokens: int | None = None,
1330 + reachable_boundaries: Sequence[int] = (),
1331 ) -> list[bool] | None:
1332 """Sparse Mamba state-snapshot retention.
1333
1334 ``retention_interval``:
1335
1336 ``None`` -> dense (cache every block; default, unchanged behavior)
1331 - ``0`` -> keep only the latest replay boundary
1337 + ``0`` -> keep only the ``reachable_boundaries`` states
1338 ``> 0`` -> keep one state per ``retention_interval``-sized segment
1339 +
1340 + ``reachable_boundaries`` are proven reuse points (the replay boundary and
1341 + any cross-request shared-prefix junction, Marconi-style APC); their
1342 + boundary state is always kept so sparse retention does not defeat reuse.
1343 """
1344 if retention_interval is None or alignment_tokens is None:
1345 # Dense caching (default) or no alignment constraint imposed.
@@ -1354,13 +1364,13 @@ class MambaManager(SingleTypeKVCacheManager):
1364 for i in range(first_boundary - start_block, len(mask), per_segment):
1365 mask[i] = True
1366
1357 - # (2) Replay boundary. ``get_computed_blocks`` caps hits at
1358 - # ``num_prompt - 1``, so an exact prompt replay lands on the latest
1359 - # fine-aligned boundary. Sparse retention would otherwise skip its
1360 - # state, so keep it explicitly.
1361 - if num_prompt_tokens is not None:
1362 - latest = (num_prompt_tokens - 1) // alignment_tokens * alignment_tokens
1363 - boundary_block = latest // block_size - 1
1367 + # (2) Reachable-boundary states: the replay boundary (``num_prompt - 1``,
1368 + # capped by ``get_computed_blocks``) and any shared-prefix junction, both
1369 + # of which segments would otherwise skip under sparse retention. A Mamba
1370 + # hit needs exactly the single state block ending on the boundary.
1371 + for boundary_tokens in reachable_boundaries:
1372 + aligned = boundary_tokens // alignment_tokens * alignment_tokens
1373 + boundary_block = aligned // block_size - 1
1374 if start_block <= boundary_block < end_block:
1375 mask[boundary_block - start_block] = True
1376
vllm/v1/request.py
+5
@@ -182,6 +182,11 @@ class Request:
182 # True if this request is scheduled as a non-final prefill chunk.
183 self.is_prefill_chunk = False
184
185 + # Block-aligned token position of a proven shared prefix worth pinning
186 + # in the (sparse) prefix cache; 0 means none. Set at admission for
187 + # hybrid/Mamba models when a shared prefix is detected (Marconi-style).
188 + self.shared_prefix_boundary = 0
189 +
190 # The number of NaNs in logits. A value greater than 0
191 # indicates that the output is corrupted
192 self.num_nans_in_logits = 0
vllm/v1/simple_kv_offload/manager.py
+1 -1
@@ -262,7 +262,7 @@ class SimpleCPUOffloadScheduler:
262 max_hit_len = request.num_tokens - 1 - num_computed_tokens
263 if max_hit_len <= 0:
264 return 0, False
265 - cpu_hit_blocks, hit_length = self.cpu_coordinator.find_longest_cache_hit(
265 + cpu_hit_blocks, hit_length, _ = self.cpu_coordinator.find_longest_cache_hit(
266 remaining_hashes, max_hit_len
267 )
268