[Bugfix][HiSparse] Adopt GPU prefix copies after the hit's allocation (#59282)

Signed-off-by: Matthew Bonanni <mbonanni@redhat.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Matthew Bonanni committed Sep 30, 2026 at 15:43 UTC 3eb6cec22ad9bb098393021b956feaa97081fc85
2 files changed +61 -3
tests/v1/core/test_prefix_caching.py
+46
@@ -1139,6 +1139,52 @@ def test_hisparse_prefix_hit_adopts_gpu_shadow_pages():
1139 )
1140
1141
1142 +@pytest.mark.parametrize("free_blocks,adopted_pages", [(7, []), (10, [0, 1])])
1143 +def test_hisparse_prefix_hit_under_pressure_adopts_surviving_copies(
1144 + free_blocks, adopted_pages
1145 +):
1146 + """A host prefix hit must not starve its own allocation of free blocks.
1147 +
1148 + Admission counts free GPU copies as free, but adopting a copy pins it. When
1149 + adoption ran before the hit's allocation, it took blocks the allocation was
1150 + promised and the pool ran dry (``Cannot get N free blocks``). The allocation
1151 + now goes first, evicting a prefix's copies tail first; the copies it leaves
1152 + are adopted.
1153 + """
1154 + manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
1155 + tokens = list(range(4 * HISPARSE_BLOCK_SIZE))
1156 + original = make_request("original", tokens, HISPARSE_BLOCK_SIZE, sha256)
1157 + assert manager.allocate_slots(original, num_new_tokens=len(tokens)) is not None
1158 + _publish_hisparse_pages(manager)
1159 + copy_ids = [block.block_id for block in manager.get_blocks("original").blocks[2]]
1160 + manager.free(original)
1161 + pool = manager.block_pool
1162 + pool.get_new_blocks(pool.get_num_free_blocks() - free_blocks)
1163 +
1164 + resumed = make_request("resumed", tokens, HISPARSE_BLOCK_SIZE, sha256)
1165 + computed, num_computed, _ = manager.get_computed_blocks(resumed)
1166 + assert num_computed == 3 * HISPARSE_BLOCK_SIZE
1167 + assert manager.allocate_slots(
1168 + resumed,
1169 + num_new_tokens=len(tokens) - num_computed,
1170 + num_new_computed_tokens=num_computed,
1171 + new_computed_blocks=computed,
1172 + )
1173 +
1174 + resumed_blocks = manager.get_blocks("resumed").blocks
1175 + assert [block.block_id for block in resumed_blocks[2][:3]] == [
1176 + copy_ids[page] if page in adopted_pages else pool.null_block.block_id
1177 + for page in range(3)
1178 + ]
1179 + gpu_ids = [
1180 + block.block_id
1181 + for group in resumed_blocks[1:]
1182 + for block in group
1183 + if not block.is_null
1184 + ]
1185 + assert len(gpu_ids) == len(set(gpu_ids))
1186 +
1187 +
1188 def test_hisparse_host_backed_request_accepts_local_prefix_hit():
1189 """Local group-completion hits need no external host import allocation."""
1190 manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
vllm/v1/hisparse/coordinator.py
+15 -3
@@ -66,6 +66,8 @@ class _HiSparseRequestState:
66 copies_recorded_blocks: int = 0
67 pinned_clean: set[int] = field(default_factory=set)
68 unpinned_pages: set[int] = field(default_factory=set)
69 + # Prefix pages whose GPU copies are adopted after the admitting allocation.
70 + pages_to_adopt: int = 0
71
72
73 @dataclass
@@ -195,7 +197,9 @@ class HiSparseCoordinator:
197 state = self._get_request_state(request_id)
198 state.valid_pages.update(range(num_host_pages))
199 state.ready_prefix_pages = max(num_host_pages, state.ready_prefix_pages)
198 - self._adopt_copies(request_id, state, host_blocks[:num_host_pages])
200 + # Adopting pins copies out of the free pool, so it must wait until the
201 + # allocation admission counted on has run (see update_residency).
202 + state.pages_to_adopt = max(state.pages_to_adopt, num_host_pages)
203 state.copies_recorded_blocks = max(state.copies_recorded_blocks, num_host_pages)
204
205 def take_host_block_copies(self) -> tuple[KVCacheBlockCopy, ...]:
@@ -345,7 +349,8 @@ class HiSparseCoordinator:
349 return True
350
351 def _unpin_clean_pages(self, request_id: str, state: _HiSparseRequestState) -> None:
348 - for page_idx in sorted(state.pinned_clean):
352 + # Tail first, so a prefix loses its tail pages before its head.
353 + for page_idx in sorted(state.pinned_clean, reverse=True):
354 self._unpin_page(request_id, state, page_idx)
355
356 def _page_became_clean(
@@ -391,6 +396,11 @@ class HiSparseCoordinator:
396 if not self.resident_managers:
397 return
398 state = self._get_request_state(request_id)
399 + if state.pages_to_adopt:
400 + assert self.host_manager is not None
401 + host_blocks = self.host_manager.req_to_blocks.get(request_id, ())
402 + self._adopt_copies(request_id, state, host_blocks[: state.pages_to_adopt])
403 + state.pages_to_adopt = 0
404 if not self._can_read_from_host(request_id):
405 assert self.gpu_pool is not None
406 if self.gpu_pool.get_num_free_blocks() >= self.transition_watermark:
@@ -791,7 +801,9 @@ class HiSparseCoordinator:
801 blocks = manager.req_to_blocks.get(request_id)
802 if not blocks:
803 continue
794 - for page_idx, block in enumerate(blocks):
804 + # Tail first, so a prefix loses its tail pages before its head.
805 + for page_idx in reversed(range(len(blocks))):
806 + block = blocks[page_idx]
807 if block.is_null:
808 continue
809 if page_idx in state.unpinned_pages: