跳到主要内容

数据截至 (上游 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_tokens and num_tokens_with_spec. ... 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.

也就是说:

  • 一条请求 = 一个进度条: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_budgetlen(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 开始。对每条请求:

  1. num_new_tokens = num_tokens_with_spec + num_output_placeholders - num_computed_tokens(:583-587),即「还差多少」;
  2. 依次被 long_prefill_token_thresholdtoken_budgetmax_model_len 截断(:588-603)——chunked prefill 就是第一处截断的宏观表现
  3. kv_cache_manager.allocate_slots 要块(:654-659);
  4. 要不到就进入抢占循环(: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)和编码器缓存;
  • 状态置 PREEMPTEDnum_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)——本步没踢过人才收新。对队首请求:

  1. 先问前缀缓存kv_cache_manager.get_computed_blocks(request) 拿到命中块和命中 token 数,新请求可能「一进系统就已完成大半 prefill」;
  2. 剩余 token 数再被预算、单请求上限截断;
  3. 额外约束逐一检查:max_num_running_reqsnum_running 达到上限则 break)、LoRA 槽位上限、KV 连接器的异步加载(load_kv_async 时本步先不算 token);
  4. 成功后从 waiting 移到 running。

队列策略由 create_request_queuevllm/v1/core/sched/request_queue.py:201)创建:SchedulingPolicy.FCFS(默认,deque)或 PRIORITYvllm/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_tokensreq_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_schedulevllm/v1/core/sched/scheduler.py:1435)配合:调度一完成就乐观地推进 num_computed_tokens(投机 token 被拒时再回退),这样下一条调度立刻可见最新进度——这是 async scheduling(调度与 GPU 执行重叠)正确性的关键一环


5. 关键细节与坑

  • 抢占过的 step 不收新请求scheduleif 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 里数值越大优先级越低。