3、调度器基础:调度器的定义、作用、在vLLM中的位置

3.1 什么是调度器:从“排队”到“资源分配”

在理解 vLLM 的调度器之前,我们先从一个最朴素的场景切入:假设你经营一家只有 4 张桌子的餐厅,门口有 12 位客人排队。你不可能让 12 位客人同时坐下,因为桌子不够;你也不可能让 4 张桌子空着而让客人干等,因为那会浪费资源。你需要一个“调度策略”:谁先入座、坐哪张桌子、拼桌还是等位、预计吃多久。

vLLM 的调度器(Scheduler)扮演的正是这个角色,只不过它的“桌子”是 GPU 显存中的 KV Cache 块,“客人”是来自不同请求的 Token 序列。调度器的核心职责可以归纳为三点:

  • 准入控制(Admission Control):决定哪些请求可以被系统接受并开始推理,哪些请求需要排队等待。这取决于当前可用的 KV Cache 块数量。
  • 资源分配(Resource Allocation):为被接受的请求分配物理块(Physical Block),并维护逻辑块到物理块的映射关系。
  • 执行序列化(Execution Serialization):决定每一轮迭代(Iteration)中,哪些请求的 Token 被送入模型执行,以及执行的顺序。

调度器不是简单的“先来先服务”队列,它需要同时考虑吞吐量、延迟、公平性和显存利用率。在 vLLM 中,调度器与 PagedAttention 紧密耦合,共同构成了高效推理的核心引擎。

关键认知:调度器管理的最小单位不是“请求”,而是“Token 序列”和“KV Cache 块”。一个请求可能包含多个序列(如 beam search 场景),而一个序列的 KV Cache 可能分布在多个不连续的物理块上。调度器必须精确掌握每个块的状态(空闲、已分配、待释放)。

请求队列 Req A (prompt=128) Req B (prompt=256) Req C (prompt=64) Req D (prompt=512) Req E (prompt=32) 等待被调度 vLLM 调度器 准入控制 检查可用块数 块分配 逻辑块→物理块映射 序列管理 等待/运行/交换状态 迭代调度 决定本轮执行哪些序列 核心数据结构:SchedulerOutput 模型执行引擎 KV Cache 物理块池 块0 块1 块2 块3 块4 块5 块6 块7 块8 块9 空闲 已分配(运行中) 已分配(等待中) GPU 执行 Attention / MLP / 采样 每轮迭代后释放完成块 释放块 / 更新状态 / 触发新一轮调度

3.2 调度器在 vLLM 架构中的位置

vLLM 的整体架构可以看作一个三层流水线:前端 API 层 → 调度与执行层 → 硬件加速层。调度器位于中间层,是连接用户请求与 GPU 算力的“交通枢纽”。

具体来说,调度器在 vLLM 中的位置体现在以下三个接口关系上:

  1. 上游:LLMEngine 与请求队列。LLMEngine 接收来自 OpenAI 兼容 API 或离线推理的请求,将其封装为 SequenceGroup 后放入等待队列。调度器从等待队列中取出这些 SequenceGroup,而不是直接与 API 层交互。
  2. 下游:Worker 与模型执行。调度器通过 Worker 对象调用模型。每个 Worker 管理一个 GPU(或一个 GPU 分区),调度器将 SchedulerOutput 转换为具体的执行指令,驱动 Attention 算子和 MLP 算子运行。
  3. 横向:BlockManager 与显存管理。调度器依赖 BlockManager 来分配和释放物理块。BlockManager 维护了块表(Block Table),记录每个序列的物理块映射关系。调度器在准入控制时查询 BlockManager 的空闲块数,在迭代结束时通知 BlockManager 释放不再使用的块。

下面这张表总结了调度器与周边组件的关系:

组件 与调度器的关系 关键交互内容
LLMEngine 上游调用方 提交 SequenceGroup,获取调度结果
BlockManager 资源提供方 查询空闲块、分配块、释放块
Worker / ModelRunner 下游执行方 接收序列批次,执行前向传播
CacheEngine 辅助组件 管理 KV Cache 的 GPU/CPU 迁移(swap)
OutputProcessor 结果处理方 处理采样结果,判断序列是否终止

3.3 调度器的核心工作流程

调度器的工作不是一次性的,而是在每个迭代(iteration)中反复执行。一个典型的调度循环包含以下步骤:

调度循环(每步迭代):
1. 从等待队列中取出可调度的 SequenceGroup
   - 检查是否满足 max_num_seqs 和 max_num_batched_tokens 限制
   - 检查是否有足够的 KV Cache 物理块
2. 将新序列加入运行队列(running queue)
3. 对运行队列中的序列执行 preemption 检查
   - 如果显存不足,选择 victim 序列并执行 swap out 或 abort
4. 构造 batch 输入
   - 收集每个序列的 input_ids、position_ids、block_table
5. 调用 model.execute(batch)
6. 处理输出
   - 更新 KV Cache 块引用计数
   - 释放已终止序列的块
   - 将新生成的 token 追加到序列

这个循环在 vLLM 的 LLMEngine.step() 方法中被驱动。调度器本身是无状态的,它的状态完全由 BlockManager 和序列状态决定。这种设计使得调度逻辑可以独立测试和优化。

实操提示:在 vLLM 源码中,调度器的核心类位于 vllm/core/scheduler.py。你可以通过设置环境变量 VLLM_LOG_LEVEL=DEBUG 来观察调度器的决策日志,例如哪些请求被抢占、哪些块被释放。这对于理解调度行为非常有帮助。

3.4 案例:一次典型的调度决策

假设当前 GPU 显存中有 10 个物理块(编号 0-9),其中块 0、1、5、8、9 空闲,其余已被占用。此时有两个请求到达:

  • 请求 A:prompt 长度为 200 tokens,需要 4 个块(每个块 64 tokens)
  • 请求 B:prompt 长度为 128 tokens,需要 2 个块

调度器的决策过程如下:

  1. 检查空闲块:当前空闲块为 5 个(0、1、5、8、9),满足请求 A 的 4 块需求。
  2. 检查序列数限制:假设 max_num_seqs=2,当前运行队列为空,可以同时接受两个请求。
  3. 分配块:BlockManager 将块 0、1、5、8 分配给请求 A,将块 9 分配给请求 B。注意:请求 B 还需要 1 个块,但空闲块已用完,因此请求 B 只能部分分配。
  4. 处理部分分配:调度器将请求 B 标记为“等待更多块”,将其放回等待队列,只将请求 A 送入执行。
  5. 执行与释放:请求 A 执行完一轮后,生成了新的 token,但 KV Cache 需要新增块。此时如果块 9 仍被请求 B 占用,调度器可能触发抢占(preemption),将请求 B 的块交换到 CPU,腾出空间给请求 A。

这个案例展示了调度器在资源受限时的核心权衡:是牺牲吞吐量等待更多块,还是牺牲某个请求的延迟来保证整体进度。vLLM 默认采用“先到先服务 + 抢占式调度”的策略,但你可以通过配置 preemption_mode 来调整行为。

3.5 常见误区与复盘

误区一:认为调度器只是“队列管理器”。实际上,调度器直接参与显存分配和释放,它的决策直接影响 KV Cache 的碎片化程度和利用率。一个低效的调度器可能导致显存碎片化,即使总空闲块足够,也无法分配出连续的块给大请求。

误区二:忽略抢占(Preemption)的开销。当显存不足时,调度器会将某些序列的 KV Cache 从 GPU 交换到 CPU(swap out),这涉及 PCIe 数据传输,开销远大于纯计算。频繁的抢占会严重降低吞吐量。在配置 gpu_memory_utilization 时,应预留一定余量,避免触发频繁抢占。

误区三:混淆“迭代级调度”和“请求级调度”。vLLM 的调度器在每个迭代(iteration)都会重新决策,而不是在请求开始时一次性决定。这意味着一个长请求可能在执行过程中被暂停、恢复或抢占。理解这一点对于调优 max_num_batched_tokens 和 max_num_seqs 至关重要。

复盘建议:在部署 vLLM 服务时,建议先通过 /metrics 端点监控 vllm:num_requests_running、vllm:num_requests_waiting 和 vllm:num_preemptions_total 这三个指标。如果 num_preemptions_total 持续增长,说明你的显存配置偏紧,或者调度策略需要调整。通过观察这些指标,你可以更直观地理解调度器在系统中的作用,而不是把它当作一个黑盒。