调度器架构总览:主要组件、模块划分与整体工作流程
vLLM 的调度器(Scheduler)并非一个孤立的单点模块,而是一套围绕“请求生命周期”组织的协同系统。理解它的架构,关键在于把握三个层次:核心数据结构(调度器如何记录状态)、功能模块划分(谁负责决策、谁负责执行)、以及整体工作流程(请求从进入到退出的完整路径)。本节将以上述三层为主线,绘制一张完整的架构总览图。
4.1 核心数据结构:调度器的“记忆”
调度器的所有决策都建立在状态之上。vLLM 调度器维护的核心数据结构包括:
- Waiting Queue(等待队列):存放已接收但尚未获得足够 GPU 资源的序列组(SequenceGroup)。每个序列组包含一个或多个序列(Sequence),以及其采样参数、优先级元数据。
- Running Queue(运行队列):当前正在被 GPU 执行的序列组。调度器每次迭代(iteration)都会从等待队列中挑选若干序列组加入运行队列,直到 GPU 内存(KV Cache)耗尽或达到最大批处理大小。
- Swapped Queue(交换队列):当 KV Cache 压力过大时,被抢占(preempted)的序列组会暂时移入此队列,其 KV Cache 被释放到 CPU 内存。待资源充足后再换回运行队列。
- Block Table(块表):每个序列组到物理 KV Cache 块的映射表。调度器通过块表管理显存碎片,支持 PagedAttention 的非连续显存分配。
关键认知:这三个队列(Waiting / Running / Swapped)构成了调度器状态机的骨架。调度器的每一次“调度决策”,本质上就是在这三个队列之间移动序列组,并同步更新 Block Table 的过程。
4.2 功能模块划分:调度器的“四层职责”
从功能角度,vLLM 调度器可划分为四个协作模块,每个模块职责单一、接口清晰:
| 模块 | 核心职责 | 关键接口/数据结构 |
|---|---|---|
| 策略决策器(Policy) | 决定“下一个该调度谁”。包含队列选择策略(FCFS、优先级排序)、抢占策略(最早优先被抢占)、交换策略。 | SequenceGroup 的优先级字段、到达时间、已执行步数 |
| 资源管理器(Resource Manager) | 监控 GPU KV Cache 空闲块数、最大序列长度、最大批处理 token 数。提供“能否容纳新序列”的判定。 | BlockAllocator、可用块数、max_num_seqs、max_model_len |
| 执行器(Executor) | 将调度决策转化为实际的 GPU 操作。负责组织输入 token IDs、注意力掩码、位置编码,并调用模型前向传播。 | ModelRunner、InputBatch、Sampler |
| 状态同步器(State Sync) | 在每次迭代结束后,更新各队列状态、释放已完成序列的 KV Cache、处理新加入的请求。 | SequenceGroup 状态机、BlockTable 更新 |
这种模块划分使得调度策略(Policy)与资源管理(Resource Manager)解耦:即使未来引入更复杂的优先级算法,也无需改动底层显存管理逻辑。
4.3 整体工作流程:一次调度迭代的完整路径
vLLM 的调度器采用迭代式调度(Iterative Scheduling),即每次 GPU 执行完一个 batch 后,调度器立即运行一次调度循环。以下是一次典型迭代的五个步骤:
- 接收新请求:HTTP 服务或离线引擎将新的 SequenceGroup 放入 Waiting Queue,并记录其到达时间与优先级。
- 资源预检:资源管理器检查当前空闲 KV Cache 块数、Running Queue 中序列总数是否达到上限(max_num_seqs)。若资源充足,进入第 3 步;否则尝试触发抢占(见第 5 步)。
- 队列调度:策略决策器按优先级(默认 FCFS,可配置优先级队列)从 Waiting Queue 中选取序列组,为其分配 Block Table 中的物理块,并移入 Running Queue。此过程会持续到资源耗尽或等待队列为空。
- 执行与更新:执行器将 Running Queue 中所有序列的当前 token 拼接为 batch,执行一次前向传播,得到新 token。随后状态同步器更新每个序列的 KV Cache 块引用,并检查是否有序列已生成结束符(EOS)。
- 后处理与抢占:若步骤 2 中资源不足,调度器会从 Running Queue 中选择若干序列组(通常是最新加入或优先级最低的)进行抢占:将其 KV Cache 换出到 CPU,移入 Swapped Queue。若 Swapped Queue 中有序列且资源已释放,则优先换回。
下面这张 SVG 图展示了调度器各模块与队列之间的数据流关系,以及一次迭代的完整闭环:
4.4 案例:一次优先级调度与抢占的完整过程
假设当前 GPU 显存仅剩 100 个空闲 KV Cache 块,Running Queue 中已有 3 个序列组(A、B、C),共占用 900 块。此时到达两个新请求:
- 请求 P1:高优先级(priority=10),预估需要 300 块 KV Cache。
- 请求 P2:普通优先级(priority=0),预估需要 200 块 KV Cache。
调度器执行以下步骤:
- 资源预检:空闲 100 块,无法满足 P1 或 P2 的需求。
- 触发抢占:策略决策器检查 Running Queue,选择优先级最低且最晚加入的序列组 C(假设其优先级为 0)进行抢占。C 的 KV Cache 被换出到 CPU,释放 300 块显存。
- 重新预检:现在空闲 400 块,可以满足 P1(300 块)的需求。
- 调度 P1:P1 被移入 Running Queue,分配 300 块物理块。剩余空闲 100 块,不足以调度 P2。
- P2 留在 Waiting Queue:等待下一轮迭代或更多资源释放。
设计意图:vLLM 的抢占策略默认是“先来先服务 + 最早被抢占”,即优先抢占最晚加入的序列。但在实际生产环境中,你可以通过自定义 Policy 模块,改为“按优先级抢占”或“按预估剩余时间抢占”,从而让高优先级任务获得更稳定的延迟。
4.5 常见误区与复盘
误区一:认为调度器只在请求开始时工作一次。实际上,vLLM 调度器在每个 decode 迭代后都会运行,持续进行资源再分配。请求的优先级可能在执行过程中被动态调整(例如用户取消或升级),调度器必须响应这些变化。
误区二:混淆“队列”与“数据结构”。Waiting Queue 并非简单的 Python list,而是基于堆(heap)实现的优先队列,其排序键可以是到达时间、优先级或自定义元组。理解这一点有助于你分析调度延迟。
误区三:忽略 Block Table 的碎片化问题。即使总空闲块数足够,也可能因为物理块不连续而无法分配。vLLM 的 BlockAllocator 采用“前缀共享 + 按需分配”策略,但极端情况下仍可能产生外部碎片。监控 num_free_blocks 与 num_waiting_seqs 的比值,是判断是否需要扩容或调整最大批处理大小的有效手段。
复盘建议:在阅读 vLLM 源码时,建议从 scheduler.py 中的 schedule() 方法入手,逐行对照本节所述的五个步骤。你会发现,实际代码中的 self.waiting、self.running、self.swapped 三个列表,正是本节架构图的直接实现。