[Bugfix][HiSparse] Fix a chunked-prefill preemption livelock (#59494)

Signed-off-by: Matthew Bonanni <mbonanni@redhat.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

Matthew Bonanni committed Oct 1, 2026 at 13:02 UTC 3a6963664537ed21172e2ec12e96e3a2dcd3718c
3 files changed +106 -29
tests/v1/core/test_prefix_caching.py
+60 -1
@@ -1259,6 +1259,63 @@ def test_hisparse_cleanup_publishes_only_finalized_terminal_pages(
1259 assert not coordinator.has_pending_work()
1260
1261
1262 +@pytest.mark.parametrize("num_pages,reads_host", [(4, True), (3, False)])
1263 +def test_hisparse_prefill_reads_host_once_it_fills_admission_window(
1264 + num_pages, reads_host
1265 +):
1266 + """A prefill switches to host reads once it fills its admission window.
1267 +
1268 + Admission caps each request's resident pages at the in-flight window,
1269 + assuming older pages move to host, but pinned pages were released only
1270 + once free blocks fell below a fixed watermark. A chunked prefill larger
1271 + than the watermark outgrew the pool first and was preempted.
1272 + """
1273 + window_pages = 4
1274 + manager = make_hisparse_kv_cache_manager(
1275 + 32,
1276 + 16,
1277 + enable_caching=True,
1278 + max_in_flight_tokens=window_pages * HISPARSE_BLOCK_SIZE,
1279 + )
1280 + coordinator = get_hisparse_coordinator(manager)
1281 + tokens = list(range(2 * window_pages * HISPARSE_BLOCK_SIZE))
1282 + request = make_request("prefill", tokens, HISPARSE_BLOCK_SIZE, sha256)
1283 + assert manager.allocate_slots(request, num_pages * HISPARSE_BLOCK_SIZE) is not None
1284 + pool = coordinator.gpu_pool
1285 + assert pool is not None
1286 + assert pool.get_num_free_blocks() >= coordinator.transition_watermark
1287 +
1288 + hot_manager = coordinator.hot_managers[0]
1289 + assert (request.request_id in hot_manager.hot_required) == reads_host
1290 +
1291 +
1292 +def test_hisparse_preempted_prefill_resumes_from_durable_prefix():
1293 + """A prefill preempted mid-way must resume from the pages already on host.
1294 +
1295 + Host writes trail a chunked prefill by a chunk, and host pages were only
1296 + published once the whole computed prefix was durable. Each chunk moved
1297 + that target past the finished writes, so nothing was published before the
1298 + prefill ended: a prefill preempted each time it outgrew the GPU pool
1299 + recomputed from the start forever.
1300 + """
1301 + manager = make_hisparse_kv_cache_manager(32, 16, enable_caching=True)
1302 + coordinator = get_hisparse_coordinator(manager)
1303 + chunk = 2 * HISPARSE_BLOCK_SIZE
1304 + tokens = list(range(3 * chunk))
1305 + request = make_request("long", tokens, HISPARSE_BLOCK_SIZE, sha256)
1306 + assert manager.allocate_slots(request, chunk) is not None
1307 + request.num_computed_tokens = chunk
1308 + first_chunk_writes = coordinator.build_offload_command().page_transfers
1309 + assert manager.allocate_slots(request, chunk) is not None
1310 + request.num_computed_tokens = 2 * chunk
1311 + acks = {transfer.transfer_id: 1 for transfer in first_chunk_writes}
1312 + coordinator.update_spills(acks, acks)
1313 + manager.free(request)
1314 +
1315 + resumed = make_request("resumed", tokens, HISPARSE_BLOCK_SIZE, sha256)
1316 + assert manager.get_computed_blocks(resumed)[1] == chunk
1317 +
1318 +
1319 def test_hisparse_terminal_prefix_waits_for_all_workers_and_preserves_identity():
1320 """Old completions retain their pages through pressure and request-ID reuse."""
1321 manager = make_hisparse_kv_cache_manager(32, 8, enable_caching=True)
@@ -1285,7 +1342,9 @@ def test_hisparse_terminal_prefix_waits_for_all_workers_and_preserves_identity()
1342 assert not {block.block_id for block in old_blocks[:2]} & {
1343 block.block_id for block in pressure + new_blocks
1344 }
1288 - assert all(block.block_hash is None for block in old_blocks)
1345 + # Only the page every worker finished writing is published.
1346 + assert old_blocks[0].block_hash is not None
1347 + assert all(block.block_hash is None for block in old_blocks[1:])
1348 coordinator.update_spills({}, partial)
1349 repeated = make_request("probe", tokens, HISPARSE_BLOCK_SIZE, sha256)
1350 assert manager.get_computed_blocks(repeated)[1] == 2 * HISPARSE_BLOCK_SIZE
vllm/v1/core/single_type_kv_cache_manager.py
+7 -6
@@ -112,7 +112,7 @@ class SingleTypeKVCacheManager(ABC):
112 self.kv_cache_spec = kv_cache_spec
113 self.block_pool = block_pool
114 self.enable_caching = enable_caching
115 - self._max_admission_blocks_per_request = max_admission_blocks_per_request
115 + self.max_admission_blocks_per_request = max_admission_blocks_per_request
116 # Record newly allocated block ids only when worker-side zeroing will
117 # consume them and this manager holds a spec type that gets zeroed.
118 self._record_new_block_ids = (
@@ -202,7 +202,7 @@ class SingleTypeKVCacheManager(ABC):
202 model in spec decode). w/o spec decode, it is num_tokens;
203 with spec decode, it is num_tokens - num_lookahead_tokens.
204 apply_admission_cap: If True, clamp by `num_required_blocks` by
205 - `_max_admission_blocks_per_request`for recycling-aware specs
205 + `max_admission_blocks_per_request`for recycling-aware specs
206 (SWA, chunked-local).
207 prefill_end: The token index the request's prefill ends at, the
208 same value the scheduler splits chunks against. Mamba reserves
@@ -214,7 +214,7 @@ class SingleTypeKVCacheManager(ABC):
214
215 """
216 num_required_blocks = cdiv(num_tokens, self.block_size)
217 - if apply_admission_cap and self._max_admission_blocks_per_request is not None:
217 + if apply_admission_cap and self.max_admission_blocks_per_request is not None:
218 # Recycling-aware specs (SWA, chunked-local) cap the per-request
219 # reservation here so admission matches the startup pool sizer
220 # (`SlidingWindowSpec.max_admission_blocks_per_request` / its
@@ -225,7 +225,7 @@ class SingleTypeKVCacheManager(ABC):
225 # Drift between the two would re-introduce the deadlock from
226 # issue #39734 or, worse, mid-prefill OOM.
227 num_required_blocks = min(
228 - num_required_blocks, self._max_admission_blocks_per_request
228 + num_required_blocks, self.max_admission_blocks_per_request
229 )
230 num_req_blocks = len(self.req_to_blocks.get(request_id, ()))
231
@@ -2488,6 +2488,8 @@ class HiSparseHotManager(_HiSparseAuxiliaryManager):
2488 class HiSparseResidentManager(_HiSparseAuxiliaryManager):
2489 """Track GPU-resident pages for otherwise host-backed KV."""
2490
2491 + max_admission_blocks_per_request: int
2492 +
2493 def get_num_blocks_to_allocate(
2494 self,
2495 request_id: str,
@@ -2514,8 +2516,7 @@ class HiSparseResidentManager(_HiSparseAuxiliaryManager):
2516 host_pages = cdiv(num_local_computed_tokens, self.block_size)
2517 required = cdiv(num_tokens, self.block_size)
2518 if apply_admission_cap:
2517 - assert self._max_admission_blocks_per_request is not None
2518 - required = min(required, self._max_admission_blocks_per_request)
2519 + required = min(required, self.max_admission_blocks_per_request)
2520 return max(required - max(existing, host_pages), 0)
2521
2522 def allocate_external_computed_blocks(
vllm/v1/hisparse/coordinator.py
+39 -22
@@ -318,6 +318,19 @@ class HiSparseCoordinator:
318 manager.has_hot(request_id) for manager in self.hot_managers
319 )
320
321 + def _fills_admission_window(self, request_id: str) -> bool:
322 + """Whether the request holds the resident pages admission reserved.
323 +
324 + Admission caps a request's resident pages at the in-flight window,
325 + assuming older pages move to host; keeping them pinned past it lets a
326 + chunked prefill outgrow the pool it was admitted into.
327 + """
328 + return any(
329 + len(manager.req_to_blocks.get(request_id, ()))
330 + >= manager.max_admission_blocks_per_request
331 + for manager in self.resident_managers
332 + )
333 +
334 def _resident_page_blocks(
335 self, request_id: str, page_idx: int
336 ) -> list[KVCacheBlock] | None:
@@ -390,8 +403,9 @@ class HiSparseCoordinator:
403
404 A request that can read from host releases every clean sealed page to
405 the pool. One that cannot keeps its pages pinned until the shared pool
393 - runs low, then asks for a hot region. Pages remain pinned until that region
394 - is allocated on a subsequent scheduling pass.
406 + runs low, or until it holds the resident pages admission reserved for
407 + it, then asks for a hot region. Pages remain pinned until that region is
408 + allocated on a subsequent scheduling pass.
409 """
410 if not self.resident_managers:
411 return
@@ -403,7 +417,10 @@ class HiSparseCoordinator:
417 state.pages_to_adopt = 0
418 if not self._can_read_from_host(request_id):
419 assert self.gpu_pool is not None
406 - if self.gpu_pool.get_num_free_blocks() >= self.transition_watermark:
420 + if (
421 + self.gpu_pool.get_num_free_blocks() >= self.transition_watermark
422 + and not self._fills_admission_window(request_id)
423 + ):
424 return
425 for manager in self.hot_managers:
426 manager.require_hot(request_id)
@@ -453,47 +470,47 @@ class HiSparseCoordinator:
470 *,
471 replay_boundaries: Sequence[int],
472 ) -> None:
456 - """Publish host-source hashes only after their pages are durable."""
473 + """Publish host-source hashes of the pages that are already durable."""
474 manager = self.host_manager
475 if manager is None:
476 return
460 - num_pages = num_computed_tokens // manager.block_size
477 request_id = request.request_id
462 - state = self._get_request_state(request_id)
463 - if state.ready_prefix_pages >= num_pages:
464 - manager.publish_blocks(
465 - request,
466 - num_computed_tokens,
467 - retention_interval=retention_interval,
468 - replay_boundaries=replay_boundaries,
469 - )
470 - self._record_copies(request_id, num_computed_tokens)
471 - state.publication = None
472 - return
473 - state.publication = _PendingPublication(
478 + self._get_request_state(request_id).publication = _PendingPublication(
479 request=request,
480 num_computed_tokens=num_computed_tokens,
476 - num_pages=num_pages,
481 + num_pages=num_computed_tokens // manager.block_size,
482 retention_interval=retention_interval,
483 replay_boundaries=replay_boundaries,
484 )
485 + self._publish_host_blocks_if_ready(request_id)
486
487 def _publish_host_blocks_if_ready(self, request_id: str) -> None:
488 + """Publish the durable prefix of the pending publication.
489 +
490 + Host writes trail a long prefill by about a chunk, so waiting for the
491 + whole computed prefix would publish nothing until the prefill ends, and
492 + a prefill preempted before then would recompute from the start.
493 + """
494 state = self.request_states.get(request_id)
495 if state is None:
496 return
497 publication = state.publication
486 - if publication is None or state.ready_prefix_pages < publication.num_pages:
498 + if publication is None:
499 return
500 assert self.host_manager is not None
501 + num_tokens = min(
502 + publication.num_computed_tokens,
503 + state.ready_prefix_pages * self.host_manager.block_size,
504 + )
505 self.host_manager.publish_blocks(
506 publication.request,
491 - publication.num_computed_tokens,
507 + num_tokens,
508 retention_interval=publication.retention_interval,
509 replay_boundaries=publication.replay_boundaries,
510 )
495 - self._record_copies(request_id, publication.num_computed_tokens)
496 - state.publication = None
511 + self._record_copies(request_id, num_tokens)
512 + if state.ready_prefix_pages >= publication.num_pages:
513 + state.publication = None
514
515 def record_pending_host_import(self, request_id: str, num_tokens: int) -> None:
516 """Note a prefix an external load is populating in host pages."""