[Bugfix][HiSparse][NIXL] Import full blocks without tail prefill on D (#57049)
Signed-off-by: NickLucche <nicolo.lucchesi@mistral.ai> Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com> Co-authored-by: OpenAI Codex <noreply@openai.com> Co-authored-by: Lucas Wilkinson <lwilkins@redhat.com>
Nicolò Lucchesi committed
Sep 17, 2026 at 21:26 UTC
67e5b0acc9988afc50d019db64ad9da0dadaa15e
8 files changed
+277
-53
tests/v1/core/test_prefix_caching.py
+95
-9
@@ -325,7 +325,7 @@ def test_hisparse_reports_when_context_is_fully_resident():
325
326
327
def test_hisparse_host_prefix_can_be_completed_by_indexer_offload():
328
- """Keep indexer-only imports host-backed in the resident block table."""
328
+ """Restore the final host page when completing an indexer-only import."""
329
manager = make_hisparse_kv_cache_manager(
330
32,
331
16,
@@ -360,11 +360,15 @@ def test_hisparse_host_prefix_can_be_completed_by_indexer_offload():
360
source, indexer, resident, hot = manager.get_blocks(resumed.request_id).blocks
361
assert len(source) == len(indexer) == len(resident) == 4
362
assert len(hot) == 2
363
- # The local prefix is adopted from shadow pages (GPU-resident), while the
364
- # externally imported page stays host-backed until its tail allocation.
363
assert not any(block.is_null for block in resident[:2])
366
- assert resident[2].is_null
364
+ assert not resident[2].is_null
365
assert not resident[3].is_null
366
+ coordinator = get_hisparse_coordinator(manager)
367
+ assert not coordinator.build_offload_command().page_transfers
368
+ coordinator.finish_host_import(resumed.request_id, failed=False)
369
+ transfers = coordinator.build_offload_command().page_transfers
370
+ assert len(transfers) == 1 and transfers[0].restore
371
+ assert transfers[0].resident_block_ids == (resident[2].block_id,)
372
373
374
def test_hisparse_indexer_offload_is_capped_by_missing_host_prefix():
@@ -465,7 +469,8 @@ def test_connector_completes_partial_prefix_without_importing_missing_host_kv(
469
)
470
allocated = manager.get_blocks(resumed.request_id).blocks
471
assert len(allocated[0]) == host_blocks
468
- assert allocated[2][1].is_null == (external > 0)
472
+ assert allocated[2][1].is_null == (host_blocks > 2)
473
+ assert not allocated[2][-1].is_null
474
manager.free(resumed)
475
476
# A surviving indexer offload must not make absent host KV look computed.
@@ -490,6 +495,85 @@ def allocate_external_prefix(
495
)
496
497
498
+@pytest.mark.parametrize("num_tokens", [1, 15, 16, 17, 31, 32, 33, 127])
499
+def test_nixl_hisparse_full_block_import_keeps_a_writable_tail(num_tokens):
500
+ """Import every real token and restore the page used by last-token replay."""
501
+ from tests.v1.kv_connector.unit.utils import create_vllm_config
502
+ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.pull_scheduler import (
503
+ NixlPullConnectorScheduler,
504
+ )
505
+
506
+ manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
507
+ config = create_vllm_config(block_size=HISPARSE_BLOCK_SIZE)
508
+ connector = NixlPullConnectorScheduler(config, "test", manager.kv_cache_config)
509
+ request = make_request(
510
+ "import", list(range(num_tokens)), HISPARSE_BLOCK_SIZE, sha256
511
+ )
512
+ request.kv_transfer_params = {"do_remote_prefill": True}
513
+ count, is_async = connector.get_num_new_matched_tokens(request, 0)
514
+ assert count == num_tokens and is_async
515
+ assert allocate_external_prefix(manager, request, count) is not None
516
+ source, _, resident, _ = manager.get_blocks(request.request_id).blocks
517
+ assert len(source) == len(resident) == (num_tokens + 15) // 16
518
+ assert all(block.is_null for block in resident[:-1])
519
+ assert not resident[-1].is_null
520
+ coordinator = get_hisparse_coordinator(manager)
521
+ assert not coordinator.build_offload_command().page_transfers
522
+
523
+ coordinator.finish_host_import(request.request_id, failed=False)
524
+ transfers = coordinator.build_offload_command().page_transfers
525
+ assert len(transfers) == 1
526
+ restore = transfers[0]
527
+ assert restore.restore and not restore.after_forward
528
+ assert restore.host_block_id == source[-1].block_id
529
+ assert restore.resident_block_ids == (resident[-1].block_id,)
530
+ request.num_computed_tokens = count
531
+ scheduler = SimpleNamespace(
532
+ connector=connector,
533
+ kv_cache_manager=manager,
534
+ failed_recving_kv_req_ids=set(),
535
+ finished_recving_kv_req_ids={request.request_id},
536
+ )
537
+ Scheduler._update_waiting_for_remote_kv(scheduler, request)
538
+ assert request.num_tokens - request.num_computed_tokens == 1
539
+ assert manager.allocate_slots(request, num_new_tokens=1) is not None
540
+ assert coordinator.build_row_mirrors([(request.request_id, num_tokens - 1, 1)])
541
+ # A pending restore must not expose uninitialized GPU copies to prefix hits.
542
+ assert all(resident[-1] not in copies for copies in coordinator.copies.values())
543
+
544
+ coordinator.update_spills({restore.transfer_id: 2}, {restore.transfer_id: 1})
545
+ assert resident[-1].ref_cnt == 2
546
+ assert all(resident[-1] not in copies for copies in coordinator.copies.values())
547
+ coordinator.update_spills({}, {restore.transfer_id: 1})
548
+ assert resident[-1].ref_cnt == 1
549
+ assert coordinator.request_states[request.request_id].valid_pages == set(
550
+ range(num_tokens // HISPARSE_BLOCK_SIZE)
551
+ )
552
+ if num_tokens % HISPARSE_BLOCK_SIZE:
553
+ assert source[-1].block_hash is None
554
+ else:
555
+ assert coordinator.copies[source[-1].block_hash] == (resident[-1],)
556
+
557
+
558
+def test_hisparse_aborted_tail_restore_retains_both_endpoints():
559
+ """An abort cannot recycle storage still used by a queued tail restore."""
560
+ manager = make_hisparse_kv_cache_manager(16, 16)
561
+ request = make_request("import", list(range(17)), HISPARSE_BLOCK_SIZE, sha256)
562
+ assert allocate_external_prefix(manager, request, 17) is not None
563
+ source, _, resident, _ = manager.get_blocks(request.request_id).blocks
564
+ host_tail, gpu_tail = source[-1], resident[-1]
565
+ coordinator = get_hisparse_coordinator(manager)
566
+ coordinator.finish_host_import(request.request_id, failed=False)
567
+ restore = coordinator.build_offload_command().page_transfers[0]
568
+ manager.free(request)
569
+ assert host_tail.ref_cnt == gpu_tail.ref_cnt == 1
570
+ coordinator.update_spills({restore.transfer_id: 2}, {restore.transfer_id: 1})
571
+ assert host_tail.ref_cnt == gpu_tail.ref_cnt == 1
572
+ coordinator.update_spills({}, {restore.transfer_id: 1})
573
+ assert host_tail.ref_cnt == gpu_tail.ref_cnt == 0
574
+ assert not coordinator.has_pending_work()
575
+
576
+
577
@pytest.mark.parametrize("enable_caching", [False, True])
578
def test_hisparse_keeps_resident_pages_until_hot_buffer_is_allocated(enable_caching):
579
"""Later admissions must not remove a scheduled request's only readable tier."""
@@ -669,7 +753,7 @@ def test_hisparse_host_cow_copy_is_drained_without_a_gpu_pool():
753
def test_hisparse_inflight_host_import_reserves_remaining_gpu_pages():
754
"""An in-flight host import must reserve its unwritten resident pages."""
755
manager = make_hisparse_kv_cache_manager(
672
- 11,
756
+ 12,
757
16,
758
transfer_device_cache=True,
759
)
@@ -1127,9 +1211,10 @@ def test_hisparse_external_import_uses_hard_gpu_footprint():
1211
assert len(source) == num_prompt_blocks
1212
assert len(indexer) == num_prompt_blocks
1213
assert len(resident) == num_prompt_blocks
1130
- assert all(block.is_null for block in resident)
1214
+ assert all(block.is_null for block in resident[:-1])
1215
+ assert not resident[-1].is_null
1216
assert len(hot) == 2
1132
- assert manager.block_pool.get_num_free_blocks() == 1
1217
+ assert manager.block_pool.get_num_free_blocks() == 0
1218
1219
1220
def test_hisparse_external_import_survives_capacity_retry():
@@ -1149,7 +1234,8 @@ def test_hisparse_external_import_survives_capacity_retry():
1234
manager.free(first)
1235
assert allocate_external_prefix(manager, second, len(tokens)) is not None
1236
_, _, resident, hot = manager.get_blocks(second.request_id).blocks
1152
- assert all(block.is_null for block in resident)
1237
+ assert all(block.is_null for block in resident[:-1])
1238
+ assert not resident[-1].is_null
1239
assert len(hot) == 2
1240
1241
tests/v1/worker/test_utils.py
+65
@@ -531,6 +531,71 @@ def test_hisparse_eager_mirror_records_transfer_without_page_copy():
531
worker._enqueue_transfers.assert_not_called()
532
533
534
+@pytest.mark.parametrize("is_host_writer", [False, True])
535
+@pytest.mark.parametrize("device", ["cpu", "cuda"])
536
+def test_hisparse_tail_restore_preserves_imported_rows(
537
+ monkeypatch, request, is_host_writer, device
538
+):
539
+ """Restore externally pinned host rows on every rank under sync checking."""
540
+ if device == "cuda" and not torch.cuda.is_available():
541
+ pytest.skip("CUDA is required for the device copy check")
542
+ worker = _make_hisparse_worker()
543
+ worker.is_host_writer = is_host_writer
544
+ worker.kernel_block_size = 4
545
+ worker.host_caches = tuple(
546
+ torch.arange(24, dtype=torch.float32).reshape(12, 2) + layer * 100
547
+ for layer in range(2)
548
+ )
549
+ if device == "cuda":
550
+ # Match production's cudaHostRegister pool, which torch.is_pinned()
551
+ # does not recognize, rather than torch's caching pinned allocator.
552
+ host_pool, registered = hisparse_runtime_module.allocate_pinned_host_pool(
553
+ sum(cache.nbytes for cache in worker.host_caches)
554
+ )
555
+ request.addfinalizer(
556
+ lambda: hisparse_runtime_module.release_pinned_state([], [registered])
557
+ )
558
+ host_caches = host_pool.view(torch.float32).view(2, 12, 2).unbind()
559
+ for destination, source in zip(host_caches, worker.host_caches):
560
+ destination.copy_(source)
561
+ worker.host_caches = host_caches
562
+ worker.resident_caches = tuple(
563
+ torch.full((4, 4, 2), -1.0, device=device) for _ in range(2)
564
+ )
565
+ worker.cache_handles = [
566
+ SimpleNamespace(
567
+ runtime=SimpleNamespace(resident_source_index=i, eager_host_mirror=True)
568
+ )
569
+ for i in range(2)
570
+ ]
571
+ worker._record_transfer_completion = MagicMock()
572
+ stream = torch.cuda.current_stream() if device == "cuda" else MagicMock()
573
+ monkeypatch.setattr(hisparse_worker_module, "current_stream", lambda: stream)
574
+ transfer = SparseKVPageTransfer(7, 2, (1, 3), False, restore=True)
575
+
576
+ restore_pages = worker._restore_pages
577
+ if device == "cuda":
578
+ import vllm.utils.gpu_sync_debug as gpu_sync_debug
579
+
580
+ monkeypatch.setattr(gpu_sync_debug, "_SYNC_CHECK_MODE", "error")
581
+ monkeypatch.setattr(gpu_sync_debug, "_sync_check_enabled", False)
582
+ gpu_sync_debug.enable_gpu_sync_check()
583
+ restore_pages = gpu_sync_debug.with_gpu_sync_check(restore_pages)
584
+ restore_pages([transfer])
585
+
586
+ # Appending into unused capacity must preserve every imported prompt row.
587
+ for cache, host, block in zip(
588
+ worker.resident_caches, worker.host_caches, transfer.resident_block_ids
589
+ ):
590
+ assert torch.equal(cache[block].cpu(), host[8:12])
591
+ cache[block, 2:] = 999
592
+ assert torch.equal(cache[block, :2].cpu(), host[8:10])
593
+ assert torch.all(cache[0] == -1)
594
+ worker._record_transfer_completion.assert_called_once_with(
595
+ [transfer], stream=stream
596
+ )
597
+
598
+
599
def test_hisparse_lazy_mirror_copies_transfer_pages():
600
worker = _make_hisparse_worker()
601
worker.cache_handles = [
vllm/distributed/kv_transfer/kv_connector/v1/hisparse/worker.py
+45
-10
@@ -22,6 +22,7 @@ from vllm.distributed.parallel_state import (
22
get_tensor_model_parallel_rank,
23
get_tp_group,
24
)
25
+from vllm.utils.gpu_sync_debug import gpu_sync_allowed
26
from vllm.utils.torch_utils import current_stream
27
from vllm.v1.core.kv_cache_utils import KVCacheBlockCopy
28
from vllm.v1.hisparse.layout import HISPARSE_HOT_SUFFIX
@@ -414,11 +415,18 @@ class HiSparseConnectorWorker:
415
transfers = (
416
metadata.command.page_transfers if metadata.command is not None else []
417
)
418
+ self._restore_pages([transfer for transfer in transfers if transfer.restore])
419
self._post_forward_transfers = [
418
- transfer for transfer in transfers if transfer.after_forward
420
+ transfer
421
+ for transfer in transfers
422
+ if not transfer.restore and transfer.after_forward
423
]
424
self._submit_transfers(
421
- [transfer for transfer in transfers if not transfer.after_forward]
425
+ [
426
+ transfer
427
+ for transfer in transfers
428
+ if not transfer.restore and not transfer.after_forward
429
+ ]
430
)
431
self._pending_invalid_block_ids.extend(metadata.source_block_ids)
432
if request_state_indices is not None:
@@ -708,11 +716,17 @@ class HiSparseConnectorWorker:
716
self._submitted_mirror_layers.update(pending_layers)
717
718
def _record_transfer_completion(
711
- self, transfers: list[SparseKVPageTransfer]
719
+ self,
720
+ transfers: list[SparseKVPageTransfer],
721
+ *,
722
+ stream: torch.Stream | None = None,
723
) -> None:
713
- if not transfers or not self.is_host_writer:
724
+ if not transfers:
725
return
715
- stream = self.dma_stream
726
+ if stream is None:
727
+ if not self.is_host_writer:
728
+ return
729
+ stream = self.dma_stream
730
assert stream is not None
731
completion_event = torch.Event()
732
completion_event.record(stream)
@@ -720,6 +734,30 @@ class HiSparseConnectorWorker:
734
self._pending_transfer_events.append((completion_event, transfer_ids))
735
self._enqueued_transfer_ids.extend(transfer_ids)
736
737
+ def _restore_pages(self, transfers: list[SparseKVPageTransfer]) -> None:
738
+ """Restore imported tails on every rank before its forward or graph replay."""
739
+ if not transfers:
740
+ return
741
+ for layer_index, cache in enumerate(self.cache_handles):
742
+ source_index = cache.runtime.resident_source_index
743
+ source = self.host_caches[layer_index]
744
+ destination = self.resident_caches[layer_index]
745
+ for transfer in transfers:
746
+ host_start = transfer.host_block_id * self.kernel_block_size
747
+ resident_block = transfer.resident_block_ids[source_index]
748
+ # Each import pays one H2D page copy and enqueue per layer/rank
749
+ # instead of tail prefill on D. Restore the prompt rows before
750
+ # decode appends to this page; unused rows do not extend the
751
+ # logical sequence.
752
+ # cudaHostRegister pins this pool outside PyTorch's allocator,
753
+ # so the sync checker cannot recognize it via is_pinned().
754
+ with gpu_sync_allowed():
755
+ destination[resident_block].copy_(
756
+ source[host_start : host_start + self.kernel_block_size],
757
+ non_blocking=True,
758
+ )
759
+ self._record_transfer_completion(transfers, stream=current_stream())
760
+
761
def _submit_transfers(self, transfers: list[SparseKVPageTransfer]) -> None:
762
if self.cache_handles[0].runtime.eager_host_mirror:
763
self._record_transfer_completion(transfers)
@@ -733,15 +771,12 @@ class HiSparseConnectorWorker:
771
descriptor_count = len(transfers) * num_layers
772
descriptors = self._acquire_dma_descriptors(descriptor_count)
773
destination_rows = np.fromiter(
736
- (
737
- transfer.destination_block_id * self.kernel_block_size
738
- for transfer in transfers
739
- ),
774
+ (transfer.host_block_id * self.kernel_block_size for transfer in transfers),
775
dtype=np.int64,
776
count=len(transfers),
777
)
778
source_blocks_by_transfer = np.asarray(
744
- [transfer.source_block_ids for transfer in transfers], dtype=np.int64
779
+ [transfer.resident_block_ids for transfer in transfers], dtype=np.int64
780
)
781
if source_blocks_by_transfer.ndim != 2:
782
raise RuntimeError(
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py
+2
-1
@@ -3278,7 +3278,8 @@ class NixlBaseConnectorWorker:
3278
"""Pair an uncached decode suffix with the same prefill regions."""
3279
assert len(decode_block_ids) == len(prefill_block_ids)
3280
if not any(decode_block_ids):
3281
- return [], prefill_block_ids
3281
+ empty_regions: list[list[int]] = [[] for _ in decode_block_ids]
3282
+ return empty_regions, empty_regions.copy()
3283
3284
if num_computed_blocks is not None:
3285
assert num_remote_blocks is not None
vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py
+8
-2
@@ -168,8 +168,14 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
168
local_block_ids = meta.local_physical_block_ids
169
remote_region_groups = self.dst_region_group_ids[engine_id]
170
local_region_groups = self.region_group_ids or remote_region_groups
171
- groups_differ = local_region_groups != remote_region_groups
172
- if groups_differ:
171
+ if not local_block_ids:
172
+ # Region expansion cannot index empty groups. Pass empty specs to
173
+ # _read_blocks so its existing cache-hit notification path runs.
174
+ read_specs = [
175
+ ReadSpec(remote_rank=rank, local_block_ids=[], remote_block_ids=[])
176
+ for rank in plan.all_source_ranks
177
+ ]
178
+ elif local_region_groups != remote_region_groups:
179
if not self.use_mla or self._has_mamba:
180
raise NotImplementedError(
181
"Different NIXL cache-group layouts are only supported for "
vllm/v1/core/single_type_kv_cache_manager.py
+12
-8
@@ -2498,12 +2498,14 @@ class HiSparseResidentManager(_HiSparseAuxiliaryManager):
2498
del num_tokens_main_model
2499
assert not new_computed_blocks
2500
if total_computed_tokens > num_local_computed_tokens:
2501
- if total_computed_tokens % self.block_size != 0:
2502
- raise ValueError(
2503
- "A host-only HiSparse import must end on a cache-block boundary."
2504
- )
2505
- imported_pages = total_computed_tokens // self.block_size
2506
- return max(cdiv(num_tokens, self.block_size) - imported_pages, 0)
2501
+ # Keep the last imported page writable, including the last-token
2502
+ # replay on a full prompt hit. Only earlier pages stay host-only.
2503
+ imported_pages = cdiv(total_computed_tokens, self.block_size)
2504
+ existing = len(self.req_to_blocks.get(request_id, ()))
2505
+ needs_tail = self.get_resident_page(request_id, imported_pages - 1) is None
2506
+ return int(needs_tail) + max(
2507
+ cdiv(num_tokens, self.block_size) - max(imported_pages, existing), 0
2508
+ )
2509
existing = len(self.req_to_blocks.get(request_id, ()))
2510
host_pages = cdiv(num_local_computed_tokens, self.block_size)
2511
required = cdiv(num_tokens, self.block_size)
@@ -2518,13 +2520,15 @@ class HiSparseResidentManager(_HiSparseAuxiliaryManager):
2520
num_local_computed_tokens: int,
2521
num_external_computed_tokens: int,
2522
) -> None:
2521
- """Represent imported host history with null resident pages."""
2523
+ """Reserve a writable final page; earlier imported pages stay on host."""
2524
assert num_external_computed_tokens > 0
2525
num_tokens = num_local_computed_tokens + num_external_computed_tokens
2526
blocks = self.req_to_blocks[request_id]
2525
- tail_page = cdiv(num_tokens, self.block_size) - 1
2527
+ tail_page = (num_tokens - 1) // self.block_size
2528
if len(blocks) <= tail_page:
2529
blocks.extend([self._null_block] * (tail_page + 1 - len(blocks)))
2530
+ if blocks[tail_page].is_null:
2531
+ blocks[tail_page] = self.block_pool.get_new_blocks(1)[0]
2532
2533
def add_local_computed_blocks(
2534
self,
vllm/v1/hisparse/coordinator.py
+47
-21
@@ -73,6 +73,7 @@ class _PendingSpill:
73
request_state: _HiSparseRequestState
74
host_block: KVCacheBlock
75
resident_blocks: tuple[KVCacheBlock, ...]
76
+ restore: bool = False
77
expected_worker_completions: int = 0
78
worker_completions: int = 0
79
enqueue_applied: bool = False
@@ -271,25 +272,29 @@ class HiSparseCoordinator:
272
num_computed_tokens // self.host_manager.block_size, len(host_blocks)
273
)
274
for host_idx in range(state.copies_recorded_blocks, num_host_blocks):
274
- host_block = host_blocks[host_idx]
275
- if host_block.is_null or host_block.block_hash is None:
276
- continue
277
- blocks: list[KVCacheBlock] = []
278
- for manager in self.resident_managers:
279
- block = manager.get_resident_page(request_id, host_idx)
280
- if block is None:
281
- break
282
- blocks.append(block)
283
- if len(blocks) != len(self.resident_managers):
275
+ if host_idx in state.pending_pages:
276
continue
285
- self._drop_copy(host_block.block_hash)
286
- self.copies[host_block.block_hash] = tuple(blocks)
287
- for block in blocks:
288
- self._copy_by_block[block.block_id] = host_block.block_hash
277
+ self._record_copy(request_id, host_idx, host_blocks[host_idx])
278
state.copies_recorded_blocks = max(
279
state.copies_recorded_blocks, num_host_blocks
280
)
281
282
+ def _record_copy(
283
+ self, request_id: str, page_idx: int, host_block: KVCacheBlock
284
+ ) -> None:
285
+ if host_block.is_null or host_block.block_hash is None:
286
+ return
287
+ blocks: list[KVCacheBlock] = []
288
+ for manager in self.resident_managers:
289
+ block = manager.get_resident_page(request_id, page_idx)
290
+ if block is None:
291
+ return
292
+ blocks.append(block)
293
+ self._drop_copy(host_block.block_hash)
294
+ self.copies[host_block.block_hash] = tuple(blocks)
295
+ for block in blocks:
296
+ self._copy_by_block[block.block_id] = host_block.block_hash
297
+
298
def _drop_copy(self, host_hash: BlockHashWithGroupId) -> None:
299
blocks = self.copies.pop(host_hash, None)
300
if blocks is None:
@@ -406,9 +411,14 @@ class HiSparseCoordinator:
411
assert self.host_manager is not None
412
host_block_size = self.host_manager.block_size
413
num_pages = num_computed_tokens // host_block_size
414
+ importing_pages = cdiv(
415
+ self._pending_imports.get(request_id, 0), host_block_size
416
+ )
417
state = self._get_request_state(request_id)
418
budget = max(self.max_spill_pages - len(self.spills_to_send), 0)
419
for page_idx in range(num_pages):
420
+ if page_idx < importing_pages:
421
+ continue
422
if budget == 0:
423
break
424
if page_idx in state.pending_pages:
@@ -420,7 +430,7 @@ class HiSparseCoordinator:
430
for manager in self.resident_managers
431
):
432
continue
423
- if self._plan_spill(request_id, page_idx, after_forward=True):
433
+ if self._plan_page_transfer(request_id, page_idx, after_forward=True):
434
budget -= 1
435
436
def publish_when_ready(
@@ -491,17 +501,27 @@ class HiSparseCoordinator:
501
state = self._get_request_state(request_id)
502
state.valid_pages = set(range(num_pages))
503
state.ready_prefix_pages = num_pages
504
+ if num_computed_tokens:
505
+ self._plan_page_transfer(
506
+ request_id,
507
+ (num_computed_tokens - 1) // block_size,
508
+ after_forward=False,
509
+ restore=True,
510
+ )
511
self._publish_host_blocks_if_ready(request_id)
512
496
- def _plan_spill(
513
+ def _plan_page_transfer(
514
self,
515
request_id: str,
516
page_idx: int,
517
*,
518
after_forward: bool,
519
+ restore: bool = False,
520
) -> bool:
521
assert self.host_manager is not None
522
state = self._get_request_state(request_id)
523
+ if page_idx in state.pending_pages:
524
+ return False
525
host_blocks = self.host_manager.req_to_blocks.get(request_id)
526
if host_blocks is None or page_idx >= len(host_blocks):
527
return False
@@ -523,9 +543,10 @@ class HiSparseCoordinator:
543
self.next_spill_id += 1
544
plan = SparseKVPageTransfer(
545
transfer_id=spill_id,
526
- destination_block_id=host_block.block_id,
527
- source_block_ids=tuple(block.block_id for block in blocks),
546
+ host_block_id=host_block.block_id,
547
+ resident_block_ids=tuple(block.block_id for block in blocks),
548
after_forward=after_forward,
549
+ restore=restore,
550
)
551
self.pending_spills[spill_id] = _PendingSpill(
552
transfer_id=spill_id,
@@ -533,6 +554,7 @@ class HiSparseCoordinator:
554
request_state=state,
555
host_block=host_block,
556
resident_blocks=tuple(blocks),
557
+ restore=restore,
558
)
559
state.pending_pages[page_idx] = spill_id
560
self.spills_to_send.append(plan)
@@ -666,7 +688,7 @@ class HiSparseCoordinator:
688
self._complete_host_writes()
689
690
def _apply_enqueued_spills(self) -> None:
669
- """Drop the DMA pins once the worker has enqueued a spill."""
691
+ """Drop the GPU pins once every worker has completed the transfer."""
692
for pending in self.pending_spills.values():
693
if (
694
pending.enqueue_applied
@@ -692,8 +714,12 @@ class HiSparseCoordinator:
714
request_id, page_idx = pending.page
715
pending.request_state.pending_pages.pop(page_idx, None)
716
if self.request_states.get(request_id) is pending.request_state:
695
- pending.request_state.valid_pages.add(page_idx)
696
- self._page_became_clean(request_id, pending.request_state, page_idx)
717
+ if not pending.restore:
718
+ pending.request_state.valid_pages.add(page_idx)
719
+ if page_idx in pending.request_state.valid_pages:
720
+ self._page_became_clean(request_id, pending.request_state, page_idx)
721
+ if pending.restore:
722
+ self._record_copy(request_id, page_idx, pending.host_block)
723
completed_request_ids.add(request_id)
724
assert self.host_manager is not None
725
self.host_manager.block_pool.free_blocks([pending.host_block])
vllm/v1/hisparse/types.py
+3
-2
@@ -9,9 +9,10 @@ class SparseKVPageTransfer:
9
"""Copy one logical KV page between cache-manager and worker-owned tiers."""
10
11
transfer_id: int
12
- destination_block_id: int
13
- source_block_ids: tuple[int, ...]
12
+ host_block_id: int
13
+ resident_block_ids: tuple[int, ...]
14
after_forward: bool
15
+ restore: bool = False
16
17
18
@dataclass(frozen=True)