vLLM 推理内核深度解析

第3章 调度器:Token 的交通指挥

作者 杨艺韬 · 11,010 字 · 发布于 · 更新于

调度就是不断做选择,而每一个选择都有代价。Prefill 是 compute-bound,Decode 是 memory-bound——V1 把两者塞进同一个 token 预算里,代价与收益都在这一章。

本章要点

  • 理解 Prefill 与 Decode 两种计算形态的根本差异:一个是 compute-bound,一个是 memory-bound
  • 弄清楚 V1 为什么用"只有 N 个 Token 要处理"这一个抽象统一了调度模型
  • 跟着 Scheduler.schedule() 走完预算编列 → RUNNING 续行 → WAITING 入场 → SchedulerOutput 打包的完整路径
  • 学会用 max_num_batched_tokens × max_num_seqs 这两个旋钮在吞吐与延迟之间划线
  • 掌握分块预填充(Chunked Prefill)在 budget 消费侧的精确算法:下一块切多大、剩多少、什么时候下一步
  • 看懂抢占(Preemption)的两种模式——RECOMPUTE 与 SWAP——以及 V1 为什么只保留前者,被抢者的 num_computed_tokens 又为什么敢直接清零
  • 理解前缀缓存命中如何改变调度结果:命中块多少 → 真实要计算的 Token 少多少 → budget 释放多少
  • 读懂 update_from_output 这一拍结束后的状态迁移,以及 num_computed_tokens 为什么在 schedule() 里就先加上去
  • 学会用 SchedulerStats 解读一个生产环境是不是"真的在连续批处理"

3.1 LLM 推理调度的根本困境

在深入代码前,先理解为什么 LLM 推理调度特别难。传统 Web 服务的调度是"同质化"的——每个请求都走同样的代码路径、消耗差不多的 CPU。LLM 推理恰恰相反,一个请求在生命周期里会表现出两种截然不同的计算形态:

3.1.1 Prefill 与 Decode 的差异

一个推理请求(以 prompt_tokens=500, max_new_tokens=200 为例)的两个阶段:

预填充(Prefill)——处理用户输入的 500 个 Token。在单次 GPU kernel 调用里,500 个 Token 的 Query / Key / Value 一起算,attention 矩阵是 500×500 的三角矩阵。这一步compute-bound——GPU 的 TensorCore 满载,算力被吃完;FLOPs 需求正比于 prompt_len²;只产出 1 个输出 Token(prompt 末尾位置的采样结果)。

解码(Decode)——逐个生成后续 200 个 Token。每一步只算 1 个新 Token 的 Q,但要和前面 500 + 已生成数 个 Token 的 K/V 算 attention。这一步memory-bound——GPU 大部分时间在从 HBM 搬 KV Cache 进 SRAM,TensorCore 反而是闲的;FLOPs 需求正比于 current_len(线性增长);每步产出 1 个 Token。

graph LR
    subgraph "Prefill 特征"
        P1["Compute-bound<br/>TensorCore 满载"]
        P2["一次算 N 个 Token<br/>FLOPs ∝ N²"]
        P3["算力吃得满<br/>SM 是瓶颈"]
        P4["决定 TTFT<br/>Time To First Token"]
    end
    subgraph "Decode 特征"
        D1["Memory-bound<br/>HBM 带宽打满"]
        D2["每步只算 1 Token<br/>FLOPs ∝ ctx_len"]
        D3["算力吃不满<br/>HBM 带宽是瓶颈"]
        D4["决定 TPOT<br/>Time Per Output Token"]
    end
    style P1 fill:#2563eb,color:#fff,stroke:none
    style P2 fill:#2563eb,color:#fff,stroke:none
    style P3 fill:#2563eb,color:#fff,stroke:none
    style P4 fill:#2563eb,color:#fff,stroke:none
    style D1 fill:#10b981,color:#333,stroke:none
    style D2 fill:#10b981,color:#333,stroke:none
    style D3 fill:#10b981,color:#333,stroke:none
    style D4 fill:#10b981,color:#333,stroke:none

两个观察:

(1) GPU 利用率形态完全不同。Prefill 时 GPU 是"吃撑"的,把请求排紧点没问题;Decode 时 GPU 是"饿"的,把 batch_size 加大是用不完的计算单元换更高的 KV Cache 搬运吞吐。这意味着一个好的调度器应该让这两种形态同时发生在一个 batch 里——Decode 请求填满计算空闲时间,Prefill 请求贡献高 GPU 利用率。这正是 V1 统一调度的起点。

(2) 两种指标口径分别。服务 SLA 通常关心两个数:首 Token 延迟 TTFT(从请求到达到第一个 Token 输出的时间)与每 Token 延迟 TPOT(生成每个后续 Token 的平均时间)。Prefill 决定 TTFT,Decode 决定 TPOT。调度策略的每一个取舍都会落在这两个指标的跷跷板上。

3.1.2 静态批处理的崩塌

在 LLM 推理被当成通用 DL 推理服务之前,大家的第一反应是 TF-Serving / TorchServe 式的静态批处理:攒一批请求、一起跑、出结果、下一批。理论上简单,实际上灾难性低效:

时间轴:静态批处理(batch size = 4)
        Req A (100 tokens)    ████████████░░░░░░░░░░░░░░  12ms
        Req B (250 tokens)    ███████████████████████░░░  23ms
        Req C (  5 tokens)    █░░░░░░░░░░░░░░░░░░░░░░░░░   1ms (早完成但干等 22ms)
        Req D ( 80 tokens)    ███████████░░░░░░░░░░░░░░░  11ms (早完成但干等 12ms)

(上面这张条形图的毫秒数是示意,用来看比例关系。)短请求(C/D)早就完成了,但必须等整个 batch 里最长的请求(B)跑完,GPU 才能开始处理下一批。"座位"不释放——批次等效于一个"最慢请求"拉动的同步 barrier。浪费的比例直接由 batch 内输出长度的方差决定:长度分布越散,浪费越大。

vLLM 采用连续批处理(Continuous Batching):每一步结束后重新决定下一步参与计算的请求集合。一个请求完成了,立刻释放它的 KV 块和 batch 位置;一个新请求到了,如果有预算就立刻放进来。每一步都是一个"瞬时快照",不存在"等别人"这回事。

但这就把问题推给了调度器——每一步都要做一次全局决策。做得好 GPU 100% 吃满,做得差抖来抖去。Scheduler 就是做这个决策的那个组件。

3.2 V1 的范式转移:统一 Token 调度

V0 时代的调度器代码(vllm/core/scheduler.py)相当复杂:Prefill 有一套路径,Decode 有一套路径,Chunked Prefill 是后来补丁进去的第三套路径,SequenceGroup(支持束搜索的组抽象)又是另一层。最终调度一个请求要走的分支数量,足以让新来的贡献者第一周只看懂主流程。

V1 做了一个核心的简化:调度器的输出本质上就是一个字典。

# vllm/v1/core/sched/output.py:82(节选,字段名与源码一致)
@dataclass
class SchedulerOutput:
    # 本步第一次被调度的请求(要把完整元数据发给 worker)
    scheduled_new_reqs: list[NewRequestData]
    # 之前调度过的请求(worker 已缓存,只发 diff)
    scheduled_cached_reqs: list[CachedRequestData]

    # 关键字段:每个请求本步要处理多少个 Token
    num_scheduled_tokens: dict[str, int]
    total_num_scheduled_tokens: int
    # 投机解码:每个请求本步带了哪些草稿 token
    scheduled_spec_decode_tokens: dict[str, list[int]]
    # 多模态:本步要跑 encoder 的输入下标
    scheduled_encoder_inputs: dict[str, list[int]]
    # cascade attention 用:running 队列的公共前缀块数
    num_common_prefix_blocks: int

    # 上一拍到这一拍之间完成的请求(让 worker 清掉本地状态)
    finished_req_ids: set[str]
    free_encoder_input_ids: list[tuple[str, int]]
    # 结构化输出
    structured_output_request_ids: dict[str, int]
    grammar_bitmask: Optional[npt.NDArray[np.int32]]
    kv_connector_metadata: Optional[KVConnectorMetadata] = None

注意这里没有 preempted_req_ids 字段。被抢占的请求对 worker 而言和"没被调度"没区别——它不会出现在 num_scheduled_tokens 里,worker 什么都不用做;抢占只是 EngineCore 进程内部的队列搬运。

核心字段 num_scheduled_tokens: dict[str, int] 说明了一切:调度器不区分 Prefill 和 Decode,它只说"请求 A 这一步处理 128 个 Token,请求 B 这一步处理 1 个 Token"。128 可能是 A 的第一段 Prefill,也可能是 A 的中段 Prefill,也可能是 A 进了 decode 阶段第一次以 spec-decode 一次产 3-5 token——调度器不关心。

# 一次典型调度的输出可能长这样:
{
    "req-001": 128,   # A:可以是新请求的前 128 个 prompt token
    "req-002": 1,     # B:老请求解码 1 个 token
    "req-003": 64,    # C:另一个请求 prompt 的中间 64 个 token(chunked)
    "req-004": 4,     # D:spec-decode 阶段一次产 4 个 token
}

这种统一抽象带来了连锁优势:

1. Chunked Prefill 是天然的——把"Prefill 500 个 Token 必须一次做完"换成"可以分几拍做",只需要在 budget 上限制每拍给出多少个 Token。没有特殊 codepath。

2. Prefill / Decode 混合批是天然的——SchedulerOutput 里既有 {A: 128} 也有 {B: 1},Worker 拿到这个 dict 后把所有请求的 Token 拼接成一个大张量送进 attention kernel。现代的 FlashAttention-3 和 FlashInfer 原生支持 variable-length batches,不需要 padding,不需要 segment mask 之外的额外开销。

3. 调度决策集中在一个方法里——schedule() 是 374 行的单方法(§3.10 有实测),没有 _schedule_prefills / _schedule_decodes 之类的分支子方法,prefill 与 decode 共用同一份 token_budget 局部变量。

4. 单元测试友好——输出是纯数据(dict + dataclass),给定输入状态就能精确断言输出,不必 mock worker(schedule() 本身会改写队列和 num_computed_tokens,断言时连同这些状态一起检查)。

V0 的 SequenceGroup 被拿掉了。它原本是为束搜索(beam search)设计的:一个请求在搜索空间里有多个候选分支,每个分支都是一个 Sequence,共享同一份 prompt KV。V1 的判断是:绝大多数在线流量走的是采样(sampling)或贪心(greedy),束搜索是少数派。把束搜索作为核心复杂性源头挂在调度路径上,让每一个采样请求也跟着交税——不划算。V1 把束搜索外化到 API 层(如果真要用,由前端的 EngineClient.beam_search() / LLM.beam_search() 逐步为每条候选各发一个 max_tokens=1 的生成请求),引擎核心保持纤细(截至当前版本,vllm/v1/ 下没有任何 beam_search 代码)。

3.2.1 统一抽象的核心不变式:num_tokens_with_spec

这个统一模型能自圆其说,靠的是 Request 类的一个单一标量字段。翻开 vllm/v1/request.py 第 110 行:

@property
def num_tokens_with_spec(self) -> int:
    return len(self._all_token_ids) + len(self.spec_token_ids)

加上 num_computed_tokens(已算过的 token 数),调度器的全部工作用一句话描述:每一拍,尽量让每个 RUNNING 请求的 num_computed_tokens 追平它的 num_tokens_with_spec。

这里 num_tokens_with_spec 包含三类 token:

  1. prompt token(用户输入)
  2. output token(已生成)
  3. spec token(投机解码草稿模型提前预测的)

从调度器视角,这三者毫无区别——都是"等着被主模型 attention 一遍"的 token。这就是 Woosuk Kwon 在 scheduler.py line 138–147 写给后来贡献者的设计注释的精髓:

There's no "decoding phase" nor "prefill phase" in the scheduler. Each request just has the num_computed_tokens and num_tokens_with_spec. ... This is general enough to cover chunked prefills, prefix caching, speculative decoding, and the "jump decoding" optimization in the future.

把"算力要算多少 token"抽象成一个减法(num_new_tokens = num_tokens_with_spec - num_computed_tokens)之后,Chunked Prefill、Prefix Cache、投机解码、未来还要加的 Jump Decoding,全部变成对同一个 budget 的不同消费模式——不需要为每种新特性再拉一条新代码路径。这是 V1 调度器主干能收在一个方法里的底层原因。后续第 12 章讲投机解码时会回到这个字段:草稿模型预测了 K 个 token 就把它们塞进 spec_token_ids,调度器自然地在下一拍分配 K+1 的预算(1 是上一拍刚采样出、还没过主模型的那个 token)给它——主干算式不用改,调度器里只多了两小段专门代码:schedule() 按实际分到的预算裁剪 spec_token_ids,update_from_output 在草稿被拒时回退 num_computed_tokens。

3.3 Scheduler.schedule():逐阶段拆解

我们打开 vllm/v1/core/sched/scheduler.py,看 schedule() 方法。先说清楚一件事:V1 的 schedule() 是一个 374 行的单方法(源码 line 137–510),它内部没有被拆成 _schedule_running() / _schedule_waiting() 这样的子方法——所有阶段共用同一个 token_budget 局部变量。下面这段是把那 374 行按阶段抽出来的结构示意,不是可以在源码里 grep 到的函数:

def schedule(self) -> SchedulerOutput:
    # 阶段一:编列本步预算(真实变量名与源码一致)
    token_budget = self.max_num_scheduled_tokens   # = max_num_batched_tokens
    encoder_budget = self.max_num_encoder_input_tokens

    # 阶段二:先续行 RUNNING 队列
    req_index = 0
    while req_index < len(self.running) and token_budget > 0:
        ...   # 见 §3.3.2

    # 阶段三:再让 WAITING 队列入场
    while self.waiting and token_budget > 0:
        if len(self.running) == self.max_num_running_reqs:
            break
        ...   # 见 §3.3.3

    # 阶段四:就地构造 SchedulerOutput 返回
    ...

看起来平平无奇的四个阶段,每一步背后都有微妙的取舍。

3.3.1 阶段一:编列本步预算

预算由两个"旋钮"共同决定:

  • max_num_batched_tokens——每步可以处理的最大 Token 数(Prefill + Decode 加起来)。这是直接约束 GPU 单次 kernel 规模的旋钮,典型值 2048~8192。
  • max_num_seqs——同时 RUNNING 的最大请求数。这是约束 KV Cache 占用规模和 sampler 吞吐的旋钮,典型值 128~512。

用户不设时,V1 的默认值由 EngineArgs._set_default_args_v1()(vllm/engine/arg_utils.py:1565)按显存和使用场景给出:单卡显存 ≥ 70 GiB(H100、MI300x 这一档)时,max_num_batched_tokens 在离线 LLM 类下是 16384、在 OpenAI API Server 下是 8192,max_num_seqs 是 1024;其他 GPU 分别是 8192 / 2048,max_num_seqs 是 256。V0 下 max_num_seqs 默认 256,max_num_batched_tokens 由 SchedulerConfig.__post_init__ 兜底:开 chunked prefill 时是 2048(_DEFAULT_MAX_NUM_BATCHED_TOKENS),不开时取 max(max_model_len, 2048)。

两个旋钮相互独立:

graph TB
    subgraph "Budget 二维空间"
        A[max_num_batched_tokens=4096<br/>max_num_seqs=256<br/>balanced 在线服务]
        B[max_num_batched_tokens=16384<br/>max_num_seqs=1024<br/>高吞吐离线批]
        C[max_num_batched_tokens=2048<br/>max_num_seqs=64<br/>低延迟交互]
    end
    style A fill:#2563eb,color:#fff,stroke:none
    style B fill:#10b981,color:#333,stroke:none
    style C fill:#f59e0b,color:#333,stroke:none

一个经验法则:max_num_batched_tokens 应该略大于你期待的"一次典型 Prefill 长度 × 同时能处理的 Prefill 请求数 + 当前 Decode 的请求数"。把它调小,可以让每步 GPU 计算时间短、单步延迟低——但连续批处理就"吃不饱"了。把它调大,吞吐上去了——但单步延迟上升,Decode 请求的 TPOT 抖动。

还有第三个约束,但它不是一个旋钮、也不进 token_budget 的算式:KV 池剩余块数。源码里没有 token_budget = min(token_budget, free_blocks * block_size) 这样一行;「够不够块」这件事完全由 kv_cache_manager.allocate_slots() 返回 None 来表达——要不到就抢占(RUNNING 段)或者停止收人(WAITING 段)。这个「试了才知道」的风格贯穿整个 schedule(),下一节就能看到。

3.3.2 阶段二:续行 RUNNING 队列

RUNNING 循环给每个已经在 RUNNING 状态的请求分配 num_tokens_with_spec - num_computed_tokens 个 Token——纯 decode 时这个差值恰好是 1,chunked prefill 未完成时是剩下的那一段,投机解码时是 K+1。注意下面几个细节:

对应源码是 schedule() 里的第一个 while 循环(line 175 起),核心只有三步:算出这个请求"还欠多少 token"、向 KVCacheManager 要 slot、扣预算。

# vllm/v1/core/sched/scheduler.py:176::Scheduler.schedule(节选、去掉 encoder/spec/结构化输出分支)
req_index = 0
while req_index < len(self.running) and token_budget > 0:
    request = self.running[req_index]

    # 统一抽象:欠多少就调度多少,decode 时这个差值恰好是 1
    num_new_tokens = (request.num_tokens_with_spec -
                      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)
    num_new_tokens = min(num_new_tokens,
                         self.max_model_len - request.num_computed_tokens)

    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 None:
            # 要不到 slot:抢占 running 队尾(最年轻)的请求,然后重试
            preempted_req = self.running.pop()
            self.kv_cache_manager.free(preempted_req)
            preempted_req.status = RequestStatus.PREEMPTED
            preempted_req.num_computed_tokens = 0
            self.waiting.appendleft(preempted_req)
            preempted_reqs.append(preempted_req)  # §3.3.3 靠它决定本拍还收不收新请求
            if preempted_req == request:
                can_schedule = False      # 连自己都抢了,放弃本拍
                break
        else:
            can_schedule = True
            break
    if not can_schedule:
        break

    scheduled_running_reqs.append(request)
    num_scheduled_tokens[request.request_id] = num_new_tokens
    token_budget -= num_new_tokens
    req_index += 1

注意 allocate_slots 是 KVCacheManager 在 V1 里的唯一分配入口——没有 append_slots / num_blocks_needed_for_append / free_blocks 这些方法;"够不够块"这件事由 allocate_slots 返回 None 来表达。

优先续行已有请求、宁可抢占后来者。这个排序是有深意的:正在 decode 的请求最怕抖动——用户看到的"流式打字机"效果要求 token 以稳定的节奏吐出来。一旦有一拍少给它 token,用户就可能感到"卡"。相反,一个刚到的新请求晚一点开始出 token,用户感知通常只是"稍微慢了"。所以decode 对延迟抖动通常比 prefill 更敏感,调度器对 running 队列网开一面。

抢占被推到 RUNNING 队列里。如果一个正在 RUNNING 的请求拿不到 1 个 token 的 KV 块(池子满了),它会被就地抢占回 WAITING(V1 没有独立的 _preempt() 方法,这段逻辑内联在 schedule() 的 RUNNING 循环里)。这个决策的一个副作用:抢占的受害者是最新加入 RUNNING 的那个还是最老的?V1 的策略(v0.8.5 里也是唯一的策略)是按入场时间从新到旧找受害者——新进来的刚吃了 Prefill 的预算,抢它损失最小;老请求已经生成了好多 token 了,抢它几乎是让用户白等。

3.3.3 阶段三:WAITING 队列入场

剩下的预算(token_budget 减去 RUNNING 请求本拍吃掉的部分——decode 请求各 1 个,没做完的 chunked prefill 则是一整段)交给 WAITING 队列使用。这里要解决两类请求:

新请求(New Request)——刚从 API Server 收到 add_request 进入 WAITING,status == WAITING。它需要做完整 Prefill(或在前缀缓存命中时跳过部分)。

被抢占后回到队列的请求——status == PREEMPTED,同样躺在 waiting 队列里(抢占时被 appendleft 插到队首)。它的 num_computed_tokens 已被清零,要靠前缀缓存把已算过的部分找回来。

两类请求的处理逻辑是一样的——都是"给它多少个 Token 的预算,让它吞掉对应长度的 Prefill"。真实源码(schedule() line 294 起)长这样:

# vllm/v1/core/sched/scheduler.py:295::Scheduler.schedule(节选、去掉 encoder/FSM/KVConnector 分支;
# num_external_tokens 的来历见 §3.8.5,不用 KVConnector 时为 0)
if not preempted_reqs:                       # ← 本拍发生过抢占就完全不收新请求
    while self.waiting and token_budget > 0:
        if len(self.running) == self.max_num_running_reqs:
            break
        request = self.waiting[0]            # 取队首(FCFS)

        # max_loras 约束:超了就跳过这个请求(而不是 break)
        if self.lora_config and request.lora_request and (
                len(scheduled_loras) == self.lora_config.max_loras
                and request.lora_request.lora_int_id not in scheduled_loras):
            self.waiting.popleft()
            skipped_waiting_requests.appendleft(request)
            continue

        # 前缀缓存:查出已经算过的块和 token 数
        computed_blocks, num_computed_tokens = \
            self.kv_cache_manager.get_computed_blocks(request)

        # 本次要算多少 token —— 用 num_tokens 而不是 num_prompt_tokens,
        # 因为被抢占恢复的请求已经有 output token 了
        num_new_tokens = request.num_tokens - 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)

        new_blocks = self.kv_cache_manager.allocate_slots(
            request, num_new_tokens + num_external_tokens, computed_blocks,
            num_lookahead_tokens=self.num_lookahead_tokens)
        if new_blocks is None:
            break                            # KV 不够,后面的请求也别排了

        self.waiting.popleft()
        self.running.append(request)
        num_scheduled_tokens[request.request_id] = num_new_tokens
        token_budget -= num_new_tokens
        request.status = RequestStatus.RUNNING
        request.num_computed_tokens = num_computed_tokens

几个关键点:

(1) 预算只扣"真的算了"的 Token 数。num_new_tokens = request.num_tokens - num_computed_tokens,而 num_computed_tokens 是前缀缓存命中数(再加上 KVConnector 的外部命中数)。命中的部分从减法里直接消失,压根不进 token_budget。这是前缀缓存对调度的直接影响——命中率高的场景,单步能塞进更多请求。

(2) 一被调度就进 RUNNING,不等 Prefill 做完。request.status = RequestStatus.RUNNING 和 self.running.append(request) 发生在第一次被调度的那一拍,哪怕这一拍只吃掉了 prompt 的前 512 个 token。后续的 chunk 由 §3.3.2 的 RUNNING 循环继续推进——这正是"统一 Token 调度"的落点:调度器不需要知道某个请求"还在 prefill"还是"已经在 decode"。

(3) 单请求的预算还有第二道闸。long_prefill_token_threshold 给单个请求在单拍能吃的 token 数再设上限,见 §3.8.4。

(4) 两种"跳过"的语义不同。max_loras 超限时是 popleft + skipped_waiting_requests.appendleft() + continue——把这个请求暂存起来看后面的;KV 块不够时是 break——整个 WAITING 扫描直接结束。前者是"这个请求这拍不合适",后者是"资源没了,后面谁也别想要"。源码在 running 循环里也留了同样风格的注释:continue 会不严格遵守 FCFS,是有意为之的取舍。

(5) 本拍发生过抢占,就一个新请求都不收。整个 WAITING 循环包在 if not preempted_reqs: 里。逻辑上很自然:刚刚才因为 KV 不够踢掉了跑着的请求,这时候再放新请求进来只会立刻引发下一轮抢占。

3.3.4 阶段四:打包 SchedulerOutput

schedule() 的最后一段就地把三类请求拼成 SchedulerOutput(源码 line 420 起)。这一段没有独立的 _make_output() 方法:

# vllm/v1/core/sched/scheduler.py:422
# 先做一轮自检——统一 token 调度的三条不变式
total_num_scheduled_tokens = sum(num_scheduled_tokens.values())
assert total_num_scheduled_tokens <= self.max_num_scheduled_tokens
assert token_budget >= 0
assert len(self.running) <= self.max_num_running_reqs

# cascade attention 用:running 队列的最长公共前缀有几块
num_common_prefix_blocks = 0
if self.running:
    any_request = self.running[0]
    num_common_prefix_blocks = (
        self.kv_cache_manager.get_num_common_prefix_blocks(
            any_request, len(self.running)))

new_reqs_data = [
    NewRequestData.from_request(req, req_to_new_block_ids[req.request_id])
    for req in scheduled_new_reqs
]
resumed_reqs_data = [                      # 被抢占后恢复:整表替换 block ids
    self._make_cached_request_data(..., resumed_from_preemption=True)
    for req in scheduled_resumed_reqs
]
running_reqs_data = [                      # 正常续行:只追加新 block ids
    self._make_cached_request_data(..., resumed_from_preemption=False)
    for req in scheduled_running_reqs
]
scheduler_output = SchedulerOutput(
    scheduled_new_reqs=new_reqs_data,
    scheduled_cached_reqs=resumed_reqs_data + running_reqs_data,
    num_scheduled_tokens=num_scheduled_tokens,
    total_num_scheduled_tokens=total_num_scheduled_tokens,
    ...
)

值得注意的是 resumed_from_preemption 这个布尔:被抢占恢复的请求,旧的块表已经被 free 掉,本拍发下去的是重新拼出的整张块表(前缀缓存命中的块 + 新分配的块,即 computed_blocks + new_blocks),所以要让 worker 整表替换 block ids;正常续行的请求只需要 append 新增的那几块。同一个 CachedRequestData 结构,靠一个标志位区分两种语义——这就是"有状态 Worker + 差量同步"在数据契约层面的样子。

至此调度的一拍结束。

3.4 Chunked Prefill:把长 prompt 切开

Chunked Prefill 在 V1 里始终启用。它的动因不是"节省算力"——把 500 个 Token 的 Prefill 拆成 3×128 + 1×116 并不会减少总 FLOPs,反而略增(额外的 kernel 启动开销)。真正的动因是避免长 prompt 阻塞 Decode。

考虑一个场景:10 个 Decode 请求正在稳定跑(每步 1 Token),这时候来了个超长 prompt(8192 Token)。如果一次 Prefill 完它,这一拍的 GPU 时间会远长于 decode 一拍(prefill 的 FLOPs 正比于序列长度、decode 只算 1 个 token)——那 10 个 Decode 请求就要干等一整个 prefill,TPOT 出现一次巨大的突刺,用户感知到明显卡顿。下面两张图用假设的毫秒数把这个比例画出来。

Chunked Prefill 的解法:把 8192 切成 8 拍 × 1024 Token。10 个 Decode 请求穿插进去:

gantt
    title 不启用 Chunked Prefill
    dateFormat X
    axisFormat %Lms
    section GPU
    Prefill 8192 tokens   :p1, 0, 200
    Decode 10 reqs        :d1, 200, 250
    section TPOT 感知
    10 decode 请求等待    :crit, w1, 0, 200
gantt
    title 启用 Chunked Prefill (chunk=1024)
    dateFormat X
    axisFormat %Lms
    section GPU
    P1 + 10 Decodes :p1, 0, 28
    P2 + 10 Decodes :p2, 28, 56
    P3 + 10 Decodes :p3, 56, 84
    P4 + 10 Decodes :p4, 84, 112
    P5 + 10 Decodes :p5, 112, 140
    P6 + 10 Decodes :p6, 140, 168
    P7 + 10 Decodes :p7, 168, 196
    P8 + 10 Decodes :p8, 196, 224
    section TPOT 感知
    smooth 28ms/token :smooth, 0, 224

代价:

  1. 总墙钟时间略长——Prefill 本来是 compute-bound,切开后每一块都要和 decode 抢同一批 SM,两边都拿不到峰值;再加上多出来的 kernel 启动次数。
  2. 长 prompt 的 TTFT 增加(第一个 Token 要等最后一块 chunk 跑完才出)。
  3. 调度逻辑复杂度上升——需要跟踪 num_computed_tokens。

收益:

  1. Decode 的 TPOT 抖动量级从"一整个长 prefill 的时间"降到"一个 chunk 的时间"——上界由 max_num_batched_tokens(以及 long_prefill_token_threshold)直接控制,而不再由最长那条 prompt 决定。
  2. p99 TPOT 向平均 TPOT 收敛(很重要的服务质量指标)。
  3. 长 prompt 不再"霸占"整个引擎。

上面两张 gantt 图里的毫秒数是为了让比例关系可读而编的示意值,不是实测——本机没有 GPU,本专栏不给未经测量的绝对延迟。真正要拿到你自己那套模型 + 硬件上的数字,用 §1.9.3 的 benchmark 姿势跑一遍。

V1 把 Chunked Prefill 从 V0 时代的"opt-in 优化"(V0 只在 max_model_len > 32K 等条件下默认打开,见 _set_default_args_v0())升级为始终启用——_set_default_args_v1() 直接写死 self.enable_chunked_prefill = True(注释:"V1 always uses chunked prefills."),没有开关可关。纯离线批处理(用户完全不关心 TPOT 抖动)想让长 prompt 尽量一拍做完,只能把 max_num_batched_tokens 调大。

3.5 前缀缓存对调度的影响

前缀缓存(Prefix Caching)是 vLLM 的另一个关键优化——相同 prompt 前缀的 KV Cache 块可以在请求间共享。具体机制在第 10 章详讲,这里只关注它对调度器行为的影响。

假设一个长对话的系统提示词是 1500 Token,用户发来的新消息前面这 1500 Token 每次都一样。第一个请求来的时候,调度器给它完整的 Prefill 预算;第二个请求来的时候,前缀缓存命中 1500 Token,实际只需要算用户消息那部分(比如 50 Token;前缀缓存按整块命中,实际要算的会略多一点,见下图)。

对调度器意味着什么?

(1) num_computed_tokens 初始值不是 0。命中前缀缓存后,num_computed_tokens 被初始化为命中的 Token 数,调度器在后续 Prefill 时只看剩余部分。

(2) token_budget 消费是基于"真的算了多少"。如果本步给一个请求分配了 512 token 预算,但前 256 都命中缓存,实际 budget 只扣 256——剩下的 256 预算可以给别的请求用。这直接提升了单步吞吐。

(3) KV 块分配更轻量。命中的部分不需要新分配块(BlockPool.touch() 把 ref_cnt 加 1 即可),只有没命中的部分要向 get_new_blocks() 要新块。这在 KV 池紧张时极其宝贵——命中率越高,同样的负载消耗的新块越少,触发抢占的频率就越低。

graph TB
    subgraph "不命中缓存"
        A1[prompt: 1550 tokens] --> A2[分配: 97 blocks<br/>算 1550 个 token]
        A2 --> A3[token_budget 扣 1550]
    end
    subgraph "1500 tokens 前缀可复用(命中 93 整块 = 1488)"
        B1[prompt: 1550 tokens] --> B2[分配: 4 new blocks<br/>+ refcount 93 blocks<br/>算 62 个 token]
        B2 --> B3[token_budget 扣 62]
    end
    style A2 fill:#dc2626,color:#fff,stroke:none
    style B2 fill:#10b981,color:#333,stroke:none

图里按 block_size=16 算:前缀缓存只按整块命中(get_computed_blocks 比对的是满块的哈希),1500 个 token 的公共前缀里只有前 93 块、共 1488 个 token 能命中,第 94 块混进了用户消息,要连同剩下的部分一起算——所以实际扣的是 1550 − 1488 = 62。

生产环境里,前缀缓存命中率是个重要的"便宜指标"——同一个 API Key 的连续请求、RAG 场景拼接的固定系统提示、编程助手里反复的工具定义,都会让同一段前缀被反复命中。命中率由 vllm:gpu_prefix_cache_queries_total 与 vllm:gpu_prefix_cache_hits_total 两个 counter 相除得到(源码里注册的 Counter 名字不带后缀,prometheus_client 在暴露时自动补 _total,抓取端看到的就是带后缀的这两条),是上线后第一个该看的数。

3.6 抢占(Preemption):显存告急时的取舍

当 KV 池满了、新请求又来了,调度器必须抉择:放弃新请求?还是抢一些正在跑的请求?

V1 的策略是抢占。在 schedule() 主函数内联处理(vllm/v1/core/sched/scheduler.py line 221–237):

# 当一个 RUNNING 请求申请不到 KV slot 时
new_blocks = self.kv_cache_manager.allocate_slots(
    request, num_new_tokens,
    num_lookahead_tokens=self.num_lookahead_tokens)
if new_blocks is None:
    # 抢占 running 队尾(最年轻)的请求
    preempted_req = self.running.pop()
    self.kv_cache_manager.free(preempted_req)
    preempted_req.status = RequestStatus.PREEMPTED
    preempted_req.num_computed_tokens = 0    # ← 关键:清零
    self.waiting.appendleft(preempted_req)   # 插回 WAITING 队首

这里有个容易误读的细节:num_computed_tokens 被清零,不是保留。所以字面意义上 V1 的抢占就是经典的 RECOMPUTE——被抢者下次入场要从 0 开始。那它凭什么比 V0 默认配置下的 RECOMPUTE 成本低?

答案藏在重新入场那段代码(line 323–326):被抢者回到 waiting 队列后,下一次被 WAITING 循环取出时,第一步就是查前缀缓存:

computed_blocks, num_computed_tokens = \
    self.kv_cache_manager.get_computed_blocks(request)

如果该请求的 KV 块没被别的请求挤掉(被 free 的块只是回到空闲队列,还留着哈希,直到被重新分配出去才真正失效),get_computed_blocks 会把 num_computed_tokens 填回到接近抢占前的值(只按满块命中,末尾不满一块的部分要重算)——对外表现就像"从断点续跑",但架构上没有任何"保存断点"的专用机制。

这个设计把"抢占恢复"的责任完全外包给了前缀缓存:调度器本身无状态、抢占恢复不走特殊代码路径、KV 复用天然兜底。V0 时代需要维护 SWAP 缓冲、RECOMPUTE 特判两套分支(vllm/core/scheduler.py 的 _preempt():未指定 --preemption-mode 时,单序列的组用 RECOMPUTE,束搜索这类多序列的组用 SWAP;而且 V0 默认不开前缀缓存,重算就是真的从头算),V1 用"num_computed_tokens=0 + 查前缀缓存"就涵盖了所有场景。据此可以这样理解 V1 不保留 SWAP 的取舍(源码与提交记录里没有写明动机,这是推断):当前缀缓存本来就要做、且命中率高时,SWAP 省下的重算收益不一定抵得过它带来的代码复杂度。

前缀缓存失效的情形——被抢者等的时间长、或者期间其他请求把它的 block 挤掉了——那就真的要从 0 重算。这时候 Chunked Prefill 发挥第二重作用:重算也是分块进行,不会在单拍里制造长尖峰。

stateDiagram-v2
    [*] --> WAITING
    WAITING --> RUNNING: schedule → 分配 KV
    RUNNING --> RUNNING: decode 1 token
    RUNNING --> WAITING: 被抢占 / KV 不足
    RUNNING --> FINISHED: stop / max_tokens / abort
    WAITING --> FINISHED: abort
    FINISHED --> [*]
    note right of WAITING
        被抢占时 num_computed_tokens 清零
        下次入场靠前缀缓存把它填回来
    end note

何时触发抢占?在 schedule() 的 RUNNING 循环里,如果一个 RUNNING 请求需要新 KV 块但池子空了——抢它。抢占策略的选择是"抢最年轻的":self.running.pop()——从队尾(最新进 RUNNING 的)开始。这是基于"最年轻的请求投入最少"的推理:它生成的 Token 最少,被迫重走 Prefill 的浪费最小。

抢占的观测:Prometheus 指标 vllm:num_preemptions_total 是排查"为什么吞吐不稳"的第一信号。正常流量下它应该一段时间内平坦;如果它稳步上涨,说明 KV 池不够,要么加 GPU、要么降 max_num_seqs、要么压低 max_model_len。

3.7 update_from_output:一拍结束后的状态迁移

调度完 → 执行完 → Scheduler 还要收尾。这个收尾就是 update_from_output(源码 line 625–754):

# vllm/v1/core/sched/scheduler.py:625::Scheduler.update_from_output(节选)
def update_from_output(
    self,
    scheduler_output: SchedulerOutput,
    model_runner_output: ModelRunnerOutput,
) -> EngineCoreOutputs:
    sampled_token_ids = model_runner_output.sampled_token_ids
    num_scheduled_tokens = scheduler_output.num_scheduled_tokens
    new_running: list[Request] = []
    outputs: list[EngineCoreOutput] = []

    # NOTE(woosuk): As len(self.running) can be up to 1K or more, the below
    # loop can be a performance bottleneck.
    for request in self.running:
        req_id = request.request_id
        if num_scheduled_tokens.get(req_id, 0) == 0:
            new_running.append(request)      # 本拍没排到它,原样留下
            continue

        req_index = model_runner_output.req_id_to_index[req_id]
        new_token_ids = sampled_token_ids[req_index]

        # 投机解码:草稿被拒多少个,就把 num_computed_tokens 退回多少
        scheduled_spec = scheduler_output.scheduled_spec_decode_tokens.get(req_id)
        if scheduled_spec:
            num_tokens_rejected = len(scheduled_spec) + 1 - len(new_token_ids)
            request.num_computed_tokens -= num_tokens_rejected

        stopped = False
        for num_new, output_token_id in enumerate(new_token_ids, 1):
            request.append_output_token_ids(output_token_id)
            stopped = check_stop(request, self.max_model_len)   # 见 §2.7
            if stopped:
                self._free_request(request)
                del new_token_ids[num_new:]   # 停下之后的 token 全部丢掉
                break

        if new_token_ids:
            outputs.append(EngineCoreOutput(
                request_id=req_id,
                new_token_ids=new_token_ids,
                finish_reason=request.get_finished_reason(),
                stop_reason=request.stop_reason,
                events=request.take_events()))

        if not stopped:
            new_running.append(request)

    self.running = new_running
    return EngineCoreOutputs(outputs=outputs,
                             scheduler_stats=self.make_stats(...))

有三个设计点值得注意。

num_computed_tokens 是在 schedule() 里就先加上去的,不是这里。源码在 schedule() 末尾(line 505–506)有一行 self.requests[req_id].num_computed_tokens += num_scheduled_token,注释解释了动机:这样下一拍可以立刻继续调度这个 prefill 请求,不必等 GPU 结果回来。update_from_output 只在投机解码草稿被拒时把这个数减回去。这是一个"乐观推进 + 事后回退"的模式。

self.running 是整表重建的,不是原地删除。循环里把该留下的请求塞进 new_running,最后一次性 self.running = new_running。这样一次遍历同时完成"推进状态"和"淘汰已完成请求"两件事,避免在遍历中改容器——而源码注释也点明了为什么在意这个循环:len(self.running) 可以到 1K 以上,这个循环是 CPU 侧的热点。

GPU 拿回结果才做任何状态迁移。你可能以为 Scheduler 在 schedule() 里就应该"预测"某个请求会 stop,提前把它从 running 移除。V1 明确不这么做——除了上面那个 num_computed_tokens 的乐观推进,所有关于"生成了什么、要不要停"的判断都基于真实采样结果。check_stop() 一旦返回 True,del new_token_ids[num_new:] 会把这个 token 之后多生成的部分(投机解码一次可能吐好几个)直接截掉,保证前端拿到的输出不会越过停止点。

3.8 策略细节:公平性、优先级、延迟因子

V1 Scheduler 除了 FCFS,还支持几个高级策略:

3.8.1 Priority(优先级)——v0.8.5 的 V1 还不支持

SchedulerConfig.policy 的类型是 Literal["fcfs", "priority"],--scheduling-policy priority 会让调度器按请求携带的整数优先级(小者先)而不是到达顺序出队。用途很直白:不同租户用不同 priority,付费用户排在前面。

但这条路径在 v0.8.5 上只有 V0 实现。EngineArgs._is_v1_supported_oracle() 里有一段:

# vllm/engine/arg_utils.py:1322
if self.scheduling_policy != SchedulerConfig.policy:
    _raise_or_fallback(feature_name="--scheduling-policy",
                       recommend_to_remove=False)
    return False

也就是说,一旦你把 --scheduling-policy 设成 fcfs 以外的值,引擎会整体回退到 V0(或者在 VLLM_USE_V1=1 强制时直接报错)。V1 的 schedule() 里只有 self.waiting[0] 这一种取法,没有任何 priority 分支。要在 V1 上做优先级,当前只能在 API Server 层自己排队。

3.8.2 LoRA 亲和性

max_loras 参数限制一拍里能用的 LoRA 适配器数量——因为每个 LoRA 都要占一份显存,同时使用太多会 OOM。Scheduler 在 WAITING 循环里会检查:如果加入这个请求会导致本拍 LoRA 数超过 max_loras,就把它 popleft 到 skipped_waiting_requests 里、continue 看下一个(循环结束时再 extendleft 放回队首)。第 16 章会详讲。

3.8.3 Delay Factor(V0 参数,V1 同样不支持)

--scheduler-delay-factor > 0 时,V0 的 Scheduler 会推迟新请求的入场:即使 token budget 还有空,也不立刻让 WAITING 请求进来,先让 running 请求多出几个 token。源码里只有 vllm/core/scheduler.py 的 _passed_delay()(line 1833 起)读取它。

和 priority 一样,它也在 V1 的回退清单里:

# vllm/engine/arg_utils.py:1332
if self.scheduler_delay_factor != SchedulerConfig.delay_factor:
    _raise_or_fallback(feature_name="--scheduler-delay-factor", ...)

生产中本来也很少启用——绝大多数场景对新请求的 TTFT 同样敏感,不值得为 decode 的 TPOT 把 TTFT 推高。在 V1 上,"保护 decode"这件事已经由 chunked prefill + long_prefill_token_threshold 两道闸接管了。

3.8.4 long_prefill_token_threshold:对长 prompt 的二次节流

max_num_batched_tokens 决定的是"一拍的总预算",并不限制单个请求能独占多大预算。当流量里混入一个 32K 的超长 prompt、而 max_num_batched_tokens=8192,只要这个长请求在 running 队列里排在前面,它每拍都会一口吃光 8192 的预算——排在它后面的 decode 请求本拍一个 token 也分不到。

SchedulerConfig.long_prefill_token_threshold 就是为此设的第二重节流:给单个请求在单拍能吃的 Token 数再设一个上限。它最早随 V0 的并发部分预填充引入(#10235,2025-02),V1 在 #15419(2025-03)里接入;默认值是 0,即不启用(V0 下设了 max_num_partial_prefills > 1 才会自动取 max_model_len 的 4%)。源码里两处都用到了它(scheduler.py line 181–184 和 342–345):

if (0 < self.scheduler_config.long_prefill_token_threshold <
        num_new_tokens):
    num_new_tokens = (
        self.scheduler_config.long_prefill_token_threshold)

典型用法:max_num_batched_tokens=8192 + long_prefill_token_threshold=2048——这样任何一个请求每拍最多吃 2048 个 token 的 Prefill 预算,剩下的 6144 可以留给其他请求(含 decode)。在线服务场景下这是保护 TPOT 稳定的第三重闸门(第一重是 Chunked Prefill、第二重是 prefill/decode 同拍混合、第三重是本参数)。

3.8.5 KVConnector:外挂 KV 缓存与 PD 分离部署

schedule() 里有一段容易被忽视的代码(line 328–335):

num_external_tokens = (
    0 if self.connector is None else
    self.connector.get_num_new_matched_tokens(
        request, num_computed_tokens))
num_computed_tokens += num_external_tokens

KVConnector 是 V1 在 2025 年引入的扩展点(KV Connector API V1,#15960,2025-04),用于接外部 KV 缓存源——包括:

  1. KV 卸载到外部存储:把冷的 KV 存到 CPU 内存或磁盘,热请求回来时从外部 store 拉回显存,避免重算。v0.8.5 的 vllm/distributed/kv_transfer/kv_connector/v1/ 下只有 lmcache_connector.py 与 shared_storage_connector.py 两个实现;Mooncake 的连接器(mooncake_store_connector.py)还只在 V0 那一侧。
  2. PD Disaggregation(Prefill-Decode 分离):prefill 节点做完 KV 后推给 decode 节点,两类节点异构部署。DistServe (OSDI '24)、Mooncake (FAST '25) 两篇论文讨论的正是这个架构。
  3. 跨节点 KV 复用:多机集群内共享前缀缓存。

对调度器而言,外部命中的 Token 和本地前缀缓存命中是等价的——都记入 num_computed_tokens 并按"不消耗 budget"处理。这种统一抽象让调度器代码不需要为 PD 分离写特殊分支,但对底层 get_num_new_matched_tokens 实现提出了要求:它在 schedule() 里被同步调用,必须能快速判断远端命中情况,否则会拖慢每一拍的调度。

3.8.6 Cascade Attention 与共同前缀检测

schedule() 末尾(line 432–439)还有一段:

num_common_prefix_blocks = 0
if self.running:
    any_request = self.running[0]
    num_common_prefix_blocks = (
        self.kv_cache_manager.get_num_common_prefix_blocks(
            any_request, len(self.running)))

它计算 RUNNING 队列里所有请求共同前缀的块数,把这个数字附在 SchedulerOutput 上。底层 attention kernel 拿到后可以启用 Cascade Attention:共同前缀的 KV 只读一次,每个请求独有的尾段 KV 各读各的。在多用户共享系统提示词的场景(RAG、Agent),共同前缀往往是几千 token,Cascade Attention 能把这部分 memory-bound 的读取从 N 份请求 × 前缀长度压到 1 份前缀 + N 份尾段,decode 阶段带宽利用效率显著提升。

3.9 观测调度器:看懂 SchedulerStats

一个健康的生产 vLLM 引擎,调度器的指标应该是什么样的?

指标名以 vllm/v1/metrics/loggers.py 里注册的为准(注意分隔符是冒号,不是下划线):

vllm:num_requests_running          健康时贴着 max_num_seqs 但不总是打满
vllm:num_requests_waiting          大部分时间 = 0,突发流量时短暂 > 0
vllm:gpu_cache_usage_perc          有余量、不长期贴 1.0
vllm:iteration_tokens_total        每拍 token 数直方图——混合批处理是否真的在混
vllm:prompt_tokens_total           与 generation_tokens_total 的比值反映负载形态
vllm:generation_tokens_total
vllm:num_preemptions_total         长期平坦
vllm:gpu_prefix_cache_queries_total  两者相除 = 前缀缓存命中率
vllm:gpu_prefix_cache_hits_total

不健康的信号:

  • gpu_cache_usage_perc 持续 > 95% + num_preemptions_total 持续上涨 → KV 池饥饿,需要降 max_num_seqs 或加卡
  • num_requests_waiting 长期 > 0 + num_requests_running == max_num_seqs → 吞吐被 max_num_seqs 卡住,可以尝试调大
  • num_requests_waiting 长期 > 0 + gpu_cache_usage_perc 持续偏低 → 可能有 request leak,去看 abort 记录
  • num_requests_waiting > 0 但 iteration_tokens_total 一直很小 → 有请求被跳过,先查 max_loras 是否设得太小(§3.8.2)

V1 的 SchedulerStats 每一步都产出,通过 EngineCore → API Server → Prometheus 的链路暴露。这些指标是生产调优的"仪表盘"——所有性能问题的第一线索都在这儿。

3.10 实测:Scheduler 是 1298 行的引擎、schedule() 是 374 行的主方法

把整个 V1 调度器子系统按文件实测——

文件 行 角色
vllm/v1/core/sched/scheduler.py 838 本章主角——Scheduler 类全部实现
vllm/v1/request.py 178 Request 数据类 + RequestStatus 枚举(WAITING / WAITING_FOR_FSM / RUNNING / PREEMPTED / FINISHED_* 四种)
vllm/v1/core/sched/interface.py 134 SchedulerInterface ABC——V1 调度器的抽象接口(EngineCore 按它持有 scheduler;V0 的 vllm/core/scheduler.py 不实现它)
vllm/v1/core/sched/output.py 126 3 个 dataclass:NewRequestData (line 20) + CachedRequestData (line 53) + SchedulerOutput (line 82)
vllm/v1/core/sched/utils.py 22 小工具
__init__.py 0 —
合计 1298 —

scheduler.py 内部方法分布(13 个 method)——

方法 起始行 估算行数 角色
schedule() 137 374 本章 §3.3 主角——四阶段编排:预算编列 → RUNNING 续行 → WAITING 入场 → 打包
update_from_output() 625 ~130 本章 §3.7 主角——拿到 GPU 结果后状态迁移
_make_cached_request_data() 511 ~30 helper
_try_schedule_encoder_inputs() 542 ~80 encoder cache 试调度
add_request / finish_requests / _free_request / get_num_unfinished_requests / has_finished_requests / reset_prefix_cache / make_stats / make_spec_decoding_stats 756-838 余下 API 入口 + 统计

两条值得记住的物理事实——

  1. schedule() 单方法 374 行 = 整个 scheduler.py 45%,是本子系统最大的单方法。本章 §3.3.1-3.3.4 拆出的"四阶段"在源码里就是这 374 行的内部分段:所有调度逻辑放在一个方法里,不切成 _schedule_running / _schedule_waiting 几个子方法(V0 有这种切分),这与 §3.2 的"统一 Token 调度"一致。单方法包揽的直接效果是 running 段和 waiting 段共用同一个 token_budget 局部变量,不必在子方法之间来回同步预算和队列状态
  2. 整个 V1 调度器 1298 行 vs 实测 V0 vllm/core/scheduler.py 单文件 2060 行——少 37%(wc -l 于 v0.8.5)——V0 那 2060 行里拆出了 _schedule_running / _schedule_swapped / _schedule_priority_preemption / _schedule_prefills / _schedule_default / _schedule_chunked_prefill 等子方法,V1 把这些分支收进了一个 schedule()。注意这只是调度器本身的对比:两边的 KV 块管理都在调度器文件之外,不在这两个数字里

SchedulerOutput 所在的 output.py 仅 3 个 dataclass、126 行——是 §3.3.4 "打包"环节的全部数据契约——印证 vLLM 在 ch01 §1.5 "四类跨边界 DTO" 的设计纪律:调度器和 worker 之间的协议表面极薄(126 行),背后调度逻辑很厚(schedule() 374 行)。

3.11 本章小结

调度器是 vLLM 性能优化的核心战场。一次好的调度决策能让 GPU 吃饱、让 decode 请求 TPOT 平稳、让长 prompt 不打断别人。V1 的核心设计:

  • 统一 Token 调度——消除 Prefill / Decode 的代码路径差异,用 {req_id: num_tokens} 统一描述调度决策
  • 单方法 schedule()——预算编列 → 续行 RUNNING(欠多少调多少,decode 时恰好是 1)→ WAITING 入场(本拍抢占过就一个不收)→ 就地打包 SchedulerOutput
  • 两个核心旋钮——max_num_batched_tokens 控制每拍 GPU kernel 规模,max_num_seqs 控制并发 KV 占用
  • Chunked Prefill 始终开启——长 prompt 不再霸占引擎;decode 的 TPOT 抖动上界从"最长那条 prompt"变成"一个 chunk",由 max_num_batched_tokens 直接控制
  • 前缀缓存直接提升 budget——命中的 token 不计入本拍预算,单步吞吐被命中率放大
  • 抢占只做 RECOMPUTE——V1 砍掉 SWAP,配合前缀缓存把重算代价最小化;抢占最年轻的 RUNNING 请求
  • 状态迁移基于真实结果——num_computed_tokens 在 schedule() 末尾乐观推进(好让下一拍能立刻续排 prefill),投机草稿被拒时在 update_from_output 里回退;生成了什么、要不要停,一律等 GPU 结果
  • SchedulerStats——生产调优的仪表盘,vllm:gpu_cache_usage_perc / vllm:num_preemptions_total / vllm:gpu_prefix_cache_hits_total 是三个必看指标

物理事实(wc -l 于 v0.8.5):V1 sched/ 子系统 1298 行 vs V0 scheduler.py 2060 行,少 37%;schedule() 单方法 374 行占 scheduler.py 45%——单方法包揽是为了让 running 段和 waiting 段共用同一份 token budget;SchedulerOutput 仅 126 行 3 dataclass,印证 ch01 §1.5「四类跨边界 DTO」数据契约表面薄、调度逻辑厚的纪律。


源码导航

  • V1 Scheduler 主类:vllm/v1/core/sched/scheduler.py
  • SchedulerOutput 定义:vllm/v1/core/sched/output.py
  • Request 状态枚举:vllm/v1/request.py(RequestStatus)
  • 预算与配置:vllm/config.py(SchedulerConfig)
  • KV 分配配合点:vllm/v1/core/kv_cache_manager.py
  • Metric 定义:vllm/v1/metrics/stats.py
  • V0 Scheduler(历史对照):vllm/core/scheduler.py