v1/core/sched/scheduler.py: the Scheduler
Code version: vLLM @ 3e44c0d · 2026-10-22
Purpose#
vllm/v1/core/sched/scheduler.py decides, once per engine step, which requests run and how many tokens each gets, and it applies the results of each step to the requests.
| File | vllm/v1/core/sched/scheduler.py, 3,539 lines of Python. One class, Scheduler (vllm/v1/core/sched/scheduler.py:85), with 64 methods. |
|---|---|
| Main class | Scheduler(SchedulerInterface). The default subclass for serving is AsyncScheduler in async_scheduler.py (vllm/v1/core/sched/async_scheduler.py:14). |
| Runs in | The EngineCore process, in its main thread (the busy loop). One scheduler per engine core, so one per data parallel rank. Pure Python on the CPU. |
| Called by | EngineCore in vllm/v1/engine/core.py: schedule, update_from_output, add_request, finish_requests, get_grammar_bitmask, statistics. |
| Calls | KVCacheManager, the request queues, EncoderCacheManager, StructuredOutputManager, the KV and EC connectors (scheduler side), check_stop, the KV event publisher. |
| Not in this file | Placeholders for async scheduling (async_scheduler.py), the queues (request_queue.py), check_stop (sched/utils.py), the output dataclasses (sched/output.py), all block logic (kv_cache_manager.py and below), the busy loop (core.py). |
The page The V1 scheduler: one token budget, no phases explains the algorithm with figures and a simulator. This page is a map of the file and a line-by-line reading of its two main methods.
Where it sits#
The file is long, but it has a clear order. Use this map to find a section:
| Lines | Section |
|---|---|
vllm/v1/core/sched/scheduler.py:1-83 | Imports and the logger. |
vllm/v1/core/sched/scheduler.py:86-472 | __init__: limits (129-160), KV connector (162-205), queues and request dict (214-254), encoder cache (256-285), speculative decoding (286-323), KVCacheManager (325-361), PP and prefill cadence (363-404), Mamba (406-440), deferred frees (442-448), metrics and pause state (450-472). |
vllm/v1/core/sched/scheduler.py:474-620 | Chunk helpers: Mamba block alignment, the local prefix-cache lookup, the MTP prefill lookahead. |
vllm/v1/core/sched/scheduler.py:622-1643 | schedule(): setup (622-712), running pass (713-954), waiting pass (956-1479), checks (1481-1502), building the output (1504-1643). |
vllm/v1/core/sched/scheduler.py:1645-1837 | Helpers of schedule(): _preempt_request, _update_after_schedule, streaming sessions, _make_cached_request_data. |
vllm/v1/core/sched/scheduler.py:1839-2084 | Encoder inputs (multimodal): which items to run, budget, cache. |
vllm/v1/core/sched/scheduler.py:2086-2115 | get_grammar_bitmask. |
vllm/v1/core/sched/scheduler.py:2117-2523 | update_from_output(). |
vllm/v1/core/sched/scheduler.py:2525-2703 | Helpers of the update: blocked statuses, re-queue, stops, token append, encoder frees, draft tokens. |
vllm/v1/core/sched/scheduler.py:2705-2938 | Bookkeeping: counts, usage, add_request, finish_requests, freeing, pause state, has_*. |
vllm/v1/core/sched/scheduler.py:2940-3090 | Cache resets, statistics, shutdown. |
vllm/v1/core/sched/scheduler.py:3092-3539 | KV connector methods: finished transfers, remote KV waits, invalid blocks. |
Who calls it#
- Construction.
EngineCore.__init__resolves the class withget_scheduler_cls()and builds it after the KV cache is sized (vllm/v1/engine/core.py:164,vllm/v1/engine/core.py:177-185). The class isAsyncSchedulerwhen async scheduling is on (vllm/config/scheduler.py:231-239). - Every step.
step_with_batch_queue(the default) andstepcallschedule(),get_grammar_bitmask()andupdate_from_output()(vllm/v1/engine/core.py:725,vllm/v1/engine/core.py:740-742,vllm/v1/engine/core.py:783;vllm/v1/engine/core.py:667-681). - New requests and aborts.
EngineCore.add_requestandabort_requests(vllm/v1/engine/core.py:548,vllm/v1/engine/core.py:554-559). - Speculative decoding.
update_draft_token_idsandupdate_draft_token_ids_in_output(vllm/v1/engine/core.py:696,vllm/v1/engine/core.py:801). - Load reporting.
get_request_countsandget_kv_cache_usagefor the DP coordinator (vllm/v1/engine/core.py:1627-1631). - Utilities.
reset_prefix_cache(vllm/v1/engine/core.py:874).
The interface of all these methods is the abstract class SchedulerInterface (vllm/v1/core/sched/interface.py:38). A custom class from --scheduler-cls must implement it (Hooks in the engine core and the workers).
What it calls#
KVCacheManager:get_computed_blocks,allocate_slots,free,cache_blocks,usage(vllm/v1/core/sched/scheduler.py:329-347). See v1/core/kv_cache_manager.py: KVCacheManager and KVCacheBlocks.create_request_queueforwaitingandkv_holding_waiting(vllm/v1/core/sched/scheduler.py:224-228).check_stopfromsched/utils.py(vllm/v1/core/sched/scheduler.py:56,vllm/v1/core/sched/scheduler.py:2603).StructuredOutputManager:grammar_bitmask,accept_tokens,validate_tokens(vllm/v1/core/sched/scheduler.py:2103-2107,vllm/v1/core/sched/scheduler.py:2279-2281,vllm/v1/core/sched/scheduler.py:2664).- The KV connector with the SCHEDULER role, only when
--kv-transfer-configis set (vllm/v1/core/sched/scheduler.py:172-181). - The encoder cache manager, the KV event publisher and the stats classes (
vllm/v1/core/sched/scheduler.py:197-200,vllm/v1/core/sched/scheduler.py:283-285).
Key classes and functions#
| Name | Line | What it does |
|---|---|---|
Scheduler.__init__ | vllm/v1/core/sched/scheduler.py:86 | reads the limits, builds the queues, the connectors and the KVCacheManager |
schedule | vllm/v1/core/sched/scheduler.py:622 | the running and waiting passes; returns a SchedulerOutput |
_preempt_request | vllm/v1/core/sched/scheduler.py:1658 | frees the blocks, sets num_computed_tokens = 0, status PREEMPTED, front of waiting |
_update_after_schedule | vllm/v1/core/sched/scheduler.py:1703 | advances num_computed_tokens, sets is_prefill_chunk; AsyncScheduler extends it |
_make_cached_request_data | vllm/v1/core/sched/scheduler.py:1780 | the diff for requests that the workers already know |
_try_schedule_encoder_inputs | vllm/v1/core/sched/scheduler.py:1876 | which images or audio items to encode in this step |
get_grammar_bitmask | vllm/v1/core/sched/scheduler.py:2086 | the token masks of structured output requests for this step |
update_from_output | vllm/v1/core/sched/scheduler.py:2117 | applies a ModelRunnerOutput; returns EngineCoreOutputs per front end |
_handle_stopped_request | vllm/v1/core/sched/scheduler.py:2564 | finished, or (for resumable sessions) back to waiting |
_update_request_with_output | vllm/v1/core/sched/scheduler.py:2591 | appends tokens one by one and runs check_stop |
update_draft_token_ids | vllm/v1/core/sched/scheduler.py:2647 | stores the drafts for the next step (synchronous path) |
get_request_counts, get_kv_cache_usage | vllm/v1/core/sched/scheduler.py:2705-2711 | numbers for load balancing and metrics |
add_request | vllm/v1/core/sched/scheduler.py:2716 | puts a new request in the waiting queue, or feeds a streaming session |
finish_requests | vllm/v1/core/sched/scheduler.py:2749 | aborts from outside: removes from the queues, frees the blocks |
_free_request, _free_blocks | vllm/v1/core/sched/scheduler.py:2814-2849 | connector hooks, encoder cache, finished_req_ids, then the blocks |
_free_request_blocks | vllm/v1/core/sched/scheduler.py:2866 | frees now, or defers while a step may still write the blocks |
reset_prefix_cache | vllm/v1/core/sched/scheduler.py:2940 | optionally preempts all running requests, then resets the cache |
make_stats | vllm/v1/core/sched/scheduler.py:3015 | SchedulerStats: counts, KV cache usage, prefix-cache stats |
_handle_blocked_waiting_request | vllm/v1/core/sched/scheduler.py:3275 | promotes a request whose grammar or remote KV is ready |
_update_from_kv_xfer_finished | vllm/v1/core/sched/scheduler.py:3312 | marks finished remote loads; frees blocks after finished sends |
Walk through the main methods#
__init__: the limits#
# Scheduling constraints.
self.max_num_running_reqs = self.scheduler_config.max_num_seqs
# Admission into RUNNING. Separate from max_num_running_reqs, which
# is the model-runner slot count.
self.max_num_active_reqs = (
self.scheduler_config.max_num_active_seqs
if self.scheduler_config.max_num_active_seqs is not None
else self.max_num_running_reqs
)
self.max_num_scheduled_tokens = (
self.scheduler_config.max_num_scheduled_tokens
if self.scheduler_config.max_num_scheduled_tokens is not None
else self.scheduler_config.max_num_batched_tokens
)
self.adaptive_long_prefill_threshold = (
self.scheduler_config.long_prefill_token_threshold_adaptive
)
self.max_model_len = vllm_config.model_config.max_model_len
self.enable_kv_cache_events = (
self.kv_events_config is not None
and self.kv_events_config.enable_kv_cache_events
)
# Pooling and diffusion models do not append a sampled token after
# prefill, so they should not reserve one context slot while scheduling.
self.num_sampled_tokens_per_step = (
0
if (
vllm_config.model_config.runner_type == "pooling"
or vllm_config.model_config.is_diffusion
)
else 1
)- Line 130.
max_num_running_reqsismax_num_seqs. The end ofschedule()asserts it (vllm/v1/core/sched/scheduler.py:1496). - Lines 133-137.
max_num_active_reqs: the admission limit of the waiting pass. - Lines 138-142. The token budget. See the real defaults on The V1 scheduler: one token budget, no phases.
- Lines 153-160. Generative models keep one slot for the sampled token; pooling and diffusion models do not.
The rest of __init__ builds the queues (vllm/v1/core/sched/scheduler.py:216-232) and the KVCacheManager (vllm/v1/core/sched/scheduler.py:329-347). It raises ValueError for an unknown policy (vllm/v1/core/sched/scheduler.py:217-222).
schedule: an overview#
The method is about 1,000 lines. The deep dive below reads it in parts. In short:
- Set the two budgets:
token_budgetandinput_budget(vllm/v1/core/sched/scheduler.py:642-648). - Running pass: tokens, blocks, preemption (
vllm/v1/core/sched/scheduler.py:713-943). - Waiting pass, only without preemption (
vllm/v1/core/sched/scheduler.py:956-1479). - Checks, the
SchedulerOutput, connector metadata (vllm/v1/core/sched/scheduler.py:1481-1634). _update_after_schedule(vllm/v1/core/sched/scheduler.py:1641-1643).
_update_after_schedule: the counters move#
def _update_after_schedule(self, scheduler_output: SchedulerOutput) -> None:
# Advance the number of computed tokens for the request AFTER
# the request is scheduled.
# 1. The scheduler_output of the current step has to include the
# original number of scheduled tokens to determine input IDs.
# 2. Advance the number of computed tokens here allowing us to
# schedule the prefill request again immediately in the next
# scheduling step.
# 3. If some tokens (e.g. spec tokens) are rejected later, the number of
# computed tokens will be adjusted in update_from_output.
num_scheduled_tokens = scheduler_output.num_scheduled_tokens
for req_id, num_scheduled_token in num_scheduled_tokens.items():
request = self.requests[req_id]
request.num_computed_tokens += num_scheduled_token
request.num_in_flight_tokens += num_scheduled_token
if self.defer_block_free:
# Record the in-flight step, to fence deferred block freeing.
request.last_sched_seq = self.sched_step_seq
request.is_prefill_chunk = request.num_computed_tokens < (
request.num_tokens + request.num_output_placeholders
)
scheduler_output.has_structured_output_requests |= (
request.use_structured_output and not request.is_prefill_chunk
)
# Drop from the in-flight-prefill set once it's no longer prefilling.
if not request.is_prefill_chunk:
self._inflight_prefills.discard(request)
# Clear the finished and preempted request IDs.
# NOTE: We shouldn't just clear() here because it will also affect
# the scheduler output.
self.finished_req_ids = set()
self.reset_preempted_req_ids = set()- Lines 1704-1712. The comment gives the reason for the order: the output must carry the old counts, and the counters move now so that a prefill can continue in the next step at once.
- Lines 1716-1717.
num_computed_tokensmoves forward optimistically;num_in_flight_tokensremembers what is not confirmed. - Lines 1721-1723. A prefill chunk is a request that still misses tokens after this step.
- Lines 1734-1735. New sets, not
clear(): the output that just left still points to the old sets.
update_from_output: an overview#
For each request in num_scheduled_tokens, the method:
- skips requests that finished during the step (for example, aborted) (
vllm/v1/core/sched/scheduler.py:2184-2192); - rolls back rejected draft tokens (
vllm/v1/core/sched/scheduler.py:2203-2221); - appends the new tokens and checks for a stop (
vllm/v1/core/sched/scheduler.py:2258-2262); - lets the grammar accept the tokens (
vllm/v1/core/sched/scheduler.py:2279-2290); - frees stopped requests and makes an
EngineCoreOutputwhen there is something to send (vllm/v1/core/sched/scheduler.py:2293-2295,vllm/v1/core/sched/scheduler.py:2316-2341).
After the loop it removes stopped requests from the queues, fails requests with errors, updates the connectors, publishes KV events and attaches the stats (vllm/v1/core/sched/scheduler.py:2388-2523).
_update_request_with_output: append and stop#
def _update_request_with_output(
self, request: Request, new_token_ids: list[int], is_stale: bool = False
) -> tuple[list[int], bool]:
# Append generated tokens and check for stop. Note that if
# a request is still being prefilled, we expect the model runner
# to return empty token ids for the request.
stopped = False
for num_new, output_token_id in enumerate(new_token_ids, 1):
request.append_output_token_ids(output_token_id)
# Check for stop and update request state.
# This must be called before we make the EngineCoreOutput.
stopped = check_stop(request, self.max_model_len)
if stopped:
# num_computed_tokens covers all new tokens but the last one.
# Keep that true after trimming: resumable requests resume from it.
if not is_stale:
request.num_computed_tokens -= len(new_token_ids) - num_new
del new_token_ids[num_new:] # Trim new tokens if needed.
break
return new_token_ids, stopped- Line 2599. Each token is appended alone; the request updates its block hashes (
vllm/v1/request.py:272-283). - Line 2603.
check_stopruns after every token, so a stop in the middle of several accepted tokens is found (vllm/v1/core/sched/utils.py:98-140). - Lines 2607-2609. Tokens after the stop are cut, and
num_computed_tokensdrops by their number.
AsyncScheduler overrides this method: it removes the placeholders of the new tokens and caches the full blocks (vllm/v1/core/sched/async_scheduler.py:59-80).
add_request and finish_requests#
else:
if request.resumable:
request.streaming_queue = deque()
self._enqueue_waiting_request(request)
self.requests[request.request_id] = request
if self.spec_decode_metrics_level != "none":
request.spec_decode_metrics = RequestSpecDecodeMetrics.new(
self.num_spec_tokens
)
if self.connector is not None:
self.connector.on_new_request(request)
kv_transfer_params = request.kv_transfer_params
if kv_transfer_params and kv_transfer_params.get("do_remote_prefill"):
# Count P/D requests on arrival: connector matching is
# skipped while all run slots are taken.
self._set_kv_fetch_stage(request, KV_FETCH_WAITING_TO_START)
if self.log_stats:
request.record_event(EngineCoreEventType.QUEUED)A new request goes into the waiting queue (or into kv_holding_waiting when it already holds blocks) and into the requests dict (vllm/v1/core/sched/scheduler.py:2556-2562). Its QUEUED event is recorded only when stats are on.
finish_requests handles aborts from outside. It removes the requests from all queues at once, sets the finished status, and frees them (vllm/v1/core/sched/scheduler.py:2776-2812). With None it finishes every request. A request that waits for a remote KV load may keep its blocks until the load ends (lines 2802-2807).
Deep dive#
The sections below give the full detail. Open them one at a time, or use Open all deep dives at the top of the page.
Deep diveschedule(), part 1: setup and the running pass
scheduled_new_reqs: list[Request] = []
scheduled_resumed_reqs: list[Request] = []
scheduled_running_reqs: list[Request] = []
preempted_reqs: list[Request] = []
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
# Encoder-related.
scheduled_encoder_inputs: dict[str, list[int]] = {}
encoder_compute_budget = self.max_num_encoder_input_tokens
# Spec decode-related.
scheduled_spec_decode_tokens: dict[str, list[int]] = {}
# Whether the running batch contains any prefill requests.
prefill_scheduled = False
# Whether a waiting request was admitted with prompt left to compute.
# Separate from `prefill_scheduled`, which covers only the running loop.
prefill_admitted = False
# Whether any scheduled request has a synchronous connector KV load.
has_sync_kv_loads = False
# For logging.
scheduled_timestamp = time.monotonic()
self.kv_cache_manager.new_step_starts()
# DP prefill balancing: on a throttled (non-cadence-aligned) step, defer
# all prefill compute unless saturated.
defer_prefills = (
throttle_prefills and not self.prefill_capacity_bound
) and any(not r.is_prefill_chunk for r in self.running)
# `long_prefill_token_threshold` exists to stop a long prefill from
# starving other requests of the token budget. When it is the only
# request there is nobody to starve, so let it use the whole budget.
num_running, num_waiting = self.get_request_counts()
num_eligible_reqs = num_running + num_waiting
long_prefill_token_threshold = (
self.scheduler_config.long_prefill_token_threshold
if num_eligible_reqs > 1
else 0
)- Lines 635-641. The lists and dicts that the method fills: new, resumed and running requests, preempted requests, new blocks and token counts per request.
- Lines 642-648.
token_budgetlimits the scheduled tokens;input_budgetlimits the input rows, which includedraft_slotsper request: 1 for a separate draft model, more for parallel drafting, else 0 (vllm/config/speculative.py:1977-1995). A full pause sets the budget to 0. - Lines 664-666. One monotonic timestamp for all
SCHEDULEDandPREEMPTEDevents of this call. - Lines 670-672. Data parallel prefill throttling: on a throttled step, defer prefills while decodes run.
- Lines 677-689. The long-prefill cap is 0 when only one request exists, and the adaptive mode raises it to a fair share.
# First, schedule the RUNNING requests.
req_index = 0
while req_index < len(self.running) and token_budget > 0:
request = self.running[req_index]
if input_budget <= draft_slots:
break
if (
request.num_output_placeholders > 0
# This is (num_computed_tokens + 1) - (num_output_placeholders - 1).
# Since output placeholders are also included in the computed tokens
# count, we subtract (num_output_placeholders - 1) to remove any draft
# tokens, so that we can be sure no further steps are needed even if
# they are all rejected.
and request.num_computed_tokens + 2 - request.num_output_placeholders
>= request.num_prompt_tokens + request.max_tokens
):
# Async scheduling: Avoid scheduling an extra step when we are sure that
# the previous step has reached request.max_tokens. We don't schedule
# partial draft tokens since this prevents uniform decode optimizations.
req_index += 1
continue
if self.current_step < request.next_decode_eligible_step:
# V2+PP+async: enforce `pp_size` steps between same-req decodes
# to match worker-side sampled-tokens broadcast slot ring cadence.
req_index += 1
continue
if defer_prefills and request.is_prefill_chunk:
# DP prefill balancing: defer this in-progress prefill chunk to a
# cadence-aligned step; decodes still run to fill this step.
req_index += 1
continue
if (
self.ec_connector is not None
and request.mm_features
and not self.ec_connector.ensure_cache_available(
request,
request.num_computed_tokens - request.num_output_placeholders,
)
):
req_index += 1
continue
num_new_tokens = (
request.num_tokens_with_spec
+ request.num_output_placeholders
- request.num_computed_tokens
)
if 0 < long_prefill_token_threshold < num_new_tokens:
num_new_tokens = long_prefill_token_threshold
num_new_tokens = min(
num_new_tokens, token_budget, input_budget - draft_slots
)
# Make sure the input position does not exceed the max model len.
# This is necessary when using spec decoding.
num_new_tokens = min(
num_new_tokens,
self.max_model_len
- request.num_computed_tokens
- self.num_sampled_tokens_per_step,
)- Lines 715-718. The loop runs while budget is left, over
self.runningby index, because preemption shortens the list. - Lines 720-734. Async scheduling: a request whose in-flight tokens already reach
max_tokensgets no extra step. - Lines 736-757. Three more skips: PP decode cadence, deferred prefill chunks, and encoder data that a connector has not delivered.
- Lines 759-777. The number of tokens and its caps.
# 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
if (
self.connector is not None
and self.connector.has_pending_block_frees()
):
break
# The request cannot be scheduled.
# Preempt the lowest-priority request.
if self.policy == SchedulingPolicy.PRIORITY:
preempted_req = max(
self.running,
key=lambda r: (r.priority, r.arrival_time),
)
else:
preempted_req = self.running[-1]
# A deferred free will not help with immediate allocation.
if not self._request_blocks_can_be_freed(preempted_req):
break
if self.policy == SchedulingPolicy.PRIORITY:
victim_index = self.running.index(preempted_req)
del self.running[victim_index]
if victim_index < req_index:
req_index -= 1
if preempted_req in scheduled_running_reqs:
preempted_req_id = preempted_req.request_id
scheduled_running_reqs.remove(preempted_req)
restored = num_scheduled_tokens.pop(preempted_req_id)
token_budget += restored
input_budget += restored + draft_slots
req_to_new_blocks.pop(preempted_req_id)
scheduled_spec_decode_tokens.pop(preempted_req_id, None)
preempted_encoder_inputs = scheduled_encoder_inputs.pop(
preempted_req_id, None
)
if preempted_encoder_inputs:
# Restore encoder compute budget if the preempted
# request had encoder inputs scheduled in this step.
num_embeds_to_restore = sum(
preempted_req.get_num_encoder_embeds(i)
for i in preempted_encoder_inputs
)
encoder_compute_budget += num_embeds_to_restore
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
# Schedule the request.
scheduled_running_reqs.append(request)
prefill_scheduled |= request.is_prefill_chunk
request_id = request.request_id
req_to_new_blocks[request_id] = new_blocks
num_scheduled_tokens[request_id] = num_new_tokens
token_budget -= num_new_tokens
input_budget -= num_new_tokens + draft_slots
req_index += 1- Lines 833-842.
allocate_slotswith the lookahead tokens of the drafter. Success leaves the loop. - Lines 844-848. A KV connector may free blocks soon; then the scheduler does not preempt and gives up on this request for now.
- Lines 852-858. The victim: highest
(priority, arrival_time), or the last running request. - Lines 861-862. With deferred block freeing (a KV consumer with overlapping steps), a victim whose blocks cannot be freed yet does not help.
- Lines 864-888. Priority only: if the victim was already scheduled in this step, its tokens, blocks, drafts and encoder budget go back.
- Lines 892-900. Preempt; stop retrying when the victim is the request itself.
- Lines 902-904. Still no blocks: the running pass ends.
- Lines 906-914. Record the request and reduce both budgets.
Deep diveschedule(), part 2: the waiting pass
num_external_computed_tokens = 0
load_kv_async = False
connector_prefix_cache_queries, connector_prefix_cache_hits = 0, 0
did_prefix_cache_lookup = False
# Get already-cached tokens.
if request.num_computed_tokens == 0:
did_prefix_cache_lookup = True
(
new_computed_blocks,
num_new_local_computed_tokens,
request.shared_prefix_boundary,
hit_diverged,
) = self._get_local_prefix_cache_hit(request)Only a request with num_computed_tokens == 0 looks up the prefix cache. shared_prefix_boundary matters only for hybrid models. The connector part (lines 1026-1099) asks for an external hit, aligned to whole blocks, and decides whether the load is asynchronous.
if load_kv_async:
# KVTransfer: loading remote KV, do not allocate for new work.
assert num_external_computed_tokens > 0
num_new_tokens = 0
elif defer_prefills and num_computed_tokens < request.num_tokens - 1:
# DP prefill balancing: defer this step's local prefill
# compute to a cadence-aligned step.
break
else:
request_token_budget = min(token_budget, input_budget - draft_slots)
if did_prefix_cache_lookup:
# Fresh admission: local and/or sync external hit.
num_replay_tokens = self._mark_prefix_replay(
request, num_computed_tokens, window_loaded
)
num_computed_tokens -= num_replay_tokens
# Number of tokens to be scheduled.
# We use `request.num_tokens` instead of
# `request.num_prompt_tokens` to consider the resumed
# requests, which have output tokens.
num_new_tokens = request.num_tokens - num_computed_tokens
# Pad new decode requests to uniform spec decoding size to
# preserve full cudagraph for this step.
# Not for diffusion where draft tokens can't be padded.
if (
(self.num_spec_tokens > 0 and self.dynamic_sd_lookup is None)
and self.num_sampled_tokens_per_step > 0
and num_new_tokens == 1
and not prefill_scheduled
and (scheduled_running_reqs or num_computed_tokens > 0)
):
padded_num_tokens = 1 + self.num_spec_tokens
# Pad only when there is room for the sampled token(s).
if (
num_computed_tokens
+ padded_num_tokens
+ self.num_sampled_tokens_per_step
<= self.max_model_len
):
if padded_num_tokens > request_token_budget:
# Prefer to not schedule than schedule un-padded.
break
num_new_tokens = padded_num_tokens
pad_spec_decode = True
if 0 < long_prefill_token_threshold < num_new_tokens:
num_new_tokens = long_prefill_token_threshold
# chunked prefill has to be enabled explicitly to allow
# pooling requests to be chunked
if (
not self.scheduler_config.enable_chunked_prefill
and num_new_tokens > request_token_budget
and not self.is_mm_encoder_only
):
# If chunked_prefill is disabled,
# we can stop the scheduling here.
break
num_new_tokens = min(num_new_tokens, request_token_budget)- Lines 1171-1174. An asynchronous KV load schedules 0 tokens now.
- Lines 1175-1178. Deferred prefill on a throttled step stops the pass.
- Line 1191.
num_tokens, notnum_prompt_tokens: a resumed request recomputes its output tokens too. - Lines 1196-1215. With speculative decoding, a 1-token request in a decode-only step is padded to
1 + num_spec_tokensrows, to keep a uniform batch. - Lines 1222-1229. Chunked prefill off and too many tokens: stop the pass.
reserved_blocks = 0
if load_kv_async:
# An async load holds its blocks for the whole transfer with
# no forward progress and isn't preemptible here. Admit it
# only if it fits in (free - other in-flight reservations)
# plus its own spec decode step blocks, to avoid deadlock and
# predictable preemptions.
reserved_blocks = (
self._inflight_prefill_reserved_blocks()
+ self._spec_decode_step_blocks
)
# Replayed tokens are already counted in the adopted hit; a
# chunk that ends inside the replayed range needs no new slots.
num_tokens_past_hit = max(num_new_tokens - num_replay_tokens, 0)
new_blocks = self.kv_cache_manager.allocate_slots(
request,
num_tokens_past_hit,
num_new_computed_tokens=num_new_local_computed_tokens,
new_computed_blocks=new_computed_blocks,
num_lookahead_tokens=effective_lookahead_tokens,
num_external_computed_tokens=num_external_computed_tokens,
delay_cache_blocks=load_kv_async,
skip_zeroing_group_ids=(
self.connector.get_loaded_kv_cache_group_ids(request)
if load_kv_async and self.connector is not None
else ()
),
num_encoder_tokens=num_encoder_tokens,
full_sequence_must_fit=self.scheduler_reserve_full_isl,
reserved_blocks=reserved_blocks,
has_scheduled_reqs=bool(self.running),
)
if new_blocks is None:
# The request cannot be scheduled.
# NOTE: we need to untouch the request from the encode cache
# manager
if request.has_encoder_inputs:
self.encoder_cache_manager.free(request)
break- Lines 1305-1315. An async load reserves the blocks that other in-flight prefills still need, so two loads cannot block each other.
- Lines 1320-1337.
allocate_slotsadopts the prefix hit and checks the whole sequence (full_sequence_must_fit). - Lines 1339-1346. No blocks: stop the waiting pass, after a cleanup of the encoder cache.
Lines 1413-1467 move the request to the running queue (see The V1 scheduler: one token budget, no phases, Step 5). Lines 1469-1473 put skipped requests back at the front of their queues.
Deep diveschedule(), part 3: checks and the output
# 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
# Count this step before stamping the cadence below, so that
# last_prefill_step includes the step that carried the prefill.
if total_num_scheduled_tokens > 0:
self.num_executed_steps += 1
# Restart the cadence interval from any step that carried prefill,
# whether a new admission or a running chunk.
if self.local_prefill_interval > 1 and (prefill_scheduled or prefill_admitted):
self.last_prefill_step = self.num_executed_steps
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)
# Construct the scheduler output.
if self.use_v2_model_runner:
scheduled_new_reqs.extend(scheduled_resumed_reqs)
scheduled_resumed_reqs.clear()
new_reqs_data = [
NewRequestData.from_request(
req,
req_to_new_blocks[req.request_id].get_block_ids(),
req._all_token_ids,
uses_mrope=self.model_uses_mrope,
)
for req in scheduled_new_reqs
]- Line 1483. The sum of all scheduled tokens never exceeds the budget.
- Lines 1494-1502. Invariants: no negative budget, no more running requests than
max_num_seqs. - Lines 1505-1516. Model Runner V2: resumed requests travel as new requests, with all their token ids (
req._all_token_ids) and block ids.
Lines 1527-1534 build CachedRequestData for running and resumed requests: the new block ids (or None), the computed tokens and the output count (vllm/v1/core/sched/scheduler.py:1780-1837). Lines 1561-1570 collect copy-on-write block copies of partial prefix hits. Lines 1588-1609 build the SchedulerOutput. Then the connectors add their metadata (1615-1631), and _update_after_schedule runs last (1641-1643).
Deep diveupdate_from_output(), line by line
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
# Drain any stale share (see _preempt_request) in lockstep.
if request.num_stale_output_tokens > 0:
output_is_stale = True
request.num_stale_output_tokens -= num_tokens_scheduled
assert request.num_stale_output_tokens >= 0
if failed_kv_load_req_ids and req_id in failed_kv_load_req_ids:
# skip failed or rescheduled requests from KV load failure
continue
if request is None or request.is_finished():
# The request is already finished. This can happen if the
# request is aborted while the model is executing it (e.g.,
# in pipeline parallelism or in async scheduling).
# NOTE(Kuntai): When delay_free_blocks=True (for async KV
# cache transfer in KV connector), the aborted request will not
# be set to None (in order to finish async KV transfer).
# In this case, we use is_finished() to check.
continue
# Drop-mode stale output (same-step resume) is discarded entirely.
if output_is_stale and request.drop_stale_output:
continue
req_index = model_runner_output.req_id_to_index[req_id]
generated_token_ids = (
sampled_token_ids[req_index] if sampled_token_ids else []
)- Line 2175. The tokens of this step are no longer in flight.
- Lines 2176-2180. A request preempted after this step started gets "stale" output: it is delivered but does not change the counters.
- Lines 2181-2192. Skip failed KV loads and requests that finished meanwhile (an abort during the step).
- Line 2198.
req_id_to_indexmaps the request to its row of the runner output.
scheduled_spec_token_ids = (
scheduler_output.scheduled_spec_decode_tokens.get(req_id)
)
if scheduled_spec_token_ids and (
generated_token_ids or self.num_sampled_tokens_per_step == 0
):
num_draft_tokens = len(scheduled_spec_token_ids)
num_sampled = self.num_sampled_tokens_per_step
num_accepted = max(len(generated_token_ids) - num_sampled, 0)
num_rejected = num_draft_tokens - num_accepted
# Rejections roll back num_computed_tokens (and, under async
# scheduling, num_output_placeholders, which covers the spec
# tokens). A stale rejection count predates the preemption
# rollback and must not apply.
if not output_is_stale:
if request.num_computed_tokens > 0:
request.num_computed_tokens -= num_rejected
if request.num_output_placeholders > 0:
request.num_output_placeholders -= num_rejectedAccepted drafts = sampled tokens − 1 (the bonus token). The rejected drafts roll back num_computed_tokens and the placeholders.
# Check for stop and update request status.
if new_token_ids:
new_token_ids, stopped = self._update_request_with_output(
request, new_token_ids, is_stale=output_is_stale
)
elif request.pooling_params and pooler_output is not None:
# Pooling stops as soon as there is output.
request.status = RequestStatus.FINISHED_STOPPED
stopped = True
elif (
self.is_mm_encoder_only
and request.num_computed_tokens >= request.num_prompt_tokens
):
# An encoder instance runs the encoder and publishes the
# embeddings instead of sampling, so it stops as soon as the
# whole prompt is consumed. Encoder inputs are never scheduled
# past a multi-modal item the encoder cache could not admit, so
# a consumed prompt also means every item in it was encoded.
request.status = RequestStatus.FINISHED_STOPPED
stopped = True
if new_token_ids and not self.structured_output_manager.accept_tokens(
request, new_token_ids
):
logger.error(
"Unexpected: grammar rejected tokens %s for request %s. "
"Terminating request.",
new_token_ids,
req_id,
)
request.status = RequestStatus.FINISHED_ERROR
request.resumable = False
stopped = True
routed_experts = None
should_emit_output = bool(
new_token_ids or pooler_output is not None or stopped
)- Lines 2259-2262. Generation: append and
check_stop. - Lines 2263-2266. Pooling: the request stops as soon as it has output.
- Lines 2279-2290. If the grammar rejects a sampled token, the request ends with
FINISHED_ERRORand an error log. - Lines 2293-2295. Output only for new tokens, pooler output or a stop. A prefill chunk sends nothing.
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 (
prefill_stats is not None
and kv_transfer_params is not None
and kv_transfer_params.get("do_remote_prefill")
):
# P-side cache hits, so D can report them in
# prompt_tokens_details instead of its own (~100%)
# hit rate from the KV transfer.
kv_transfer_params["remote_prefill_cached_tokens"] = (
prefill_stats.num_cached_tokens
)
else:
continuing_reqs.append(request)
if status_before_stop == RequestStatus.RUNNING:
stopped_running_reqs.add(request)
else:
stopped_preempted_reqs.add(request)The finish reason is read before _handle_stopped_request, because a streaming session can go back to waiting. A finished request is freed at once; its id goes into finished_req_ids for the next SchedulerOutput, so the workers drop its state (vllm/v1/core/sched/scheduler.py:2836).
# Remove stopped requests from their queues, then enqueue the ones
# that continue, so each ends up in exactly one queue.
if stopped_running_reqs:
self.running = remove_all(self.running, stopped_running_reqs)
if stopped_preempted_reqs:
self.waiting.remove_requests(stopped_preempted_reqs)
self.kv_holding_waiting.remove_requests(stopped_preempted_reqs)
self.deferred_waiting.difference_update(stopped_preempted_reqs)
for request in continuing_reqs:
self._enqueue_waiting_request(request)The stopped requests leave the queues in one pass, which is cheaper than one removal per request. Then come errors (2399-2425), connector updates (2427-2467), KV events (2469-2484) and the result: one EngineCoreOutputs per front end (client_index), with the stats on only one of them (vllm/v1/core/sched/scheduler.py:2486-2523).
Deep diveData flow: types in and out
| Value | Type | Where |
|---|---|---|
self.running | list[Request] | vllm/v1/core/sched/scheduler.py:232 |
self.waiting, self.kv_holding_waiting | RequestQueue (FCFS deque or priority heap) | vllm/v1/core/sched/scheduler.py:224-228 |
self.requests | dict[str, Request], all unfinished requests | vllm/v1/core/sched/scheduler.py:215 |
num_scheduled_tokens | dict[str, int], request id to tokens | vllm/v1/core/sched/scheduler.py:641 |
req_to_new_blocks | dict[str, KVCacheBlocks] | vllm/v1/core/sched/scheduler.py:640 |
return of schedule | SchedulerOutput | vllm/v1/core/sched/output.py:231-313 |
input of update_from_output | SchedulerOutput of that step, ModelRunnerOutput | vllm/v1/core/sched/scheduler.py:2117-2121 |
return of update_from_output | dict[int, EngineCoreOutputs], by front-end index | vllm/v1/core/sched/scheduler.py:2488-2491 |
The scheduler is not thread-safe and needs no lock: the busy loop of the main thread makes every call. The input thread only puts requests into a queue that the main thread reads (The engine core loop: threads, queues and the busy loop).
Deep diveError paths: what raises, what only logs
| Situation | Result | Where |
|---|---|---|
Unknown --scheduling-policy | ValueError at start-up | vllm/v1/core/sched/scheduler.py:217-222 |
| A waiting request with an unexpected status | RuntimeError | vllm/v1/core/sched/scheduler.py:1423-1428 |
| Grammar compilation failed | the request finishes with FINISHED_ERROR and a validation_error | vllm/v1/core/sched/scheduler.py:3296-3301, vllm/v1/core/sched/scheduler.py:2399-2425 |
| The grammar rejects a sampled token | ERROR log; FINISHED_ERROR | vllm/v1/core/sched/scheduler.py:2279-2290 |
reset_prefix_cache(reset_running_requests=True) still fails | RuntimeError | vllm/v1/core/sched/scheduler.py:2969-2975 |
Remote KV load failed, policy recompute (default) | recompute the missing tokens | vllm/v1/core/sched/scheduler.py:3444-3457 |
Remote KV load failed, policy fail | the request finishes with an error | vllm/v1/core/sched/scheduler.py:2404-2405 |
Budget or max_num_seqs broken by a bug | AssertionError | vllm/v1/core/sched/scheduler.py:1483, vllm/v1/core/sched/scheduler.py:1494-1496 |
Deep diveConfiguration that this file reads
The file reads no environment variable. It reads these config fields (defaults and flags on The V1 scheduler: one token budget, no phases):
scheduler_config:max_num_seqs,max_num_active_seqs,max_num_scheduled_tokens,max_num_batched_tokens,long_prefill_token_threshold(_adaptive),enable_chunked_prefill,policy,prefill_schedule_interval,scheduler_reserve_full_isl,watermark,async_scheduling(vllm/v1/core/sched/scheduler.py:129-145,vllm/v1/core/sched/scheduler.py:380-404).cache_config:num_gpu_blocks,enable_prefix_caching,block_size, Mamba options (vllm/v1/core/sched/scheduler.py:207-210,vllm/v1/core/sched/scheduler.py:333).kv_transfer_config,ec_transfer_config,kv_events_config,speculative_config,lora_config,observability_config(vllm/v1/core/sched/scheduler.py:97-117).
Related concepts#
- The V1 scheduler: one token budget, no phases: the algorithm, with figures and a simulator.
- The KV cache manager: blocks, the block pool and memory sizing: what
allocate_slotsandfreedo. - Async scheduling: overlap the CPU and the GPU:
AsyncSchedulerand the placeholders. - Data contracts between the parts of vLLM:
SchedulerOutputandEngineCoreOutputs. - Tour: One engine step: one step through this file with real numbers.
- v1/core/kv_cache_manager.py: KVCacheManager and KVCacheBlocks and v1/core/block_pool.py: BlockPool and the prefix-cache map: the next files down.
Check yourself#
- 1In which process and thread doeseasy
Scheduler.schedule()run?Show answer
In the engine core process, in its main thread, called by the step function of the busy loop (
vllm/v1/engine/core.py:725). There is one scheduler per engine core. - 2Why doesmedium
_update_after_schedulerun after theSchedulerOutputis built, and not before?Show answer
The output must carry the counts from before the step, so that the worker knows which input ids and positions to use. The comment at
vllm/v1/core/sched/scheduler.py:1704-1712gives this reason. The call is the last line ofschedule()(vllm/v1/core/sched/scheduler.py:1641-1643). - 3A request samples 3 tokens in one step (2 accepted drafts and a bonus token). The second token is EOS. What doesmedium
update_from_outputsend?Show answer
Two tokens.
_update_request_with_outputrunscheck_stopafter each token, finds EOS at the second, and cuts the list there (vllm/v1/core/sched/scheduler.py:2598-2610). The request is freed and the output carriesfinish_reasonstop. - 4With the priority policy, the victim of a preemption was already scheduled earlier in the same running pass. What does the scheduler undo?hard
Show answer
It removes the victim from
scheduled_running_reqs, gives its tokens back totoken_budgetandinput_budget(with the draft slots), drops its new blocks and draft tokens from the plan, and restores the encoder budget. It also movesreq_indexback when the victim was before the current request (vllm/v1/core/sched/scheduler.py:864-888). With FCFS this cannot happen, because the victim is the last request.