vLLM 推理内核深度解析
第3章 调度器:Token 的交通指挥
调度就是不断做选择,而每一个选择都有代价。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:
- prompt token(用户输入)
- output token(已生成)
- 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
代价:
- 总墙钟时间略长——Prefill 本来是 compute-bound,切开后每一块都要和 decode 抢同一批 SM,两边都拿不到峰值;再加上多出来的 kernel 启动次数。
- 长 prompt 的 TTFT 增加(第一个 Token 要等最后一块 chunk 跑完才出)。
- 调度逻辑复杂度上升——需要跟踪
num_computed_tokens。
收益:
- Decode 的 TPOT 抖动量级从"一整个长 prefill 的时间"降到"一个 chunk 的时间"——上界由
max_num_batched_tokens(以及long_prefill_token_threshold)直接控制,而不再由最长那条 prompt 决定。 - p99 TPOT 向平均 TPOT 收敛(很重要的服务质量指标)。
- 长 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 缓存源——包括:
- 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 那一侧。 - PD Disaggregation(Prefill-Decode 分离):prefill 节点做完 KV 后推给 decode 节点,两类节点异构部署。DistServe (OSDI '24)、Mooncake (FAST '25) 两篇论文讨论的正是这个架构。
- 跨节点 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 入口 + 统计 |
两条值得记住的物理事实——
schedule()单方法 374 行 = 整个 scheduler.py 45%,是本子系统最大的单方法。本章 §3.3.1-3.3.4 拆出的"四阶段"在源码里就是这 374 行的内部分段:所有调度逻辑放在一个方法里,不切成_schedule_running/_schedule_waiting几个子方法(V0 有这种切分),这与 §3.2 的"统一 Token 调度"一致。单方法包揽的直接效果是 running 段和 waiting 段共用同一个token_budget局部变量,不必在子方法之间来回同步预算和队列状态- 整个 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