@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 3b3d5287fa
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 1, 18:45 UTC

[BugFix] Resolve multiple async kv load deadlock (#44560)

Signed-off-by: Nick Hill <nickhill123@gmail.com>

Nick Hill committed Jun 6, 2026 at 16:05 UTC 3b3d5287faee2d4cf84a93419fe28a1f65c93749
6 files changed +214 -1
tests/v1/core/test_scheduler.py
+78
@@ -4474,3 +4474,81 @@ def test_ec_connector_pending_prefetch_only_checks_future_mm_features():
4474 f"Expected only {HASH_FUTURE!r} from future mm feature filtering, "
4475 f"got {future_hashes!r}. Past/boundary features must be filtered out."
4476 )
4477 +
4478 +
4479 +def test_async_load_reservation_prevents_wedge_e2e():
4480 + """Same wedge scenario as PR #40968's lateral-preemption e2e test, but
4481 + resolved by reservation-based admission control instead of preemption.
4482 +
4483 + A (8 blocks) and B (5 blocks) both want an async KV load, sharing a 4-block
4484 + prefix, in a 10-block pool (9 usable). Admitting both loads would wedge:
4485 + once their recvs finish neither can complete its local prefill (8+5 > 9).
4486 +
4487 + Here the reservation gate refuses to admit B's load while A's full sequence
4488 + is still reserved, so B never holds blocks and A is free to complete - no
4489 + deadlock, and (unlike lateral preemption) B is never preempted.
4490 + """
4491 + BLOCK_SIZE = 16
4492 + A_TOKENS = BLOCK_SIZE * 8 # bigger request
4493 + B_TOKENS = BLOCK_SIZE * 5 # smaller request
4494 + MATCHED_TOKENS = BLOCK_SIZE * 4 # 4-block prefix loaded for both
4495 + NUM_BLOCKS = 10 # 9 usable; both prefixes fit, but not both full sequences
4496 +
4497 + scheduler = create_scheduler(
4498 + block_size=BLOCK_SIZE,
4499 + num_blocks=NUM_BLOCKS,
4500 + max_num_seqs=4,
4501 + max_num_batched_tokens=A_TOKENS * 2,
4502 + use_kv_connector=mock_kv(matched_tokens=MATCHED_TOKENS, is_async=True),
4503 + )
4504 +
4505 + [a] = create_requests(
4506 + num_requests=1, num_tokens=A_TOKENS, block_size=BLOCK_SIZE, req_ids=["a"]
4507 + )
4508 + [b] = create_requests(
4509 + num_requests=1, num_tokens=B_TOKENS, block_size=BLOCK_SIZE, req_ids=["b"]
4510 + )
4511 + scheduler.add_request(a)
4512 + scheduler.add_request(b)
4513 +
4514 + EMPTY_OUTPUT = ModelRunnerOutput(
4515 + req_ids=[],
4516 + req_id_to_index={},
4517 + sampled_token_ids=[],
4518 + logprobs=None,
4519 + prompt_logprobs_dict={},
4520 + pooler_output=[],
4521 + )
4522 +
4523 + req_to_blocks = scheduler.kv_cache_manager.coordinator.single_type_managers[
4524 + 0
4525 + ].req_to_blocks
4526 +
4527 + # Step 1: A's load is admitted; B's is held back by the reservation (B never
4528 + # holds blocks, so the wedge precondition - both holding prefixes - is gone).
4529 + out1 = scheduler.schedule()
4530 + assert a.status == RequestStatus.WAITING_FOR_REMOTE_KVS
4531 + assert a.num_computed_tokens == MATCHED_TOKENS
4532 + assert b.status == RequestStatus.WAITING
4533 + assert b.request_id not in req_to_blocks
4534 + assert len(scheduler.running) == 0
4535 + scheduler.update_from_output(out1, EMPTY_OUTPUT)
4536 +
4537 + # Step 2: nothing changes until A's recv lands.
4538 + out2 = scheduler.schedule()
4539 + assert len(scheduler.running) == 0
4540 + a_finished = dataclasses.replace(
4541 + EMPTY_OUTPUT,
4542 + kv_connector_output=KVConnectorOutput(finished_recving=[a.request_id]),
4543 + )
4544 + scheduler.update_from_output(out2, a_finished)
4545 +
4546 + # Step 3: A makes forward progress straight to RUNNING - no preemption was
4547 + # needed because B never wedged it.
4548 + out3 = scheduler.schedule()
4549 + assert a.status == RequestStatus.RUNNING
4550 + assert a in scheduler.running
4551 + assert a.request_id in {req.req_id for req in out3.scheduled_new_reqs}
4552 + assert b.status == RequestStatus.WAITING
4553 + assert b.num_preemptions == 0
4554 + assert b.request_id not in req_to_blocks
tests/v1/e2e/general/test_mamba_prefix_cache.py
+2
@@ -180,6 +180,7 @@ def get_fake_allocate_slots_fn(original_allocate_slots_fn: Callable):
180 delay_cache_blocks: bool = False,
181 num_encoder_tokens: int = 0,
182 full_sequence_must_fit: bool = False,
183 + reserved_blocks: int = 0,
184 ):
185 ret = original_allocate_slots_fn(
186 self,
@@ -192,6 +193,7 @@ def get_fake_allocate_slots_fn(original_allocate_slots_fn: Callable):
193 delay_cache_blocks,
194 num_encoder_tokens,
195 full_sequence_must_fit,
196 + reserved_blocks,
197 )
198 if cur_step_action is not None:
199 cur_block_ids = self.coordinator.single_type_managers[0].req_to_blocks[
tests/v1/kv_connector/unit/test_remote_prefill_lifecycle.py
+79
@@ -655,3 +655,82 @@ def test_p_side_chunked_prefill_mamba(mock_platform):
655 outputs = engine_core_outputs[0].outputs
656 assert len(outputs) == 1
657 assert outputs[0].finish_reason == FinishReason.LENGTH
658 +
659 +
660 +def test_async_load_reserves_blocks_for_inflight():
661 + """A second async KV-connector load is not admitted if its initial
662 + allocation would consume blocks reserved for an already in-flight sequence.
663 +
664 + req_a gets a 1-block prefix (full sequence = 4 blocks), reserving 3 more.
665 + req_b would need a 4-block initial allocation, but only
666 + (free - req_a's 3-block reservation) = 3 blocks are available to it, so it is
667 + held back in WAITING (holding no blocks) rather than wedging req_a.
668 + """
669 + vllm_config = create_vllm_config()
670 + BLOCK_SIZE = vllm_config.cache_config.block_size
671 + scheduler = create_scheduler(vllm_config, num_blocks=8) # usable = 7
672 +
673 + req_a = create_request(
674 + request_id=1,
675 + block_size=BLOCK_SIZE,
676 + num_tokens=BLOCK_SIZE * 4,
677 + do_remote_prefill=True,
678 + num_remote_blocks=1,
679 + )
680 + req_b = create_request(
681 + request_id=2,
682 + block_size=BLOCK_SIZE,
683 + num_tokens=BLOCK_SIZE * 5,
684 + do_remote_prefill=True,
685 + num_remote_blocks=1,
686 + )
687 + scheduler.add_request(req_a)
688 + scheduler.add_request(req_b)
689 +
690 + # Partial external matches: req_a loads 1 block, req_b loads 4 blocks.
691 + with patch.object(
692 + scheduler.connector,
693 + "get_num_new_matched_tokens",
694 + side_effect=[(BLOCK_SIZE, True), (BLOCK_SIZE * 4, True)],
695 + ):
696 + scheduler.schedule()
697 +
698 + assert req_a.status == RequestStatus.WAITING_FOR_REMOTE_KVS
699 + assert req_b.status == RequestStatus.WAITING
700 +
701 + req_to_blocks = scheduler.kv_cache_manager.coordinator.single_type_managers[
702 + 0
703 + ].req_to_blocks
704 + assert req_a.request_id in req_to_blocks
705 + assert req_b.request_id not in req_to_blocks
706 +
707 +
708 +def test_async_loads_both_admitted_when_pool_fits():
709 + """Sanity: with a pool large enough, the reservation gate admits both async
710 + loads (it is not over-conservative)."""
711 + vllm_config = create_vllm_config()
712 + BLOCK_SIZE = vllm_config.cache_config.block_size
713 + scheduler = create_scheduler(vllm_config, num_blocks=64)
714 +
715 + reqs = [
716 + create_request(
717 + request_id=i,
718 + block_size=BLOCK_SIZE,
719 + num_tokens=BLOCK_SIZE * 5,
720 + do_remote_prefill=True,
721 + num_remote_blocks=1,
722 + )
723 + for i in (1, 2)
724 + ]
725 + for req in reqs:
726 + scheduler.add_request(req)
727 +
728 + with patch.object(
729 + scheduler.connector,
730 + "get_num_new_matched_tokens",
731 + side_effect=[(BLOCK_SIZE, True), (BLOCK_SIZE, True)],
732 + ):
733 + scheduler.schedule()
734 +
735 + for req in reqs:
736 + assert req.status == RequestStatus.WAITING_FOR_REMOTE_KVS
tests/v1/kv_connector/unit/utils.py
+1
@@ -56,6 +56,7 @@ def assert_scheduler_empty(scheduler: Scheduler):
56 assert len(scheduler.running) == 0
57 assert len(scheduler.finished_req_ids) == 0
58 assert len(scheduler.finished_recving_kv_req_ids) == 0
59 + assert len(scheduler._inflight_prefills) == 0
60
61 # EncoderCacheManager.
62 assert len(scheduler.encoder_cache_manager.freed) == 0
vllm/v1/core/kv_cache_manager.py
+8 -1
@@ -246,6 +246,7 @@ class KVCacheManager:
246 delay_cache_blocks: bool = False,
247 num_encoder_tokens: int = 0,
248 full_sequence_must_fit: bool = False,
249 + reserved_blocks: int = 0,
250 ) -> KVCacheBlocks | None:
251 """Add slots for a request with new tokens to append.
252
@@ -271,6 +272,11 @@ class KVCacheManager:
272 free blocks to hold the full sequence, accounting for prefix cache hits
273 and sliding window. Used as an admission gate to prevent over-admitting
274 requests when chunked prefill would otherwise only check the first chunk
275 + reserved_blocks: Number of free blocks that must be left available for
276 + other in-flight sequences to complete. The actual allocation is only
277 + made if it fits within (free blocks - reserved_blocks). Used to gate
278 + async KV-connector loads so their initial allocation cannot consume
279 + blocks an already in-flight (prefilling) sequence is relying on.
280
281 Blocks layout:
282 ```
@@ -386,7 +392,8 @@ class KVCacheManager:
392 num_tokens_main_model=num_tokens_main_model,
393 )
394
389 - if num_blocks_to_allocate > self.block_pool.get_num_free_blocks():
395 + available_blocks = self.block_pool.get_num_free_blocks() - reserved_blocks
396 + if num_blocks_to_allocate > available_blocks:
397 # Cannot allocate new blocks
398 return None
399
vllm/v1/core/sched/scheduler.py
+46
@@ -286,6 +286,10 @@ class Scheduler(SchedulerInterface):
286
287 self._pause_state: PauseState = PauseState.UNPAUSED
288
289 + # In-flight requests still prefilling (prefill chunks + in-progress
290 + # async KV loads). Their remaining-block reservation gates async loads.
291 + self._inflight_prefills: set[Request] = set()
292 +
293 def _mamba_block_aligned_split(
294 self,
295 request: Request,
@@ -746,6 +750,14 @@ class Scheduler(SchedulerInterface):
750 for i in encoder_inputs_to_schedule
751 )
752
753 + reserved_blocks = 0
754 + if load_kv_async:
755 + # An async load holds its blocks for the whole transfer with
756 + # no forward progress and isn't preemptible here. Admit it
757 + # only if it fits in (free - other in-flight reservations), to
758 + # avoid deadlock and predictable preemptions.
759 + reserved_blocks = self._inflight_prefill_reserved_blocks()
760 +
761 new_blocks = self.kv_cache_manager.allocate_slots(
762 request,
763 num_new_tokens,
@@ -756,6 +768,7 @@ class Scheduler(SchedulerInterface):
768 delay_cache_blocks=load_kv_async,
769 num_encoder_tokens=num_encoder_tokens,
770 full_sequence_must_fit=self.scheduler_reserve_full_isl,
771 + reserved_blocks=reserved_blocks,
772 )
773
774 if new_blocks is None:
@@ -807,6 +820,7 @@ class Scheduler(SchedulerInterface):
820 # _update_waiting_for_remote_kv will then cache
821 # only the successfully loaded tokens.
822 request.num_computed_tokens = num_computed_tokens
823 + self._inflight_prefills.add(request)
824 continue
825
826 self.running.append(request)
@@ -830,6 +844,9 @@ class Scheduler(SchedulerInterface):
844 token_budget -= num_new_tokens
845 request.status = RequestStatus.RUNNING
846 request.num_computed_tokens = num_computed_tokens
847 + # Only track requests that will still be prefilling after this chunk.
848 + if num_computed_tokens + num_new_tokens < request.num_tokens:
849 + self._inflight_prefills.add(request)
850 # Encoder-related.
851 if encoder_inputs_to_schedule:
852 scheduled_encoder_inputs[request_id] = encoder_inputs_to_schedule
@@ -965,6 +982,7 @@ class Scheduler(SchedulerInterface):
982 )
983 self.kv_cache_manager.free(request)
984 self.encoder_cache_manager.free(request)
985 + self._inflight_prefills.discard(request)
986 request.status = RequestStatus.PREEMPTED
987 request.num_computed_tokens = 0
988 if request.spec_token_ids:
@@ -996,6 +1014,9 @@ class Scheduler(SchedulerInterface):
1014 scheduler_output.has_structured_output_requests |= (
1015 request.use_structured_output and not request.is_prefill_chunk
1016 )
1017 + # Drop from the in-flight-prefill set once it's no longer prefilling.
1018 + if not request.is_prefill_chunk:
1019 + self._inflight_prefills.discard(request)
1020
1021 # Snapshot block IDs for routed experts before forward starts.
1022 # A concurrent schedule() may preempt requests and free blocks
@@ -1869,6 +1890,7 @@ class Scheduler(SchedulerInterface):
1890 ) -> dict[str, Any] | None:
1891 assert request.is_finished()
1892
1893 + self._inflight_prefills.discard(request)
1894 connector_delay_free_blocks, kv_xfer_params = self._connector_finished(request)
1895 self.encoder_cache_manager.free(request)
1896 request_id = request.request_id
@@ -2105,6 +2127,30 @@ class Scheduler(SchedulerInterface):
2127
2128 return self.connector.request_finished_all_groups(request, block_ids)
2129
2130 + def _request_remaining_blocks(self, request: Request) -> int:
2131 + """Blocks `request` still needs to allocate to hold its full sequence."""
2132 + full_num_tokens = min(request.num_tokens, self.max_model_len)
2133 + return self.kv_cache_manager.coordinator.get_num_blocks_to_allocate(
2134 + request_id=request.request_id,
2135 + num_tokens=full_num_tokens,
2136 + new_computed_blocks=self.kv_cache_manager.empty_kv_cache_blocks.blocks,
2137 + num_encoder_tokens=0,
2138 + total_computed_tokens=request.num_computed_tokens,
2139 + num_tokens_main_model=full_num_tokens,
2140 + apply_admission_cap=True,
2141 + )
2142 +
2143 + def _inflight_prefill_reserved_blocks(self) -> int:
2144 + """Blocks in-flight prefills still need to finish (their reservation).
2145 +
2146 + Sums remaining full-ISL blocks over `self._inflight_prefills` (running
2147 + prefills + in-progress async loads). The candidate async load isn't yet
2148 + in the set, so it's naturally excluded.
2149 + """
2150 + return sum(
2151 + self._request_remaining_blocks(req) for req in self._inflight_prefills
2152 + )
2153 +
2154 def _update_waiting_for_remote_kv(self, request: Request) -> None:
2155 """
2156 KV Connector: update request state after async recv is finished.