Skip to content

第3章 调度器:Token 的交通指挥

"Scheduling is about making choices, and every choice has consequences." -- 匿名系统工程师

本章要点

  • 理解 LLM 推理调度的核心挑战:预填充与解码的资源竞争
  • 掌握 V1 统一 Token 调度的设计理念及其优势
  • 深入 Scheduler.schedule() 的决策过程
  • 理解连续批处理(Continuous Batching)的实现机制
  • 认识抢占策略:当显存告急时调度器如何取舍

3.1 调度的本质困境

在深入代码之前,让我们先理解 LLM 推理调度为什么困难。

一个 LLM 推理请求分为两个阶段:

预填充(Prefill)——处理用户输入的全部 Token。假设用户输入了 500 个 Token,预填充阶段需要一次性计算这 500 个 Token 的注意力。这是一个计算密集型操作,GPU 的算力被充分利用,但只产生一个输出 Token。

解码(Decode)——逐个生成输出 Token。每一步只处理 1 个新 Token(上一步生成的),但需要与之前所有 Token 的 KV Cache 做注意力计算。这是一个内存带宽密集型操作——GPU 大部分时间在搬运 KV Cache 数据,计算单元反而在"等数据"。

困境在于:预填充和解码对 GPU 的使用模式截然不同,但它们需要共享同一块显存。

传统做法是静态批处理(Static Batching)——收集一批请求,一起处理完再处理下一批。问题是,批内的请求生成长度不同:有的生成 10 个 Token 就结束了,有的要生成 500 个。短请求完成后,它的"座位"空着却不能被新请求占用,直到整批结束。GPU 利用率随着批处理进行逐渐下降。

vLLM 使用连续批处理(Continuous Batching)——请求可以随时加入和离开当前批次。一个请求完成了,它的位置立刻被新请求填上。调度器每一步都重新决定哪些请求参与计算。

但连续批处理引入了新的问题:预填充和解码要不要放在同一个批次中?预填充的大量 Token 会不会"饿死"正在解码的请求?分块预填充(只处理一部分预填充 Token)的块大小怎么定?

这些就是调度器要回答的问题。

3.2 V1 的哲学:统一 Token 调度

V0 的调度器把预填充和解码作为两个不同的阶段处理,有不同的代码路径、不同的数据结构。这导致了大量的条件分支和边界情况处理。

V1 做了一个大胆的简化:不区分预填充和解码,只有"处理 N 个 Token"

对调度器来说,一个新请求需要预填充 500 个 Token,和一个老请求需要解码 1 个 Token,区别仅仅是 N 的大小不同。调度器的输出是一个极其简洁的数据结构:

python
# 调度器的输出:{request_id: num_tokens}
{
    "req-001": 128,    # 可以是预填充的前 128 个 Token
    "req-002": 1,      # 可以是解码的 1 个 Token
    "req-003": 64,     # 可以是预填充的中间 64 个 Token(分块预填充)
}

这个统一抽象带来了几个连锁优势:

  1. 分块预填充变得自然——预填充 500 个 Token 不需要一口气做完,调度器可以分几步完成(比如 128 + 128 + 128 + 116),每一步都和解码请求混合在一起。这降低了首 Token 延迟(TTFT)——解码请求不会被长预填充阻塞。

  2. 代码简洁——不再需要"if 预填充 then ... else if 解码 then ..."的分支逻辑。一套代码处理所有情况。

  3. FlashAttention3 的天然匹配——FlashAttention3 支持在同一批次中混合不同长度的序列,预填充和解码的 Token 可以在一次内核调用中一起计算。

3.3 调度决策过程

让我们深入 Scheduler.schedule() 的源码,看看每一步调度是怎么做决策的。

打开 vllm/v1/core/sched/scheduler.py,调度过程可以概括为三个阶段:

阶段一:预算计算

每一步开始前,调度器需要知道自己有多少"预算"——即本步最多能处理多少个 Token。

预算的约束来自两个维度:

  • 显存约束——空闲的 KV Cache 块数量。每个块能存 16 个 Token 的 KV 对,空闲块数量乘以 16 就是 Token 预算的上限。
  • 计算约束——max_num_batched_tokens 配置限制了每步处理的最大 Token 数。这个参数控制了 GPU 的计算负载——太大会导致单步延迟过高,太小会导致 GPU 利用率不足。

实际预算是两者的较小值。

阶段二:选择请求

有了预算之后,调度器按优先级选择请求:

优先级一:正在运行的请求——已经在解码中的请求优先级最高。每个解码请求只需要 1 个 Token 的预算,成本很低,但中断它的代价很高(用户会感到输出"卡顿")。

优先级二:等待中的请求——新到达或被抢占的请求。调度器按 FCFS(先来先服务)顺序处理它们。

对于等待中的请求,调度器需要决定本步给它分配多少 Token:

python
# 简化逻辑
def schedule_waiting_request(request, remaining_budget):
    total_tokens = len(request.prompt_tokens)
    already_computed = request.num_computed_tokens

    # 还需要预填充多少 Token
    need = total_tokens - already_computed

    # 在预算范围内尽可能多地处理
    num_tokens = min(need, remaining_budget)

    # 但也不能超过分块大小限制
    num_tokens = min(num_tokens, max_num_batched_tokens)

    return num_tokens

如果一个新请求有 500 个 Token 需要预填充,但当前预算只剩 128,调度器就只给它 128——这就是分块预填充。下一步(或之后几步),再继续处理剩余的 Token。请求的 num_computed_tokens 记录了已经处理到哪里,下次从断点继续。

阶段三:分配 KV Cache

选好请求和 Token 数量后,调度器请求 KV Cache Manager 为每个请求分配所需的块。

分配可能失败——当空闲块不足时。如果发生这种情况,调度器有两个选择:

  1. 不调度新请求——保持现有请求继续运行,等有请求完成释放块后再接纳新请求
  2. 抢占——强制中断一些正在运行的请求,释放它们的 KV Cache 块,给新请求使用

抢占是最后手段。被抢占的请求回到等待队列,之后需要重新预填充(如果它的 KV Cache 块被覆盖了)。V1 中,由于前缀缓存的存在,被抢占的请求在重新调度时可能只需要部分重新计算——如果它的前缀块仍然在缓存中。

3.4 连续批处理的实现

理解了调度决策后,让我们看看连续批处理在整个系统中是如何运转的。

关键观察:

  • 请求随时加入——D 在 Step 2 加入,不需要等 A 完成
  • 请求随时离开——B 在 Step 3 完成,它的位置立刻被 E 占用
  • 预填充和解码混合——每一步都可以同时包含预填充和解码请求
  • 预填充可分块——A 的 384 个 Token 分三步完成(256 + 128 + 进入解码)

这就是连续批处理的威力:GPU 的每一个计算周期都不浪费,总有请求在填满它。

3.5 与 V0 调度器的对比

V0 的调度器(vllm/core/scheduler.py)使用了一个更复杂的抽象——SequenceGroup。每个请求可能包含多个序列(比如束搜索中的多个候选),调度器需要跟踪每个 SequenceGroup 内部的序列状态。

V1 砍掉了 SequenceGroup,只保留了 Request。原因是:

  1. 束搜索在 LLM 服务场景中极少使用——绝大多数请求都是贪心或采样解码,不需要 SequenceGroup 的复杂性
  2. SequenceGroup 增加了调度的认知负担——调度器需要考虑"组内序列共享前缀""组内序列是否都要调度"等问题
  3. V1 将束搜索移到了外部——如果需要束搜索,在 API 层面实现,引擎核心不承担这个复杂性

这个决策体现了 V1 的设计哲学:为 99% 的场景优化,不为 1% 的场景增加所有人的复杂性

3.6 调度策略的调优

调度器有几个关键参数影响行为:

max_num_batched_tokens——每步最大 Token 数。增大这个值可以提高吞吐量(更多请求并行),但会增加单步延迟。对于在线服务,通常设为 2048-4096;对于离线批处理,可以设得更大。

max_num_seqs——最大并发请求数。限制同时运行的请求数量,间接控制 KV Cache 的总消耗。太大会导致频繁抢占,太小会浪费 GPU 资源。

delay_factor——调度延迟因子。当设为非零值时,调度器会稍微推迟新请求的预填充,让更多的解码请求先完成。这可以减少排队延迟,但会增加首 Token 延迟。

这些参数之间存在张力:吞吐量和延迟是天然矛盾的。增大批处理大小提高吞吐量,但每步计算时间变长,所有请求的延迟都会增加。调度器的艺术就在于找到合适的平衡点。

3.7 本章小结

调度器是 vLLM 性能优化的核心战场:

  • 统一 Token 调度——V1 消除了预填充/解码的区分,用 {request_id: num_tokens} 统一描述调度决策,天然支持分块预填充和混合批处理
  • 三阶段调度——预算计算 → 选择请求 → 分配 KV Cache,每一步都在显存、吞吐和延迟之间权衡
  • 连续批处理——请求随时加入和离开,GPU 每一个周期都不浪费
  • 抢占机制——显存告急时,牺牲低优先级请求换取系统稳定性
  • 简化抽象——砍掉 SequenceGroup,为 99% 场景优化

下一章,我们将进入 vLLM 最核心的创新——PagedAttention。这不仅是一个算法,更是一种思维方式:用操作系统的智慧解决深度学习的问题。


源码导航

  • V1 调度器:vllm/v1/core/sched/scheduler.py
  • V0 调度器(对比参考):vllm/core/scheduler.py
  • 调度输出数据结构:vllm/v1/core/sched/output.py
  • Request 类:vllm/v1/request.py

基于 VitePress 构建