vLLM 源码精读:调度器 —— Continuous Batching 与抢占

— schedule() 完整循环 + RUNNING/WAITING/Preemption + update_from_output
源码精读 AI Infra vLLM V1 Scheduler Continuous Batching Stage 1 / Week 3
目录
  1. 1. 调度器的位置与职责
  2. 2. schedule() 主循环六步全景
  3. 3. Step 1-2:收尾 + 算剩余资源
  4. 4. Step 3:调度 RUNNING 请求
  5. 5. Step 4-5:调度 WAITING 请求
  6. 6. 抢占:_preempt_request
  7. 7. Step 6:打包 SchedulerOutput
  8. 8. update_from_output:下一轮前的收尾
  9. 9. 论文映射 + 关键文件索引
  10. 10. ⭐ 高频卡点答疑

1. 调度器的位置与职责

Scheduler(scheduler.py,113KB)是 vLLM V1 的中枢。一句话职责:

每生成一个 token,决定下一轮 GPU 帮谁干活、帮几个、资源不够时抢谁。

它持有的核心状态:

状态类型含义
self.runninglist[Request]正在执行的请求列表
self.waitingRequestQueue等待调度的请求队列(FCFS 或 PRIORITY)
self.skipped_waitingRequestQueue因异步依赖/约束被跳过的请求
self.kv_cache_managerKVCacheManagerKV Cache 门面(博客 09 讲过的)
self.max_num_scheduled_tokensint每轮 token_budget 上限(由 scheduler_config 配置)

2. schedule() 主循环六步全景

schedule()(行 351 ~ 970,约 620 行)每轮被 Engine 调用一次。六步流程:

1. 收尾 上一轮完成的请求 → free block 2. 算账 剩余 free block - decode 预留 = 可用 block 3. RUNNING 已在跑的请求继续执行(优先保障) 4. WAITING 从等待队列拉新请求(用剩余资源) 5. 抢占 资源不够 → 抢占最后的请求回收 block → 重试 WAITING 6. 打包 组装 SchedulerOutput → 发给 Worker 执行
调度顺序的设计哲学
RUNNING 优先 → WAITING 用剩余。已跑起来的请求不容打断,新请求只能用余粮。

3. Step 1-2:收尾 + 算剩余资源

Step 1 并不在 schedule() 内部

schedule() 开头调 kv_cache_manager.new_step_starts() 重置计数器,但真正"收尾"发生在上轮 Worker 返回后、Engine 调 update_from_output() 时(见 §8)。Scheduler 上轮结束后,已完成请求的 block 已被释放。

Step 2:token_budget

token_budget = self.max_num_scheduled_tokens
# 如果调度器 paused,token_budget = 0(任何请求都不调度)

token_budget 约束本轮能处理的总 token 数。RUNNING 和 WAITING 共享这个预算,消耗完即止。

另外,block 资源约束是隐式的:当 kv_cache_manager.allocate_slots() 返回 None 时,表示 block 不够。

4. Step 3:调度 RUNNING 请求

遍历 self.running 列表,逐个尝试加入本轮 batch:

while req_index < len(self.running) and token_budget > 0:
    request = self.running[req_index]

    # ---- 跳过条件 ----
    if 已达 max_tokens 且没 spec tokens 可验证: continue
    if 未到 next_decode_eligible_step(V2+PP+async): continue
    if num_new_tokens == 0(PP 未完成/达上限/encoder budget 耗尽): continue

    # ---- 确定本步 token 数 ----
    num_new_tokens = 请求剩余待处理 token 数
    if long_prefill_token_threshold 生效: 截断到这个阈值
    num_new_tokens = min(num_new_tokens, token_budget)
    num_new_tokens = min(num_new_tokens, max_model_len - 1 - 已计算 token 数)

    # ---- 分配 block ----
    while True:
        new_blocks = kv_cache_manager.allocate_slots(request, num_new_tokens)
        if new_blocks is not None:
            break  # 分配成功
        # 分配失败 → 抢占
        preempted_req = self.running.pop()  # 默认抢最后一个
        或 max(running, key=priority)       # PRIORITY 策略
        self._preempt_request(preempted_req)
        preempted_reqs.append(preempted_req)

    # ---- 加入本轮 ----
    scheduled_running_reqs.append(request)
    token_budget -= num_new_tokens
    req_index += 1
跳过条件备忘
num_new_tokens == 0 时不 break 而是 continue——代码注释说这是有意放宽 FCFS,允许后面的请求先调度。

5. Step 4-5:调度 WAITING 请求

RUNNING 调度完成后,用剩余 token_budget 从等待队列拉新请求:

while (self.waiting or self.skipped_waiting) and token_budget > 0:
    request_queue = self._select_waiting_queue_for_scheduling()
    request = request_queue.peek()

    # ---- 跳过条件 ----
    if 已停等(lora 约束 / kv connector 传输中等): 跳过 / 放回
    if 超过 max_loras 限制: break  # 整个队列停止

    # ---- 分配 block(同 RUNNING 流程) ----
    new_blocks = kv_cache_manager.allocate_slots(request, num_new_tokens)
    if new_blocks is None:
        if 已经有过抢占: break  # 不能再抢了
        抢占最后一个 running 请求
        continue  # 重试

    # ---- 加入本轮 ----
    scheduled_new_reqs.append(request)
    token_budget -= num_new_tokens
WAITING 段的独特之处
和 RUNNING 不同,WAITING 是有上限的循环——如果已经触发过一次抢占还是不够,就停止拉新。因为继续抢占下去会导致整个系统不稳定。

6. 抢占:_preempt_request

仅 23 行(行 995-1017):

def _preempt_request(self, request, timestamp):
    # 1. 释放该请求的所有 KV block
    self.kv_cache_manager.free(request.request_id)
    # 键:释放后 block 回到 free_block_queue,hash 保留(Lazy Eviction)

    # 2. 重置请求状态
    request.status = waiting 状态
    # 重新 put 到 waiting 队列头部 → 等有资源后优先恢复

V1 代码里不区分 Swap 和 Recomputation——抢占 = 释放 block + 重新入队。论文描述的两种策略在 V1 被统一为"释放重排"——block 回到 free_queue 时 hash 还留着,如果短时间内恢复且命中 Prefix Cache 就是"免费召回";如果 block 被新请求占用则等价于 recomputation。

抢占的"最小化伤害"
抢的是 running 列表中最后面的(默认方案)或最低优先级的(PRIORITY 方案)。保证先到的请求不被后来者挤掉。

7. Step 6:打包 SchedulerOutput

schedule() 末尾把本轮决策组装成 SchedulerOutput(定义在 interface.py):

字段类型Worker 用来做什么
scheduled_new_reqslist[Request]本轮新加入的 Prefill 请求
scheduled_resumed_reqslist[Request]之前被抢占、本轮恢复的请求
scheduled_running_reqslist[Request]本轮继续执行的 Decode 请求
preempted_reqslist[Request]被抢占的请求
num_scheduled_tokensdict[str, int]每个请求本轮要处理的 token 数
req_to_new_blocksdict[str, KVCacheBlocks]每个请求新分配的 KV block
finished_req_idsset[str]已确认完成(供 Worker 跳过)
scheduled_spec_decode_tokensdict[str, list[int]]投机解码的 draft token

8. update_from_output:下一轮前的收尾

update_from_output()(行 1350 ~ 1673,约 320 行)在 Worker 完成后被 Engine 调用。核心逻辑:

def update_from_output(self, scheduler_output, model_runner_output):
    for req_id, output in model_runner_output.items():
        request = self.requests[req_id]

        # 1. 更新请求状态
        request.num_computed_tokens += output.num_tokens
        追加 output.new_token_ids

        # 2. 处理完成
        if 生成了 EOS 或 stop_string 或达到 max_tokens:
            self._free_request(request)   # 释放所有 block
            running 列表移除

        # 3. 处理抢占后的恢复
        如果之前被抢占的请求恢复 → scheduled_resumed_reqs

        # 4. 更新 running 列表
        self._update_after_schedule(scheduler_output)

核心操作:_free_request → kv_cache_manager.free(request_id) → block_pool.free_blocks → ref_cnt - 1 → 到 0 归还。

9. 论文映射 + 关键文件索引

论文概念代码对应文件对应行
Iteration-level schedulingscheduler.pyschedule() ~351
Continuous Batchingscheduler.pyRUNNING + WAITING 混排 ~387-780
FCFS schedulingscheduler.pyFCFS 为默认策略 + _select_waiting_queue_for_scheduling
Preemption(抢占)scheduler.py_preempt_request ~995
SchedulerOutputinterface.py + output.py共 19KB
请求队列request_queue.pyRequestQueue / FCFSQueue
论文没提的token_budget / long_prefill_threshold / PRIORITY 策略 / async PP 约束

10. ⭐ 高频卡点答疑

Q1:为什么先调度 RUNNING 再调度 WAITING?
已跑起来的请求优先保障,新请求只能用剩余资源。反过来会导致新请求挤占正在运行的请求造成不稳定。
Q2:RUNNING 请求什么情况下会被跳过?
① 已达 max_tokens;② 未到 next_decode_eligible_step(V2+PP+async);③ num_new_tokens==0(PP 未完/达上限/encoder budget 耗尽)。跳过时用 continue 而非 break,允许后面的请求先调度。
Q3:抢占选谁?
默认(FCFS):running.pop()——抢最后进入 running 列表的;PRIORITY:max(running, key=priority, arrival_time)——抢最低优先级且最晚到的。
Q4:_preempt_request 到底做了什么?
① kv_cache_manager.free() 释放 block;② 请求放回 waiting 队列。V1 不区分 Swap/Recompute——统一为释放重入队。block 的 hash 保留实现免费召回(命中 Prefix Cache 时无需重算)。
Q5:update_from_output 的核心操作?
① 更新 num_computed_tokens 和 token_ids;② 完成的请求调 _free_request 释放 block → 移出 running;③ _update_after_schedule 同步 running 列表状态。
Q6:token_budget 是什么?
本轮 batch 能处理的最大 token 数。RUNNING 和 WAITING 共享此预算,消耗完即停止拉新。由 max_num_scheduled_tokens 配置。
Q7:SchedulerOutput 的关键字段?
scheduled_new_reqs / scheduled_running_reqs / scheduled_resumed_reqs(三类请求)、preempted_reqs(被抢的)、num_scheduled_tokens(每请求 token 数)、req_to_new_blocks(新 KV block)、finished_req_ids(已完成的)。

这是 Stage 1 / Week 3 的内容,对应 AI Infra 转型学习计划中"vLLM 调度器"任务。
上一篇:09 · KV Cache 管理层 | 下一篇预告:worker/ 和 attention/ 模块。