[Bugfix][HiSparse] Preserve host prefix publication after request completion (#59007)

Signed-off-by: Yueh-Ting Chen <yueh.ting.chen@gmail.com> Co-authored-by: Matthew Bonanni <mbonanni@redhat.com>

Yueh-Ting (eop) Chen committed Oct 1, 2026 at 00:14 UTC ff1b87cca25690fef6bd12667fd0d26690d949af
3 files changed +223
tests/v1/core/test_prefix_caching.py
+75
@@ -1178,6 +1178,81 @@ def test_hisparse_finished_request_leaves_free_copies():
1178 assert not coordinator.spills_to_send
1179
1180
1181 +@pytest.mark.parametrize(
1182 + "status,expected_cached_pages",
1183 + [
1184 + (RequestStatus.FINISHED_STOPPED, 2),
1185 + (RequestStatus.FINISHED_ABORTED, 2),
1186 + (RequestStatus.RUNNING, 0),
1187 + ],
1188 + ids=["stopped", "aborted", "running"],
1189 +)
1190 +def test_hisparse_cleanup_publishes_only_finalized_terminal_pages(
1191 + status, expected_cached_pages
1192 +):
1193 + """Only terminal cleanup may publish completed, finalized pages."""
1194 + manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
1195 + coordinator = get_hisparse_coordinator(manager)
1196 + request = make_request("terminal", list(range(33)), HISPARSE_BLOCK_SIZE, sha256)
1197 + request.spec_token_ids = list(range(33, 49))
1198 + assert manager.allocate_slots(request, 49, num_lookahead_tokens=16) is not None
1199 + host_blocks = list(manager.get_blocks(request.request_id).blocks[0])
1200 + command = coordinator.build_offload_command()
1201 + counts = {transfer.transfer_id: 1 for transfer in command.page_transfers}
1202 + assert len(counts) == 2
1203 + request.status = status
1204 + manager.free(request)
1205 + coordinator.update_spills(counts, counts)
1206 + assert all(
1207 + block.block_hash is not None for block in host_blocks[:expected_cached_pages]
1208 + )
1209 + assert all(
1210 + block.block_hash is None for block in host_blocks[expected_cached_pages:]
1211 + )
1212 + assert all(block.ref_cnt == 0 for block in host_blocks)
1213 + assert not coordinator.has_pending_work()
1214 +
1215 +
1216 +def test_hisparse_terminal_prefix_waits_for_all_workers_and_preserves_identity():
1217 + """Old completions retain their pages through pressure and request-ID reuse."""
1218 + manager = make_hisparse_kv_cache_manager(32, 8, enable_caching=True)
1219 + coordinator = get_hisparse_coordinator(manager)
1220 + host_pool = coordinator.get_host_block_pool()
1221 + tokens = list(range(2 * HISPARSE_BLOCK_SIZE + 1))
1222 + request = make_request("reused", tokens, HISPARSE_BLOCK_SIZE, sha256)
1223 + assert manager.allocate_slots(request, len(tokens)) is not None
1224 + old_blocks = list(manager.get_blocks(request.request_id).blocks[0])
1225 + command = coordinator.build_offload_command()
1226 + counts = {transfer.transfer_id: 2 for transfer in command.page_transfers}
1227 + partial = dict.fromkeys(counts, 1)
1228 + first_completions = partial.copy()
1229 + first_completions[next(iter(counts))] = 2
1230 + coordinator.update_spills(counts, first_completions)
1231 + request.status = RequestStatus.FINISHED_LENGTH_CAPPED
1232 + manager.free(request)
1233 + replacement = make_request(
1234 + "reused", list(range(100, 117)), HISPARSE_BLOCK_SIZE, sha256
1235 + )
1236 + assert manager.allocate_slots(replacement, 17) is not None
1237 + new_blocks = list(manager.get_blocks(replacement.request_id).blocks[0])
1238 + pressure = host_pool.get_new_blocks(host_pool.get_num_free_blocks())
1239 + assert not {block.block_id for block in old_blocks[:2]} & {
1240 + block.block_id for block in pressure + new_blocks
1241 + }
1242 + assert all(block.block_hash is None for block in old_blocks)
1243 + coordinator.update_spills({}, partial)
1244 + repeated = make_request("probe", tokens, HISPARSE_BLOCK_SIZE, sha256)
1245 + assert manager.get_computed_blocks(repeated)[1] == 2 * HISPARSE_BLOCK_SIZE
1246 + assert all(block.block_hash is None for block in new_blocks)
1247 + assert all(block.ref_cnt == 0 for block in old_blocks[:2])
1248 + coordinator.update_spills({}, partial)
1249 + assert all(block.ref_cnt == 0 for block in old_blocks[:2])
1250 + host_pool.free_blocks(pressure)
1251 + manager.free(replacement)
1252 + _publish_hisparse_pages(manager)
1253 + assert not coordinator.has_pending_work()
1254 +
1255 +
1256 def test_hisparse_reset_prefix_cache_drops_copies():
1257 """Reset must forget GPU copies along with the host hashes they mirror."""
1258 manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
tests/v1/e2e/general/test_hisparse.py
+99
@@ -1,6 +1,8 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +import time
5 +
6 import prometheus_client
7 import pytest
8 import torch
@@ -18,6 +20,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.multi_connector import (
20 MultiConnector,
21 )
22 from vllm.platforms import current_platform
23 +from vllm.sampling_params import SamplingParams
24 from vllm.utils.gpu_sync_debug import gpu_sync_allowed
25 from vllm.v1.hisparse.coordinator import get_hisparse_coordinator
26
@@ -301,3 +304,99 @@ def test_hisparse_host_exhaustion_defers_requests(
304 assert checked_steps > 0
305 assert refusals > 0
306 assert actual == expected
307 +
308 +
309 +@pytest.mark.skipif(
310 + not current_platform.is_cuda(), reason="HiSparse requires NVIDIA CUDA"
311 +)
312 +@fork_new_process_for_each_test
313 +def test_hisparse_terminal_prefix_reuse(
314 + monkeypatch: pytest.MonkeyPatch,
315 + vllm_runner: type[VllmRunner],
316 +):
317 + """Finished requests publish mirrored host KV after a late acknowledgement."""
318 + capability = current_platform.get_device_capability()
319 + if capability is None or capability.major < 9:
320 + pytest.skip("Sparse MLA requires Hopper or newer")
321 + monkeypatch.setenv("VLLM_ENABLE_V1_MULTIPROCESSING", "0")
322 + monkeypatch.setenv("VLLM_DEEP_GEMM_WARMUP", "skip")
323 + target = [1000 + i % 64 for i in range(257)]
324 + sampling = SamplingParams(temperature=0, max_tokens=1, ignore_eos=True)
325 + with vllm_runner(
326 + MODEL,
327 + load_format="dummy",
328 + hf_overrides=_shrink_config,
329 + attention_config=AttentionConfig(
330 + hisparse_config=HiSparseConfig(
331 + device_buffer_size=512, eager_host_mirror=True
332 + )
333 + ),
334 + kv_transfer_config=KVTransferConfig(
335 + kv_connector="HiSparseConnector",
336 + kv_role="kv_both",
337 + kv_connector_extra_config={"host_pool_gib": 0.01},
338 + ),
339 + block_size=64,
340 + max_model_len=320,
341 + max_num_batched_tokens=512,
342 + max_num_seqs=2,
343 + num_gpu_blocks_override=128,
344 + enable_prefix_caching=True,
345 + enable_chunked_prefill=True,
346 + enforce_eager=True,
347 + ) as runner:
348 + engine = runner.llm.llm_engine.engine_core.engine_core
349 + coordinator = get_hisparse_coordinator(engine.scheduler.kv_cache_manager)
350 + worker = _get_hisparse_worker(runner)
351 + original_updates = worker.take_transfer_updates
352 + original_free = coordinator.free
353 + held_completions: list[int] = []
354 + update_calls = 0
355 + terminal_pending_pages = 0
356 +
357 + def take_updates():
358 + nonlocal update_calls
359 + with gpu_sync_allowed():
360 + torch.accelerator.synchronize()
361 + enqueued, completed = original_updates()
362 + held_completions.extend(completed)
363 + update_calls += 1
364 + if update_calls <= 2:
365 + return enqueued, []
366 + completed = held_completions.copy()
367 + held_completions.clear()
368 + return enqueued, completed
369 +
370 + def free(request_id):
371 + nonlocal terminal_pending_pages
372 + state = coordinator.request_states.get(request_id)
373 + if state is not None:
374 + terminal_pending_pages += len(state.pending_pages)
375 + original_free(request_id)
376 +
377 + def drain_pending_work():
378 + deadline = time.monotonic() + 10
379 + while coordinator.has_pending_work():
380 + assert time.monotonic() < deadline, "HiSparse transfers did not drain"
381 + runner.llm.llm_engine.step()
382 +
383 + monkeypatch.setattr(worker, "take_transfer_updates", take_updates)
384 + monkeypatch.setattr(coordinator, "free", free)
385 + [first] = runner.llm.generate(
386 + [{"prompt_token_ids": target}], sampling, use_tqdm=False
387 + )
388 + assert terminal_pending_pages == 4
389 + assert first.num_cached_tokens == 0
390 + assert coordinator.has_pending_work()
391 + drain_pending_work()
392 + assert not coordinator.has_pending_work()
393 + assert not held_completions
394 + assert update_calls > 2
395 +
396 + [repeated] = runner.llm.generate(
397 + [{"prompt_token_ids": target}], sampling, use_tqdm=False
398 + )
399 + assert repeated.num_cached_tokens == 256
400 + assert repeated.outputs[0].token_ids == first.outputs[0].token_ids
401 + drain_pending_work()
402 + assert not coordinator.has_pending_work()
vllm/v1/hisparse/coordinator.py
+49
@@ -44,6 +44,8 @@ class _PendingPublication:
44 num_pages: int
45 retention_interval: int | None
46 replay_boundaries: Sequence[int]
47 + detached_blocks: list[KVCacheBlock] | None = None
48 + num_cached_blocks: int = 0
49
50
51 @dataclass
@@ -724,6 +726,10 @@ class HiSparseCoordinator:
726 if pending.restore:
727 self._record_copy(request_id, page_idx, pending.host_block)
728 completed_request_ids.add(request_id)
729 + elif pending.request_state.publication is not None:
730 + if not pending.restore:
731 + pending.request_state.valid_pages.add(page_idx)
732 + self._publish_detached_blocks(pending.request_state)
733 assert self.host_manager is not None
734 self.host_manager.block_pool.free_blocks([pending.host_block])
735 for request_id in completed_request_ids:
@@ -732,12 +738,55 @@ class HiSparseCoordinator:
738 state.ready_prefix_pages += 1
739 self._publish_host_blocks_if_ready(request_id)
740
741 + def _publish_detached_blocks(self, state: _HiSparseRequestState) -> None:
742 + publication = state.publication
743 + if publication is None or state.pending_pages:
744 + return
745 + blocks = publication.detached_blocks
746 + if blocks is None:
747 + return
748 + assert self.host_manager is not None
749 + while state.ready_prefix_pages in state.valid_pages:
750 + state.ready_prefix_pages += 1
751 + # The host group uses full-attention retention. Only sealed pages with
752 + # completed copies can outlive the request, including on cancellation.
753 + self.host_manager.block_pool.cache_full_blocks(
754 + request=publication.request,
755 + blocks=blocks,
756 + num_cached_blocks=publication.num_cached_blocks,
757 + num_full_blocks=min(state.ready_prefix_pages, publication.num_pages),
758 + block_size=self.host_manager.block_size,
759 + kv_cache_group_id=self.host_manager.kv_cache_group_id,
760 + )
761 + self.host_manager.block_pool.free_blocks(reversed(blocks))
762 + state.publication = None
763 +
764 def free(self, request_id: str) -> None:
765 """Detach the request; its clean pages stay readable copies in the pool."""
766 self._pending_imports.pop(request_id, None)
767 state = self.request_states.pop(request_id, None)
768 if state is None:
769 return
770 + publication = state.publication
771 + if (
772 + publication is not None
773 + and publication.request.is_finished()
774 + and state.pending_pages
775 + ):
776 + assert self.host_manager is not None
777 + publication.detached_blocks = self.host_manager.req_to_blocks[request_id][
778 + : publication.num_pages
779 + ]
780 + publication.num_cached_blocks = self.host_manager.num_cached_block.get(
781 + request_id, 0
782 + )
783 + # Completed pages need leases too: another allocation may evict
784 + # them before the remaining copies finish and the prefix publishes.
785 + self.host_manager.block_pool.touch(publication.detached_blocks)
786 + else:
787 + # Preemption can cancel a scheduled forward after its spills were
788 + # planned, so its copies do not prove that the KV was computed.
789 + state.publication = None
790 for manager in self.resident_managers:
791 blocks = manager.req_to_blocks.get(request_id)
792 if not blocks: