The V1 scheduler: one token budget, no phases
Code version: vLLM @ 3e44c0d · 2026-10-22
In one sentence
In each step, the scheduler gives each request the tokens it misses, running requests first, until the token budget or the KV blocks run out.
The idea#
The scheduler lives in the EngineCore process. Once per engine step, it decides which requests run and how many of their tokens. DeepInfer Explained teaches the basics: continuous batching and chunked prefill (Batching), and KV blocks and preemption (The KV cache). It also shows a scheduler with both (The scheduler). This page explains what vLLM does differently.
The vLLM scheduler has no prefill phase and no decode phase. Each request has two numbers: num_computed_tokens (tokens whose KV values are in the cache) and its number of tokens (prompt plus output). The scheduler gives each request the difference, as far as the token budget allows. A new prompt asks for many tokens, so it gets a prefill. A request in the middle of its answer asks for one token, so it gets a decode. A long prompt that does not fit gets a part of it: that is chunked prefill. One rule covers all three cases.
Each call of schedule() has two passes. The first pass serves the running queue, from the front. The second pass starts requests from the waiting queue, but only if the first pass did not preempt anything. Each scheduled request also needs KV blocks. When the blocks run out, the scheduler takes away all the blocks of the last running request. That request must compute its tokens again later: this is recompute preemption. After the GPU step, update_from_output() appends the sampled tokens and finishes the requests that must stop.
An everyday comparison. Think of a print shop that can print 16 pages per hour. Each job asks for the pages that it still needs. The shop serves the jobs that it already started first, then new jobs. A job also needs paper trays (the KV blocks). When the trays run out, the shop takes all the trays of the newest job, and that job must start again later. The comparison stops here. In vLLM, a restarted request can often get some of its old work back from the prefix cache. Also, a print job does not make its own next input.
The picture#
Read the panels from top to bottom. Blue boxes are the running pass, violet boxes the waiting pass, and the amber box is preemption. Note the two exits that differ: in the running pass, a request with 0 tokens is skipped (continue). In the waiting pass, the first request that does not fit stops the whole pass (break).
A worked example: three steps#
Try it yourself#
This interactive visual needs JavaScript. Turn on JavaScript in your browser to use it. The text on this page explains the same idea.
The simulator follows these rules of the real code. It leaves out everything in the box above.
- The running pass goes first, from the front of
self.running, while budget is left (vllm/v1/core/sched/scheduler.py:713-715). - A request gets
num_tokens - num_computed_tokenstokens, at most the rest of the budget (vllm/v1/core/sched/scheduler.py:759-768). - A request needs
ceil((num_computed_tokens + new) / block_size)blocks in total (vllm/v1/core/single_type_kv_cache_manager.py:214,vllm/v1/core/single_type_kv_cache_manager.py:401-402). - If blocks are short, the scheduler preempts the last running request, and stops the pass if that was the request itself (
vllm/v1/core/sched/scheduler.py:857-858,vllm/v1/core/sched/scheduler.py:889-904). - The waiting pass runs only if nothing was preempted (
vllm/v1/core/sched/scheduler.py:957), and only while fewer thanmax_num_seqsrequests run (vllm/v1/core/sched/scheduler.py:979-981). - A waiting request starts only if all its tokens fit in free blocks (
vllm/v1/core/kv_cache_manager.py:522-539). If not, or if chunked prefill is off and the prompt does not fit the budget, the pass stops (vllm/v1/core/sched/scheduler.py:1222-1229,vllm/v1/core/sched/scheduler.py:1339-1346). - Freed blocks go to the front of the free queue in reverse order (
vllm/v1/core/single_type_kv_cache_manager.py:597-598,vllm/v1/core/block_pool.py:797-807). - A request that reaches the end of its known tokens samples one token. It stops at
max_tokens(vllm/v1/core/sched/utils.py:114-119).
In the code#
Step 1: the request state that the scheduler reads#
The scheduler works on Request objects (vllm/v1/request.py). Three counters drive every decision:
@property
def num_tokens(self) -> int:
return len(self._all_token_ids)
@property
def num_tokens_with_spec(self) -> int:
return len(self._all_token_ids) + len(self.spec_token_ids)
@property
def num_output_tokens(self) -> int:
return len(self._output_token_ids)num_tokens is the prompt plus the output tokens so far. num_tokens_with_spec adds the draft tokens of speculative decoding. num_computed_tokens starts at 0 (vllm/v1/request.py:184). The comment at the top of schedule() states the rule with these names:
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.The status of a request is an IntEnum. Every value after PREEMPTED means "finished":
class RequestStatus(enum.IntEnum):
"""Status of a request."""
WAITING = enum.auto()
WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR = enum.auto()
WAITING_FOR_REMOTE_KVS = enum.auto()
WAITING_FOR_STREAMING_REQ = enum.auto()
RUNNING = enum.auto()
PREEMPTED = enum.auto()
# Note: anything after PREEMPTED will be considered
# as a finished status.
FINISHED_STOPPED = enum.auto()
FINISHED_LENGTH_CAPPED = enum.auto()
FINISHED_ABORTED = enum.auto()
FINISHED_IGNORED = enum.auto()
FINISHED_ERROR = enum.auto()
FINISHED_REPETITION = enum.auto()
def __str__(self) -> str:
return self.name
@staticmethod
def is_finished(status: "RequestStatus") -> bool:
return status > RequestStatus.PREEMPTEDA request with structured output starts in WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR, not WAITING (vllm/v1/request.py:116-117). Its grammar compiles in a thread pool, and the waiting pass skips the request until the grammar is ready (Step 5).
Step 2: the token budget and max_num_seqs#
The budget of one step is max_num_scheduled_tokens. When you do not set it, the scheduler uses max_num_batched_tokens:
# 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
)The dataclass defaults (2048 tokens, 128 sequences) are only test values (vllm/config/scheduler.py:41-43, vllm/config/scheduler.py:48-53). For vllm serve, the engine arguments pick the real defaults from the GPU memory (vllm/engine/arg_utils.py:2960-2993):
| GPU memory | max_num_batched_tokens | max_num_seqs |
|---|---|---|
| 160 GiB or more (for example B200) | 16384 | 1024 |
| 70 GiB or more, not A100, not GB10 (for example H100) | 8192 | 1024 |
| other GPUs (for example A100) | 2048 | 256 |
--performance-mode throughput doubles both values when you did not set them (vllm/engine/arg_utils.py:3171-3175). Some later rules can change an unset value again, for example when chunked prefill is off, the token default is at least max_model_len (vllm/engine/arg_utils.py:3177-3221). TPU and CPU have their own defaults (vllm/engine/arg_utils.py:2995-3024). max_num_seqs limits the length of the running queue: the scheduler asserts it after each call (vllm/v1/core/sched/scheduler.py:1496). The waiting pass uses max_num_active_seqs, which is max_num_seqs unless you set a smaller value (vllm/v1/core/sched/scheduler.py:133-137). The page Tuning vLLM: throughput and latency knobs, their real defaults and what each one does explains how to choose these values.
Step 3: the running pass#
The running pass walks self.running from the front while budget is left (vllm/v1/core/sched/scheduler.py:713-715). For each request it computes the missing tokens and caps them:
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 759-763. The missing tokens.
num_output_placeholderscounts tokens that a started step will sample; it is 0 without async scheduling. - Lines 764-765. The long_prefill_token_threshold. Its default is 0, which means no cap (
vllm/config/scheduler.py:79-84). The scheduler also ignores it when only one request is running or waiting (vllm/v1/core/sched/scheduler.py:677-683). - Lines 766-768. The rest of the budget.
input_budgetismax_num_batched_tokens.draft_slotsis 0 without speculative decoding and for most drafting methods. It is 1 for a separate draft model, and more for parallel drafting (vllm/v1/core/sched/scheduler.py:644-645,vllm/config/speculative.py:1977-1995). - Lines 772-777. The position must stay below
max_model_len; one slot stays free for the sampled token.
When the result is 0, the scheduler skips the request and goes on to the next one. The comment explains the choice:
if num_new_tokens == 0:
# The request cannot be scheduled because one of the following
# reasons:
# 1. No new tokens to schedule. This may happen when
# (1) PP>1 and we have already scheduled all prompt tokens
# but they are not finished yet.
# (2) Async scheduling and the request has reached to either
# its max_total_tokens or max_model_len.
# 2. The encoder budget is exhausted.
# 3. The encoder cache is exhausted.
# 4. Insufficient budget for a block-aligned chunk in hybrid
# models with mamba cache mode \"align\".
# 5. Insufficient budget to keep a multi-module MTP prefill
# chunk out of the prefill-lookahead window.
# 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
continueWith async scheduling, a request can skip a step for one more reason. When its in-flight tokens surely reach max_tokens, the scheduler plans no extra step (vllm/v1/core/sched/scheduler.py:720-734).
Step 4: KV blocks, and preemption when they run out#
Each request then asks the KV cache manager for blocks with allocate_slots (vllm/v1/core/sched/scheduler.py:833-842). The page The KV cache manager: blocks, the block pool and memory sizing explains that call. If it returns None, the scheduler picks a victim:
# 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]With the default policy (fcfs), the victim is the last request in the running queue. That is usually the request that started last. With the priority policy, it is the request with the largest (priority, arrival_time): the lowest priority, and among equals the latest arrival. The victim leaves the running queue, and _preempt_request resets it:
assert request.status == RequestStatus.RUNNING, (
"Only running requests can be preempted"
)
if self.aux_output_connector is not None:
self.aux_output_connector.request_finished(request)
self._free_request_blocks(request)
self.encoder_cache_manager.free(request)
self._inflight_prefills.discard(request)
request.status = RequestStatus.PREEMPTED
request.num_computed_tokens = 0
if request.spec_token_ids:
request.spec_token_ids = []
# Async scheduling: mark all in-flight output as stale. Its tokens are
# still delivered on return (dropping them would perturb spec-decode
# acceptance) but must not mutate the reset counters; each step drains
# its share in update_from_output. num_in_flight_tokens already
# includes any undrained stale share, so assign rather than accumulate.
# An undrained drop-mode share stays dropped: its positions have
# already been resampled.
request.drop_stale_output = drop_stale_output or (
request.drop_stale_output and request.num_stale_output_tokens > 0
)
request.num_stale_output_tokens = request.num_in_flight_tokens
request.num_output_placeholders = 0
request.num_preemptions += 1
if self.log_stats:
request.record_event(EngineCoreEventType.PREEMPTED, timestamp)
# Put the request back to the waiting queue.
self.waiting.prepend_request(request)
self.reset_preempted_req_ids.add(request.request_id)- Line 1676. All KV blocks of the request go back to the block pool. Only with a consumer KV connector and overlapping steps does the scheduler hold them until the running step ends (
vllm/v1/core/sched/scheduler.py:187-193,vllm/v1/core/sched/scheduler.py:2866-2875). - Lines 1679-1680. The status becomes
PREEMPTED, andnum_computed_tokensbecomes 0. The output tokens stay in the request. So when it runs again, it computes the prompt and its output tokens again: this is recompute. - Lines 1683-1694. With async scheduling, tokens of a started step can still arrive. They are marked stale, so they do not change the reset counters.
- Lines 1700-1701. The request goes to the front of the waiting queue, and its id goes into
preempted_req_idsof the nextSchedulerOutput.
Then the scheduler tries allocate_slots again. If the victim was the request itself, the running pass stops (vllm/v1/core/sched/scheduler.py:898-904). The blocks keep their hashes when prefix caching is on. So the preempted request can find its own full blocks again in the prefix cache, if no other request has reused them (Prefix caching in the engine core: hashes, lookup, caching, eviction and KV events).
Step 5: the waiting pass#
# Next, schedule the WAITING requests.
if not preempted_reqs and self._pause_state != PauseState.PAUSED_ALL:
step_skipped_waiting: deque[Request] = deque()
step_skipped_kv_holding: deque[Request] = deque()
def skip_request(from_queue: RequestQueue) -> None:
request_to_skip = from_queue.pop_request()
self.deferred_waiting.add(request_to_skip)
if self._holds_kv_blocks(request_to_skip):
step_skipped_kv_holding.appendleft(request_to_skip)
else:
step_skipped_waiting.appendleft(request_to_skip)
while token_budget > 0:
# Requests holding KV blocks are always drained first, and are
# the only ones admitted under PAUSED_NEW (e.g. async KV loads).
request_queue = self.kv_holding_waiting
if not request_queue and self._pause_state == PauseState.UNPAUSED:
request_queue = self.waiting
if not request_queue or 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_active_reqs:
break
request = request_queue.peek_request()
request_id = request.request_id
ready_to_schedule = self._handle_blocked_waiting_request(request)
if not ready_to_schedule:
skip_request(request_queue)
continue- Line 957. The waiting pass runs only if the running pass preempted nothing. It makes no sense to start a new request when a running one just lost its blocks.
- Lines 972-974. Waiting requests that still hold KV blocks (for example after a remote KV load) go first, from
kv_holding_waiting. - Lines 979-981. The
max_num_seqslimit (throughmax_num_active_seqs). - Lines 986-989. A request whose grammar is not compiled yet, or whose remote KV has not arrived, is skipped for this step. The pass continues with the next request (
vllm/v1/core/sched/scheduler.py:3275-3310).
A LoRA request is also skipped when max_loras different adapters already run and its adapter is not one of them (vllm/v1/core/sched/scheduler.py:998-1009). A request with num_computed_tokens == 0 then looks up the prefix cache (vllm/v1/core/sched/scheduler.py:1016-1024). A KV connector can add tokens from an external cache. An asynchronous load parks the request in WAITING_FOR_REMOTE_KVS until the data arrives (vllm/v1/core/sched/scheduler.py:1384-1411, KV connectors). Next, the number of tokens, and the rule for chunked prefill:
num_new_tokens = request.num_tokens - num_computed_tokens 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)With chunked prefill on, the request takes the rest of the budget and continues in the next step. With chunked prefill off, a prompt that does not fit stops the pass. Chunked prefill is on by default for generative models that are not encoder-decoder models (vllm/engine/arg_utils.py:3031-3035, vllm/config/model.py:2229-2236).
The request then calls allocate_slots with full_sequence_must_fit=True by default, so all its tokens must fit in free blocks, not only the first chunk (vllm/config/scheduler.py:190-194, vllm/v1/core/sched/scheduler.py:1320-1337). If the call fails, the pass stops (vllm/v1/core/sched/scheduler.py:1339-1346). If it succeeds, the request moves to the running queue:
request = request_queue.pop_request()
self.deferred_waiting.discard(request)
self.running.append(request)
if num_external_computed_tokens > 0:
# load_kv_async is False here
has_sync_kv_loads = True
if self.log_stats:
request.record_event(
EngineCoreEventType.SCHEDULED, scheduled_timestamp
)
if request.status == RequestStatus.WAITING:
scheduled_new_reqs.append(request)
elif request.status == RequestStatus.PREEMPTED:
scheduled_resumed_reqs.append(request)
else:
raise RuntimeError(f"Invalid request status: {request.status}")
if self.lora_config and request.lora_request:
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_tokensStep 6: speculative tokens use the same budget#
Draft tokens are part of num_tokens_with_spec (Step 1), so the running pass counts them in the same budget. After the request is scheduled, the scheduler keeps only the draft tokens that fit and moves them into scheduled_spec_decode_tokens:
# Speculative decode related.
if request.spec_token_ids:
num_scheduled_spec_tokens = (
num_new_tokens
+ request.num_computed_tokens
- request.num_tokens
- request.num_output_placeholders
)
if num_scheduled_spec_tokens > 0:
spec_token_ids = request.spec_token_ids
if len(spec_token_ids) > num_scheduled_spec_tokens:
spec_token_ids = spec_token_ids[:num_scheduled_spec_tokens]
scheduled_spec_decode_tokens[request.request_id] = spec_token_ids
# New spec tokens will be set in `update_draft_token_ids` before the
# next step when applicable.
request.spec_token_ids = []Rejected drafts roll back num_computed_tokens later, in update_from_output (vllm/v1/core/sched/scheduler.py:2206-2221). The page Speculative decoding in vLLM serving explains the methods.
Step 7: the SchedulerOutput, then the counters move#
At the end, schedule() builds the SchedulerOutput:
scheduler_output = SchedulerOutput(
scheduled_new_reqs=new_reqs_data,
scheduled_cached_reqs=cached_reqs_data,
num_scheduled_tokens=num_scheduled_tokens,
total_num_scheduled_tokens=total_num_scheduled_tokens,
scheduled_spec_decode_tokens=scheduled_spec_decode_tokens,
scheduled_encoder_inputs=scheduled_encoder_inputs,
scheduled_encoder_input_stats=scheduled_encoder_input_stats,
preempted_req_ids=self.reset_preempted_req_ids,
# finished_req_ids is an existing state in the scheduler,
# instead of being newly scheduled in this step.
# It contains the request IDs that are finished in between
# the previous and the current steps.
finished_req_ids=self.finished_req_ids,
free_encoder_mm_hashes=self.encoder_cache_manager.get_freed_mm_hashes(),
new_block_ids_to_zero=self._get_new_block_ids_to_zero(),
has_sync_kv_loads=has_sync_kv_loads,
kv_cache_block_copies=pending_kv_cache_block_copies,
kv_connector_block_state=kv_connector_block_state,
num_spec_tokens_to_schedule=num_spec_tokens_to_schedule,
ec_manager_metadata=self.encoder_cache_manager.get_manager_metadata(),
)With Model Runner V2, a resumed request travels as a new request, with all its block ids (vllm/v1/core/sched/scheduler.py:1505-1516). Only after the output exists does _update_after_schedule advance the counters:
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()A request is a prefill chunk when it still has tokens to compute after this step (line 1721). With the default AsyncScheduler, every request that is not a prefill chunk also gets an output placeholder for the token that the step will sample (vllm/v1/core/sched/async_scheduler.py:32-47). That is why the running pass adds num_output_placeholders (Step 3). The page Async scheduling: overlap the CPU and the GPU explains the overlap. All fields of the output are on Data contracts between the parts of vLLM.
Step 8: update_from_output appends tokens and finishes requests#
When the step's result arrives, the engine core calls update_from_output. For each scheduled request, it takes the sampled tokens and checks for a 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, stoppedcheck_stop tests in this order: EOS, a stop token id, the length limits (max_model_len or max_tokens), then min_tokens and repetition detection (vllm/v1/core/sched/utils.py:98-140). A finished request frees its blocks (vllm/v1/core/sched/scheduler.py:2316-2323), and leaves the running queue (vllm/v1/core/sched/scheduler.py:2390-2391). The scheduler makes an EngineCoreOutput only for requests with new tokens, a pooler output or a stop (vllm/v1/core/sched/scheduler.py:2293-2295). So a prefill chunk sends nothing to the front end. Tour Tour: One engine step follows this method with real numbers.
Step 9: the scheduling policy#
The scheduling policy is --scheduling-policy, with fcfs as the default (vllm/config/scheduler.py:159-165). It selects the class of the waiting queue:
def create_request_queue(policy: SchedulingPolicy) -> RequestQueue:
"""Create request queue based on scheduling policy."""
if policy == SchedulingPolicy.PRIORITY:
return PriorityRequestQueue()
elif policy == SchedulingPolicy.FCFS:
return FCFSRequestQueue()
else:
raise ValueError(f"Unknown scheduling policy: {policy}")The FCFS queue is a deque: new requests go to the end, preempted requests to the front (vllm/v1/core/sched/request_queue.py:78-94). The priority queue is a heap ordered by Request.__lt__: smaller priority first, then earlier arrival_time, then the request id (vllm/v1/core/sched/request_queue.py:131-157, vllm/v1/request.py:357-367).
A client sets the priority with the priority field of the request body. The header X-Vllm-Priority overrides the field when it holds an integer (vllm/entrypoints/generate/base/serving.py:254-265).
Step 10: replace the scheduler with scheduler_cls#
--scheduler-cls takes a class or a "module.Class" path. Without it, vLLM picks AsyncScheduler when async scheduling is on, else Scheduler:
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
if not (
isinstance(self.scheduler_cls, str)
and self.scheduler_cls.startswith("vllm.")
):
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]
)
if not isinstance(self.scheduler_cls, str):
return cast(type["SchedulerInterface"], self.scheduler_cls)
return resolve_obj_by_qualname(self.scheduler_cls)The engine core builds the class in EngineCore.__init__ (vllm/v1/engine/core.py:164, vllm/v1/engine/core.py:177-185). The warning on lines 247-253 is a real trap: a custom class that subclasses Scheduler instead of AsyncScheduler loses the placeholders of async scheduling. The page Hooks in the engine core and the workers lists this hook with the other engine hooks.
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 diveThe running pass, line by line
Lines of vllm/v1/core/sched/scheduler.py, in the order that they run for one running request:
- 642-648.
token_budget = max_num_scheduled_tokens;input_budget = max_num_batched_tokens. A full pause (PAUSED_ALL) sets the budget to 0. - 666.
kv_cache_manager.new_step_starts()tells the managers that a step begins. - 670-711. Prefill throttling for data parallel MoE and the local
prefill_schedule_interval(default 1, so off) (vllm/config/scheduler.py:203-209). When it is active, running prefill chunks wait for a later step, and decodes still run (lines 742-746). - 713-718. The loop. It breaks when
input_budgetcannot hold one more token plus the draft slots. - 720-734. Async scheduling: skip a request whose in-flight tokens already reach
max_tokens. - 736-740. Model Runner V2 with PP and async scheduling: a request decodes at most every
pp_sizesteps. - 748-757. An encoder-cache connector can hold a multimodal request back.
- 759-777. The token count and its four caps (Step 3).
- 780-809. Mamba block alignment, encoder inputs and the MTP lookahead can lower the count further.
- 811-829. A count of 0:
continue. - 831-904.
allocate_slotsand the preemption loop. With a KV connector that has pending block frees, the loop stops without a preemption (lines 844-848). - 906-914. The request is scheduled:
num_scheduled_tokens[req_id] = num_new_tokens; both budgets go down. - 916-943. Draft tokens (Step 6) and encoder inputs.
- 945-954. The LoRA ids of all running requests, for the
max_lorascheck of the waiting pass.
With the priority policy, the victim can be a request that this pass already scheduled. Then the scheduler gives its tokens back to both budgets and removes its blocks and draft tokens from the plan (lines 864-888). With FCFS this cannot happen: the victim is the last request, and the pass has not reached it yet.
Deep diveThe waiting pass, line by line
- 958-967.
skip_requestmoves a request out of the queue for this step only. Skipped requests go back to the front of their queue at the end (lines 1469-1473), so they keep their place. - 969-981. Loop while budget is left; queue choice;
max_num_active_seqs. - 986-996. Blocked statuses, and stale output of a preempted request that is still in flight.
- 998-1009. LoRA: skip.
- 1016-1146. First admission (
num_computed_tokens == 0): local prefix-cache hit, then the KV connector hit, then bookkeeping for statistics. - 1147-1158. A request that already has computed tokens (after a remote KV load, or a streaming-input session) uses them.
- 1171-1283. The token count. An async KV load schedules 0 tokens. With speculative decoding, a waiting request with only 1 token to compute can be padded to
1 + num_spec_tokens. This happens only when no prefill chunk runs in the step, and keeps a uniform batch for CUDA graphs (lines 1196-1215). Then the long-prefill cap, the chunked-prefill rule and the budget. - 1320-1346.
allocate_slots, with the prefix-cache hit andfull_sequence_must_fit.None:break. - 1378-1382. Prefix-cache statistics are recorded only for admitted requests.
- 1413-1467. The request moves to
running. Its status before the move decides the list:WAITINGgoes toscheduled_new_reqs,PREEMPTEDtoscheduled_resumed_reqs.
Note the asymmetry. In the waiting pass, a request that does not fit stops the pass. A smaller request behind it does not get a chance in this step. Only skipped requests (blocked, LoRA, 0 tokens) let the pass continue.
Deep divePreemption: cost, metrics and what comes back
What the victim loses. All its KV blocks and its num_computed_tokens. It keeps its prompt and output tokens. Take a victim with a 1000-token prompt and 200 output tokens. Without a prefix-cache hit, it must compute 1200 tokens again when it resumes (derived from lines 1680 and 1191). The waiting pass treats it like a new request. It looks up the prefix cache, because num_computed_tokens is 0, and all its tokens must fit.
What comes back. _free_request_blocks frees the blocks in reverse order (vllm/v1/core/sched/scheduler.py:2866-2875, vllm/v1/core/single_type_kv_cache_manager.py:597-598). Blocks with a hash go to the end of the free queue. Blocks without a hash, for example the partial last block, go to the front (vllm/v1/core/block_pool.py:776-807). So the full blocks stay in the prefix cache as long as nobody needs their memory. If they survive, the resumed request hits them and computes only the rest.
How you see it. The scheduler records a PREEMPTED event when stats are on (vllm/v1/core/sched/scheduler.py:1696-1697). The front end counts these events in vllm:num_preemptions (vllm/v1/metrics/loggers.py:738-745). The periodic log line adds "Preemptions: N" when N is above 0 (vllm/v1/metrics/loggers.py:310-312).
Docs and code. docs/configuration/optimization.md shows a WARNING line "Sequence group 0 is preempted by PreemptionMode.RECOMPUTE mode ..." (docs/configuration/optimization.md:33-35). The V1 scheduler does not log a line per preemption: _preempt_request has no log call (vllm/v1/core/sched/scheduler.py:1658-1701). Watch the metric or the "Preemptions" field instead. The same page says that recompute is the default mode in V1 (docs/configuration/optimization.md:47). In the V1 code it is the only mode: _preempt_request always frees the blocks and resets num_computed_tokens (vllm/v1/core/sched/scheduler.py:1676-1680).
Other preemptions. reset_prefix_cache(reset_running_requests=True) preempts every running request, from the end, with their stale output dropped (vllm/v1/core/sched/scheduler.py:2950-2960).
Deep diveThe worked example, continued
The simulator continues the figure with the same rules. Values right after each step (budget 16, block size 4, 8 blocks):
| Step | Scheduled (tokens) | Free blocks after | What happens |
|---|---|---|---|
| 1 | A 10, B 6 | 3 | A finishes its prompt and samples token 1. B is a chunk. C waits: no budget left. |
| 2 | A 1, B 6, C 6 | 0 | B ends its prompt; C starts. 13 of 16 tokens used. |
| 3 | A 1, B 1 | 1 | B needs block 4 of its 13 tokens: preempt C. No waiting pass. |
| 4 | A 1, B 1 | 4 | A needs a 4th block and takes the last free one. C needs 2 blocks for its 7 tokens: 0 free, the pass stops. A reaches max_tokens and frees 4 blocks. |
| 5 | B 1, C 7 | 6 | C resumes and recomputes 7 tokens (6 prompt + 1 output). B finishes. |
The real engine has prefix caching on. After step 3, C's first block (block 7: 4 prompt tokens, full and hashed) waits at the end of the free queue. But in step 4, A takes the last free block, and that is block 7: get_new_blocks evicts its hash (vllm/v1/core/block_pool.py:681-691). So in this example C computes all 7 tokens again in the real engine too. With one more free block, block 7 would survive, and C would compute only 3 tokens (derived).
Deep diveConfiguration fields of the scheduler
| Flag / field | Default | Effect |
|---|---|---|
--max-num-batched-tokens | by GPU memory (Step 2) | input_budget; also the default token budget (vllm/config/scheduler.py:48-53) |
max_num_scheduled_tokens | None → max_num_batched_tokens | the token budget; can be lower when the model appends tokens, for example drafts (vllm/config/scheduler.py:55-60, vllm/config/vllm.py:2320-2327) |
--max-num-seqs | by GPU memory (Step 2) | size of the running queue and of the model runner (vllm/config/scheduler.py:62-67) |
max_num_active_seqs | None → max_num_seqs | admission limit of the waiting pass only (vllm/config/scheduler.py:69-77) |
--long-prefill-token-threshold | 0 (off) | cap per request and step (vllm/config/scheduler.py:79-84); long_prefill_token_threshold_adaptive raises it to a fair share (vllm/config/scheduler.py:86-90) |
--enable-chunked-prefill | on for generative non-encoder-decoder models | chunking in the waiting pass (vllm/engine/arg_utils.py:3031-3035) |
--scheduling-policy | fcfs | queue order and victim (Step 9) |
--scheduler-reserve-full-isl | True | a new request starts only if all its tokens fit (vllm/config/scheduler.py:190-194) |
watermark | 0.0 | fraction of blocks kept free when a waiting request starts (vllm/config/scheduler.py:196-201, vllm/v1/core/kv_cache_manager.py:509-516) |
prefill_schedule_interval | 1 | prefill only every N steps (vllm/config/scheduler.py:203-209) |
--async-scheduling | None → on in most cases | chooses AsyncScheduler (vllm/config/vllm.py:1664-1698) |
--scheduler-cls | None | your own class (Step 10) |
max_num_queued_reqs, max_num_queued_tokens | None | checked in AsyncLLM in the API server, not here (vllm/v1/engine/async_llm.py:352-382); the field docs say the client gets HTTP 503 (vllm/config/scheduler.py:92-132) |
The scheduler checks some combinations at start-up: max_num_batched_tokens must be at least max_model_len when chunked prefill is off, and at least max_num_seqs (vllm/config/scheduler.py:317-336).
Deep diveParts of schedule() that this page skips
- Encoder inputs (images, audio): a separate encoder compute budget and encoder cache; a chunk can stop before an image that does not fit (
vllm/v1/core/sched/scheduler.py:1876-1903). - Mamba "align" mode: chunks end on block boundaries so that states can be cached (
vllm/v1/core/sched/scheduler.py:474-488). See The hybrid KV cache: specs, groups and one manager per layer type. - Pause states:
PAUSED_ALLschedules nothing;PAUSED_NEWadmits only requests that hold blocks (vllm/v1/core/sched/scheduler.py:646-648,vllm/v1/core/sched/scheduler.py:970-974). - Streaming-input sessions (
resumablerequests): a stopped turn waits inWAITING_FOR_STREAMING_REQfor the next input (vllm/v1/core/sched/scheduler.py:2564-2589). - KV load failures of a connector: the scheduler recomputes the bad blocks or fails the request, by
kv_load_failure_policy(vllm/v1/core/sched/scheduler.py:184-185,vllm/v1/core/sched/scheduler.py:3444-3457).
What can go wrong#
- Many preemptions. The KV cache is too small for the running requests. Each preemption throws away computed work. Watch
vllm:num_preemptions. Raise--gpu-memory-utilization, or lower--max-num-seqs(The KV cache manager: blocks, the block pool and memory sizing). - Decodes slow down during a long prompt. A prompt chunk can take the whole rest of the budget, so the step gets long for every request in it.
--long-prefill-token-thresholdcaps one request's share (vllm/v1/core/sched/scheduler.py:764-765). - Head-of-line blocking. The first waiting request that does not fit stops the waiting pass (
vllm/v1/core/sched/scheduler.py:1339-1346). With chunked prefill off, a long prompt at the front stops it too (vllm/v1/core/sched/scheduler.py:1222-1229). - Requests wait with free memory.
max_num_seqsrequests already run (vllm/v1/core/sched/scheduler.py:979-981), or the LoRA requests wait for an adapter slot (vllm/v1/core/sched/scheduler.py:998-1009). - The priority field does nothing. The server runs
fcfs. Start it with--scheduling-policy priority. - A custom scheduler is slower. It subclasses
Scheduler, so async scheduling is lost (vllm/config/scheduler.py:247-253).
Why it matters. The scheduler sets the shape of every step: how many tokens, which requests, how much of a prompt. TTFT, inter-token latency and throughput all follow from these choices. When you tune max_num_batched_tokens or max_num_seqs, or read the preemption metric, you change or watch this one function.
Check yourself#
- 1Does the vLLM scheduler have a prefill phase and a decode phase?easy
Show answer
No. Each request asks for
num_tokens_with_spec - num_computed_tokenstokens, and the scheduler gives them from one budget. A new prompt asks for many tokens, a decoding request for one (vllm/v1/core/sched/scheduler.py:624-633,vllm/v1/core/sched/scheduler.py:759-768). - 2With the default policy, which request does the scheduler preempt when a running request cannot get blocks?easy
Show answer
The last request in the running queue:
self.running.pop()(vllm/v1/core/sched/scheduler.py:889-890). With theprioritypolicy, it is the request with the largest(priority, arrival_time)(vllm/v1/core/sched/scheduler.py:852-856). - 3In a step, the running pass preempts one request. A short new request waits, and 2000 tokens of budget are left. Does the short request start in this step?medium
Show answer
No. The waiting pass runs only when nothing was preempted in this step (
vllm/v1/core/sched/scheduler.py:957). The budget stays unused. - 4Why does the running pass usemedium
continuefor a request with 0 tokens, while the waiting pass usesbreakwhenallocate_slotsfails?Show answer
A running request with 0 tokens (for example, all its tokens are in flight) must not block the requests behind it. The comment says that the scheduler gives up strict FCFS for this (
vllm/v1/core/sched/scheduler.py:825-829). In the waiting pass, a failed allocation means the free blocks are short, so the scheduler stops admitting new requests (vllm/v1/core/sched/scheduler.py:1339-1346). - 5A request has a 1000-token prompt and 200 output tokens when it is preempted. Prefix caching is on, block size 16, and no other request reused its blocks. How many tokens does it compute when it resumes, at the minimum?hard
Show answer
_preempt_requestsetsnum_computed_tokensto 0 (vllm/v1/core/sched/scheduler.py:1680), so the waiting pass looks up the prefix cache again (vllm/v1/core/sched/scheduler.py:1017-1024). The request has 1200 tokens. Its full blocks were cached and stay cached until reused. The lookup returns full blocks only, up tonum_tokens - 1= 1199 tokens (vllm/v1/core/kv_cache_manager.py:289-300): 74 blocks = 1184 tokens. So it computes at least 1200 − 1184 = 16 tokens. This assumes that every full block was cached; with async scheduling, the blocks of the last in-flight tokens may not be cached yet (derived).