The V1 scheduler, in code
vllm/v1/core/sched/scheduler.pyvllm/v1/core/sched/output.pyvllm/v1/core/kv_cache_manager.py
a556f3f · sglang 7d89325One file, 3,037 lines, and one method inside it that runs for 844 of them. This chapter is a guided read of vllm/v1/core/sched/scheduler.py at SHA a556f3f — the map first, then schedule() block by block, then the invariants you must not break.
The problem
You have a scheduling bug. Maybe requests are starving, maybe the token budget is being overrun, maybe a preempted request came back with the wrong num_computed_tokens. So you open the file:
$ wc -l vllm/v1/core/sched/scheduler.py
3037 vllm/v1/core/sched/scheduler.py
$ grep -c " def " vllm/v1/core/sched/scheduler.py
54
Fifty-four methods. The one you want, schedule(), starts at line 484 and ends at line 1327 — a single function longer than most files in the repo, with a five-deep nesting level at its worst point, two nested while loops, and an inner while True that mutates the very list the outer loop is indexing into. There is no unit of it you can read in isolation, because almost every branch adjusts a counter that a later branch reads.
Worse, a naive reading is actively misleading. You will miss that request.num_computed_tokens is advanced after SchedulerOutput is built, in a different method; that a preemption inside the running loop can rewind the loop cursor; and that the two budget counters in scope are not the same budget.
§1.4 owns the concepts — the state machine, admission, the preemption policy, recompute-vs-swap, starvation, the watermark. None of that is repeated here. This chapter owns the code: where things are, what calls what, what each block of schedule() actually does, and which asserts encode the contract.
Mental model
Strip everything else away and schedule() is a single-pass greedy fill of a token budget across two ordered collections. First the self.running list, in list order, index by index. Then the waiting queues, front to back. Each request that fits takes num_new_tokens off the budget and gets KV blocks; each one that does not is either skipped (continue) or ends the pass (break), and which of the two it is encodes a policy decision.
Three properties make it readable:
No backtracking
The pass never revisits a request it already decided on — except through preemption, which is the one place where an earlier decision is undone, and the code explicitly restores the budget it had spent.
Accumulators, not fields
Everything the pass decides lives in local variables declared at L497-L518. The only durable side effects during the pass are on the KV cache manager and on request status.
Bookkeeping happens last
num_computed_tokens advances in _update_after_schedule(), after the output is sealed. The output must carry the pre-advance view so the runner can compute input ids.
Figure 1 — the map of scheduler.py: major methods, line ranges, and who calls whom. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit
The file, mapped
Before walking the code, get the layout in your head. Every range below is from grep -n " def " vllm/v1/core/sched/scheduler.py at a556f3f; the end of each range is the line before the next def.
vllm/v1/core/sched/scheduler.py — line ranges, not measurements.| Lines | Region | What lives here |
|---|---|---|
| 74–373 | Construction | __init__. Builds the KVCacheManager, the encoder cache manager, the connectors, and the two waiting queues. Every scheduler-wide constant is set here. |
| 374–483 | Chunk sizing | _mamba_block_aligned_split, _get_local_prefix_cache_hit, _reserve_prefill_lookahead — all three exist only to clamp num_new_tokens. |
| 484–1327 | schedule() | The whole scheduling pass. Running loop, waiting loop, assert block, SchedulerOutput construction. |
| 1328–1719 | Post-schedule helpers | _preempt_request, _update_after_schedule, _make_cached_request_data, _try_schedule_encoder_inputs — all called by schedule(). |
| 1720–2168 | The return path | get_grammar_bitmask and update_from_output, both called between one schedule() and the next. |
| 2169–2503 | Lifecycle | Queue-selection helpers, add_request, finish_requests, _free_request, _free_blocks, the deferred-free machinery. |
| 2504–3037 | Introspection and connectors | has_requests, reset_prefix_cache, make_stats, shutdown, and every KV-connector hook — almost entirely inert without a connector configured. |
That last row matters more than it looks. Roughly a ninth of the file — 339 of 3,037 lines — is connector code: _connector_finished (L2699), _request_remaining_blocks (L2736), _inflight_prefill_reserved_blocks (L2750), _update_waiting_for_remote_kv (L2757), _update_from_kv_xfer_finished (L2836), _update_requests_with_invalid_blocks (L2865) and _handle_invalid_blocks (L2968), none of which executes unless vllm_config.kv_transfer_config is non-None. Do not write off the whole L2699-L3037 span, though: _try_promote_blocked_waiting_request (L2800-L2835) sits inside it and is called unconditionally from the waiting loop at L775-L777, because it also unblocks requests waiting on grammar compilation and on streaming input — plain single-node features.
schedule(), read top to bottom
The entry point declares the algorithm in a comment before it declares a variable:
def schedule(self, throttle_prefills: bool = False) -> SchedulerOutput:
self.current_step += 1
# NOTE(woosuk) on the scheduling algorithm:
# There's no "decoding phase" nor "prefill phase" in the scheduler.
# Each request just has the num_computed_tokens and
# num_tokens_with_spec. num_tokens_with_spec =
# len(prompt_token_ids) + len(output_token_ids) + len(spec_token_ids).
# At each step, the scheduler tries to assign tokens to the requests
# so that each request's num_computed_tokens can catch up its
# num_tokens_with_spec. This is general enough to cover
# chunked prefills, prefix caching, speculative decoding,
# and the "jump decoding" optimization in the future.
Take this literally. There is no prefill branch and no decode branch in the 844 lines. There is one quantity, num_tokens_with_spec - num_computed_tokens, and the method decides how much of that gap to close.
The two budgets
req_to_new_blocks: dict[str, KVCacheBlocks] = {}
num_scheduled_tokens: dict[str, int] = {}
token_budget = self.max_num_scheduled_tokens
spec = self.vllm_config.speculative_config
draft_slots = spec.max_num_new_slots_for_drafting if spec is not None else 0
input_budget = self.scheduler_config.max_num_batched_tokens
if self._pause_state == PauseState.PAUSED_ALL:
# Do not schedule any requests when paused.
token_budget = 0
This is the first thing a casual reader gets wrong. There are two budgets, and they are decremented by different amounts. token_budget counts tokens the scheduler issues. input_budget counts rows the model runner's input buffers must hold — which is larger, because with speculative decoding the worker appends draft tokens the scheduler never issued. So every admission does:
token_budget -= num_new_tokens
input_budget -= num_new_tokens + draft_slots
req_index += 1
and both loops carry a guard if input_budget <= draft_slots: break at their top, so the pass stops admitting once one more request could not have its drafts accommodated. max_num_scheduled_tokens defaults to max_num_batched_tokens when unset (vllm/config/scheduler.py:L56-L61), which is why the distinction is invisible without spec decode on. The class constant for both is 2,048 (vllm/config/scheduler.py:L42-L49), but the field's own docstring calls that value "mainly for convenience when testing" — a served engine never sees it. EngineArgs.get_batch_defaults (vllm/engine/arg_utils.py:L2580-L2638) resolves the budget by device and usage context, and on an H100 that is 8,192 for vllm serve and 16,384 for the in-process LLM class. For Llama-3-8B at 128 KiB of KV per token, an 8,192-token step writes 8192 × 128 KiB = 1 GiB of fresh KV in a single forward pass, which is the arithmetic that keeps the budget from going much higher.
Phase 1: the running loop
# First, schedule the RUNNING requests.
req_index = 0
while req_index < len(self.running) and token_budget > 0:
request = self.running[req_index]
Note the shape: a manual index into a mutable list, not for request in self.running. That is why the preemption branch below can splice an element out mid-iteration without corrupting the walk.
Between L536 and L564 sit three continue guards — async-scheduling exhaustion, the PP decode-cadence gate next_decode_eligible_step, and the DP prefill-throttle deferral. Each of them advances req_index and moves on, so a blocked request never blocks the requests behind it. Then the arithmetic:
num_new_tokens = (
request.num_tokens_with_spec
+ request.num_output_placeholders
- request.num_computed_tokens
)
if 0 < self.scheduler_config.long_prefill_token_threshold < num_new_tokens:
num_new_tokens = self.scheduler_config.long_prefill_token_threshold
num_new_tokens = min(
num_new_tokens, token_budget, input_budget - draft_slots
)
That is the token-budget clamp of chunked prefill, which §1.5 owns. Four further clamps follow: max_model_len − num_computed_tokens − num_sampled_tokens_per_step (L579-L584), Mamba block alignment (L587-L590), the encoder compute budget via _try_schedule_encoder_inputs (L596-L608), and the multi-module-MTP prefill-lookahead reserve _reserve_prefill_lookahead (L612-L614). Each can only shrink num_new_tokens. If the result is zero:
# NOTE(woosuk): Here, by doing `continue` instead of `break`,
# we do not strictly follow the FCFS scheduling policy and
# allow the lower-priority requests to be scheduled.
req_index += 1
continue
# Schedule newly needed KV blocks for the request.
This is a documented departure from strict FCFS: a blocked earlier request can be skipped for a later eligible one. It can be completely deterministic given identical state. Nondeterminism instead requires varying arrivals, asynchronous results or other inputs; policy ordering and reproducibility are separate properties.
The allocation loop and what None means
# Schedule newly needed KV blocks for the request.
with record_function_or_nullcontext("schedule: allocate_slots"):
while True:
new_blocks = self.kv_cache_manager.allocate_slots(
request,
num_new_tokens,
num_lookahead_tokens=self.num_lookahead_tokens,
)
if new_blocks is not None:
# The request can be scheduled.
break
allocate_slots returns KVCacheBlocks | None (vllm/v1/core/kv_cache_manager.py:L347-L360). None is not an error. It means "there were not enough free blocks under the watermark", and it is the sole signal that triggers preemption. It is also the sole failure mode: no exception, no log line, no counter incremented at this level. If you are looking for why a request did not get scheduled, this is the return value to breakpoint on. Note that the running-loop call passes only three arguments; the waiting-loop call at L1041-L1054 passes eleven, because admission has to reason about prefix-cache hits, external KV, and full-sequence reservation that a decode step does not.
On None, the inner while True preempts and retries. In FCFS mode the victim is the tail:
else:
preempted_req = self.running.pop()
self._preempt_request(
preempted_req,
scheduled_timestamp,
drop_stale_output=self.requires_kv_delivery,
)
preempted_reqs.append(preempted_req)
if preempted_req == request:
# No more request to preempt. Cannot schedule this request.
break
if new_blocks is None:
# Cannot schedule this request.
break
Three things to read carefully here. First, the loop terminates because self.running shrinks by one per iteration and the request being scheduled is itself in that list — when it becomes its own victim, the loop stops. Second, in PRIORITY mode (L651-L684) the victim can be a request already scheduled earlier in this same pass, so the code pops it out of scheduled_running_reqs and refunds num_scheduled_tokens, token_budget, input_budget, req_to_new_blocks, scheduled_spec_decode_tokens, scheduled_encoder_inputs and encoder_compute_budget. It is not a complete rollback, and the gap is worth knowing: prefill_scheduled |= request.is_prefill_chunk (L704) is never un-set, and a stale True changes the spec-decode padding test at L949; request.spec_token_ids = [] (L728) is destructive; and the encoder-cache allocations are freed separately, by _preempt_request at L1364. If you add an accumulator, add it here too — and check whether the two above should have been. Third, the priority path adjusts req_index when the victim sat before the cursor — the comment calls out that omitting this would "silently omit" the next request.
The outer break at L698-L700 is a break, not a continue: once the cache is genuinely full, there is no point trying the rest of the running list.
Phase 2: the waiting loop
# Next, schedule the WAITING requests.
if not preempted_reqs and self._pause_state == PauseState.UNPAUSED:
step_skipped_waiting = create_request_queue(self.policy)
while (self.waiting or self.skipped_waiting) and token_budget > 0:
if input_budget <= draft_slots:
break
# Paused streaming sessions (WAITING_FOR_STREAMING_REQ) are not
# in `running` but still hold a model-runner request slot.
num_running = len(self.running) + self.num_waiting_for_streaming_input
if num_running >= self.max_num_running_reqs:
break
request_queue = self._select_waiting_queue_for_scheduling()
assert request_queue is not None
The guard if not preempted_reqs is the anti-thrash rule: a step that had to evict anyone admits nobody. One line, and the most consequential policy statement in the method.
The body is over 400 lines but structurally it is: peek, filter, look up the prefix cache, size the chunk, allocate, and only then pop_request() at L1090. That ordering matters: the request stays at the head of its queue through every rejection path, so a break leaves the queue exactly as it started.
The filters between L771 and L806 all share a shape: pop_request() then step_skipped_waiting.prepend_request(request). Requests blocked on grammar compilation, on a remote KV transfer, on stale in-flight output, or on the max_loras cap are moved out of the way rather than blocking the queue — and re-queued into self.skipped_waiting at L1169-L1170 after the pass. That is why there are two waiting queues, and why get_request_counts() at L2323-L2325 sums both.
Admission itself is the tail of the loop:
scheduled_loras.add(request.lora_request.lora_int_id)
req_to_new_blocks[request_id] = self.kv_cache_manager.get_blocks(
request_id
)
num_scheduled_tokens[request_id] = num_new_tokens
token_budget -= num_new_tokens
input_budget -= num_new_tokens + draft_slots
request.status = RequestStatus.RUNNING
request.num_computed_tokens = num_computed_tokens
if pad_spec_decode:
Note the asymmetry with the running loop: there, req_to_new_blocks holds the blocks newly returned by allocate_slots; here it holds get_blocks(request_id), the request's entire block table. That distinction is what CachedRequestData.resumed_req_ids encodes downstream — for a resumed request the legacy runner replaces the block table, for a running one it appends. On the V2 path, which is what a dense model takes, that set is always empty: L1202-L1204 folds scheduled_resumed_reqs into scheduled_new_reqs and clears it before _make_cached_request_data runs, so a resumed request is re-sent whole as a NewRequestData with prefill_token_ids, and V2 drops the old state on preemption rather than patching it (vllm/v1/worker/gpu/model_runner.py:L944-L946, L963-L978).
Figure 2 — schedule() control flow with the real branch conditions and their line numbers. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit
The data structures
RequestQueue and its two policies
vllm/v1/core/sched/request_queue.py is 208 lines and worth reading in full — it is the simplest file in the subsystem. An ABC at L20-L72 declares ten @abstractmethods; two implementations follow.
class FCFSRequestQueue(deque[Request], RequestQueue):
"""A first-come-first-served queue that supports deque operations."""
def add_request(self, request: Request) -> None:
"""Add a request to the queue according to FCFS policy."""
self.append(request)
def pop_request(self) -> Request:
"""Pop a request from the queue according to FCFS policy."""
return self.popleft()
FCFSRequestQueue subclasses deque, so prepend_request is appendleft — O(1), which is what makes preemption cheap: _preempt_request pushes the victim back to the front of the waiting queue at L1387. PriorityRequestQueue at L131-L198 wraps a heapq and orders by Request.__lt__ (vllm/v1/request.py:L350-L361): priority, then arrival time, then request id, then object identity. Two of its methods are traps — prepend_request and prepend_requests silently degrade to ordinary inserts, with docstrings that say so. Preemption under PRIORITY therefore does not give the victim any head-of-line advantage.
The Request fields schedule() actually reads
Request (vllm/v1/request.py) — who writes, who reads.| Field | Written by | Read by |
|---|---|---|
num_computed_tokens | _update_after_schedule L1403; reset to 0 by _preempt_request L1367; rewound on spec rejection in update_from_output L1857 | schedule() L569, allocate_slots |
num_tokens_with_spec | derived property, L292 | schedule() L567 |
num_output_placeholders | AsyncScheduler._update_after_schedule L39 | schedule() L568, _free_encoder_inputs |
num_in_flight_tokens | incremented L1404, decremented L1813 | _preempt_request L1380 |
is_prefill_chunk | _update_after_schedule L1408 | get_grammar_bitmask L1732, running-loop throttle L560 |
status | L1143, L1366, check_stop, finish_requests | every phase; RequestStatus.is_finished is status > PREEMPTED (L386-L387) |
spec_token_ids | update_draft_token_ids L2264; cleared L728, L1369 | schedule() L713, L721 |
last_sched_seq | _update_after_schedule L1407 | _free_request_blocks L2470 |
The RequestStatus enum at vllm/v1/request.py:L364-L380 has a load-bearing ordering trick: everything after PREEMPTED is a finished state, and is_finished() is an integer comparison. Insert a non-finished status after PREEMPTED and you break every finish check at once.
SchedulerOutput
vllm/v1/core/sched/output.py is 305 lines and holds five dataclasses — NewRequestData, CachedRequestData, ScheduledEncoderInputStats, SchedulerOutput and GrammarOutput. SchedulerOutput itself (L206-L297) has nine required fields and twelve optional ones. The delta encoding is real and is the reason there are two request lists:
class SchedulerOutput:
# list of the requests that are scheduled for the first time.
# We cache the request's data in each worker process, so that we don't
# need to re-send it every scheduling step.
scheduled_new_reqs: list[NewRequestData]
# list of the requests that have been scheduled before.
# Since the request's data is already cached in the worker processes,
# we only send the diff to minimize the communication cost.
scheduled_cached_reqs: CachedRequestData
# req_id -> num_scheduled_tokens
# Number of tokens scheduled for each request.
num_scheduled_tokens: dict[str, int]
# Total number of tokens scheduled for all requests.
# Equal to sum(num_scheduled_tokens.values())
total_num_scheduled_tokens: int
NewRequestData (L35-L75) carries the full prompt token ids, sampling params, LoRA request and block ids — everything the worker needs to build a cache entry. CachedRequestData (L129-L195) is column-oriented: parallel lists req_ids, new_block_ids, num_computed_tokens, num_output_tokens, plus a resumed_req_ids set. Its own comment at L132-L134 states the append-versus-replace rule for new_block_ids. new_token_ids is populated only under pipeline parallelism without async scheduling (scheduler.py:L1506-L1516) — under any other configuration it is an empty list, which is a common source of "why is this always empty" confusion.
Figure 3 — SchedulerOutput: who writes each field inside schedule, and who reads it downstream. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit
On the way back, update_from_output returns dict[int, EngineCoreOutputs] keyed by client index. EngineCoreOutput (vllm/v1/engine/__init__.py:L196-L238) is a msgspec.Struct with array_like=True and omit_defaults=True — positional serialisation, which is why every comment about a new field says "appended last so positional serialization stays backward compatible". If you add a field anywhere but the end, you break rolling upgrades. EngineCoreOutputs (L253-L279) wraps the list plus scheduler_stats, finished_requests, and the DP wave signals.
The invariants, from the asserts
grep -n "assert" vllm/v1/core/sched/scheduler.py returns 39 hits. They are the author's own statement of what must hold, and the densest cluster is the block that closes the scheduling pass:
# Check if the scheduling constraints are satisfied.
total_num_scheduled_tokens = sum(num_scheduled_tokens.values())
assert total_num_scheduled_tokens <= self.max_num_scheduled_tokens
assert token_budget >= 0
assert input_budget >= 0
assert len(self.running) <= self.max_num_running_reqs
# Since some requests in the RUNNING queue may not be scheduled in
# this step, the total number of scheduled requests can be smaller than
# len(self.running).
assert len(scheduled_new_reqs) + len(scheduled_resumed_reqs) + len(
scheduled_running_reqs
) <= len(self.running)
| Invariant | Enforced at |
|---|---|
| The issued token count never exceeds the configured budget. | L1179 |
| Neither budget counter ever goes negative — every refund path must be complete. | L1181–L1182 |
The running set never exceeds max_num_seqs. | L1183 |
| Scheduled requests are a subset of running requests. The reverse is not required. | L1187–L1189 |
Only a RUNNING request may be preempted — a request must have blocks to free. | L1360–L1362 |
Every entry in num_scheduled_tokens is strictly positive. | L1809 |
| Computed tokens never exceed the request's total tokens. | L892, L908 |
| A waiting-loop admission always schedules at least one token. | L975 |
Concurrent LoRA adapters never exceed max_loras. | L753 |
| Blocks are freed only for a finished request. | L2425, L2452 |
| The engine never emits prompt logprobs for a partial prefill. | L2058 |
Reclamation must respect ownership: an allocation retry may use only slots whose references and outstanding GPU writes are finished. The shown preemption helper calls the free path synchronously in the simple case, but async/connector paths can defer physical availability. If no usable memory was reclaimed, terminate or defer the retry safely rather than spinning. A function call named free is not itself an independent proof of safe reuse.
vLLM ships with assertions live under the default python invocation, but python -O strips every one of these. If you run a modified scheduler under -O, none of the above is checked and a budget overrun surfaces much later as a shape mismatch inside the model runner.
update_from_output(): the retire path
update_from_output spans L1744-L2168, and its centre is a single loop over scheduler_output.num_scheduled_tokens — the same dict the scheduler wrote. The author flags it as hot:
# NOTE(woosuk): As len(num_scheduled_tokens) can be up to 1K or more,
# the below loop can be a performance bottleneck. We should do our best
# to avoid expensive operations inside the loop.
stopped_running_reqs: set[Request] = set()
stopped_preempted_reqs: set[Request] = set()
for req_id, num_tokens_scheduled in num_scheduled_tokens.items():
assert num_tokens_scheduled > 0
request = self.requests.get(req_id)
output_is_stale = False
if request is not None:
request.num_in_flight_tokens -= num_tokens_scheduled
That is the budget for this loop stated plainly: it runs once per forward pass, and for Llama-3-8B on an H100 the weights-only batch-1 decode floor is 4.48 ms (§0.4; §1.1's 6.1 ms is the same step with the KV read at $s = 2{,}048$ added). At the H100 serving default of max_num_seqs = 1024 (vllm/engine/arg_utils.py:L2619-L2628 — not the 128 in the SchedulerConfig class body) the loop body can run a thousand times inside that window; anything allocating per iteration shows up directly in inter-token latency.
The body, in order:
- Drain in-flight accounting (
L1813-L1818).num_in_flight_tokenscomes down; if the request was preempted while this step was in flight, its stale share drains too. - Skip the dead (
L1822-L1830).request is None or request.is_finished()means the request was aborted mid-flight. Continue silently. - Spec-decode reconciliation (
L1841-L1877).num_accepted = max(len(generated_token_ids) - num_sampled, 0), and rejections rollnum_computed_tokensback — the counter_update_after_scheduleoptimistically advanced. - Append and stop-check (
L1898-L1901calling_update_request_with_outputatL2212-L2230), which appends each sampled token and callscheck_stopfromsched/utils.py:L94-L130. That function is where EOS, stop-token,max_tokens,max_model_lenand repetition detection are all evaluated, and whererequest.statusis set to itsFINISHED_*value. Its signature ischeck_stop(request, max_model_len) -> bool, so it never sees the token list; the caller trimsnew_token_idsat the stop point,del new_token_ids[num_new:]atL2227. - Grammar advance (
L1918-L1944). If a grammar rejects the sampled token the request becomesFINISHED_ERRORwith a logged "Unexpected: grammar rejected tokens" line. - Retire:
finish_reason = None
if stopped:
# Capture finish_reason BEFORE _handle_stopped_request, which may
# reset the status to WAITING for streaming requests that continue.
finish_reason = request.get_finished_reason()
finished = self._handle_stopped_request(request)
if finished:
kv_transfer_params, ec_transfer_params = self._free_request(request)
if status_before_stop == RequestStatus.RUNNING:
stopped_running_reqs.add(request)
else:
stopped_preempted_reqs.add(request)
Freeing is two-stage. _free_request (L2422-L2450) fires the connector hooks, frees the encoder cache, adds the id to self.finished_req_ids — which the next schedule() ships to the worker so it can drop its cached request state — and then calls _free_blocks, which returns the KV blocks and deletes the entry from self.requests. Removal from the running list happens once, in bulk, after the loop:
# Remove the stopped requests from the running and waiting queues.
if stopped_running_reqs:
self.running = remove_all(self.running, stopped_running_reqs)
if stopped_preempted_reqs:
# This is a rare case and unlikely to impact performance.
self.waiting.remove_requests(stopped_preempted_reqs)
self.skipped_waiting.remove_requests(stopped_preempted_reqs)
remove_all (sched/utils.py:L62-L91) mutates in place for the single-element case and rebuilds the list otherwise — which is why the assignment self.running = remove_all(...) is mandatory and its docstring says so.
Extension points
vllm/v1/core/sched/interface.py is the pluggable contract: SchedulerInterface(ABC) at L38 with seventeen @abstractmethods. A custom scheduler must implement __init__, schedule, get_grammar_bitmask, update_from_output, update_draft_token_ids, update_draft_token_ids_in_output, add_request, finish_requests, get_num_unfinished_requests, has_finished_requests, pause_state, set_pause_state, reset_prefix_cache, reset_encoder_cache, get_request_counts, make_stats and shutdown. Six methods are concrete defaults you can inherit: has_unfinished_requests (L173), has_requests (L193), get_kv_cache_usage (L238), and the three connector accessors get_kv_connector, get_ec_connector and get_kv_event_publisher_config (L255-L262), which all return None.
Selection happens in config, not in the engine:
def get_scheduler_cls(self) -> type["SchedulerInterface"]:
if self.scheduler_cls is None:
if self.async_scheduling:
from vllm.v1.core.sched.async_scheduler import AsyncScheduler
return AsyncScheduler
from vllm.v1.core.sched.scheduler import Scheduler
return Scheduler
# The first half of this warning can be removed once the Scheduler interface is
# finalized and we can maintain support for scheduler classes that implement it
logger.warning_once(
"Using custom scheduler class %s. This scheduler interface is not public "
"and compatibility may not be maintained. If you have subclassed Scheduler "
"instead of AsyncScheduler, you will see degraded performance due to async "
"scheduling being disabled.",
self.scheduler_cls, # type: ignore[arg-type]
)
Two things to take from this. The interface is explicitly not public and its own warning says compatibility may not be maintained. And async_scheduling is on unless something disables it: the field defaults to None (vllm/config/scheduler.py:L148), and None resolves to True for an ordinary generation model on a supporting executor (vllm/config/vllm.py:L1279-L1328), so AsyncScheduler is what a stock server gets — so a plugin that subclasses Scheduler silently disables async scheduling, which the warning also tells you. Extend AsyncScheduler unless you have a reason not to. EngineCore.__init__ calls get_scheduler_cls() at vllm/v1/engine/core.py:L148 and types the field as SchedulerInterface at L161; §11.5 covers plugin registration generally.
Worked trace: one request, with breakpoints
A 900-token prompt arrives, generates 40 tokens, and finishes on EOS. No spec decode, no connector, and --no-async-scheduling so that the base Scheduler runs — a stock server gets AsyncScheduler instead (see §7), in which case steps 9 and 12 run the overrides at async_scheduler.py:L19 and L51 and step 14's num_new_tokens picks up num_output_placeholders. Set breakpoints in this order.
vllm/v1/core/sched/scheduler.py unless noted.| # | Method : line | What happens |
|---|---|---|
| 1 | add_request : L2331 | No existing entry, so _enqueue_waiting_request (L2176) puts it in self.waiting; self.requests[req_id] = request; a QUEUED event is recorded. |
| 2 | schedule : L759 | Waiting loop entered. Running loop did nothing — the request is not in self.running yet. |
| 3 | _select_waiting_queue_for_scheduling : L2182 | FCFS: returns self.skipped_waiting or self.waiting. Ours is in waiting. |
| 4 | _get_local_prefix_cache_hit : L451 | num_computed_tokens == 0, so the prefix cache is queried. Say 512 tokens hit. |
| 5 | schedule : L974 | num_new_tokens = min(900 - 512, request_token_budget). With a 2,048 budget and an empty batch, all 388 fit — one step, no chunking. |
| 6 | KVCacheManager.allocate_slots : kv_cache_manager.py L347 | Eleven arguments including num_new_computed_tokens=512 and full_sequence_must_fit. Returns KVCacheBlocks. Breakpoint here on None if admission ever stalls. |
| 7 | schedule : L1090–L1144 | pop_request(), self.running.append(), SCHEDULED event, scheduled_new_reqs.append(), status = RUNNING, num_computed_tokens = 512. |
| 8 | schedule : L1281 | SchedulerOutput built. This request appears in scheduled_new_reqs with its full block table; num_scheduled_tokens[req_id] == 388. |
| 9 | _update_after_schedule : L1403–L1408 | num_computed_tokens 512 → 900, num_in_flight_tokens += 388, is_prefill_chunk evaluates 900 < 900 → False. |
| 10 | EngineCore.step : core.py L595 | execute_model(scheduler_output, non_block=True). §11.4 owns everything past this line. |
| 11 | update_from_output : L1808 | Loop reaches our req_id; num_in_flight_tokens back to 0. |
| 12 | _update_request_with_output : L2212 | One token appended; check_stop returns False. |
| 13 | update_from_output : L2032 | EngineCoreOutput appended to outputs[client_index]. |
| 14 | steps 2–13, ×39 | Now the running loop at L533 handles it: num_new_tokens = 901 - 900 = 1, three-argument allocate_slots, admitted at L702. |
| 15 | check_stop : utils.py L104 | Token 40 is EOS → status = FINISHED_STOPPED, returns True. |
| 16 | _handle_stopped_request : L2194 | Not resumable → returns True. |
| 17 | _free_request : L2422 → _free_blocks : L2451 | Encoder cache freed, id added to self.finished_req_ids, blocks returned, del self.requests[req_id]. |
| 18 | update_from_output : L2062 | self.running = remove_all(self.running, stopped_running_reqs). |
| 19 | next schedule : L1295 | finished_req_ids=self.finished_req_ids ships the id to the worker; _update_after_schedule at L1437 then resets the set. |
Step 19 is the one people miss: the id is announced to the worker one step after the request finished, and the reset at L1434-L1438 deliberately rebinds rather than calling clear(), because the previously-built SchedulerOutput still holds a reference to that same set object.
Reading pitfalls
The async path is a 70-line subclass
async_scheduler.py overrides exactly three methods: __init__ (L13), _update_after_schedule (L19) and _update_request_with_output (L51). Everything else is inherited. If you are reading schedule() and wondering where async scheduling happens — it does not, except through num_output_placeholders, which the override increments at async_scheduler.py:L39-L41 and which schedule() then reads at L568.
Gated-off code that looks live
Every self.connector is not None branch — roughly L813-L890, L1064-L1084, and guarded portions of L2699-L3037 (not unconditional helpers inside that range) — is dead without --kv-transfer-config. Same for self.ec_connector, self.need_mamba_block_aligned_split and self.enable_return_routed_experts. self.use_v2_model_runner is the opposite trap: it is normally on — vllm/config/vllm.py:L743 ends return is_default_v2_architecture or not model_config.is_moe, so eligible dense models default to V2 after all preceding guards — which makes the V2 branches at L1202, L1234 and L1521 the live ones and their else arms the dead ones. Check the constructor before assuming a branch runs, in either direction.
continue versus break is policy
In the running loop, exhausting a request's tokens is continue (L634) but exhausting the KV cache is break (L700). In the waiting loop nearly everything is break, except the four blocked-status filters which are pop plus re-queue. Misreading one for the other inverts the fairness behaviour you think you are looking at.
Two allocate_slots call sites
L639 (three args, decode) and L1041 (eleven args, admission). They have different failure semantics: the first triggers preemption, the second just breaks the loop and — note L1058-L1062 — un-touches the encoder cache first. Grep for allocate_slots and you will find both; know which one your stack trace is in.
num_computed_tokens is optimistic
It is advanced at L1403 before the forward pass has run, and rolled back at L1857 if spec tokens are rejected. A debugger snapshot taken between schedule() and update_from_output() shows a value the GPU has not yet earned. The comment at request.py:L168-L171 says so explicitly.
There is no swap path
V1's described policy is recomputation rather than a general swap mechanism. The free path releases ownership subject to references and in-flight safety; retained hashed blocks may still be reusable until overwritten. Thus resetting num_computed_tokens does not imply that all prefix work is lost, nor that every freed block is immediately available.
The waiting-loop variable step_skipped_waiting (L757) is a fresh queue built per pass, and it is prepended into the persistent self.skipped_waiting at L1170, not appended. The comment says "ahead of older skipped items". So the skipped queue is LIFO with respect to passes, FIFO within a pass.
Hands-on
Reproduce the map, then watch the budget move. From a vLLM checkout at a556f3f:
# 1. The map, regenerated. Compare against Figure 1.
grep -n "^ def \|^ async def " vllm/v1/core/sched/scheduler.py
# 2. The invariants, as the author wrote them.
grep -n "assert" vllm/v1/core/sched/scheduler.py
# 3. Which branches are dead in your configuration -- and note that
# use_v2_model_runner is normally ON, so its ELSE arms are the dead ones.
grep -n "self.connector is not None\|self.ec_connector is not None\|self.use_v2_model_runner\|need_mamba_block_aligned_split" vllm/v1/core/sched/scheduler.py
# 4. The exit points of schedule(): every break and continue, with context.
awk 'NR>=484 && NR<=1327 && /break|continue/ {print NR": "$0}' vllm/v1/core/sched/scheduler.py
Then instrument. The single most informative one-line patch to this file is to log the budget at the assert block — insert after L1179:
logger.info(
"step=%d issued=%d/%d running=%d waiting=%d skipped=%d preempted=%d",
self.current_step, total_num_scheduled_tokens,
self.max_num_scheduled_tokens, len(self.running),
len(self.waiting), len(self.skipped_waiting), len(preempted_reqs),
)
During pure decode, issued tokens approximately follow the number of scheduled requests; prefill-heavy phases can fill the token budget. Occasional preemption under changing lengths is not proof of thrashing. Diagnose repeated eviction/recomputation with poor forward progress, retained-prefix length and completed tokens per unit work.
Exercises
- Read and answer. Open
request_queue.py.FCFSRequestQueue.remove_requests(L109-L116) clears the deque and rebuilds it. Why can it not usedeque.removein a loop, and what is the complexity of each option? - Read and answer. In the running loop,
req_to_new_blocks[request_id]is assignednew_blocks(L706). In the waiting loop it is assignedself.kv_cache_manager.get_blocks(request_id)(L1137-L1139). Trace both through_make_cached_request_dataandNewRequestData.from_requestand explain why the difference is correct. - Predict, then verify. Suppose you delete the
if not preempted_reqsguard at L756. Predict what changes in the log from the Hands-on patch under a workload that saturates KV. Then reason about whethertotal_num_scheduled_tokens <= max_num_scheduled_tokensstill holds. - Predict, then verify. A contributor adds a new accumulator
scheduled_foo: dict[str, int]written at L707 alongsidenum_scheduled_tokens. Which assert fires, and where must the corresponding rollback go? - Read and answer.
RequestStatus.is_finishedisstatus > RequestStatus.PREEMPTED(request.py:L386-L387). Find every place inscheduler.pythat depends on this ordering rather than calling the helper.
Answers
1. deque.remove is O(n) per call, so removing k items costs O(nk); worse, mutating a deque while iterating it raises. The rebuild is O(n) once, at the cost of a temporary list. Note remove_all in utils.py:L84-L89 makes the opposite tradeoff for the single-item case, where the in-place path wins.
2. A running request already exists in the worker's cache with a block table; it needs only the blocks appended this step, which is exactly new_blocks. A newly admitted or resumed request either has no worker-side entry (goes to NewRequestData, needs the full table) or has a stale one whose table must be replaced — which is what CachedRequestData.resumed_req_ids signals on the legacy path, per the comment at output.py:L132-L134. Sending only the delta for a resumed request would leave the worker with a table pointing at freed blocks. On the V2 path the same hazard is avoided differently: L1202-L1204 moves resumed requests into scheduled_new_reqs, so resumed_req_ids is always empty and the whole table is re-sent as new.
3. The preempted count rises and running oscillates: each step evicts to make room, then immediately re-admits from the waiting queue, whose head is the request just evicted (prepended at L1387). You get a preempt/re-admit cycle with no forward progress. The token-budget assert still holds — it is enforced by the arithmetic at L707-L709, which the guard has nothing to do with. The guard protects throughput, not correctness.
4. None of them, directly — and that is the danger. The rollback in the PRIORITY preemption branch (L668-L684) refunds num_scheduled_tokens, token_budget, input_budget, req_to_new_blocks, scheduled_spec_decode_tokens and the encoder budget. A new accumulator not refunded there leaks an entry for a request that is no longer running, and the failure surfaces downstream as a KeyError in the model runner or as silently wrong metadata. Add the pop next to L673.
5. The direct comparisons are at request.py:L386-L387 itself and the enum's own ordering comment at L373-L374; within scheduler.py the dependence is indirect — _is_blocked_waiting_status (L2169-L2175) enumerates the three WAITING_FOR_* members explicitly, so adding a fourth blocked status requires editing that tuple as well as placing the member before PREEMPTED.
Key takeaways
schedule()spansL484-L1327and has exactly two loops. Other code includes helpers, retirement and guarded connector paths; unconditional helpers remain live even within a connector-heavy source range.- There are two budgets, not one.
token_budgetcounts issued tokens;input_budgetadditionally reservesdraft_slotsper scheduled request for tokens the worker appends. Both are asserted non-negative atL1181-L1182. allocate_slotsreturningNonesignals KV allocation failure. Admission can also stop at token/input budgets, sequence limits, grammar waits, LoRA limits, encoder constraints and policy guards; inspect which gate was reached.num_computed_tokensis advanced optimistically in_update_after_scheduleafter the output is sealed, and rolled back inupdate_from_outputwhen spec tokens are rejected. Any snapshot between the two shows work the GPU has not done.- Assertions cover selected invariants, not the entire contract. Physical reuse additionally requires that references and in-flight holders have ended; synchronous helper invocation is not proof of immediate reclamation on every async or connector path.
AsyncScheduleris 70 lines and overrides three methods. Subclass it, notScheduler:get_scheduler_clswarns that doing otherwise silently disables async scheduling.
Further reading
- vLLM PR #9289 — the V1 core re-architecture that produced this scheduler shape, including the "no prefill phase, no decode phase" framing quoted at
L486-L495. - vLLM PR #19970 — the async-scheduling work behind
num_output_placeholdersand theAsyncSchedulersubclass. - vLLM V1 alpha announcement — the design rationale for the single-token-budget scheduler, written by the same author as the
L486comment. interface.pyon main — track drift in the abstract method set; it is explicitly unstable.- In this book: §1.4 for the concepts, §1.5 for the token-budget clamp, §2.2 for the block pool behind
allocate_slots, §11.2 for the engine layering above, §11.4 for what consumesSchedulerOutput, and §12.2 for SGLang's very differently shaped answer.