数据截至 (上游 commit f25c580af159)
02 · Continuous Batching 调度器
这一章讲什么: vLLM 吞吐的第二个来源。
Scheduler.schedule()是引擎每个 step 跳动一次的心脏:它决定这一圈哪些请求各算几个 token。读完你会明白 continuous batching 为什么不是「一个功能」而是「调度的默认形态」,以及显存耗尽时抢占是怎么发生的。
1. 它要解决的小问题
传统 serving 的批是静态的:凑齐 N 条请求 → 一起跑完全部生成 → 放下一批。两条 10-token 的请求和一条 2000-token 的请求同批时,短请求结束后它的 batch 槽位就空转——GPU 利用率塌方。
continuous batching 的回答:批的成员资格每个 step 重算。 一条请求生成完毕,下一步立刻有新请求补进来;新请求不用等一整批结束。这要求调度器每个 step 都做一次全局决策:谁继续、谁加入、各算多少。
2. 思路:没有「prefill 阶段」,只有「还差多少 token」
schedule() 开头有一段 woosuk 留下的注释,是整个调度器的世界观(vllm/v1/core/sched/scheduler.py:503-511):
There's no "decoding phase" nor "prefill phase" in the scheduler. Each request just has the
num_computed_tokensandnum_tokens_with_spec. ... At each step, the scheduler tries to assign tokens to the requests so that each request'snum_computed_tokenscan catch up itsnum_tokens_with_spec.
也就是说:
- 一条请求 = 一个进度条:
num_computed_tokens(已算)追赶num_tokens_with_spec(已有 + 投机 token)。 - prefill 只是「差几千个」的特例,decode 只是「差 1 个」的特例——同一个机制覆盖两者。
- chunked prefill 顺势而来:一次塞不下的长 prompt,每个 step 算一块,差额自然收敛。
图示:一个 step 的决策流
schedule() 开始
│
▼
① 领预算: token_budget = max_num_scheduled_tokens
input_budget = max_num_batched_tokens
│
▼
② 先保 RUNNING: 逐条算 num_new_tokens(差额→预算截断)
每条问 KVCacheManager 要块;不够 → ③
│
▼
③ 抢占: 把 running 末尾(或最低优先级)踢回 waiting,
释放它的块,重试当前请求
│
▼
④ 再收 WAITING: 预算与 max_num_seqs 还有余量时,
逐条拉新(先查前缀缓存命中,再分块)
│
▼
⑤ 打包 SchedulerOutput: 新请求全量信息 + 老请求 diff
+ 每条的 token 数和块号
怎么读这张图: 从上到下是一次 schedule() 的顺序;③ 是②失败时的内层循环(踢人→重试);④ 只在③没发生过时才会做——被抢占过的 step 不再收新请求,先消化存量。
3. 原理演示:预算驱动的组批
这段演示「双队列 + 双预算」的核心循环:
# 示意,非源码
def schedule(running, waiting, token_budget, max_num_reqs, kv_pool):
batch, num_scheduled = [], {}
# 第一圈:老请求优先,逐条要 token、要块
for req in list(running):
need = req.num_tokens_with_spec - req.num_computed_tokens
n = min(need, token_budget) # 预算截断 → chunked prefill
if n == 0:
continue
while kv_pool.allocate(req, n) is None: # 块不够
victim = running.pop() # 抢占末尾请求
kv_pool.free(victim)
waiting.prepend(victim)
if victim is req:
break # 连自己都被踢了,放弃
else:
batch.append(req); num_scheduled[req.id] = n
token_budget -= n
# 第二圈:没发生抢占且还有余量,才收新请求
if not preempted_any:
while waiting and token_budget > 0 and len(running) < max_num_reqs:
req = waiting[0]
hit = kv_pool.find_prefix_hit(req) # 前缀缓存先抵扣
n = min(req.remaining_after(hit), token_budget)
if kv_pool.allocate(req, n) is None:
break
waiting.popleft(); running.append(req)
batch.append(req); num_scheduled[req.id] = n
token_budget -= n
return batch, num_scheduled
重点看:所有决策都坍缩成两个数的消长(token_budget、len(running))加一次块分配询问。真实代码的复杂性来自这骨架上要兼容的特性(投机解码、多模态编码预算、LoRA 上限、KV 连接器……),骨架本身就是这段。
4. 真实实现
4.1 入口:Scheduler.schedule
在 vllm/v1/core/sched/scheduler.py:501。先领两份预算(vllm/v1/core/sched/scheduler.py:521-524):
token_budget = max_num_scheduled_tokens——这个 step 最多新算多少 token;input_budget = max_num_batched_tokens——输入侧上限(含投机解码槽位抵扣)。
4.2 第一圈:RUNNING 队列
循环在 vllm/v1/core/sched/scheduler.py:552 开始。对每 条请求:
num_new_tokens = num_tokens_with_spec + num_output_placeholders - num_computed_tokens(:583-587),即「还差多少」;- 依次被
long_prefill_token_threshold、token_budget、max_model_len截断(:588-603)——chunked prefill 就是第一处截断的宏观表现; - 调
kv_cache_manager.allocate_slots要块(:654-659); - 要不到就进入抢占循环(:663-706):FCFS 策略下直接
self.running.pop()踢队尾(:702);PRIORITY 策略下挑(priority, arrival_time)最大的(即最低优先级、最晚到的,vllm/v1/core/sched/scheduler.py:671-673),且若受害者本步已被排上,要把它的 token 从预算里退回。
注意 :646 起的注释:预算耗尽时 continue 而非 break——不严格执行 FCFS,让后面的低资源请求也能搭车。
4.3 抢占:_preempt_request
在 vllm/v1/core/sched/scheduler.py:1392。动作很轻:
- 释放全部块(
_free_request_blocks)和编码器缓存; - 状态置
PREEMPTED,num_computed_tokens = 0(:1385)——重算式抢占:vLLM 选择不保留 KV、恢复时从头重算(前缀缓存通常能命中大部分,重算代价比想象小); - 放回
waiting队首(self.waiting.prepend_request(request),:1405),下次优先被收。
4.4 第二圈:WAITING 队列
从 vllm/v1/core/sched/scheduler.py:774 开始,入口守卫是 if not preempted_reqs and ...(:772)——本步没踢过人才收新。对队首请求:
- 先问前缀缓存:
kv_cache_manager.get_computed_blocks(request)拿到命中块和命中 token 数,新请求可能「一进系统就已完成大半 prefill」; - 剩余 token 数再被预算、单请求上限截断;
- 额外约束逐一检查:
max_num_running_reqs(num_running达到上限则 break)、LoRA 槽位上限、KV 连接器的异步加载(load_kv_async时本步先不算 token); - 成功后从 waiting 移到 running。
队列策略由 create_request_queue(vllm/v1/core/sched/request_queue.py:201)创建:SchedulingPolicy.FCFS(默认,deque)或 PRIORITY(vllm/v1/core/sched/request_queue.py:13-17)。
4.5 产物:SchedulerOutput
调度结果是个纯数据包(vllm/v1/core/sched/output.py:219),关键字段:
| 字段 | 含义 |
|---|---|
scheduled_new_reqs | 首次调度的请求,带全量信息(prompt、采样参数、块号) |
scheduled_cached_reqs | 老请求,只发 diff(新块号、是否换槽位) ——worker 侧有缓存,省通信 |
num_scheduled_tokens | req_id → 本步算几个 token |
scheduled_spec_decode_tokens | 投机解码草稿 token |
finished_req_ids | 通知 worker 释放本地缓存状态 |
4.6 收尾:update_from_output
模型跑完后,结果回灌调度器(vllm/v1/core/sched/scheduler.py:1789)。它逐条:
- 减去在途 token 计数(
num_in_flight_tokens)、核销抢占时标记的 stale 输出; - 追加采样 token、检查停止条件(EOS /
max_tokens/ stop 字符串); - 对结束的请求
free掉,块回池(哈希留着,等待被下一条命中); - 汇总成
EngineCoreOutputs返回给引擎核心。
与 _update_after_schedule(vllm/v1/core/sched/scheduler.py:1435)配合:调度一完成就乐观地推进 num_computed_tokens(投机 token 被拒时再回退),这样下一条调度立刻可见最新进度——这是 async scheduling(调度与 GPU 执行重叠)正确性的关键一环。
5. 关键细节与坑
- 抢占过的 step 不收新请求(
schedule里if not preempted_reqs守卫)。直觉:刚显存紧张过, 先别添乱。 - 抢占 = 全量重算,不是换出。 块直接释放,
num_computed_tokens归零。好在链式哈希让恢复时大概率命中自己的旧块——只要那些块还没被驱逐。 - 被抢占请求回队首而非队尾,保证它下一步最先被考虑,避免饿死。
max_num_batched_tokens是最影响手感的旋钮:太小则长 prompt 被切得很碎(prefill 慢),太大则 decode 步被大 prefill 块挤占(首 token 延迟 vs 吞吐的经典权衡)。- 投机解码会临时抬高差额:
num_tokens_with_spec含草稿 token,调度器为它们也留块(num_lookahead_tokens),被拒的草稿在update_from_output里回退进度。 - 优先级策略的抢占方向容易看反:
max(running, key=lambda r: (r.priority, r.arrival_time))选的是 priority 数值最大者,而 vLLM 里数值越大优先级越低。