[Perf][HiSparse] Avoid repeated prefix scans and residency updates (#57930)
Signed-off-by: S1ro1 <matej.sirovatka@gmail.com> Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com> Signed-off-by: Matthew Bonanni <mbonanni@redhat.com> Co-authored-by: Lucas Wilkinson <lwilkins@redhat.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Matthew Bonanni <mbonanni@redhat.com>
Matej Sirovatka committed
Oct 1, 2026 at 14:48 UTC
ca65eb67d995f5401c575ec90cba523757127ec7
5 files changed
+236
-83
tests/v1/core/test_prefix_caching.py
+134
-46
@@ -168,6 +168,7 @@ def make_hisparse_kv_cache_config(
168
host_num_blocks: int,
169
*,
170
transfer_device_cache: bool = False,
171
+ num_resident_groups: int = 1,
172
) -> KVCacheConfig:
173
source_spec = FullAttentionSpec(
174
block_size=HISPARSE_BLOCK_SIZE,
@@ -210,6 +211,13 @@ def make_hisparse_kv_cache_config(
211
enable_kv_transfer=False,
212
)
213
)
214
+ for group_idx in range(1, num_resident_groups):
215
+ groups.extend(
216
+ replace(
217
+ group, layer_names=[f"{name}_{group_idx}" for name in group.layer_names]
218
+ )
219
+ for group in groups[2:4]
220
+ )
221
return KVCacheConfig(
222
num_blocks=num_blocks,
223
hisparse_host_num_blocks=host_num_blocks,
@@ -225,6 +233,7 @@ def make_hisparse_kv_cache_manager(
233
max_model_len: int = 128,
234
max_in_flight_tokens: int | None = None,
235
enable_caching: bool = False,
236
+ num_prefill_lookahead: int = 0,
237
**config_kwargs,
238
) -> KVCacheManager:
239
config = make_hisparse_kv_cache_config(num_blocks, host_num_blocks, **config_kwargs)
@@ -234,9 +243,53 @@ def make_hisparse_kv_cache_manager(
243
max_in_flight_tokens=max_in_flight_tokens,
244
enable_caching=enable_caching,
245
hash_block_size=HISPARSE_BLOCK_SIZE,
246
+ num_prefill_lookahead=num_prefill_lookahead,
247
)
248
249
250
+def _allocate_scheduled(
251
+ manager: KVCacheManager,
252
+ request: Request,
253
+ num_new_tokens: int,
254
+ *,
255
+ num_new_computed_tokens: int = 0,
256
+ num_external_computed_tokens: int = 0,
257
+ **kwargs,
258
+) -> KVCacheBlocks | None:
259
+ """``allocate_slots`` plus the per-step residency work the connector runs."""
260
+ blocks = manager.allocate_slots(
261
+ request,
262
+ num_new_tokens,
263
+ num_new_computed_tokens=num_new_computed_tokens,
264
+ num_external_computed_tokens=num_external_computed_tokens,
265
+ **kwargs,
266
+ )
267
+ if blocks is None or kwargs.get("delay_cache_blocks"):
268
+ return blocks
269
+ num_computed_tokens = min(
270
+ request.num_computed_tokens
271
+ + num_new_computed_tokens
272
+ + num_external_computed_tokens,
273
+ manager.max_model_len,
274
+ )
275
+ num_tokens = min(num_computed_tokens + num_new_tokens, request.num_tokens)
276
+ get_hisparse_coordinator(manager).advance_scheduled(
277
+ [(request.request_id, num_tokens)]
278
+ )
279
+ return blocks
280
+
281
+
282
+def test_hisparse_does_not_write_back_reprefillable_tokens():
283
+ """Multi-module MTP re-prefills the last tokens, so they are not final."""
284
+ manager = make_hisparse_kv_cache_manager(32, 16, num_prefill_lookahead=3)
285
+ request = make_request(
286
+ "request", list(range(2 * HISPARSE_BLOCK_SIZE)), HISPARSE_BLOCK_SIZE, sha256
287
+ )
288
+ assert _allocate_scheduled(manager, request, 2 * HISPARSE_BLOCK_SIZE)
289
+ transfers = get_hisparse_coordinator(manager).build_offload_command().page_transfers
290
+ assert len(transfers) == 1
291
+
292
+
293
def test_hisparse_builds_dma_row_mirrors_across_pages():
294
manager = make_hisparse_kv_cache_manager(32, 16)
295
request = make_request(
@@ -245,7 +298,7 @@ def test_hisparse_builds_dma_row_mirrors_across_pages():
298
HISPARSE_BLOCK_SIZE,
299
sha256,
300
)
248
- assert manager.allocate_slots(request, num_new_tokens=32) is not None
301
+ assert _allocate_scheduled(manager, request, num_new_tokens=32) is not None
302
coordinator = get_hisparse_coordinator(manager)
303
resident_blocks = coordinator.resident_managers[0].req_to_blocks[request.request_id]
304
host_blocks = coordinator.host_manager.req_to_blocks[request.request_id]
@@ -308,14 +361,14 @@ def test_hisparse_reports_when_context_is_fully_resident():
361
manager = make_hisparse_kv_cache_manager(32, 16)
362
tokens = list(range(4 * HISPARSE_BLOCK_SIZE))
363
request = make_request("request", tokens, HISPARSE_BLOCK_SIZE, sha256)
311
- assert manager.allocate_slots(request, num_new_tokens=len(tokens)) is not None
364
+ assert _allocate_scheduled(manager, request, num_new_tokens=len(tokens)) is not None
365
coordinator = get_hisparse_coordinator(manager)
366
scheduled = ((request.request_id, len(tokens) - 1, 1),)
367
assert coordinator.all_context_pages_resident(scheduled)
368
369
for hot_manager in coordinator.hot_managers:
370
hot_manager.require_hot(request.request_id)
318
- assert manager.allocate_slots(request, num_new_tokens=len(tokens)) is not None
371
+ assert _allocate_scheduled(manager, request, num_new_tokens=len(tokens)) is not None
372
_publish_hisparse_pages(manager)
373
pool = manager.block_pool
374
pool.get_new_blocks(pool.get_num_free_blocks())
@@ -333,7 +386,9 @@ def test_hisparse_host_prefix_can_be_completed_by_indexer_offload():
386
)
387
tokens = list(range(4 * HISPARSE_BLOCK_SIZE))
388
original = make_request("original", tokens, HISPARSE_BLOCK_SIZE, sha256)
336
- assert manager.allocate_slots(original, num_new_tokens=len(tokens)) is not None
389
+ assert (
390
+ _allocate_scheduled(manager, original, num_new_tokens=len(tokens)) is not None
391
+ )
392
spills = get_hisparse_coordinator(manager).build_offload_command().page_transfers
393
spill_counts = {spill.transfer_id: 1 for spill in spills}
394
get_hisparse_coordinator(manager).update_spills(spill_counts, spill_counts)
@@ -348,7 +403,8 @@ def test_hisparse_host_prefix_can_be_completed_by_indexer_offload():
403
assert num_local == 2 * HISPARSE_BLOCK_SIZE
404
assert [len(group_blocks) for group_blocks in blocks.blocks] == [3, 2, 0, 0]
405
351
- allocated = manager.allocate_slots(
406
+ allocated = _allocate_scheduled(
407
+ manager,
408
resumed,
409
num_new_tokens=1,
410
num_new_computed_tokens=num_local,
@@ -379,7 +435,9 @@ def test_hisparse_indexer_offload_is_capped_by_missing_host_prefix():
435
)
436
tokens = list(range(4 * HISPARSE_BLOCK_SIZE))
437
original = make_request("original", tokens, HISPARSE_BLOCK_SIZE, sha256)
382
- assert manager.allocate_slots(original, num_new_tokens=len(tokens)) is not None
438
+ assert (
439
+ _allocate_scheduled(manager, original, num_new_tokens=len(tokens)) is not None
440
+ )
441
spills = get_hisparse_coordinator(manager).build_offload_command().page_transfers
442
spill_counts = {spill.transfer_id: 1 for spill in spills}
443
get_hisparse_coordinator(manager).update_spills(spill_counts, spill_counts)
@@ -410,7 +468,7 @@ def test_connector_completes_partial_prefix_without_importing_missing_host_kv(
468
manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
469
tokens = list(range(64))
470
original = make_request("original", tokens, HISPARSE_BLOCK_SIZE, sha256)
413
- assert manager.allocate_slots(original, num_new_tokens=64) is not None
471
+ assert _allocate_scheduled(manager, original, num_new_tokens=64) is not None
472
_publish_hisparse_pages(manager)
473
host, indexer, _, _ = manager.get_blocks(original.request_id).blocks
474
missing_indexer = indexer[1].block_id
@@ -457,7 +515,8 @@ def test_connector_completes_partial_prefix_without_importing_missing_host_kv(
515
assert asynchronous == (external > 0)
516
assert [len(group) for group in blocks.blocks] == [3, 1, 0, 0]
517
assert (
460
- manager.allocate_slots(
518
+ _allocate_scheduled(
519
+ manager,
520
resumed,
521
int(external == 0),
522
delay_cache_blocks=True,
@@ -486,7 +545,8 @@ def test_connector_completes_partial_prefix_without_importing_missing_host_kv(
545
def allocate_external_prefix(
546
manager: KVCacheManager, request: Request, num_tokens: int
547
) -> KVCacheBlocks | None:
489
- return manager.allocate_slots(
548
+ return _allocate_scheduled(
549
+ manager,
550
request,
551
num_new_tokens=0,
552
num_external_computed_tokens=num_tokens,
@@ -538,7 +598,7 @@ def test_nixl_hisparse_full_block_import_keeps_a_writable_tail(num_tokens):
598
scheduler._mark_prefix_replay = MethodType(Scheduler._mark_prefix_replay, scheduler)
599
Scheduler._update_waiting_for_remote_kv(scheduler, request)
600
assert request.num_tokens - request.num_computed_tokens == 1
541
- assert manager.allocate_slots(request, num_new_tokens=1) is not None
601
+ assert _allocate_scheduled(manager, request, num_new_tokens=1) is not None
602
assert coordinator.build_row_mirrors([(request.request_id, num_tokens - 1, 1)])
603
# A pending restore must not expose uninitialized GPU copies to prefix hits.
604
assert all(resident[-1] not in copies for copies in coordinator.copies.values())
@@ -583,15 +643,15 @@ def test_hisparse_keeps_resident_pages_until_hot_buffer_is_allocated(enable_cach
643
32, 32, max_model_len=256, enable_caching=enable_caching
644
)
645
first = make_request("first", list(range(160)), HISPARSE_BLOCK_SIZE, sha256)
586
- assert manager.allocate_slots(first, num_new_tokens=128) is not None
646
+ assert _allocate_scheduled(manager, first, num_new_tokens=128) is not None
647
_publish_hisparse_pages(manager)
648
first.num_computed_tokens = 128
649
pool = manager.block_pool
650
held = pool.get_new_blocks(pool.get_num_free_blocks() - 3)
651
592
- assert manager.allocate_slots(first, num_new_tokens=1) is not None
652
+ assert _allocate_scheduled(manager, first, num_new_tokens=1) is not None
653
second = make_request("second", [1000] * 16, HISPARSE_BLOCK_SIZE, sha256)
594
- manager.allocate_slots(second, num_new_tokens=16)
654
+ _allocate_scheduled(manager, second, num_new_tokens=16)
655
coordinator = get_hisparse_coordinator(manager)
656
scheduled = (("first", 128, 1),)
657
assert coordinator.all_context_pages_resident(scheduled)
@@ -600,7 +660,7 @@ def test_hisparse_keeps_resident_pages_until_hot_buffer_is_allocated(enable_cach
660
# Once another allocation finishes, A can acquire its hot buffer and
661
# safely release its clean pages for other requests to reuse.
662
pool.free_blocks(held)
603
- assert manager.allocate_slots(first, num_new_tokens=1) is not None
663
+ assert _allocate_scheduled(manager, first, num_new_tokens=1) is not None
664
assert all(m.has_hot("first") for m in coordinator.hot_managers)
665
pool.get_new_blocks(pool.get_num_free_blocks())
666
assert not coordinator.all_context_pages_resident(scheduled)
@@ -611,21 +671,24 @@ def test_hisparse_full_pool_keeps_pages_pinned_until_preemption():
671
"""Without room for a hot buffer, admission must defer instead of losing KV."""
672
manager = make_hisparse_kv_cache_manager(18, 18, max_model_len=160)
673
first = make_request("first", list(range(128)), HISPARSE_BLOCK_SIZE, sha256)
614
- assert manager.allocate_slots(first, num_new_tokens=128) is not None
674
+ assert _allocate_scheduled(manager, first, num_new_tokens=128) is not None
675
_publish_hisparse_pages(manager)
676
assert manager.block_pool.get_num_free_blocks() == 1
677
first.num_computed_tokens = 128
618
- assert manager.allocate_slots(first, num_new_tokens=1) is None
678
+ assert _allocate_scheduled(manager, first, num_new_tokens=1) is None
679
assert get_hisparse_coordinator(manager).all_context_pages_resident(
680
(("first", 127, 1),)
681
)
682
683
manager.free(first)
684
second = make_request("second", [1000] * 16, HISPARSE_BLOCK_SIZE, sha256)
625
- assert manager.allocate_slots(second, num_new_tokens=16) is not None
685
+ assert _allocate_scheduled(manager, second, num_new_tokens=16) is not None
686
687
628
-def test_hisparse_materializes_prefix_without_allocating_hot_blocks():
688
+@pytest.mark.parametrize("num_resident_groups", [1, 3])
689
+def test_hisparse_materializes_prefix_without_allocating_hot_blocks(
690
+ num_resident_groups,
691
+):
692
"""A host prefix becomes visible only when every page is durable.
693
694
Completing a later page first must not expose a prefix with a hole.
@@ -634,10 +697,11 @@ def test_hisparse_materializes_prefix_without_allocating_hot_blocks():
697
32,
698
16,
699
enable_caching=True,
700
+ num_resident_groups=num_resident_groups,
701
)
702
tokens = list(range(2 * HISPARSE_BLOCK_SIZE))
703
request = make_request("resident", tokens, HISPARSE_BLOCK_SIZE, sha256)
640
- assert manager.allocate_slots(request, num_new_tokens=len(tokens)) is not None
704
+ assert _allocate_scheduled(manager, request, num_new_tokens=len(tokens)) is not None
705
blocks = manager.get_block_ids(request.request_id)
706
assert len(blocks[2]) == 2
707
assert blocks[3] == []
@@ -675,19 +739,21 @@ def test_hisparse_materializes_prefix_without_allocating_hot_blocks():
739
assert blocks[3] == []
740
741
678
-def test_hisparse_materialization_respects_per_step_spill_budget():
742
+@pytest.mark.parametrize("num_resident_groups", [1, 3])
743
+def test_hisparse_materialization_respects_per_step_spill_budget(num_resident_groups):
744
"""Prefix publication must not bypass the configured spill batch limit."""
745
manager = make_hisparse_kv_cache_manager(
746
32,
747
16,
748
enable_caching=True,
749
+ num_resident_groups=num_resident_groups,
750
)
751
coordinator = get_hisparse_coordinator(manager)
752
coordinator.max_spill_pages = 1
753
tokens = list(range(2 * HISPARSE_BLOCK_SIZE))
754
request = make_request("bounded", tokens, HISPARSE_BLOCK_SIZE, sha256)
755
690
- assert manager.allocate_slots(request, num_new_tokens=len(tokens)) is not None
756
+ assert _allocate_scheduled(manager, request, num_new_tokens=len(tokens)) is not None
757
first = coordinator.build_offload_command().page_transfers
758
assert len(first) == 1
759
@@ -705,7 +771,9 @@ def test_hisparse_deferred_free_retains_host_blocks_until_fence():
771
host_free = host_pool.get_num_free_blocks()
772
device_free = manager.block_pool.get_num_free_blocks()
773
request = make_request("free", list(range(16)), HISPARSE_BLOCK_SIZE, sha256)
708
- assert manager.allocate_slots(request, 16, delay_cache_blocks=True) is not None
774
+ assert (
775
+ _allocate_scheduled(manager, request, 16, delay_cache_blocks=True) is not None
776
+ )
777
blocks = manager.pop_blocks_for_free(request)
778
assert host_pool.get_num_free_blocks() < host_free
779
assert blocks and any(host_pool.blocks[b.block_id] is b for b in blocks)
@@ -903,7 +971,7 @@ def test_hisparse_host_exhaustion_defers_allocation(enable_caching):
971
"""Pages are only allocated with host backing, so every one can be reclaimed."""
972
manager = make_hisparse_kv_cache_manager(32, 5, enable_caching=enable_caching)
973
donor = make_request("donor", [99] * 16, HISPARSE_BLOCK_SIZE, sha256)
906
- assert manager.allocate_slots(donor, 16) is not None
974
+ assert _allocate_scheduled(manager, donor, 16) is not None
975
request = make_request("resident", list(range(64)), HISPARSE_BLOCK_SIZE, sha256)
976
free_gpu = manager.block_pool.get_num_free_blocks()
977
assert manager.allocate_slots(request, 64) is None
@@ -916,7 +984,7 @@ def test_hisparse_host_exhaustion_defers_allocation(enable_caching):
984
assert manager.allocate_slots(request, 64) is None
985
_publish_hisparse_pages(manager)
986
assert not coordinator.has_pending_reclamation()
919
- assert manager.allocate_slots(request, 64) is not None
987
+ assert _allocate_scheduled(manager, request, 64) is not None
988
host = coordinator.host_manager
989
assert host is not None
990
assert not any(b.is_null for b in host.req_to_blocks[request.request_id])
@@ -974,7 +1042,7 @@ def test_hisparse_recomputation_does_not_complete_failed_host_import():
1042
# allocation reaches the original import boundary before the worker runs.
1043
request.num_computed_tokens = 16
1044
manager.cache_blocks(request, 16)
977
- assert manager.allocate_slots(request, 16) is not None
1045
+ assert _allocate_scheduled(manager, request, 16) is not None
1046
state = get_hisparse_coordinator(manager).request_states.get(request.request_id)
1047
assert state is None or 1 not in state.valid_pages
1048
@@ -995,12 +1063,12 @@ def test_hisparse_resident_request_can_grow_without_hot_capacity():
1063
"""Running resident history must not be mistaken for a new host-prefix hit."""
1064
manager = make_hisparse_kv_cache_manager(8, 16)
1065
request = make_request("resident", list(range(48)), HISPARSE_BLOCK_SIZE, sha256)
998
- assert manager.allocate_slots(request, num_new_tokens=32) is not None
1066
+ assert _allocate_scheduled(manager, request, num_new_tokens=32) is not None
1067
request.num_computed_tokens = 32
1068
1069
# Three free blocks fit the next indexer/resident pages, but not a hot region.
1070
assert manager.block_pool.get_num_free_blocks() == 3
1003
- assert manager.allocate_slots(request, num_new_tokens=1) is not None
1071
+ assert _allocate_scheduled(manager, request, num_new_tokens=1) is not None
1072
assert manager.get_block_ids(request.request_id)[3] == []
1073
1074
@@ -1050,7 +1118,7 @@ def test_hisparse_events_report_host_and_device_placement():
1118
enable_kv_cache_events=True,
1119
)
1120
request = make_request("request", list(range(64)), HISPARSE_BLOCK_SIZE, sha256)
1053
- assert manager.allocate_slots(request, 64) is not None
1121
+ assert _allocate_scheduled(manager, request, 64) is not None
1122
_publish_hisparse_pages(manager)
1123
1124
stored = [
@@ -1113,7 +1181,9 @@ def test_hisparse_prefix_hit_adopts_gpu_shadow_pages():
1181
manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
1182
tokens = list(range(4 * HISPARSE_BLOCK_SIZE))
1183
original = make_request("original", tokens, HISPARSE_BLOCK_SIZE, sha256)
1116
- assert manager.allocate_slots(original, num_new_tokens=len(tokens)) is not None
1184
+ assert (
1185
+ _allocate_scheduled(manager, original, num_new_tokens=len(tokens)) is not None
1186
+ )
1187
_publish_hisparse_pages(manager)
1188
original_resident_ids = [
1189
block.block_id for block in manager.get_blocks("original").blocks[2]
@@ -1123,7 +1193,8 @@ def test_hisparse_prefix_hit_adopts_gpu_shadow_pages():
1193
resumed = make_request("resumed", tokens, HISPARSE_BLOCK_SIZE, sha256)
1194
computed, num_computed, _ = manager.get_computed_blocks(resumed)
1195
assert num_computed == 3 * HISPARSE_BLOCK_SIZE
1126
- assert manager.allocate_slots(
1196
+ assert _allocate_scheduled(
1197
+ manager,
1198
resumed,
1199
num_new_tokens=len(tokens) - num_computed,
1200
num_new_computed_tokens=num_computed,
@@ -1154,7 +1225,9 @@ def test_hisparse_prefix_hit_under_pressure_adopts_surviving_copies(
1225
manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
1226
tokens = list(range(4 * HISPARSE_BLOCK_SIZE))
1227
original = make_request("original", tokens, HISPARSE_BLOCK_SIZE, sha256)
1157
- assert manager.allocate_slots(original, num_new_tokens=len(tokens)) is not None
1228
+ assert (
1229
+ _allocate_scheduled(manager, original, num_new_tokens=len(tokens)) is not None
1230
+ )
1231
_publish_hisparse_pages(manager)
1232
copy_ids = [block.block_id for block in manager.get_blocks("original").blocks[2]]
1233
manager.free(original)
@@ -1164,7 +1237,8 @@ def test_hisparse_prefix_hit_under_pressure_adopts_surviving_copies(
1237
resumed = make_request("resumed", tokens, HISPARSE_BLOCK_SIZE, sha256)
1238
computed, num_computed, _ = manager.get_computed_blocks(resumed)
1239
assert num_computed == 3 * HISPARSE_BLOCK_SIZE
1167
- assert manager.allocate_slots(
1240
+ assert _allocate_scheduled(
1241
+ manager,
1242
resumed,
1243
num_new_tokens=len(tokens) - num_computed,
1244
num_new_computed_tokens=num_computed,
@@ -1190,14 +1264,17 @@ def test_hisparse_host_backed_request_accepts_local_prefix_hit():
1264
manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
1265
tokens = list(range(4 * HISPARSE_BLOCK_SIZE))
1266
original = make_request("original", tokens, HISPARSE_BLOCK_SIZE, sha256)
1193
- assert manager.allocate_slots(original, num_new_tokens=len(tokens)) is not None
1267
+ assert (
1268
+ _allocate_scheduled(manager, original, num_new_tokens=len(tokens)) is not None
1269
+ )
1270
_publish_hisparse_pages(manager)
1271
manager.free(original)
1272
1273
resumed = make_request("resumed", tokens, HISPARSE_BLOCK_SIZE, sha256)
1274
computed, num_computed, _ = manager.get_computed_blocks(resumed)
1275
assert num_computed == 3 * HISPARSE_BLOCK_SIZE
1200
- assert manager.allocate_slots(
1276
+ assert _allocate_scheduled(
1277
+ manager,
1278
resumed,
1279
num_new_tokens=len(tokens) - num_computed,
1280
num_new_computed_tokens=num_computed,
@@ -1211,7 +1288,9 @@ def test_hisparse_finished_request_leaves_free_copies():
1288
manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
1289
tokens = list(range(4 * HISPARSE_BLOCK_SIZE))
1290
original = make_request("original", tokens, HISPARSE_BLOCK_SIZE, sha256)
1214
- assert manager.allocate_slots(original, num_new_tokens=len(tokens)) is not None
1291
+ assert (
1292
+ _allocate_scheduled(manager, original, num_new_tokens=len(tokens)) is not None
1293
+ )
1294
_publish_hisparse_pages(manager)
1295
manager.free(original)
1296
@@ -1241,7 +1320,9 @@ def test_hisparse_cleanup_publishes_only_finalized_terminal_pages(
1320
coordinator = get_hisparse_coordinator(manager)
1321
request = make_request("terminal", list(range(33)), HISPARSE_BLOCK_SIZE, sha256)
1322
request.spec_token_ids = list(range(33, 49))
1244
- assert manager.allocate_slots(request, 49, num_lookahead_tokens=16) is not None
1323
+ assert (
1324
+ _allocate_scheduled(manager, request, 49, num_lookahead_tokens=16) is not None
1325
+ )
1326
host_blocks = list(manager.get_blocks(request.request_id).blocks[0])
1327
command = coordinator.build_offload_command()
1328
counts = {transfer.transfer_id: 1 for transfer in command.page_transfers}
@@ -1280,7 +1361,10 @@ def test_hisparse_prefill_reads_host_once_it_fills_admission_window(
1361
coordinator = get_hisparse_coordinator(manager)
1362
tokens = list(range(2 * window_pages * HISPARSE_BLOCK_SIZE))
1363
request = make_request("prefill", tokens, HISPARSE_BLOCK_SIZE, sha256)
1283
- assert manager.allocate_slots(request, num_pages * HISPARSE_BLOCK_SIZE) is not None
1364
+ assert (
1365
+ _allocate_scheduled(manager, request, num_pages * HISPARSE_BLOCK_SIZE)
1366
+ is not None
1367
+ )
1368
pool = coordinator.gpu_pool
1369
assert pool is not None
1370
assert pool.get_num_free_blocks() >= coordinator.transition_watermark
@@ -1303,10 +1387,10 @@ def test_hisparse_preempted_prefill_resumes_from_durable_prefix():
1387
chunk = 2 * HISPARSE_BLOCK_SIZE
1388
tokens = list(range(3 * chunk))
1389
request = make_request("long", tokens, HISPARSE_BLOCK_SIZE, sha256)
1306
- assert manager.allocate_slots(request, chunk) is not None
1390
+ assert _allocate_scheduled(manager, request, chunk) is not None
1391
request.num_computed_tokens = chunk
1392
first_chunk_writes = coordinator.build_offload_command().page_transfers
1309
- assert manager.allocate_slots(request, chunk) is not None
1393
+ assert _allocate_scheduled(manager, request, chunk) is not None
1394
request.num_computed_tokens = 2 * chunk
1395
acks = {transfer.transfer_id: 1 for transfer in first_chunk_writes}
1396
coordinator.update_spills(acks, acks)
@@ -1323,7 +1407,7 @@ def test_hisparse_terminal_prefix_waits_for_all_workers_and_preserves_identity()
1407
host_pool = coordinator.get_host_block_pool()
1408
tokens = list(range(2 * HISPARSE_BLOCK_SIZE + 1))
1409
request = make_request("reused", tokens, HISPARSE_BLOCK_SIZE, sha256)
1326
- assert manager.allocate_slots(request, len(tokens)) is not None
1410
+ assert _allocate_scheduled(manager, request, len(tokens)) is not None
1411
old_blocks = list(manager.get_blocks(request.request_id).blocks[0])
1412
command = coordinator.build_offload_command()
1413
counts = {transfer.transfer_id: 2 for transfer in command.page_transfers}
@@ -1336,7 +1420,7 @@ def test_hisparse_terminal_prefix_waits_for_all_workers_and_preserves_identity()
1420
replacement = make_request(
1421
"reused", list(range(100, 117)), HISPARSE_BLOCK_SIZE, sha256
1422
)
1339
- assert manager.allocate_slots(replacement, 17) is not None
1423
+ assert _allocate_scheduled(manager, replacement, 17) is not None
1424
new_blocks = list(manager.get_blocks(replacement.request_id).blocks[0])
1425
pressure = host_pool.get_new_blocks(host_pool.get_num_free_blocks())
1426
assert not {block.block_id for block in old_blocks[:2]} & {
@@ -1363,7 +1447,7 @@ def test_hisparse_reset_prefix_cache_drops_copies():
1447
manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
1448
tokens = list(range(4 * HISPARSE_BLOCK_SIZE))
1449
request = make_request("request", tokens, HISPARSE_BLOCK_SIZE, sha256)
1366
- assert manager.allocate_slots(request, num_new_tokens=len(tokens)) is not None
1450
+ assert _allocate_scheduled(manager, request, num_new_tokens=len(tokens)) is not None
1451
_publish_hisparse_pages(manager)
1452
manager.free(request)
1453
@@ -1378,7 +1462,9 @@ def test_hisparse_reused_copy_is_not_adopted():
1462
manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
1463
tokens = list(range(4 * HISPARSE_BLOCK_SIZE))
1464
original = make_request("original", tokens, HISPARSE_BLOCK_SIZE, sha256)
1381
- assert manager.allocate_slots(original, num_new_tokens=len(tokens)) is not None
1465
+ assert (
1466
+ _allocate_scheduled(manager, original, num_new_tokens=len(tokens)) is not None
1467
+ )
1468
_publish_hisparse_pages(manager)
1469
indexer_blocks = list(manager.get_blocks("original").blocks[1])
1470
manager.free(original)
@@ -1391,7 +1477,8 @@ def test_hisparse_reused_copy_is_not_adopted():
1477
resumed = make_request("resumed", tokens, HISPARSE_BLOCK_SIZE, sha256)
1478
computed, num_computed, _ = manager.get_computed_blocks(resumed)
1479
assert num_computed == 3 * HISPARSE_BLOCK_SIZE
1394
- assert manager.allocate_slots(
1480
+ assert _allocate_scheduled(
1481
+ manager,
1482
resumed,
1483
num_new_tokens=len(tokens) - num_computed,
1484
num_new_computed_tokens=num_computed,
@@ -5291,7 +5378,8 @@ def test_can_fit_full_sequence_hisparse_caps_resident_pages():
5378
)
5379
5380
assert (
5294
- manager.allocate_slots(
5381
+ _allocate_scheduled(
5382
+ manager,
5383
request,
5384
num_new_tokens=HISPARSE_BLOCK_SIZE,
5385
full_sequence_must_fit=True,
@@ -6151,7 +6239,7 @@ def test_device_eviction_keeps_host_prefix():
6239
"""Device eviction must not target a same-numbered host block."""
6240
manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
6241
request = make_request("producer", list(range(64)), HISPARSE_BLOCK_SIZE, sha256)
6154
- assert manager.allocate_slots(request, 64) is not None
6242
+ assert _allocate_scheduled(manager, request, 64) is not None
6243
_publish_hisparse_pages(manager)
6244
host, indexer, _, _ = manager.get_blocks(request.request_id).blocks
6245
device_block = indexer[0]
tests/v1/kv_connector/unit/test_hisparse_connector.py
+53
@@ -114,3 +114,56 @@ def test_full_graph_step_prepares_host_mirror_outside_model():
114
115
worker._enqueue_row_dma.assert_called_once_with((0,), ready_event=None)
116
runtime.invalidate_written_slots.assert_called_once()
117
+
118
+
119
+def test_scheduled_prefix_hit_publishes_adopted_copies():
120
+ """Copies adopted after scheduling must reach the worker's block table."""
121
+ from tests.v1.core.test_prefix_caching import (
122
+ HISPARSE_BLOCK_SIZE,
123
+ _allocate_scheduled,
124
+ _publish_hisparse_pages,
125
+ make_hisparse_kv_cache_manager,
126
+ make_request,
127
+ sha256,
128
+ )
129
+ from vllm.v1.core.kv_cache_utils import init_none_hash
130
+
131
+ init_none_hash(sha256)
132
+ manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
133
+ tokens = list(range(4 * HISPARSE_BLOCK_SIZE))
134
+ original = make_request("original", tokens, HISPARSE_BLOCK_SIZE, sha256)
135
+ assert _allocate_scheduled(manager, original, num_new_tokens=len(tokens))
136
+ _publish_hisparse_pages(manager)
137
+ copy_ids = [block.block_id for block in manager.get_blocks("original").blocks[2]]
138
+ manager.free(original)
139
+
140
+ resumed = make_request("resumed", tokens, HISPARSE_BLOCK_SIZE, sha256)
141
+ computed, num_computed, _ = manager.get_computed_blocks(resumed)
142
+ num_new_tokens = len(tokens) - num_computed
143
+ assert manager.allocate_slots(
144
+ resumed,
145
+ num_new_tokens=num_new_tokens,
146
+ num_new_computed_tokens=num_computed,
147
+ new_computed_blocks=computed,
148
+ )
149
+ scheduler = HiSparseConnectorScheduler(async_speculative=False)
150
+ scheduler.bind_coordinator(get_hisparse_coordinator(manager))
151
+ scheduler.requests[resumed.request_id] = resumed
152
+ scheduler_output = SimpleNamespace(
153
+ scheduled_new_reqs=[
154
+ SimpleNamespace(
155
+ req_id=resumed.request_id,
156
+ num_computed_tokens=num_computed,
157
+ block_ids=manager.get_block_ids(resumed.request_id),
158
+ )
159
+ ],
160
+ scheduled_cached_reqs=SimpleNamespace(
161
+ req_ids=[], num_computed_tokens=[], new_block_ids=[]
162
+ ),
163
+ num_scheduled_tokens={resumed.request_id: num_new_tokens},
164
+ )
165
+
166
+ scheduler.build_connector_meta(scheduler_output)
167
+
168
+ resident_ids = scheduler_output.block_table_updates[resumed.request_id][2]
169
+ assert resident_ids[:3] == copy_ids[:3]
vllm/distributed/kv_transfer/kv_connector/v1/hisparse/connector.py
+23
-15
@@ -106,21 +106,6 @@ class HiSparseConnectorScheduler:
106
# HiSparse rebinds worker state every step, so the load must start
107
# before the forward rather than being deferred to post-forward.
108
scheduler_output.has_sync_kv_loads = True
109
- scheduler_output.block_table_updates = (
110
- self.coordinator.take_block_table_updates() or None
111
- )
112
- command = self.coordinator.build_offload_command()
113
- host_block_copies = self.coordinator.take_host_block_copies()
114
- source_group_id = self.coordinator.host_group_id
115
- assert source_group_id is not None
116
- source_block_ids = [
117
- block_id
118
- for request in scheduler_output.scheduled_new_reqs
119
- for block_id in request.block_ids[source_group_id]
120
- ]
121
- for new_block_ids in scheduler_output.scheduled_cached_reqs.new_block_ids:
122
- if new_block_ids is not None:
123
- source_block_ids.extend(new_block_ids[source_group_id])
109
num_computed_tokens = {
110
request.req_id: request.num_computed_tokens
111
for request in scheduler_output.scheduled_new_reqs
@@ -141,6 +126,29 @@ class HiSparseConnectorScheduler:
126
scheduler_output.num_scheduled_tokens.items()
127
)
128
)
129
+ # Runs first: it plans this step's transfers and table updates.
130
+ self.coordinator.advance_scheduled(
131
+ (
132
+ request_id,
133
+ min(start + count, self.requests[request_id].num_tokens),
134
+ )
135
+ for request_id, start, count in scheduled_requests
136
+ )
137
+ scheduler_output.block_table_updates = (
138
+ self.coordinator.take_block_table_updates() or None
139
+ )
140
+ command = self.coordinator.build_offload_command()
141
+ host_block_copies = self.coordinator.take_host_block_copies()
142
+ source_group_id = self.coordinator.host_group_id
143
+ assert source_group_id is not None
144
+ source_block_ids = [
145
+ block_id
146
+ for request in scheduler_output.scheduled_new_reqs
147
+ for block_id in request.block_ids[source_group_id]
148
+ ]
149
+ for new_block_ids in scheduler_output.scheduled_cached_reqs.new_block_ids:
150
+ if new_block_ids is not None:
151
+ source_block_ids.extend(new_block_ids[source_group_id])
152
row_mirrors = {}
153
for request_id, scheduled_start, scheduled_count in scheduled_requests:
154
mirror_start = scheduled_start
vllm/v1/core/single_type_kv_cache_manager.py
-18
@@ -2366,12 +2366,6 @@ class _HiSparseAuxiliaryManager(SingleTypeKVCacheManager):
2366
2367
coordinator: "HiSparseCoordinator | None" = None
2368
2369
- def __init__(self, kv_cache_spec: KVCacheSpec, **kwargs) -> None:
2370
- # Never prefix-cached, but the per-step ``cache_blocks`` hook is where
2371
- # residency work runs, so stay opted in regardless of prefix caching.
2372
- kwargs["enable_caching"] = True
2373
- super().__init__(kv_cache_spec, **kwargs)
2374
-
2369
def cache_blocks(
2370
self,
2371
request: Request,
@@ -2551,18 +2545,6 @@ class HiSparseResidentManager(_HiSparseAuxiliaryManager):
2545
assert self.coordinator is not None
2546
self.coordinator.commit_computed_blocks(request_id, num_host_pages)
2547
2554
- def cache_blocks(
2555
- self,
2556
- request: Request,
2557
- num_tokens: int,
2558
- retention_interval: int | None = None,
2559
- *,
2560
- replay_boundaries: Sequence[int],
2561
- ) -> None:
2562
- assert self.coordinator is not None
2563
- self.coordinator.plan_prefix_materialization(request.request_id, num_tokens)
2564
- self.coordinator.update_residency(request.request_id)
2565
-
2548
def allocate_new_blocks(
2549
self, request_id: str, num_tokens: int, num_tokens_main_model: int
2550
) -> list[KVCacheBlock]:
vllm/v1/hisparse/coordinator.py
+26
-4
@@ -99,9 +99,12 @@ class HiSparseCoordinator:
99
kv_cache_config: KVCacheConfig,
100
managers: tuple[SingleTypeKVCacheManager, ...],
101
max_model_len: int,
102
+ *,
103
+ num_reprefillable_tokens: int,
104
) -> None:
105
self.managers = managers
106
self.max_model_len = max_model_len
107
+ self.num_reprefillable_tokens = num_reprefillable_tokens
108
groups = kv_cache_config.kv_cache_groups
109
110
resident_managers: list[HiSparseResidentManager] = []
@@ -264,6 +267,7 @@ class HiSparseCoordinator:
267
if manager.adopt_resident_page(request_id, host_idx, block):
268
manager.block_pool.touch([block])
269
state.pinned_clean.add(host_idx)
270
+ self.block_table_updates.add(request_id)
271
272
def _record_copies(self, request_id: str, num_computed_tokens: int) -> None:
273
"""Index the GPU copies of just-published host blocks for later hits."""
@@ -431,6 +435,22 @@ class HiSparseCoordinator:
435
# Host publication and spills
436
# ------------------------------------------------------------------
437
438
+ def advance_scheduled(self, requests: Iterable[tuple[str, int]]) -> None:
439
+ """Run the per-step residency work for each scheduled request.
440
+
441
+ ``requests`` pairs a request id with the number of tokens computed
442
+ once the step completes. As in ``KVCacheCoordinator.cache_blocks``, the
443
+ last ``num_reprefillable_tokens`` are not final yet and are not written
444
+ back.
445
+ """
446
+ if not self.resident_managers:
447
+ return
448
+ for request_id, num_tokens in requests:
449
+ self.plan_prefix_materialization(
450
+ request_id, max(0, num_tokens - self.num_reprefillable_tokens)
451
+ )
452
+ self.update_residency(request_id)
453
+
454
def plan_prefix_materialization(
455
self, request_id: str, num_computed_tokens: int
456
) -> None:
@@ -445,9 +465,8 @@ class HiSparseCoordinator:
465
)
466
state = self._get_request_state(request_id)
467
budget = max(self.max_spill_pages - len(self.spills_to_send), 0)
448
- for page_idx in range(num_pages):
449
- if page_idx < importing_pages:
450
- continue
468
+ first_page = max(state.ready_prefix_pages, importing_pages)
469
+ for page_idx in range(first_page, num_pages):
470
if budget == 0:
471
break
472
if page_idx in state.pending_pages:
@@ -852,7 +871,10 @@ def get_hisparse_coordinator(
871
assert isinstance(coordinator, HiSparseCoordinator)
872
return coordinator
873
coordinator = HiSparseCoordinator(
855
- kv_cache_manager.kv_cache_config, managers, kv_cache_manager.max_model_len
874
+ kv_cache_manager.kv_cache_config,
875
+ managers,
876
+ kv_cache_manager.max_model_len,
877
+ num_reprefillable_tokens=kv_cache_manager.coordinator.num_reprefillable_tokens,
878
)
879
if coordinator.host_manager is None:
880
raise ValueError("No HiSparse cache group is configured.")