[Core] Rework scheduler `skipped_waiting` queue (#58947)

Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Robert Shaw <114415538+robertgshaw2-redhat@users.noreply.github.com>

Nick Hill committed Sep 28, 2026 at 13:03 UTC 42dbe4578c7bbeceadc28bd8c6a3826949163f5c
13 files changed +220 -149
rust/src/engine-core-client/src/protocol/stats.rs
+2 -2
@@ -278,9 +278,9 @@ pub use kv_connector::*;
278 pub struct SchedulerStats {
279 /// Number of requests in model execution batches.
280 pub num_running_reqs: u64,
281 - /// Length of the "waiting" request queue.
281 + /// Number of waiting requests not deferred.
282 pub num_waiting_reqs: u64,
283 - /// Length of the "skipped waiting" queue.
283 + /// Number of waiting requests deferred by transient constraints or a blocked status.
284 #[serde(default)]
285 pub num_skipped_waiting_reqs: u64,
286 /// Internal DP load-balancing step counter.
tests/v1/core/test_scheduler.py
+28 -32
@@ -2273,7 +2273,8 @@ def test_spec_decode_padding_skipped_with_prefill_in_batch():
2273
2274
2275 def test_scheduler_stats_waiting_queues():
2276 - """Test that scheduler stats correctly report waiting and skipped_waiting queues."""
2276 + """Test that scheduler stats correctly report capacity-bound and blocked
2277 + waiting requests."""
2278 # Create scheduler with limited capacity so we can have waiting requests
2279 scheduler = create_scheduler(max_num_batched_tokens=100)
2280
@@ -2286,18 +2287,18 @@ def test_scheduler_stats_waiting_queues():
2287 for request in all_requests[:3]:
2288 scheduler.add_request(request)
2289
2289 - # Manually add 2 more to skipped_waiting to simulate constraint-blocked
2290 + # Add 2 more that are blocked on grammar compilation
2291 for request in all_requests[3:]:
2291 - request.status = RequestStatus.WAITING_FOR_REMOTE_KVS
2292 - scheduler.skipped_waiting.add_request(request)
2292 + request.status = RequestStatus.WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR
2293 + scheduler.add_request(request)
2294
2295 # Schedule - this will schedule 2 requests, leaving 1 in waiting
2296 output = scheduler.schedule()
2297
2298 # Verify: 2 scheduled, 1 still waiting on capacity, 2 blocked by constraints
2299 assert len(output.scheduled_new_reqs) == 2
2299 - assert len(scheduler.waiting) == 1
2300 - assert len(scheduler.skipped_waiting) == 2
2300 + assert len(scheduler.waiting) == 3
2301 + assert len(scheduler.deferred_waiting) == 2
2302
2303 # Call update_from_output() to get frontend-facing stat
2304 scheduled_req_ids = list(output.num_scheduled_tokens.keys())
@@ -2395,7 +2396,7 @@ def _step_until_done(
2396
2397
2398 def _num_waiting_requests(scheduler: Scheduler) -> int:
2398 - return len(scheduler.waiting) + len(scheduler.skipped_waiting)
2399 + return len(scheduler.waiting) + len(scheduler.kv_holding_waiting)
2400
2401
2402 def _step_until_kv_transfer_finished(scheduler: Scheduler, req_ids: list[str]):
@@ -2944,7 +2945,7 @@ def test_kv_connector_handles_preemption(
2945 if is_async:
2946 waiting_req_ids = [
2947 req.request_id
2947 - for req in scheduler.skipped_waiting
2948 + for req in scheduler.kv_holding_waiting
2949 if req.status == RequestStatus.WAITING_FOR_REMOTE_KVS
2950 ]
2951 assert len(waiting_req_ids) == 1
@@ -3807,8 +3808,8 @@ def test_schedule_skip_tokenizer_init_structured_output_request():
3808 output = scheduler.schedule()
3809 assert len(output.scheduled_new_reqs) == 0
3810 assert len(scheduler.running) == 0
3810 - assert len(scheduler.waiting) == 0
3811 - assert len(scheduler.skipped_waiting) == 1
3811 + assert len(scheduler.waiting) == 1
3812 + assert len(scheduler.deferred_waiting) == 1
3813
3814
3815 @pytest.mark.parametrize("async_grammar", [True, False])
@@ -5227,7 +5228,7 @@ def test_prepend_skipped_requests_order():
5228 req.status = RequestStatus.WAITING_FOR_REMOTE_KVS
5229 scheduler.waiting.remove_requests(expected_waiting_reqs[:2])
5230 for req in expected_waiting_reqs[:2]:
5230 - scheduler.skipped_waiting.add_request(req)
5231 + scheduler.kv_holding_waiting.add_request(req)
5232
5233 # schedule step
5234 # expect the first 2 waiting to be skipped, the third running,
@@ -5237,21 +5238,18 @@ def test_prepend_skipped_requests_order():
5238 # pop the third request which is expected to be running
5239 expected_waiting_reqs.pop(2)
5240
5240 - # verify waiting order is preserved
5241 - waiting_reqs = list(scheduler.skipped_waiting) + list(scheduler.waiting)
5241 + # verify waiting order is preserved, with the KV-holding requests first
5242 + waiting_reqs = list(scheduler.kv_holding_waiting) + list(scheduler.waiting)
5243 assert waiting_reqs == expected_waiting_reqs
5244
5245
5245 -def test_remote_kv_promotion_keeps_fcfs_with_grammar_prefix():
5246 +def test_remote_kv_promotion_drains_before_grammar_prefix():
5247 scheduler = create_scheduler(max_num_seqs=1)
5248 scheduler.connector = Mock()
5249 scheduler.connector.get_num_new_matched_tokens.return_value = (0, False)
5250
5251 requests = create_requests(num_requests=4)
5251 - for request in requests:
5252 - scheduler.add_request(request)
5253 -
5254 - req_grammar_1, req_grammar_2, req_remote, req_tail = list(scheduler.waiting)
5252 + req_grammar_1, req_grammar_2, req_remote, req_tail = requests
5253
5254 # simulate two structured-output grammar requests at the waiting head
5255 # that become ready now.
@@ -5260,31 +5258,28 @@ def test_remote_kv_promotion_keeps_fcfs_with_grammar_prefix():
5258 req_grammar_2.status = RequestStatus.WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR
5259 req_grammar_2.structured_output_request = Mock(grammar=object())
5260
5263 - # simulate a remote-KV request that is ready to be promoted now.
5261 + # simulate a remote-KV request that is ready to be promoted now. Parked
5262 + # loads hold KV blocks, so they drain ahead of the grammar requests.
5263 req_remote.status = RequestStatus.WAITING_FOR_REMOTE_KVS
5265 - scheduler.waiting.remove_requests([req_grammar_1, req_grammar_2, req_remote])
5266 - scheduler.skipped_waiting.add_request(req_grammar_1)
5267 - scheduler.skipped_waiting.add_request(req_grammar_2)
5268 - scheduler.skipped_waiting.add_request(req_remote)
5264 + for request in requests:
5265 + scheduler.add_request(request)
5266 + assert list(scheduler.kv_holding_waiting) == [req_remote]
5267 scheduler.finished_recving_kv_req_ids.add(req_remote.request_id)
5268 scheduler._update_waiting_for_remote_kv = Mock()
5269
5270 output = scheduler.schedule()
5271
5272 assert output.scheduled_new_reqs
5275 - assert output.scheduled_new_reqs[0].req_id == req_grammar_1.request_id
5276 - waiting_req_ids = [
5277 - req.request_id
5278 - for req in list(scheduler.skipped_waiting) + list(scheduler.waiting)
5279 - ]
5273 + assert output.scheduled_new_reqs[0].req_id == req_remote.request_id
5274 + waiting_req_ids = [req.request_id for req in scheduler.waiting]
5275 assert waiting_req_ids == [
5276 + req_grammar_1.request_id,
5277 req_grammar_2.request_id,
5282 - req_remote.request_id,
5278 req_tail.request_id,
5279 ]
5280
5281
5287 -def test_fcfs_mixed_skipped_waiting_types_keep_order():
5282 +def test_fcfs_mixed_blocked_types_remote_load_drains_first():
5283 scheduler = create_scheduler(max_num_batched_tokens=20)
5284 scheduler._update_waiting_for_remote_kv = Mock()
5285
@@ -5305,7 +5300,8 @@ def test_fcfs_mixed_skipped_waiting_types_keep_order():
5300 for req in (req_grammar, req_remote, req_stream, req_regular, req_tail):
5301 scheduler.add_request(req)
5302 scheduler.schedule()
5308 - assert list(scheduler.skipped_waiting) == [req_grammar, req_remote, req_stream]
5303 + assert list(scheduler.waiting) == [req_grammar, req_stream, req_tail]
5304 + assert list(scheduler.kv_holding_waiting) == [req_remote]
5305
5306 scheduler.finish_requests(req_regular.request_id, RequestStatus.FINISHED_ABORTED)
5307 assert not scheduler.running
@@ -5316,8 +5312,8 @@ def test_fcfs_mixed_skipped_waiting_types_keep_order():
5312
5313 second_output = scheduler.schedule()
5314 expected_order = [
5319 - req_grammar.request_id,
5315 req_remote.request_id,
5316 + req_grammar.request_id,
5317 req_stream.request_id,
5318 req_tail.request_id,
5319 ]
tests/v1/core/test_worker_slot_overflow.py
+2 -2
@@ -105,8 +105,8 @@ def test_streaming_pause_does_not_over_admit_worker_slot():
105 # still holds its slot.
106 scheduler.update_from_output(out, _model_runner_output(["session"], [[STOP_TOKEN]]))
107 assert session.status == RequestStatus.WAITING_FOR_STREAMING_REQ
108 - # A blocked waiting status lands in skipped_waiting, not the main queue.
109 - assert session in scheduler.skipped_waiting
108 + # The paused session keeps its KV blocks, so it lands in kv_holding_waiting.
109 + assert session in scheduler.kv_holding_waiting
110 assert scheduler.num_waiting_for_streaming_input == 1
111
112 # A different request arrives while the session is paused.
tests/v1/kv_connector/unit/test_error_propagation.py
+3 -2
@@ -119,7 +119,7 @@ def test_error_propagation_async_load(fail_scheduler: Scheduler):
119
120 scheduler_output = fail_scheduler.schedule()
121
122 - assert len(fail_scheduler.skipped_waiting) == 1
122 + assert len(fail_scheduler.kv_holding_waiting) == 1
123 assert request.status == RequestStatus.WAITING_FOR_REMOTE_KVS
124 assert request.num_computed_tokens == num_external_computed_tokens
125
@@ -145,4 +145,5 @@ def test_error_propagation_async_load(fail_scheduler: Scheduler):
145 assert output.finish_reason == FinishReason.ERROR
146
147 assert len(fail_scheduler.waiting) == 0
148 - assert len(fail_scheduler.skipped_waiting) == 0
148 + assert len(fail_scheduler.kv_holding_waiting) == 0
149 + assert not fail_scheduler.deferred_waiting
tests/v1/kv_connector/unit/test_invalid_blocks_correctness.py
+1 -1
@@ -333,7 +333,7 @@ def test_async_recompute_blocks_not_cached_when_invalid(
333 scheduler_output = recompute_scheduler.schedule()
334
335 # request should be waiting for remote KVs
336 - assert len(recompute_scheduler.skipped_waiting) == 1
336 + assert len(recompute_scheduler.kv_holding_waiting) == 1
337 assert request.status == RequestStatus.WAITING_FOR_REMOTE_KVS
338 assert request.num_computed_tokens == num_external_computed_tokens
339
tests/v1/kv_connector/unit/test_kv_load_failure_recovery.py
+10 -8
@@ -111,8 +111,8 @@ def test_async_load_failure(
111 scheduler_output = scheduler.schedule()
112
113 assert len(scheduler.waiting) == 0
114 - assert len(scheduler.skipped_waiting) == 3
115 - for request in scheduler.skipped_waiting:
114 + assert len(scheduler.kv_holding_waiting) == 3
115 + for request in scheduler.kv_holding_waiting:
116 assert request.num_computed_tokens == num_external_computed_tokens
117 assert request.status == RequestStatus.WAITING_FOR_REMOTE_KVS
118 assert scheduler.connector.get_num_new_matched_tokens.call_count == 3
@@ -132,8 +132,8 @@ def test_async_load_failure(
132 min_invalid_block_idx = min(invalid_block_idxs)
133
134 assert len(scheduler.waiting) == 0
135 - assert len(scheduler.skipped_waiting) == 3
136 - for request in scheduler.skipped_waiting:
135 + assert len(scheduler.kv_holding_waiting) == 3
136 + for request in scheduler.kv_holding_waiting:
137 if request.request_id == request2.request_id:
138 assert request.num_computed_tokens == (
139 min_invalid_block_idx * scheduler.block_size
@@ -340,8 +340,9 @@ def test_async_progressive_load_failure(
340 scheduler_output = scheduler.schedule()
341
342 assert len(scheduler.waiting) == 0
343 - assert len(scheduler.skipped_waiting) == 1
344 - assert scheduler.skipped_waiting.peek_request().request_id == request.request_id
343 + assert len(scheduler.kv_holding_waiting) == 1
344 + peeked = scheduler.kv_holding_waiting.peek_request()
345 + assert peeked.request_id == request.request_id
346 assert request.num_computed_tokens == num_external_computed_tokens
347 assert request.status == RequestStatus.WAITING_FOR_REMOTE_KVS
348 assert scheduler.connector.get_num_new_matched_tokens.call_count == 1
@@ -363,8 +364,9 @@ def test_async_progressive_load_failure(
364 min_invalid_block_idx = min(min_invalid_block_idx, invalid_block_idx)
365
366 assert len(scheduler.waiting) == 0
366 - assert len(scheduler.skipped_waiting) == 1
367 - assert scheduler.skipped_waiting.peek_request().request_id == request.request_id
367 + assert len(scheduler.kv_holding_waiting) == 1
368 + peeked = scheduler.kv_holding_waiting.peek_request()
369 + assert peeked.request_id == request.request_id
370 assert request.num_computed_tokens == (
371 min_invalid_block_idx * scheduler.block_size
372 )
tests/v1/kv_connector/unit/test_nixl_connector.py
+1 -1
@@ -3026,7 +3026,7 @@ def test_empty_recv_is_reported_only_when_awaited(
3026 request is named in neither _recving_transfers nor _failed_recv_reqs and
3027 never reaches finished_recving. The scheduler has no other way to release a
3028 WAITING_FOR_REMOTE_KVS request, and there is no timeout, so it sits in
3029 - skipped_waiting holding its blocks for the life of the process.
3029 + kv_holding_waiting holding its blocks for the life of the process.
3030
3031 Notify-only (awaiting_kvs=False): request_finished seeding an empty recv to
3032 free P's blocks for a request aborted before it was scheduled, or a readback
tests/v1/kv_connector/unit/test_remote_prefill_lifecycle.py
+70 -2
@@ -25,7 +25,7 @@ pytestmark = pytest.mark.cpu_test
25
26
27 def _num_waiting_requests(scheduler) -> int:
28 - return len(scheduler.waiting) + len(scheduler.skipped_waiting)
28 + return len(scheduler.waiting) + len(scheduler.kv_holding_waiting)
29
30
31 def test_basic_lifecycle():
@@ -64,7 +64,7 @@ def test_basic_lifecycle():
64
65 # Req waiting for KVs with no computed/scheduled toks ...
66 assert _num_waiting_requests(scheduler) == 1
67 - assert request in scheduler.skipped_waiting
67 + assert request in scheduler.kv_holding_waiting
68 assert request.status == RequestStatus.WAITING_FOR_REMOTE_KVS
69 assert request.num_computed_tokens == NUM_TOKENS
70
@@ -791,3 +791,71 @@ def test_async_load_reserves_blocks_for_promotion_margin():
791 scheduler_output = scheduler.schedule()
792 assert req_a.status == RequestStatus.RUNNING
793 assert scheduler_output.num_scheduled_tokens[req_a.request_id] > 0
794 +
795 +
796 +def test_deferred_lookup_does_not_block_parked_load():
797 + """A request with a deferred connector lookup must not strand an async
798 + load queued behind it.
799 +
800 + req_a's lookup is deferred (connector returns None), so it is parked in
801 + waiting holding no blocks; req_b is then admitted as an async
802 + load holding most of the pool. Once req_a's lookup resolves it cannot
803 + allocate, and the waiting scan stops at it every step. The parked load
804 + must still be promotable: it lives in the KV-holding queue
805 + that is drained first, so it runs, finishes, and frees its blocks for
806 + req_a.
807 + """
808 + vllm_config = create_vllm_config()
809 + BLOCK_SIZE = vllm_config.cache_config.block_size
810 + scheduler = create_scheduler(vllm_config, num_blocks=8) # usable = 7
811 +
812 + req_a = create_request(
813 + request_id=1, block_size=BLOCK_SIZE, num_tokens=BLOCK_SIZE * 4, max_tokens=1
814 + )
815 + req_b = create_request(
816 + request_id=2,
817 + block_size=BLOCK_SIZE,
818 + num_tokens=BLOCK_SIZE * 5,
819 + do_remote_prefill=True,
820 + max_tokens=1,
821 + )
822 + scheduler.add_request(req_a)
823 + scheduler.add_request(req_b)
824 +
825 + deferrals = [(None, False)]
826 +
827 + def lookup(request, num_computed_tokens):
828 + if request is req_b:
829 + return BLOCK_SIZE * 5, True
830 + return deferrals.pop() if deferrals else (0, False)
831 +
832 + with patch.object(
833 + scheduler.connector, "get_num_new_matched_tokens", side_effect=lookup
834 + ):
835 + # Step 1: req_a's lookup is deferred; req_b is admitted as an async
836 + # load holding 5 of 7 usable blocks.
837 + scheduler.schedule()
838 + assert req_a.status == RequestStatus.WAITING
839 + assert req_b.status == RequestStatus.WAITING_FOR_REMOTE_KVS
840 +
841 + # req_a's lookup resolves (no hit) while req_b's transfer is in
842 + # flight; req_a needs 4 blocks but only 2 are free, so the waiting
843 + # scan stops at it. req_b's transfer then finishes.
844 + scheduler.update_from_output(
845 + scheduler.schedule(),
846 + create_model_runner_output([], finished_recving={req_b.request_id}),
847 + )
848 +
849 + # req_b must still be reached, promoted, and scheduled despite the
850 + # scan stopping at req_a.
851 + scheduler_output = scheduler.schedule()
852 + assert req_b.status == RequestStatus.RUNNING
853 + assert scheduler_output.num_scheduled_tokens[req_b.request_id] > 0
854 +
855 + # req_b finishes and frees its blocks; now req_a fits and runs.
856 + scheduler.update_from_output(
857 + scheduler_output, create_model_runner_output([req_b])
858 + )
859 + scheduler_output = scheduler.schedule()
860 + assert req_a.status == RequestStatus.RUNNING
861 + assert scheduler_output.num_scheduled_tokens[req_a.request_id] > 0
tests/v1/kv_connector/unit/utils.py
+2
@@ -54,6 +54,8 @@ def assert_scheduler_empty(scheduler: Scheduler):
54 # Scheduler Metadata.
55 assert len(scheduler.requests) == 0
56 assert len(scheduler.waiting) == 0
57 + assert len(scheduler.kv_holding_waiting) == 0
58 + assert not scheduler.deferred_waiting
59 assert len(scheduler.running) == 0
60 assert len(scheduler.finished_req_ids) == 0
61 assert len(scheduler.finished_recving_kv_req_ids) == 0
tests/v1/streaming_input/test_scheduler_streaming.py
+3 -3
@@ -554,9 +554,9 @@ class TestStreamingScheduler(unittest.TestCase):
554 eco_cycle2 = eco_dict_cycle2[session.client_index].outputs[0]
555 assert eco_cycle2.finish_reason == FinishReason.STOP
556 assert session.status == RequestStatus.WAITING_FOR_STREAMING_REQ
557 - # Sessions paused for streaming input are blocked-waiting, so they
558 - # live in the skipped_waiting queue rather than the main waiting queue.
559 - assert session in scheduler.skipped_waiting
557 + # Sessions paused for streaming input keep their KV blocks, so they
558 + # live in the kv_holding_waiting queue.
559 + assert session in scheduler.kv_holding_waiting
560 assert session._all_token_ids == [1, 2, 3, 10, STOP_TOKEN]
561
562 # CRITICAL ASSERTION: Cached prompt_token_ids STILL must not have changed
vllm/v1/core/sched/request_queue.py
+3 -3
@@ -41,7 +41,7 @@ class RequestQueue(ABC):
41 pass
42
43 @abstractmethod
44 - def prepend_requests(self, requests: "RequestQueue") -> None:
44 + def prepend_requests(self, requests: Iterable[Request]) -> None:
45 """Prepend all requests from another queue to the front of this
46 queue."""
47 pass
@@ -93,7 +93,7 @@ class FCFSRequestQueue(deque[Request], RequestQueue):
93 """Prepend a request to the front of the queue."""
94 self.appendleft(request)
95
96 - def prepend_requests(self, requests: RequestQueue) -> None:
96 + def prepend_requests(self, requests: Iterable[Request]) -> None:
97 """Prepend all requests from another queue to the front of this
98 queue.
99
@@ -163,7 +163,7 @@ class PriorityRequestQueue(RequestQueue):
163 front. Requests are ordered by (priority, arrival_time)."""
164 self.add_request(request)
165
166 - def prepend_requests(self, requests: RequestQueue) -> None:
166 + def prepend_requests(self, requests: Iterable[Request]) -> None:
167 """Add all requests from another queue according to priority policy.
168
169 Note: In a priority queue, there is no concept of prepending to the
vllm/v1/core/sched/scheduler.py
+92 -91
@@ -210,8 +210,13 @@ class Scheduler(SchedulerInterface):
210 ) from e
211 # Priority queues for requests.
212 self.waiting = create_request_queue(self.policy)
213 - # requests skipped in waiting flow due async deps or constraints.
214 - self.skipped_waiting = create_request_queue(self.policy)
213 + # Waiting requests that hold KV blocks are always drained before
214 + # self.waiting: a block-holder must never sit behind a request whose
215 + # failed allocation would stop the scheduling scan.
216 + self.kv_holding_waiting = create_request_queue(self.policy)
217 + # Waiting requests that are deferred, i.e. were skipped by the scheduling
218 + # scan or enqueued in a blocked status.
219 + self.deferred_waiting: set[Request] = set()
220 self.running: list[Request] = []
221
222 # The request IDs that are finished in between the previous and the
@@ -606,9 +611,8 @@ class Scheduler(SchedulerInterface):
611 # `long_prefill_token_threshold` exists to stop a long prefill from
612 # starving other requests of the token budget. When it is the only
613 # request there is nobody to starve, so let it use the whole budget.
609 - num_eligible_reqs = (
610 - len(self.running) + len(self.waiting) + len(self.skipped_waiting)
611 - )
614 + num_running, num_waiting = self.get_request_counts()
615 + num_eligible_reqs = num_running + num_waiting
616 long_prefill_token_threshold = (
617 self.scheduler_config.long_prefill_token_threshold
618 if num_eligible_reqs > 1
@@ -867,10 +871,21 @@ class Scheduler(SchedulerInterface):
871
872 # Next, schedule the WAITING requests.
873 if not preempted_reqs and self._pause_state == PauseState.UNPAUSED:
870 - step_skipped_waiting = create_request_queue(self.policy)
874 + step_skipped_waiting: deque[Request] = deque()
875 + step_skipped_kv_holding: deque[Request] = deque()
876 +
877 + def skip_request(from_queue: RequestQueue) -> None:
878 + request_to_skip = from_queue.pop_request()
879 + self.deferred_waiting.add(request_to_skip)
880 + if self._holds_kv_blocks(request_to_skip):
881 + step_skipped_kv_holding.appendleft(request_to_skip)
882 + else:
883 + step_skipped_waiting.appendleft(request_to_skip)
884
872 - while (self.waiting or self.skipped_waiting) and token_budget > 0:
873 - if input_budget <= draft_slots:
885 + while token_budget > 0:
886 + # Requests holding KV blocks are always drained first.
887 + request_queue = self.kv_holding_waiting or self.waiting
888 + if not request_queue or input_budget <= draft_slots:
889 break
890 # Paused streaming sessions (WAITING_FOR_STREAMING_REQ) are not
891 # in `running` but still hold a model-runner request slot.
@@ -878,38 +893,22 @@ class Scheduler(SchedulerInterface):
893 if num_running >= self.max_num_active_reqs:
894 break
895
881 - request_queue = self._select_waiting_queue_for_scheduling()
882 - assert request_queue is not None
883 -
896 request = request_queue.peek_request()
897 request_id = request.request_id
898
887 - # try to promote blocked statuses while traversing skipped queue.
888 - if self._is_blocked_waiting_status(
889 - request.status
890 - ) and not self._try_promote_blocked_waiting_request(request):
891 - if request.status == RequestStatus.WAITING_FOR_REMOTE_KVS:
892 - logger.debug(
893 - "%s is still in WAITING_FOR_REMOTE_KVS state.",
894 - request_id,
895 - )
896 - request_queue.pop_request()
897 - step_skipped_waiting.prepend_request(request)
899 + ready_to_schedule = self._handle_blocked_waiting_request(request)
900 + if not ready_to_schedule:
901 + skip_request(request_queue)
902 continue
903
900 - if (
901 - request.num_stale_output_tokens > 0
902 - and not request.drop_stale_output
903 - ):
904 + if request.num_stale_output_tokens and not request.drop_stale_output:
905 # Deliverable stale output still in flight: resuming now
906 # could resample a position that output later delivers.
907 # It drains within the pipeline depth.
907 - request_queue.pop_request()
908 - step_skipped_waiting.prepend_request(request)
908 + skip_request(request_queue)
909 continue
910
911 - # Check that adding the request still respects the max_loras
912 - # constraint.
911 + # Check that adding the request still respects the max_loras constraint.
912 if (
913 self.lora_config
914 and request.lora_request
@@ -919,8 +918,7 @@ class Scheduler(SchedulerInterface):
918 )
919 ):
920 # Scheduling would exceed max_loras, skip.
922 - request_queue.pop_request()
923 - step_skipped_waiting.prepend_request(request)
921 + skip_request(request_queue)
922 continue
923
924 num_external_computed_tokens = 0
@@ -959,8 +957,7 @@ class Scheduler(SchedulerInterface):
957 # The request cannot be scheduled because
958 # the KVConnector couldn't determine
959 # the number of matched tokens.
962 - request_queue.pop_request()
963 - step_skipped_waiting.prepend_request(request)
960 + skip_request(request_queue)
961 continue
962
963 if self.prefix_replay_tokens:
@@ -1030,8 +1027,7 @@ class Scheduler(SchedulerInterface):
1027
1028 # Skip request with pending mm encoding prefetches
1029 if self._ec_transfer_pending(request, num_computed_tokens):
1033 - request_queue.pop_request()
1034 - step_skipped_waiting.prepend_request(request)
1030 + skip_request(request_queue)
1031 continue
1032
1033 # Track first scheduled prefill, not post-preemption repeat prefills
@@ -1052,8 +1048,7 @@ class Scheduler(SchedulerInterface):
1048 num_computed_tokens = request.num_computed_tokens
1049
1050 if self._ec_transfer_pending(request, num_computed_tokens):
1055 - request_queue.pop_request()
1056 - step_skipped_waiting.prepend_request(request)
1051 + skip_request(request_queue)
1052 continue
1053
1054 encoder_inputs_to_schedule = None
@@ -1260,12 +1255,10 @@ class Scheduler(SchedulerInterface):
1255 request, num_new_local_computed_tokens
1256 )
1257
1263 - request = request_queue.pop_request()
1258 if load_kv_async:
1259 # If loading async, allocate memory and put request
1260 # into the WAITING_FOR_REMOTE_KV state.
1261 request.status = RequestStatus.WAITING_FOR_REMOTE_KVS
1268 - step_skipped_waiting.prepend_request(request)
1262 # Set num_computed_tokens even though KVs are not yet loaded.
1263 # request.num_computed_tokens will not be used anywhere until
1264 # the request finished the KV transfer.
@@ -1286,13 +1279,16 @@ class Scheduler(SchedulerInterface):
1279 # overwrite; the zeroing could race the write.
1280 self._skip_zero_block_ids.update(
1281 self.kv_cache_manager.get_zeroing_block_ids_in_range(
1289 - request.request_id,
1282 + request_id,
1283 num_new_local_computed_tokens,
1284 num_computed_tokens,
1285 )
1286 )
1287 + skip_request(request_queue)
1288 continue
1289
1290 + request = request_queue.pop_request()
1291 + self.deferred_waiting.discard(request)
1292 self.running.append(request)
1293 if num_external_computed_tokens > 0:
1294 # load_kv_async is False here
@@ -1343,13 +1339,16 @@ class Scheduler(SchedulerInterface):
1339 self.ec_connector.update_state_after_alloc(request, i)
1340
1341 # re-queue requests skipped in this pass ahead of older skipped items.
1342 + if step_skipped_kv_holding:
1343 + self.kv_holding_waiting.prepend_requests(step_skipped_kv_holding)
1344 if step_skipped_waiting:
1347 - self.skipped_waiting.prepend_requests(step_skipped_waiting)
1345 + self.waiting.prepend_requests(step_skipped_waiting)
1346
1347 # DP prefill balancing: on a step that admitted prefills (release),
1348 # record whether it was capacity-bound.
1349 if not defer_prefills:
1352 - self.prefill_capacity_bound = bool(self.waiting)
1350 + _, num_waiting = self.get_request_counts()
1351 + self.prefill_capacity_bound = num_waiting > len(self.deferred_waiting)
1352
1353 # Check if the scheduling constraints are satisfied.
1354 total_num_scheduled_tokens = sum(num_scheduled_tokens.values())
@@ -2237,7 +2236,8 @@ class Scheduler(SchedulerInterface):
2236 if stopped_preempted_reqs:
2237 # This is a rare case and unlikely to impact performance.
2238 self.waiting.remove_requests(stopped_preempted_reqs)
2240 - self.skipped_waiting.remove_requests(stopped_preempted_reqs)
2239 + self.kv_holding_waiting.remove_requests(stopped_preempted_reqs)
2240 + self.deferred_waiting.difference_update(stopped_preempted_reqs)
2241
2242 error_req_ids = set(self.grammar_compile_error_reqs)
2243 self.grammar_compile_error_reqs.clear()
@@ -2381,24 +2381,27 @@ class Scheduler(SchedulerInterface):
2381 RequestStatus.WAITING_FOR_STREAMING_REQ,
2382 )
2383
2384 + @staticmethod
2385 + def _holds_kv_blocks(request: Request) -> bool:
2386 + # Whether a request currently has kv blocks allocated.
2387 + # While always the case when status is WAITING_FOR_REMOTE_KVS
2388 + # or WAITING_FOR_STREAMING_REQ, there are such cases where it's
2389 + # WAITING or PREEMPTED.
2390 + return (
2391 + request.num_computed_tokens > 0
2392 + # num_computed_tokens is reset to 0 if kv transfer fails for requests in
2393 + # WAITING_FOR_REMOTE_KVS state, before their allocated blocks are freed.
2394 + or request.status == RequestStatus.WAITING_FOR_REMOTE_KVS
2395 + )
2396 +
2397 def _enqueue_waiting_request(self, request: Request) -> None:
2398 if self._is_blocked_waiting_status(request.status):
2386 - self.skipped_waiting.add_request(request)
2399 + self.deferred_waiting.add(request)
2400 + if self._holds_kv_blocks(request):
2401 + self.kv_holding_waiting.add_request(request)
2402 else:
2403 self.waiting.add_request(request)
2404
2390 - def _select_waiting_queue_for_scheduling(self) -> RequestQueue | None:
2391 - if self.policy == SchedulingPolicy.FCFS:
2392 - return self.skipped_waiting or self.waiting or None
2393 -
2394 - # PRIORITY mode: compare queue heads when both queues are non-empty.
2395 - if self.waiting and self.skipped_waiting:
2396 - waiting_req = self.waiting.peek_request()
2397 - skipped_req = self.skipped_waiting.peek_request()
2398 - return self.waiting if waiting_req < skipped_req else self.skipped_waiting
2399 -
2400 - return self.waiting or self.skipped_waiting or None
2401 -
2405 def _handle_stopped_request(self, request: Request) -> bool:
2406 """Return True if finished (can be False for resumable requests)."""
2407 if not request.resumable:
@@ -2544,7 +2547,7 @@ class Scheduler(SchedulerInterface):
2547
2548 def get_request_counts(self) -> tuple[int, int]:
2549 """Returns (num_running_reqs, num_waiting_reqs)."""
2547 - return len(self.running), len(self.waiting) + len(self.skipped_waiting)
2550 + return len(self.running), len(self.waiting) + len(self.kv_holding_waiting)
2551
2552 def get_kv_cache_usage(self) -> float:
2553 """Returns the fraction of the KV cache currently in use (0.0-1.0)."""
@@ -2625,7 +2628,8 @@ class Scheduler(SchedulerInterface):
2628 self.running = remove_all(self.running, running_requests_to_remove)
2629 if waiting_requests_to_remove:
2630 self.waiting.remove_requests(waiting_requests_to_remove)
2628 - self.skipped_waiting.remove_requests(waiting_requests_to_remove)
2631 + self.kv_holding_waiting.remove_requests(waiting_requests_to_remove)
2632 + self.deferred_waiting.difference_update(waiting_requests_to_remove)
2633
2634 # Second pass: set status and free requests
2635 for request in valid_requests:
@@ -2733,14 +2737,11 @@ class Scheduler(SchedulerInterface):
2737 def get_num_unfinished_requests(self) -> int:
2738 if self._pause_state == PauseState.PAUSED_ALL:
2739 return 0
2740 + num_running, num_waiting = self.get_request_counts()
2741 if self._pause_state == PauseState.PAUSED_NEW:
2737 - return len(self.running)
2738 - num_waiting = (
2739 - len(self.waiting)
2740 - + len(self.skipped_waiting)
2741 - - self.num_waiting_for_streaming_input
2742 - )
2743 - return num_waiting + len(self.running)
2742 + return num_running
2743 + num_waiting -= self.num_waiting_for_streaming_input
2744 + return num_waiting + num_running
2745
2746 def has_finished_requests(self) -> bool:
2747 if self.finished_req_ids:
@@ -2749,10 +2750,8 @@ class Scheduler(SchedulerInterface):
2750 return False
2751 # Finished requests waiting on delayed connector cleanup remain in
2752 # self.requests after they have been removed from scheduling queues.
2752 - num_in_queues = (
2753 - len(self.waiting) + len(self.skipped_waiting) + len(self.running)
2754 - )
2755 - return len(self.requests) > num_in_queues
2753 + num_running, num_waiting = self.get_request_counts()
2754 + return len(self.requests) > (num_running + num_waiting)
2755
2756 def has_requests(self) -> bool:
2757 # Override the interface default to also keep the engine alive while a
@@ -2884,10 +2883,12 @@ class Scheduler(SchedulerInterface):
2883 ec_connector_stats_payload = (
2884 ec_connector_stats.data if ec_connector_stats else None
2885 )
2886 + num_running, num_waiting = self.get_request_counts()
2887 + num_deferred = len(self.deferred_waiting)
2888 return SchedulerStats(
2888 - num_running_reqs=len(self.running),
2889 - num_waiting_reqs=len(self.waiting),
2890 - num_skipped_waiting_reqs=len(self.skipped_waiting),
2889 + num_running_reqs=num_running,
2890 + num_waiting_reqs=num_waiting - num_deferred,
2891 + num_skipped_waiting_reqs=num_deferred,
2892 kv_cache_usage=self.kv_cache_manager.usage,
2893 prefix_cache_stats=prefix_cache_stats,
2894 connector_prefix_cache_stats=connector_prefix_cache_stats,
@@ -3093,39 +3094,39 @@ class Scheduler(SchedulerInterface):
3094 request.num_computed_tokens = request.num_tokens - 1
3095 self.finished_recving_kv_req_ids.remove(request.request_id)
3096
3096 - def _try_promote_blocked_waiting_request(self, request: Request) -> bool:
3097 - """Try to promote a blocked waiting request back to schedulable states."""
3097 + def _handle_blocked_waiting_request(self, request: Request) -> bool:
3098 + """Returns True if scheduling of this request should proceed, False if
3099 + it should be skipped for this step."""
3100 if request.status == RequestStatus.WAITING_FOR_REMOTE_KVS:
3099 - # finished_recving_kv_req_ids is populated during
3100 - # update_from_output(), based on worker-side connector signals
3101 - # in KVConnectorOutput.finished_recving
3102 - if request.request_id not in self.finished_recving_kv_req_ids:
3101 + # Check whether async loading requests are finished.
3102 + request_id = request.request_id
3103 + if request_id not in self.finished_recving_kv_req_ids:
3104 + logger.debug("%s is still in WAITING_FOR_REMOTE_KVS state.", request_id)
3105 return False
3106 + # Promote request to waiting or preempted.
3107 self._update_waiting_for_remote_kv(request)
3108 if request.num_preemptions:
3109 request.status = RequestStatus.PREEMPTED
3110 else:
3111 request.status = RequestStatus.WAITING
3109 - return True
3112
3111 - if request.status == RequestStatus.WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR:
3112 - structured_output_req = request.structured_output_request
3113 - if not structured_output_req or structured_output_req.grammar is None:
3113 + elif request.status == RequestStatus.WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR:
3114 + # Check whether grammar compilation has finished.
3115 + struct_output_req = request.structured_output_request
3116 + if not struct_output_req or struct_output_req.grammar is None:
3117 return False
3115 - if isinstance(structured_output_req.grammar, Exception):
3118 + if isinstance(struct_output_req.grammar, Exception):
3119 self.grammar_compile_error_reqs.add(request.request_id)
3120 return False
3121 + # Grammar is ready, promote to waiting state.
3122 request.status = RequestStatus.WAITING
3119 - return True
3123
3121 - if request.status == RequestStatus.WAITING_FOR_STREAMING_REQ:
3124 + elif request.status == RequestStatus.WAITING_FOR_STREAMING_REQ:
3125 + # Streaming input request waiting for more input.
3126 assert not request.streaming_queue
3127 return False
3128
3125 - raise AssertionError(
3126 - "Unexpected blocked waiting status in promotion: "
3127 - f"{request.status.name} for request {request.request_id}"
3128 - )
3129 + return True
3130
3131 def _update_from_kv_xfer_finished(self, kv_connector_output: KVConnectorOutput):
3132 """KV Connector: update the scheduler state based on the output.
@@ -3298,7 +3299,7 @@ class Scheduler(SchedulerInterface):
3299 # handle async KV loads (not cached yet, evict_blocks=False)
3300 async_load_reqs = (
3301 req
3301 - for req in self.skipped_waiting
3302 + for req in self.kv_holding_waiting
3303 if req.status == RequestStatus.WAITING_FOR_REMOTE_KVS
3304 )
3305 async_failed_req_ids, num_failed_tokens, _ = (
vllm/v1/metrics/stats.py
+3 -2
@@ -188,8 +188,9 @@ class SchedulerStats:
188
189 num_running_reqs: int = 0
190
191 - num_waiting_reqs: int = 0 # length of the "waiting" request queue
192 - num_skipped_waiting_reqs: int = 0 # length of the "skipped waiting" queue
191 + num_waiting_reqs: int = 0 # waiting requests not deferred
192 + # waiting requests deferred by transient constraints or a blocked status
193 + num_skipped_waiting_reqs: int = 0
194
195 # These are used for internal DP load-balancing.
196 step_counter: int = 0