ML Interview Notes
31 min read14 sections
Part 11 · vLLM deep dive · 11-03

The V1 scheduler, in code

Status
SOURCE PINNED
Primary sources
  • vllm/v1/core/sched/scheduler.py
  • vllm/v1/core/sched/output.py
  • vllm/v1/core/kv_cache_manager.py
Edition pins
vllm a556f3f · sglang 7d89325

One file, 3,037 lines, and one method inside it that runs for 844 of them. This chapter is a guided read of vllm/v1/core/sched/scheduler.py at SHA a556f3f — the map first, then schedule() block by block, then the invariants you must not break.

§1

The problem

You have a scheduling bug. Maybe requests are starving, maybe the token budget is being overrun, maybe a preempted request came back with the wrong num_computed_tokens. So you open the file:

shell — vLLM at a556f3f shell
$ wc -l vllm/v1/core/sched/scheduler.py
3037 vllm/v1/core/sched/scheduler.py
$ grep -c "    def " vllm/v1/core/sched/scheduler.py
54

Fifty-four methods. The one you want, schedule(), starts at line 484 and ends at line 1327 — a single function longer than most files in the repo, with a five-deep nesting level at its worst point, two nested while loops, and an inner while True that mutates the very list the outer loop is indexing into. There is no unit of it you can read in isolation, because almost every branch adjusts a counter that a later branch reads.

Worse, a naive reading is actively misleading. You will miss that request.num_computed_tokens is advanced after SchedulerOutput is built, in a different method; that a preemption inside the running loop can rewind the loop cursor; and that the two budget counters in scope are not the same budget.

§1.4 owns the concepts — the state machine, admission, the preemption policy, recompute-vs-swap, starvation, the watermark. None of that is repeated here. This chapter owns the code: where things are, what calls what, what each block of schedule() actually does, and which asserts encode the contract.

§2

Mental model

Strip everything else away and schedule() is a single-pass greedy fill of a token budget across two ordered collections. First the self.running list, in list order, index by index. Then the waiting queues, front to back. Each request that fits takes num_new_tokens off the budget and gets KV blocks; each one that does not is either skipped (continue) or ends the pass (break), and which of the two it is encodes a policy decision.

Three properties make it readable:

single pass

No backtracking

The pass never revisits a request it already decided on — except through preemption, which is the one place where an earlier decision is undone, and the code explicitly restores the budget it had spent.

local state

Accumulators, not fields

Everything the pass decides lives in local variables declared at L497-L518. The only durable side effects during the pass are on the KV cache manager and on request status.

deferred

Bookkeeping happens last

num_computed_tokens advances in _update_after_schedule(), after the output is sealed. The output must carry the pre-advance view so the runner can compute input ids.

Figure 1 — the map of scheduler.py: major methods, line ranges, and who calls whom. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…
§3

The file, mapped

Before walking the code, get the layout in your head. Every range below is from grep -n " def " vllm/v1/core/sched/scheduler.py at a556f3f; the end of each range is the line before the next def.

The seven regions of vllm/v1/core/sched/scheduler.py — line ranges, not measurements.
LinesRegionWhat lives here
74–373Construction__init__. Builds the KVCacheManager, the encoder cache manager, the connectors, and the two waiting queues. Every scheduler-wide constant is set here.
374–483Chunk sizing_mamba_block_aligned_split, _get_local_prefix_cache_hit, _reserve_prefill_lookahead — all three exist only to clamp num_new_tokens.
484–1327schedule()The whole scheduling pass. Running loop, waiting loop, assert block, SchedulerOutput construction.
1328–1719Post-schedule helpers_preempt_request, _update_after_schedule, _make_cached_request_data, _try_schedule_encoder_inputs — all called by schedule().
1720–2168The return pathget_grammar_bitmask and update_from_output, both called between one schedule() and the next.
2169–2503LifecycleQueue-selection helpers, add_request, finish_requests, _free_request, _free_blocks, the deferred-free machinery.
2504–3037Introspection and connectorshas_requests, reset_prefix_cache, make_stats, shutdown, and every KV-connector hook — almost entirely inert without a connector configured.

That last row matters more than it looks. Roughly a ninth of the file — 339 of 3,037 lines — is connector code: _connector_finished (L2699), _request_remaining_blocks (L2736), _inflight_prefill_reserved_blocks (L2750), _update_waiting_for_remote_kv (L2757), _update_from_kv_xfer_finished (L2836), _update_requests_with_invalid_blocks (L2865) and _handle_invalid_blocks (L2968), none of which executes unless vllm_config.kv_transfer_config is non-None. Do not write off the whole L2699-L3037 span, though: _try_promote_blocked_waiting_request (L2800-L2835) sits inside it and is called unconditionally from the waiting loop at L775-L777, because it also unblocks requests waiting on grammar compilation and on streaming input — plain single-node features.

§4

schedule(), read top to bottom

The entry point declares the algorithm in a comment before it declares a variable:

vllm/v1/core/sched/scheduler.py:L484-L495 vLLM
    def schedule(self, throttle_prefills: bool = False) -> SchedulerOutput:
        self.current_step += 1
        # NOTE(woosuk) on the scheduling algorithm:
        # There's no "decoding phase" nor "prefill phase" in the scheduler.
        # Each request just has the num_computed_tokens and
        # num_tokens_with_spec. num_tokens_with_spec =
        # len(prompt_token_ids) + len(output_token_ids) + len(spec_token_ids).
        # At each step, the scheduler tries to assign tokens to the requests
        # so that each request's num_computed_tokens can catch up its
        # num_tokens_with_spec. This is general enough to cover
        # chunked prefills, prefix caching, speculative decoding,
        # and the "jump decoding" optimization in the future.

Take this literally. There is no prefill branch and no decode branch in the 844 lines. There is one quantity, num_tokens_with_spec - num_computed_tokens, and the method decides how much of that gap to close.

The two budgets

vllm/v1/core/sched/scheduler.py:L502-L510 vLLM
        req_to_new_blocks: dict[str, KVCacheBlocks] = {}
        num_scheduled_tokens: dict[str, int] = {}
        token_budget = self.max_num_scheduled_tokens
        spec = self.vllm_config.speculative_config
        draft_slots = spec.max_num_new_slots_for_drafting if spec is not None else 0
        input_budget = self.scheduler_config.max_num_batched_tokens
        if self._pause_state == PauseState.PAUSED_ALL:
            # Do not schedule any requests when paused.
            token_budget = 0

This is the first thing a casual reader gets wrong. There are two budgets, and they are decremented by different amounts. token_budget counts tokens the scheduler issues. input_budget counts rows the model runner's input buffers must hold — which is larger, because with speculative decoding the worker appends draft tokens the scheduler never issued. So every admission does:

vllm/v1/core/sched/scheduler.py:L708-L710 vLLM
            token_budget -= num_new_tokens
            input_budget -= num_new_tokens + draft_slots
            req_index += 1

and both loops carry a guard if input_budget <= draft_slots: break at their top, so the pass stops admitting once one more request could not have its drafts accommodated. max_num_scheduled_tokens defaults to max_num_batched_tokens when unset (vllm/config/scheduler.py:L56-L61), which is why the distinction is invisible without spec decode on. The class constant for both is 2,048 (vllm/config/scheduler.py:L42-L49), but the field's own docstring calls that value "mainly for convenience when testing" — a served engine never sees it. EngineArgs.get_batch_defaults (vllm/engine/arg_utils.py:L2580-L2638) resolves the budget by device and usage context, and on an H100 that is 8,192 for vllm serve and 16,384 for the in-process LLM class. For Llama-3-8B at 128 KiB of KV per token, an 8,192-token step writes 8192 × 128 KiB = 1 GiB of fresh KV in a single forward pass, which is the arithmetic that keeps the budget from going much higher.

Phase 1: the running loop

vllm/v1/core/sched/scheduler.py:L531-L534 vLLM
        # First, schedule the RUNNING requests.
        req_index = 0
        while req_index < len(self.running) and token_budget > 0:
            request = self.running[req_index]

Note the shape: a manual index into a mutable list, not for request in self.running. That is why the preemption branch below can splice an element out mid-iteration without corrupting the walk.

Between L536 and L564 sit three continue guards — async-scheduling exhaustion, the PP decode-cadence gate next_decode_eligible_step, and the DP prefill-throttle deferral. Each of them advances req_index and moves on, so a blocked request never blocks the requests behind it. Then the arithmetic:

vllm/v1/core/sched/scheduler.py:L566-L576 vLLM
            num_new_tokens = (
                request.num_tokens_with_spec
                + request.num_output_placeholders
                - request.num_computed_tokens
            )
            if 0 < self.scheduler_config.long_prefill_token_threshold < num_new_tokens:
                num_new_tokens = self.scheduler_config.long_prefill_token_threshold
            num_new_tokens = min(
                num_new_tokens, token_budget, input_budget - draft_slots
            )

That is the token-budget clamp of chunked prefill, which §1.5 owns. Four further clamps follow: max_model_len − num_computed_tokens − num_sampled_tokens_per_step (L579-L584), Mamba block alignment (L587-L590), the encoder compute budget via _try_schedule_encoder_inputs (L596-L608), and the multi-module-MTP prefill-lookahead reserve _reserve_prefill_lookahead (L612-L614). Each can only shrink num_new_tokens. If the result is zero:

vllm/v1/core/sched/scheduler.py:L630-L636 vLLM
                # NOTE(woosuk): Here, by doing `continue` instead of `break`,
                # we do not strictly follow the FCFS scheduling policy and
                # allow the lower-priority requests to be scheduled.
                req_index += 1
                continue

            # Schedule newly needed KV blocks for the request.

This is a documented departure from strict FCFS: a blocked earlier request can be skipped for a later eligible one. It can be completely deterministic given identical state. Nondeterminism instead requires varying arrivals, asynchronous results or other inputs; policy ordering and reproducibility are separate properties.

The allocation loop and what None means

vllm/v1/core/sched/scheduler.py:L636-L648 vLLM
            # Schedule newly needed KV blocks for the request.
            with record_function_or_nullcontext("schedule: allocate_slots"):
                while True:
                    new_blocks = self.kv_cache_manager.allocate_slots(
                        request,
                        num_new_tokens,
                        num_lookahead_tokens=self.num_lookahead_tokens,
                    )

                    if new_blocks is not None:
                        # The request can be scheduled.
                        break

allocate_slots returns KVCacheBlocks | None (vllm/v1/core/kv_cache_manager.py:L347-L360). None is not an error. It means "there were not enough free blocks under the watermark", and it is the sole signal that triggers preemption. It is also the sole failure mode: no exception, no log line, no counter incremented at this level. If you are looking for why a request did not get scheduled, this is the return value to breakpoint on. Note that the running-loop call passes only three arguments; the waiting-loop call at L1041-L1054 passes eleven, because admission has to reason about prefix-cache hits, external KV, and full-sequence reservation that a decode step does not.

On None, the inner while True preempts and retries. In FCFS mode the victim is the tail:

vllm/v1/core/sched/scheduler.py:L685-L700 vLLM
                    else:
                        preempted_req = self.running.pop()

                    self._preempt_request(
                        preempted_req,
                        scheduled_timestamp,
                        drop_stale_output=self.requires_kv_delivery,
                    )
                    preempted_reqs.append(preempted_req)
                    if preempted_req == request:
                        # No more request to preempt. Cannot schedule this request.
                        break

            if new_blocks is None:
                # Cannot schedule this request.
                break

Three things to read carefully here. First, the loop terminates because self.running shrinks by one per iteration and the request being scheduled is itself in that list — when it becomes its own victim, the loop stops. Second, in PRIORITY mode (L651-L684) the victim can be a request already scheduled earlier in this same pass, so the code pops it out of scheduled_running_reqs and refunds num_scheduled_tokens, token_budget, input_budget, req_to_new_blocks, scheduled_spec_decode_tokens, scheduled_encoder_inputs and encoder_compute_budget. It is not a complete rollback, and the gap is worth knowing: prefill_scheduled |= request.is_prefill_chunk (L704) is never un-set, and a stale True changes the spec-decode padding test at L949; request.spec_token_ids = [] (L728) is destructive; and the encoder-cache allocations are freed separately, by _preempt_request at L1364. If you add an accumulator, add it here too — and check whether the two above should have been. Third, the priority path adjusts req_index when the victim sat before the cursor — the comment calls out that omitting this would "silently omit" the next request.

The outer break at L698-L700 is a break, not a continue: once the cache is genuinely full, there is no point trying the rest of the running list.

Phase 2: the waiting loop

vllm/v1/core/sched/scheduler.py:L755-L769 vLLM
        # Next, schedule the WAITING requests.
        if not preempted_reqs and self._pause_state == PauseState.UNPAUSED:
            step_skipped_waiting = create_request_queue(self.policy)

            while (self.waiting or self.skipped_waiting) and token_budget > 0:
                if input_budget <= draft_slots:
                    break
                # Paused streaming sessions (WAITING_FOR_STREAMING_REQ) are not
                # in `running` but still hold a model-runner request slot.
                num_running = len(self.running) + self.num_waiting_for_streaming_input
                if num_running >= self.max_num_running_reqs:
                    break

                request_queue = self._select_waiting_queue_for_scheduling()
                assert request_queue is not None

The guard if not preempted_reqs is the anti-thrash rule: a step that had to evict anyone admits nobody. One line, and the most consequential policy statement in the method.

The body is over 400 lines but structurally it is: peek, filter, look up the prefix cache, size the chunk, allocate, and only then pop_request() at L1090. That ordering matters: the request stays at the head of its queue through every rejection path, so a break leaves the queue exactly as it started.

The filters between L771 and L806 all share a shape: pop_request() then step_skipped_waiting.prepend_request(request). Requests blocked on grammar compilation, on a remote KV transfer, on stale in-flight output, or on the max_loras cap are moved out of the way rather than blocking the queue — and re-queued into self.skipped_waiting at L1169-L1170 after the pass. That is why there are two waiting queues, and why get_request_counts() at L2323-L2325 sums both.

Admission itself is the tail of the loop:

vllm/v1/core/sched/scheduler.py:L1136-L1145 vLLM
                    scheduled_loras.add(request.lora_request.lora_int_id)
                req_to_new_blocks[request_id] = self.kv_cache_manager.get_blocks(
                    request_id
                )
                num_scheduled_tokens[request_id] = num_new_tokens
                token_budget -= num_new_tokens
                input_budget -= num_new_tokens + draft_slots
                request.status = RequestStatus.RUNNING
                request.num_computed_tokens = num_computed_tokens
                if pad_spec_decode:

Note the asymmetry with the running loop: there, req_to_new_blocks holds the blocks newly returned by allocate_slots; here it holds get_blocks(request_id), the request's entire block table. That distinction is what CachedRequestData.resumed_req_ids encodes downstream — for a resumed request the legacy runner replaces the block table, for a running one it appends. On the V2 path, which is what a dense model takes, that set is always empty: L1202-L1204 folds scheduled_resumed_reqs into scheduled_new_reqs and clears it before _make_cached_request_data runs, so a resumed request is re-sent whole as a NewRequestData with prefill_token_ids, and V2 drops the old state on preemption rather than patching it (vllm/v1/worker/gpu/model_runner.py:L944-L946, L963-L978).

Figure 2 — schedule() control flow with the real branch conditions and their line numbers. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…
§5

The data structures

RequestQueue and its two policies

vllm/v1/core/sched/request_queue.py is 208 lines and worth reading in full — it is the simplest file in the subsystem. An ABC at L20-L72 declares ten @abstractmethods; two implementations follow.

vllm/v1/core/sched/request_queue.py:L75-L84 vLLM
class FCFSRequestQueue(deque[Request], RequestQueue):
    """A first-come-first-served queue that supports deque operations."""

    def add_request(self, request: Request) -> None:
        """Add a request to the queue according to FCFS policy."""
        self.append(request)

    def pop_request(self) -> Request:
        """Pop a request from the queue according to FCFS policy."""
        return self.popleft()

FCFSRequestQueue subclasses deque, so prepend_request is appendleftO(1), which is what makes preemption cheap: _preempt_request pushes the victim back to the front of the waiting queue at L1387. PriorityRequestQueue at L131-L198 wraps a heapq and orders by Request.__lt__ (vllm/v1/request.py:L350-L361): priority, then arrival time, then request id, then object identity. Two of its methods are traps — prepend_request and prepend_requests silently degrade to ordinary inserts, with docstrings that say so. Preemption under PRIORITY therefore does not give the victim any head-of-line advantage.

The Request fields schedule() actually reads

Scheduler-relevant fields of Request (vllm/v1/request.py) — who writes, who reads.
FieldWritten byRead by
num_computed_tokens_update_after_schedule L1403; reset to 0 by _preempt_request L1367; rewound on spec rejection in update_from_output L1857schedule() L569, allocate_slots
num_tokens_with_specderived property, L292schedule() L567
num_output_placeholdersAsyncScheduler._update_after_schedule L39schedule() L568, _free_encoder_inputs
num_in_flight_tokensincremented L1404, decremented L1813_preempt_request L1380
is_prefill_chunk_update_after_schedule L1408get_grammar_bitmask L1732, running-loop throttle L560
statusL1143, L1366, check_stop, finish_requestsevery phase; RequestStatus.is_finished is status > PREEMPTED (L386-L387)
spec_token_idsupdate_draft_token_ids L2264; cleared L728, L1369schedule() L713, L721
last_sched_seq_update_after_schedule L1407_free_request_blocks L2470

The RequestStatus enum at vllm/v1/request.py:L364-L380 has a load-bearing ordering trick: everything after PREEMPTED is a finished state, and is_finished() is an integer comparison. Insert a non-finished status after PREEMPTED and you break every finish check at once.

SchedulerOutput

vllm/v1/core/sched/output.py is 305 lines and holds five dataclasses — NewRequestData, CachedRequestData, ScheduledEncoderInputStats, SchedulerOutput and GrammarOutput. SchedulerOutput itself (L206-L297) has nine required fields and twelve optional ones. The delta encoding is real and is the reason there are two request lists:

vllm/v1/core/sched/output.py:L207-L222 vLLM
class SchedulerOutput:
    # list of the requests that are scheduled for the first time.
    # We cache the request's data in each worker process, so that we don't
    # need to re-send it every scheduling step.
    scheduled_new_reqs: list[NewRequestData]
    # list of the requests that have been scheduled before.
    # Since the request's data is already cached in the worker processes,
    # we only send the diff to minimize the communication cost.
    scheduled_cached_reqs: CachedRequestData

    # req_id -> num_scheduled_tokens
    # Number of tokens scheduled for each request.
    num_scheduled_tokens: dict[str, int]
    # Total number of tokens scheduled for all requests.
    # Equal to sum(num_scheduled_tokens.values())
    total_num_scheduled_tokens: int

NewRequestData (L35-L75) carries the full prompt token ids, sampling params, LoRA request and block ids — everything the worker needs to build a cache entry. CachedRequestData (L129-L195) is column-oriented: parallel lists req_ids, new_block_ids, num_computed_tokens, num_output_tokens, plus a resumed_req_ids set. Its own comment at L132-L134 states the append-versus-replace rule for new_block_ids. new_token_ids is populated only under pipeline parallelism without async scheduling (scheduler.py:L1506-L1516) — under any other configuration it is an empty list, which is a common source of "why is this always empty" confusion.

Figure 3 — SchedulerOutput: who writes each field inside schedule, and who reads it downstream. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

On the way back, update_from_output returns dict[int, EngineCoreOutputs] keyed by client index. EngineCoreOutput (vllm/v1/engine/__init__.py:L196-L238) is a msgspec.Struct with array_like=True and omit_defaults=True — positional serialisation, which is why every comment about a new field says "appended last so positional serialization stays backward compatible". If you add a field anywhere but the end, you break rolling upgrades. EngineCoreOutputs (L253-L279) wraps the list plus scheduler_stats, finished_requests, and the DP wave signals.

§6

The invariants, from the asserts

grep -n "assert" vllm/v1/core/sched/scheduler.py returns 39 hits. They are the author's own statement of what must hold, and the densest cluster is the block that closes the scheduling pass:

vllm/v1/core/sched/scheduler.py:L1177-L1189 vLLM
        # Check if the scheduling constraints are satisfied.
        total_num_scheduled_tokens = sum(num_scheduled_tokens.values())
        assert total_num_scheduled_tokens <= self.max_num_scheduled_tokens

        assert token_budget >= 0
        assert input_budget >= 0
        assert len(self.running) <= self.max_num_running_reqs
        # Since some requests in the RUNNING queue may not be scheduled in
        # this step, the total number of scheduled requests can be smaller than
        # len(self.running).
        assert len(scheduled_new_reqs) + len(scheduled_resumed_reqs) + len(
            scheduled_running_reqs
        ) <= len(self.running)
The contract a contributor must not break, with the line that enforces it.
InvariantEnforced at
The issued token count never exceeds the configured budget.L1179
Neither budget counter ever goes negative — every refund path must be complete.L1181–L1182
The running set never exceeds max_num_seqs.L1183
Scheduled requests are a subset of running requests. The reverse is not required.L1187–L1189
Only a RUNNING request may be preempted — a request must have blocks to free.L1360–L1362
Every entry in num_scheduled_tokens is strictly positive.L1809
Computed tokens never exceed the request's total tokens.L892, L908
A waiting-loop admission always schedules at least one token.L975
Concurrent LoRA adapters never exceed max_loras.L753
Blocks are freed only for a finished request.L2425, L2452
The engine never emits prompt logprobs for a partial prefill.L2058

Reclamation must respect ownership: an allocation retry may use only slots whose references and outstanding GPU writes are finished. The shown preemption helper calls the free path synchronously in the simple case, but async/connector paths can defer physical availability. If no usable memory was reclaimed, terminate or defer the retry safely rather than spinning. A function call named free is not itself an independent proof of safe reuse.

Assertions off

vLLM ships with assertions live under the default python invocation, but python -O strips every one of these. If you run a modified scheduler under -O, none of the above is checked and a budget overrun surfaces much later as a shape mismatch inside the model runner.

§7

update_from_output(): the retire path

update_from_output spans L1744-L2168, and its centre is a single loop over scheduler_output.num_scheduled_tokens — the same dict the scheduler wrote. The author flags it as hot:

vllm/v1/core/sched/scheduler.py:L1803-L1813 vLLM
        # NOTE(woosuk): As len(num_scheduled_tokens) can be up to 1K or more,
        # the below loop can be a performance bottleneck. We should do our best
        # to avoid expensive operations inside the loop.
        stopped_running_reqs: set[Request] = set()
        stopped_preempted_reqs: set[Request] = set()
        for req_id, num_tokens_scheduled in num_scheduled_tokens.items():
            assert num_tokens_scheduled > 0
            request = self.requests.get(req_id)
            output_is_stale = False
            if request is not None:
                request.num_in_flight_tokens -= num_tokens_scheduled

That is the budget for this loop stated plainly: it runs once per forward pass, and for Llama-3-8B on an H100 the weights-only batch-1 decode floor is 4.48 ms (§0.4; §1.1's 6.1 ms is the same step with the KV read at $s = 2{,}048$ added). At the H100 serving default of max_num_seqs = 1024 (vllm/engine/arg_utils.py:L2619-L2628 — not the 128 in the SchedulerConfig class body) the loop body can run a thousand times inside that window; anything allocating per iteration shows up directly in inter-token latency.

The body, in order:

  1. Drain in-flight accounting (L1813-L1818). num_in_flight_tokens comes down; if the request was preempted while this step was in flight, its stale share drains too.
  2. Skip the dead (L1822-L1830). request is None or request.is_finished() means the request was aborted mid-flight. Continue silently.
  3. Spec-decode reconciliation (L1841-L1877). num_accepted = max(len(generated_token_ids) - num_sampled, 0), and rejections roll num_computed_tokens back — the counter _update_after_schedule optimistically advanced.
  4. Append and stop-check (L1898-L1901 calling _update_request_with_output at L2212-L2230), which appends each sampled token and calls check_stop from sched/utils.py:L94-L130. That function is where EOS, stop-token, max_tokens, max_model_len and repetition detection are all evaluated, and where request.status is set to its FINISHED_* value. Its signature is check_stop(request, max_model_len) -> bool, so it never sees the token list; the caller trims new_token_ids at the stop point, del new_token_ids[num_new:] at L2227.
  5. Grammar advance (L1918-L1944). If a grammar rejects the sampled token the request becomes FINISHED_ERROR with a logged "Unexpected: grammar rejected tokens" line.
  6. Retire:
vllm/v1/core/sched/scheduler.py:L1996-L2008 vLLM
            finish_reason = None
            if stopped:
                # Capture finish_reason BEFORE _handle_stopped_request, which may
                # reset the status to WAITING for streaming requests that continue.
                finish_reason = request.get_finished_reason()
                finished = self._handle_stopped_request(request)
                if finished:
                    kv_transfer_params, ec_transfer_params = self._free_request(request)

                if status_before_stop == RequestStatus.RUNNING:
                    stopped_running_reqs.add(request)
                else:
                    stopped_preempted_reqs.add(request)

Freeing is two-stage. _free_request (L2422-L2450) fires the connector hooks, frees the encoder cache, adds the id to self.finished_req_ids — which the next schedule() ships to the worker so it can drop its cached request state — and then calls _free_blocks, which returns the KV blocks and deletes the entry from self.requests. Removal from the running list happens once, in bulk, after the loop:

vllm/v1/core/sched/scheduler.py:L2060-L2066 vLLM
        # Remove the stopped requests from the running and waiting queues.
        if stopped_running_reqs:
            self.running = remove_all(self.running, stopped_running_reqs)
        if stopped_preempted_reqs:
            # This is a rare case and unlikely to impact performance.
            self.waiting.remove_requests(stopped_preempted_reqs)
            self.skipped_waiting.remove_requests(stopped_preempted_reqs)

remove_all (sched/utils.py:L62-L91) mutates in place for the single-element case and rebuilds the list otherwise — which is why the assignment self.running = remove_all(...) is mandatory and its docstring says so.

§8

Extension points

vllm/v1/core/sched/interface.py is the pluggable contract: SchedulerInterface(ABC) at L38 with seventeen @abstractmethods. A custom scheduler must implement __init__, schedule, get_grammar_bitmask, update_from_output, update_draft_token_ids, update_draft_token_ids_in_output, add_request, finish_requests, get_num_unfinished_requests, has_finished_requests, pause_state, set_pause_state, reset_prefix_cache, reset_encoder_cache, get_request_counts, make_stats and shutdown. Six methods are concrete defaults you can inherit: has_unfinished_requests (L173), has_requests (L193), get_kv_cache_usage (L238), and the three connector accessors get_kv_connector, get_ec_connector and get_kv_event_publisher_config (L255-L262), which all return None.

Selection happens in config, not in the engine:

vllm/config/scheduler.py:L170-L191 vLLM
    def get_scheduler_cls(self) -> type["SchedulerInterface"]:
        if self.scheduler_cls is None:
            if self.async_scheduling:
                from vllm.v1.core.sched.async_scheduler import AsyncScheduler

                return AsyncScheduler
            from vllm.v1.core.sched.scheduler import Scheduler

            return Scheduler

        # The first half of this warning can be removed once the Scheduler interface is
        # finalized and we can maintain support for scheduler classes that implement it
        logger.warning_once(
            "Using custom scheduler class %s. This scheduler interface is not public "
            "and compatibility may not be maintained. If you have subclassed Scheduler "
            "instead of AsyncScheduler, you will see degraded performance due to async "
            "scheduling being disabled.",
            self.scheduler_cls,  # type: ignore[arg-type]
        )

Two things to take from this. The interface is explicitly not public and its own warning says compatibility may not be maintained. And async_scheduling is on unless something disables it: the field defaults to None (vllm/config/scheduler.py:L148), and None resolves to True for an ordinary generation model on a supporting executor (vllm/config/vllm.py:L1279-L1328), so AsyncScheduler is what a stock server gets — so a plugin that subclasses Scheduler silently disables async scheduling, which the warning also tells you. Extend AsyncScheduler unless you have a reason not to. EngineCore.__init__ calls get_scheduler_cls() at vllm/v1/engine/core.py:L148 and types the field as SchedulerInterface at L161; §11.5 covers plugin registration generally.

§9

Worked trace: one request, with breakpoints

A 900-token prompt arrives, generates 40 tokens, and finishes on EOS. No spec decode, no connector, and --no-async-scheduling so that the base Scheduler runs — a stock server gets AsyncScheduler instead (see §7), in which case steps 9 and 12 run the overrides at async_scheduler.py:L19 and L51 and step 14's num_new_tokens picks up num_output_placeholders. Set breakpoints in this order.

Arrival to retirement, in call order. All line numbers are vllm/v1/core/sched/scheduler.py unless noted.
#Method : lineWhat happens
1add_request : L2331No existing entry, so _enqueue_waiting_request (L2176) puts it in self.waiting; self.requests[req_id] = request; a QUEUED event is recorded.
2schedule : L759Waiting loop entered. Running loop did nothing — the request is not in self.running yet.
3_select_waiting_queue_for_scheduling : L2182FCFS: returns self.skipped_waiting or self.waiting. Ours is in waiting.
4_get_local_prefix_cache_hit : L451num_computed_tokens == 0, so the prefix cache is queried. Say 512 tokens hit.
5schedule : L974num_new_tokens = min(900 - 512, request_token_budget). With a 2,048 budget and an empty batch, all 388 fit — one step, no chunking.
6KVCacheManager.allocate_slots : kv_cache_manager.py L347Eleven arguments including num_new_computed_tokens=512 and full_sequence_must_fit. Returns KVCacheBlocks. Breakpoint here on None if admission ever stalls.
7schedule : L1090–L1144pop_request(), self.running.append(), SCHEDULED event, scheduled_new_reqs.append(), status = RUNNING, num_computed_tokens = 512.
8schedule : L1281SchedulerOutput built. This request appears in scheduled_new_reqs with its full block table; num_scheduled_tokens[req_id] == 388.
9_update_after_schedule : L1403–L1408num_computed_tokens 512 → 900, num_in_flight_tokens += 388, is_prefill_chunk evaluates 900 < 900False.
10EngineCore.step : core.py L595execute_model(scheduler_output, non_block=True). §11.4 owns everything past this line.
11update_from_output : L1808Loop reaches our req_id; num_in_flight_tokens back to 0.
12_update_request_with_output : L2212One token appended; check_stop returns False.
13update_from_output : L2032EngineCoreOutput appended to outputs[client_index].
14steps 2–13, ×39Now the running loop at L533 handles it: num_new_tokens = 901 - 900 = 1, three-argument allocate_slots, admitted at L702.
15check_stop : utils.py L104Token 40 is EOS → status = FINISHED_STOPPED, returns True.
16_handle_stopped_request : L2194Not resumable → returns True.
17_free_request : L2422 → _free_blocks : L2451Encoder cache freed, id added to self.finished_req_ids, blocks returned, del self.requests[req_id].
18update_from_output : L2062self.running = remove_all(self.running, stopped_running_reqs).
19next schedule : L1295finished_req_ids=self.finished_req_ids ships the id to the worker; _update_after_schedule at L1437 then resets the set.

Step 19 is the one people miss: the id is announced to the worker one step after the request finished, and the reset at L1434-L1438 deliberately rebinds rather than calling clear(), because the previously-built SchedulerOutput still holds a reference to that same set object.

§10

Reading pitfalls

pitfall 1

The async path is a 70-line subclass

async_scheduler.py overrides exactly three methods: __init__ (L13), _update_after_schedule (L19) and _update_request_with_output (L51). Everything else is inherited. If you are reading schedule() and wondering where async scheduling happens — it does not, except through num_output_placeholders, which the override increments at async_scheduler.py:L39-L41 and which schedule() then reads at L568.

pitfall 2

Gated-off code that looks live

Every self.connector is not None branch — roughly L813-L890, L1064-L1084, and guarded portions of L2699-L3037 (not unconditional helpers inside that range) — is dead without --kv-transfer-config. Same for self.ec_connector, self.need_mamba_block_aligned_split and self.enable_return_routed_experts. self.use_v2_model_runner is the opposite trap: it is normally onvllm/config/vllm.py:L743 ends return is_default_v2_architecture or not model_config.is_moe, so eligible dense models default to V2 after all preceding guards — which makes the V2 branches at L1202, L1234 and L1521 the live ones and their else arms the dead ones. Check the constructor before assuming a branch runs, in either direction.

pitfall 3

continue versus break is policy

In the running loop, exhausting a request's tokens is continue (L634) but exhausting the KV cache is break (L700). In the waiting loop nearly everything is break, except the four blocked-status filters which are pop plus re-queue. Misreading one for the other inverts the fairness behaviour you think you are looking at.

pitfall 4

Two allocate_slots call sites

L639 (three args, decode) and L1041 (eleven args, admission). They have different failure semantics: the first triggers preemption, the second just breaks the loop and — note L1058-L1062 — un-touches the encoder cache first. Grep for allocate_slots and you will find both; know which one your stack trace is in.

pitfall 5

num_computed_tokens is optimistic

It is advanced at L1403 before the forward pass has run, and rolled back at L1857 if spec tokens are rejected. A debugger snapshot taken between schedule() and update_from_output() shows a value the GPU has not yet earned. The comment at request.py:L168-L171 says so explicitly.

pitfall 6

There is no swap path

V1's described policy is recomputation rather than a general swap mechanism. The free path releases ownership subject to references and in-flight safety; retained hashed blocks may still be reusable until overwritten. Thus resetting num_computed_tokens does not imply that all prefix work is lost, nor that every freed block is immediately available.

Naming

The waiting-loop variable step_skipped_waiting (L757) is a fresh queue built per pass, and it is prepended into the persistent self.skipped_waiting at L1170, not appended. The comment says "ahead of older skipped items". So the skipped queue is LIFO with respect to passes, FIFO within a pass.

§11

Hands-on

Reproduce the map, then watch the budget move. From a vLLM checkout at a556f3f:

shell shell
# 1. The map, regenerated. Compare against Figure 1.
grep -n "^    def \|^    async def " vllm/v1/core/sched/scheduler.py

# 2. The invariants, as the author wrote them.
grep -n "assert" vllm/v1/core/sched/scheduler.py

# 3. Which branches are dead in your configuration -- and note that
#    use_v2_model_runner is normally ON, so its ELSE arms are the dead ones.
grep -n "self.connector is not None\|self.ec_connector is not None\|self.use_v2_model_runner\|need_mamba_block_aligned_split" vllm/v1/core/sched/scheduler.py

# 4. The exit points of schedule(): every break and continue, with context.
awk 'NR>=484 && NR<=1327 && /break|continue/ {print NR": "$0}' vllm/v1/core/sched/scheduler.py

Then instrument. The single most informative one-line patch to this file is to log the budget at the assert block — insert after L1179:

PSEUDOCODE — patch against vllm/v1/core/sched/scheduler.py:L1179 pseudocode
logger.info(
    "step=%d issued=%d/%d running=%d waiting=%d skipped=%d preempted=%d",
    self.current_step, total_num_scheduled_tokens,
    self.max_num_scheduled_tokens, len(self.running),
    len(self.waiting), len(self.skipped_waiting), len(preempted_reqs),
)

During pure decode, issued tokens approximately follow the number of scheduled requests; prefill-heavy phases can fill the token budget. Occasional preemption under changing lengths is not proof of thrashing. Diagnose repeated eviction/recomputation with poor forward progress, retained-prefix length and completed tokens per unit work.

§12

Exercises

  1. Read and answer. Open request_queue.py. FCFSRequestQueue.remove_requests (L109-L116) clears the deque and rebuilds it. Why can it not use deque.remove in a loop, and what is the complexity of each option?
  2. Read and answer. In the running loop, req_to_new_blocks[request_id] is assigned new_blocks (L706). In the waiting loop it is assigned self.kv_cache_manager.get_blocks(request_id) (L1137-L1139). Trace both through _make_cached_request_data and NewRequestData.from_request and explain why the difference is correct.
  3. Predict, then verify. Suppose you delete the if not preempted_reqs guard at L756. Predict what changes in the log from the Hands-on patch under a workload that saturates KV. Then reason about whether total_num_scheduled_tokens <= max_num_scheduled_tokens still holds.
  4. Predict, then verify. A contributor adds a new accumulator scheduled_foo: dict[str, int] written at L707 alongside num_scheduled_tokens. Which assert fires, and where must the corresponding rollback go?
  5. Read and answer. RequestStatus.is_finished is status > RequestStatus.PREEMPTED (request.py:L386-L387). Find every place in scheduler.py that depends on this ordering rather than calling the helper.
Answers

1. deque.remove is O(n) per call, so removing k items costs O(nk); worse, mutating a deque while iterating it raises. The rebuild is O(n) once, at the cost of a temporary list. Note remove_all in utils.py:L84-L89 makes the opposite tradeoff for the single-item case, where the in-place path wins.

2. A running request already exists in the worker's cache with a block table; it needs only the blocks appended this step, which is exactly new_blocks. A newly admitted or resumed request either has no worker-side entry (goes to NewRequestData, needs the full table) or has a stale one whose table must be replaced — which is what CachedRequestData.resumed_req_ids signals on the legacy path, per the comment at output.py:L132-L134. Sending only the delta for a resumed request would leave the worker with a table pointing at freed blocks. On the V2 path the same hazard is avoided differently: L1202-L1204 moves resumed requests into scheduled_new_reqs, so resumed_req_ids is always empty and the whole table is re-sent as new.

3. The preempted count rises and running oscillates: each step evicts to make room, then immediately re-admits from the waiting queue, whose head is the request just evicted (prepended at L1387). You get a preempt/re-admit cycle with no forward progress. The token-budget assert still holds — it is enforced by the arithmetic at L707-L709, which the guard has nothing to do with. The guard protects throughput, not correctness.

4. None of them, directly — and that is the danger. The rollback in the PRIORITY preemption branch (L668-L684) refunds num_scheduled_tokens, token_budget, input_budget, req_to_new_blocks, scheduled_spec_decode_tokens and the encoder budget. A new accumulator not refunded there leaks an entry for a request that is no longer running, and the failure surfaces downstream as a KeyError in the model runner or as silently wrong metadata. Add the pop next to L673.

5. The direct comparisons are at request.py:L386-L387 itself and the enum's own ordering comment at L373-L374; within scheduler.py the dependence is indirect — _is_blocked_waiting_status (L2169-L2175) enumerates the three WAITING_FOR_* members explicitly, so adding a fourth blocked status requires editing that tuple as well as placing the member before PREEMPTED.

§13

Key takeaways

  • schedule() spans L484-L1327 and has exactly two loops. Other code includes helpers, retirement and guarded connector paths; unconditional helpers remain live even within a connector-heavy source range.
  • There are two budgets, not one. token_budget counts issued tokens; input_budget additionally reserves draft_slots per scheduled request for tokens the worker appends. Both are asserted non-negative at L1181-L1182.
  • allocate_slots returning None signals KV allocation failure. Admission can also stop at token/input budgets, sequence limits, grammar waits, LoRA limits, encoder constraints and policy guards; inspect which gate was reached.
  • num_computed_tokens is advanced optimistically in _update_after_schedule after the output is sealed, and rolled back in update_from_output when spec tokens are rejected. Any snapshot between the two shows work the GPU has not done.
  • Assertions cover selected invariants, not the entire contract. Physical reuse additionally requires that references and in-flight holders have ended; synchronous helper invocation is not proof of immediate reclamation on every async or connector path.
  • AsyncScheduler is 70 lines and overrides three methods. Subclass it, not Scheduler: get_scheduler_cls warns that doing otherwise silently disables async scheduling.
§14

Further reading

  • vLLM PR #9289 — the V1 core re-architecture that produced this scheduler shape, including the "no prefill phase, no decode phase" framing quoted at L486-L495.
  • vLLM PR #19970 — the async-scheduling work behind num_output_placeholders and the AsyncScheduler subclass.
  • vLLM V1 alpha announcement — the design rationale for the single-token-budget scheduler, written by the same author as the L486 comment.
  • interface.py on main — track drift in the abstract method set; it is explicitly unstable.
  • In this book: §1.4 for the concepts, §1.5 for the token-budget clamp, §2.2 for the block pool behind allocate_slots, §11.2 for the engine layering above, §11.4 for what consumes SchedulerOutput, and §12.2 for SGLang's very differently shaped answer.

The Inference Engineering Course · code read at vllm@a556f3fccb and sglang@7d893255c3, pinned 2026-08-21. See SOURCES for the full provenance record.

Explore the library

Reading preferences

Appearance
18 px