vLLM Explained

v1/core/sched/scheduler.py: the Scheduler

AdvancedCode mapabout 11 min read + 7 min deep dives

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.

Filevllm/v1/core/sched/scheduler.py, 3,539 lines of Python. One class, Scheduler (vllm/v1/core/sched/scheduler.py:85), with 64 methods.
Main classScheduler(SchedulerInterface). The default subclass for serving is AsyncScheduler in async_scheduler.py (vllm/v1/core/sched/async_scheduler.py:14).
Runs inThe 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 byEngineCore in vllm/v1/engine/core.py: schedule, update_from_output, add_request, finish_requests, get_grammar_bitmask, statistics.
CallsKVCacheManager, the request queues, EncoderCacheManager, StructuredOutputManager, the KV and EC connectors (scheduler side), check_stop, the KV event publisher.
Not in this filePlaceholders 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:

LinesSection
vllm/v1/core/sched/scheduler.py:1-83Imports 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-620Chunk helpers: Mamba block alignment, the local prefix-cache lookup, the MTP prefill lookahead.
vllm/v1/core/sched/scheduler.py:622-1643schedule(): 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-1837Helpers of schedule(): _preempt_request, _update_after_schedule, streaming sessions, _make_cached_request_data.
vllm/v1/core/sched/scheduler.py:1839-2084Encoder inputs (multimodal): which items to run, budget, cache.
vllm/v1/core/sched/scheduler.py:2086-2115get_grammar_bitmask.
vllm/v1/core/sched/scheduler.py:2117-2523update_from_output().
vllm/v1/core/sched/scheduler.py:2525-2703Helpers of the update: blocked statuses, re-queue, stops, token append, encoder frees, draft tokens.
vllm/v1/core/sched/scheduler.py:2705-2938Bookkeeping: counts, usage, add_request, finish_requests, freeing, pause state, has_*.
vllm/v1/core/sched/scheduler.py:2940-3090Cache resets, statistics, shutdown.
vllm/v1/core/sched/scheduler.py:3092-3539KV connector methods: finished transfers, remote KV waits, invalid blocks.
Figure: the flow of schedule() (lines 622-1643), the same figure as on The V1 scheduler: one token budget, no phases.
Open full size ↗

Who calls it#

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#

Key classes and functions#

NameLineWhat it does
Scheduler.__init__vllm/v1/core/sched/scheduler.py:86reads the limits, builds the queues, the connectors and the KVCacheManager
schedulevllm/v1/core/sched/scheduler.py:622the running and waiting passes; returns a SchedulerOutput
_preempt_requestvllm/v1/core/sched/scheduler.py:1658frees the blocks, sets num_computed_tokens = 0, status PREEMPTED, front of waiting
_update_after_schedulevllm/v1/core/sched/scheduler.py:1703advances num_computed_tokens, sets is_prefill_chunk; AsyncScheduler extends it
_make_cached_request_datavllm/v1/core/sched/scheduler.py:1780the diff for requests that the workers already know
_try_schedule_encoder_inputsvllm/v1/core/sched/scheduler.py:1876which images or audio items to encode in this step
get_grammar_bitmaskvllm/v1/core/sched/scheduler.py:2086the token masks of structured output requests for this step
update_from_outputvllm/v1/core/sched/scheduler.py:2117applies a ModelRunnerOutput; returns EngineCoreOutputs per front end
_handle_stopped_requestvllm/v1/core/sched/scheduler.py:2564finished, or (for resumable sessions) back to waiting
_update_request_with_outputvllm/v1/core/sched/scheduler.py:2591appends tokens one by one and runs check_stop
update_draft_token_idsvllm/v1/core/sched/scheduler.py:2647stores the drafts for the next step (synchronous path)
get_request_counts, get_kv_cache_usagevllm/v1/core/sched/scheduler.py:2705-2711numbers for load balancing and metrics
add_requestvllm/v1/core/sched/scheduler.py:2716puts a new request in the waiting queue, or feeds a streaming session
finish_requestsvllm/v1/core/sched/scheduler.py:2749aborts from outside: removes from the queues, frees the blocks
_free_request, _free_blocksvllm/v1/core/sched/scheduler.py:2814-2849connector hooks, encoder cache, finished_req_ids, then the blocks
_free_request_blocksvllm/v1/core/sched/scheduler.py:2866frees now, or defers while a step may still write the blocks
reset_prefix_cachevllm/v1/core/sched/scheduler.py:2940optionally preempts all running requests, then resets the cache
make_statsvllm/v1/core/sched/scheduler.py:3015SchedulerStats: counts, KV cache usage, prefix-cache stats
_handle_blocked_waiting_requestvllm/v1/core/sched/scheduler.py:3275promotes a request whose grammar or remote KV is ready
_update_from_kv_xfer_finishedvllm/v1/core/sched/scheduler.py:3312marks 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
        )
  1. Line 130. max_num_running_reqs is max_num_seqs. The end of schedule() asserts it (vllm/v1/core/sched/scheduler.py:1496).
  2. Lines 133-137. max_num_active_reqs: the admission limit of the waiting pass.
  3. Lines 138-142. The token budget. See the real defaults on The V1 scheduler: one token budget, no phases.
  4. 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:

  1. Set the two budgets: token_budget and input_budget (vllm/v1/core/sched/scheduler.py:642-648).
  2. Running pass: tokens, blocks, preemption (vllm/v1/core/sched/scheduler.py:713-943).
  3. Waiting pass, only without preemption (vllm/v1/core/sched/scheduler.py:956-1479).
  4. Checks, the SchedulerOutput, connector metadata (vllm/v1/core/sched/scheduler.py:1481-1634).
  5. _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()
  1. 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.
  2. Lines 1716-1717. num_computed_tokens moves forward optimistically; num_in_flight_tokens remembers what is not confirmed.
  3. Lines 1721-1723. A prefill chunk is a request that still misses tokens after this step.
  4. 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:

  1. skips requests that finished during the step (for example, aborted) (vllm/v1/core/sched/scheduler.py:2184-2192);
  2. rolls back rejected draft tokens (vllm/v1/core/sched/scheduler.py:2203-2221);
  3. appends the new tokens and checks for a stop (vllm/v1/core/sched/scheduler.py:2258-2262);
  4. lets the grammar accept the tokens (vllm/v1/core/sched/scheduler.py:2279-2290);
  5. frees stopped requests and makes an EngineCoreOutput when 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
  1. Line 2599. Each token is appended alone; the request updates its block hashes (vllm/v1/request.py:272-283).
  2. Line 2603. check_stop runs after every token, so a stop in the middle of several accepted tokens is found (vllm/v1/core/sched/utils.py:98-140).
  3. Lines 2607-2609. Tokens after the stop are cut, and num_computed_tokens drops 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 dive

schedule(), 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
        )
  1. 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.
  2. Lines 642-648. token_budget limits the scheduled tokens; input_budget limits the input rows, which include draft_slots per 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.
  3. Lines 664-666. One monotonic timestamp for all SCHEDULED and PREEMPTED events of this call.
  4. Lines 670-672. Data parallel prefill throttling: on a throttled step, defer prefills while decodes run.
  5. 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,
            )
  1. Lines 715-718. The loop runs while budget is left, over self.running by index, because preemption shortens the list.
  2. Lines 720-734. Async scheduling: a request whose in-flight tokens already reach max_tokens gets no extra step.
  3. Lines 736-757. Three more skips: PP decode cadence, deferred prefill chunks, and encoder data that a connector has not delivered.
  4. 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
  1. Lines 833-842. allocate_slots with the lookahead tokens of the drafter. Success leaves the loop.
  2. Lines 844-848. A KV connector may free blocks soon; then the scheduler does not preempt and gives up on this request for now.
  3. Lines 852-858. The victim: highest (priority, arrival_time), or the last running request.
  4. Lines 861-862. With deferred block freeing (a KV consumer with overlapping steps), a victim whose blocks cannot be freed yet does not help.
  5. Lines 864-888. Priority only: if the victim was already scheduled in this step, its tokens, blocks, drafts and encoder budget go back.
  6. Lines 892-900. Preempt; stop retrying when the victim is the request itself.
  7. Lines 902-904. Still no blocks: the running pass ends.
  8. Lines 906-914. Record the request and reduce both budgets.
Deep dive

schedule(), 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)
  1. Lines 1171-1174. An asynchronous KV load schedules 0 tokens now.
  2. Lines 1175-1178. Deferred prefill on a throttled step stops the pass.
  3. Line 1191. num_tokens, not num_prompt_tokens: a resumed request recomputes its output tokens too.
  4. Lines 1196-1215. With speculative decoding, a 1-token request in a decode-only step is padded to 1 + num_spec_tokens rows, to keep a uniform batch.
  5. 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
  1. Lines 1305-1315. An async load reserves the blocks that other in-flight prefills still need, so two loads cannot block each other.
  2. Lines 1320-1337. allocate_slots adopts the prefix hit and checks the whole sequence (full_sequence_must_fit).
  3. 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 dive

schedule(), 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
            ]
  1. Line 1483. The sum of all scheduled tokens never exceeds the budget.
  2. Lines 1494-1502. Invariants: no negative budget, no more running requests than max_num_seqs.
  3. 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 dive

update_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 []
            )
  1. Line 2175. The tokens of this step are no longer in flight.
  2. Lines 2176-2180. A request preempted after this step started gets "stale" output: it is delivered but does not change the counters.
  3. Lines 2181-2192. Skip failed KV loads and requests that finished meanwhile (an abort during the step).
  4. Line 2198. req_id_to_index maps 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_rejected

Accepted 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
            )
  1. Lines 2259-2262. Generation: append and check_stop.
  2. Lines 2263-2266. Pooling: the request stops as soon as it has output.
  3. Lines 2279-2290. If the grammar rejects a sampled token, the request ends with FINISHED_ERROR and an error log.
  4. 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 dive

Data flow: types in and out

ValueTypeWhere
self.runninglist[Request]vllm/v1/core/sched/scheduler.py:232
self.waiting, self.kv_holding_waitingRequestQueue (FCFS deque or priority heap)vllm/v1/core/sched/scheduler.py:224-228
self.requestsdict[str, Request], all unfinished requestsvllm/v1/core/sched/scheduler.py:215
num_scheduled_tokensdict[str, int], request id to tokensvllm/v1/core/sched/scheduler.py:641
req_to_new_blocksdict[str, KVCacheBlocks]vllm/v1/core/sched/scheduler.py:640
return of scheduleSchedulerOutputvllm/v1/core/sched/output.py:231-313
input of update_from_outputSchedulerOutput of that step, ModelRunnerOutputvllm/v1/core/sched/scheduler.py:2117-2121
return of update_from_outputdict[int, EngineCoreOutputs], by front-end indexvllm/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 dive

Error paths: what raises, what only logs

SituationResultWhere
Unknown --scheduling-policyValueError at start-upvllm/v1/core/sched/scheduler.py:217-222
A waiting request with an unexpected statusRuntimeErrorvllm/v1/core/sched/scheduler.py:1423-1428
Grammar compilation failedthe request finishes with FINISHED_ERROR and a validation_errorvllm/v1/core/sched/scheduler.py:3296-3301, vllm/v1/core/sched/scheduler.py:2399-2425
The grammar rejects a sampled tokenERROR log; FINISHED_ERRORvllm/v1/core/sched/scheduler.py:2279-2290
reset_prefix_cache(reset_running_requests=True) still failsRuntimeErrorvllm/v1/core/sched/scheduler.py:2969-2975
Remote KV load failed, policy recompute (default)recompute the missing tokensvllm/v1/core/sched/scheduler.py:3444-3457
Remote KV load failed, policy failthe request finishes with an errorvllm/v1/core/sched/scheduler.py:2404-2405
Budget or max_num_seqs broken by a bugAssertionErrorvllm/v1/core/sched/scheduler.py:1483, vllm/v1/core/sched/scheduler.py:1494-1496
Deep dive

Configuration 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):

Check yourself#

  1. 1
    In which process and thread does Scheduler.schedule() run?
    easy
    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.

  2. 2
    Why does _update_after_schedule run after the SchedulerOutput is built, and not before?
    medium
    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-1712 gives this reason. The call is the last line of schedule() (vllm/v1/core/sched/scheduler.py:1641-1643).

  3. 3
    A request samples 3 tokens in one step (2 accepted drafts and a bonus token). The second token is EOS. What does update_from_output send?
    medium
    Show answer

    Two tokens. _update_request_with_output runs check_stop after 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 carries finish_reason stop.

  4. 4
    With 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 to token_budget and input_budget (with the draft slots), drops its new blocks and draft tokens from the plan, and restores the encoder budget. It also moves req_index back 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.