vLLM Explained

The V1 scheduler: one token budget, no phases

AdvancedvLLM conceptsabout 18 min read + 8 min deep dives

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#

Figure: one call of Scheduler.schedule(). The running pass (1) and the waiting pass (2) share one token budget. The plan (3) leaves before the counters move.
Open full size ↗

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#

Figure: three calls of schedule() with small, illustrative numbers. Step 3 preempts C, the last request in the running queue.
Open full size ↗

Try it yourself#

InteractiveScheduler simulator

This interactive visual needs JavaScript. Turn on JavaScript in your browser to use it. The text on this page explains the same idea.

Change the settings or add requests, then press Step. Back and every settings change replay the run from step 0, so the result depends only on the inputs. A simplified model: see the rules below.

The simulator follows these rules of the real code. It leaves out everything in the box above.

  1. The running pass goes first, from the front of self.running, while budget is left (vllm/v1/core/sched/scheduler.py:713-715).
  2. A request gets num_tokens - num_computed_tokens tokens, at most the rest of the budget (vllm/v1/core/sched/scheduler.py:759-768).
  3. 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).
  4. 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).
  5. The waiting pass runs only if nothing was preempted (vllm/v1/core/sched/scheduler.py:957), and only while fewer than max_num_seqs requests run (vllm/v1/core/sched/scheduler.py:979-981).
  6. 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).
  7. 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).
  8. 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.PREEMPTED

A 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 memorymax_num_batched_tokensmax_num_seqs
160 GiB or more (for example B200)163841024
70 GiB or more, not A100, not GB10 (for example H100)81921024
other GPUs (for example A100)2048256

--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,
            )
  1. Lines 759-763. The missing tokens. num_output_placeholders counts tokens that a started step will sample; it is 0 without async scheduling.
  2. 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).
  3. Lines 766-768. The rest of the budget. input_budget is max_num_batched_tokens. draft_slots is 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).
  4. 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
                continue

With 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)
  1. 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).
  2. Lines 1679-1680. The status becomes PREEMPTED, and num_computed_tokens becomes 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.
  3. 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.
  4. Lines 1700-1701. The request goes to the front of the waiting queue, and its id goes into preempted_req_ids of the next SchedulerOutput.

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
  1. 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.
  2. Lines 972-974. Waiting requests that still hold KV blocks (for example after a remote KV load) go first, from kv_holding_waiting.
  3. Lines 979-981. The max_num_seqs limit (through max_num_active_seqs).
  4. 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_tokens

Step 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, stopped

check_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 dive

The running pass, line by line

Lines of vllm/v1/core/sched/scheduler.py, in the order that they run for one running request:

  1. 642-648. token_budget = max_num_scheduled_tokens; input_budget = max_num_batched_tokens. A full pause (PAUSED_ALL) sets the budget to 0.
  2. 666. kv_cache_manager.new_step_starts() tells the managers that a step begins.
  3. 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).
  4. 713-718. The loop. It breaks when input_budget cannot hold one more token plus the draft slots.
  5. 720-734. Async scheduling: skip a request whose in-flight tokens already reach max_tokens.
  6. 736-740. Model Runner V2 with PP and async scheduling: a request decodes at most every pp_size steps.
  7. 748-757. An encoder-cache connector can hold a multimodal request back.
  8. 759-777. The token count and its four caps (Step 3).
  9. 780-809. Mamba block alignment, encoder inputs and the MTP lookahead can lower the count further.
  10. 811-829. A count of 0: continue.
  11. 831-904. allocate_slots and the preemption loop. With a KV connector that has pending block frees, the loop stops without a preemption (lines 844-848).
  12. 906-914. The request is scheduled: num_scheduled_tokens[req_id] = num_new_tokens; both budgets go down.
  13. 916-943. Draft tokens (Step 6) and encoder inputs.
  14. 945-954. The LoRA ids of all running requests, for the max_loras check 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 dive

The waiting pass, line by line

  1. 958-967. skip_request moves 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.
  2. 969-981. Loop while budget is left; queue choice; max_num_active_seqs.
  3. 986-996. Blocked statuses, and stale output of a preempted request that is still in flight.
  4. 998-1009. LoRA: skip.
  5. 1016-1146. First admission (num_computed_tokens == 0): local prefix-cache hit, then the KV connector hit, then bookkeeping for statistics.
  6. 1147-1158. A request that already has computed tokens (after a remote KV load, or a streaming-input session) uses them.
  7. 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.
  8. 1320-1346. allocate_slots, with the prefix-cache hit and full_sequence_must_fit. None: break.
  9. 1378-1382. Prefix-cache statistics are recorded only for admitted requests.
  10. 1413-1467. The request moves to running. Its status before the move decides the list: WAITING goes to scheduled_new_reqs, PREEMPTED to scheduled_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 dive

Preemption: 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 dive

The worked example, continued

The simulator continues the figure with the same rules. Values right after each step (budget 16, block size 4, 8 blocks):

StepScheduled (tokens)Free blocks afterWhat happens
1A 10, B 63A finishes its prompt and samples token 1. B is a chunk. C waits: no budget left.
2A 1, B 6, C 60B ends its prompt; C starts. 13 of 16 tokens used.
3A 1, B 11B needs block 4 of its 13 tokens: preempt C. No waiting pass.
4A 1, B 14A 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.
5B 1, C 76C 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 dive

Configuration fields of the scheduler

Flag / fieldDefaultEffect
--max-num-batched-tokensby GPU memory (Step 2)input_budget; also the default token budget (vllm/config/scheduler.py:48-53)
max_num_scheduled_tokensNone → max_num_batched_tokensthe 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-seqsby GPU memory (Step 2)size of the running queue and of the model runner (vllm/config/scheduler.py:62-67)
max_num_active_seqsNone → max_num_seqsadmission limit of the waiting pass only (vllm/config/scheduler.py:69-77)
--long-prefill-token-threshold0 (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-prefillon for generative non-encoder-decoder modelschunking in the waiting pass (vllm/engine/arg_utils.py:3031-3035)
--scheduling-policyfcfsqueue order and victim (Step 9)
--scheduler-reserve-full-islTruea new request starts only if all its tokens fit (vllm/config/scheduler.py:190-194)
watermark0.0fraction 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_interval1prefill only every N steps (vllm/config/scheduler.py:203-209)
--async-schedulingNone → on in most caseschooses AsyncScheduler (vllm/config/vllm.py:1664-1698)
--scheduler-clsNoneyour own class (Step 10)
max_num_queued_reqs, max_num_queued_tokensNonechecked 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 dive

Parts of schedule() that this page skips

What can go wrong#

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#

  1. 1
    Does 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_tokens tokens, 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).

  2. 2
    With 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 the priority policy, it is the request with the largest (priority, arrival_time) (vllm/v1/core/sched/scheduler.py:852-856).

  3. 3
    In 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.

  4. 4
    Why does the running pass use continue for a request with 0 tokens, while the waiting pass uses break when allocate_slots fails?
    medium
    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).

  5. 5
    A 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_request sets num_computed_tokens to 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 to num_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).