09. 连续批处理与调度
第 6 篇算出,Decode 每一步搬一次权重,服务的请求越多,吞吐越高;上一篇的 PagedAttention 又把显存利用率提了上来,能同时装下更多请求。这一篇讨论怎样把这些请求真正凑在一起算:传统的批处理有什么问题,连续批处理怎样做到“有人结束、立刻补位”,调度器每一步怎样挑选请求,以及显存用完时为什么要“抢占”。读完你应该能看懂推理引擎的调度参数,并解释高峰期偶尔出现的长时间停顿。
传统批处理:整批进,整批出
最直接的批处理是静态批处理:攒够一批请求,一起送进模型,等这一批全部生成完,再处理下一批。
这在图像分类这类任务上很好用,每个请求的计算量一样。但大模型的请求长短差异极大:同一批里,有人问“年假几天”,5 个 token 就答完了;有人让助手“总结整本手册”,要生成 800 个 token。
静态批处理会带来两种浪费:
- 短请求陪跑:5 个 token 的请求早就答完了,它的位置却要空着,等 800 个 token 的那个结束,整批才能释放。
- 新请求干等:这一批正在跑的时候,新到的请求只能排队,哪怕批里已经空出了很多位置。
连续批处理:每一步重新组队
连续批处理(continuous batching)的想法很简单:不以“一批请求”为单位调度,而是以“一步”为单位。Decode 本来就是一步一步进行的,每一步结束时,调度器都重新看一次:
- 哪些请求在这一步生成了结束符?把它们移出,释放 KV 块。
- 空出来的位置和显存,够不够放进新的请求?够就把等待中的请求加进来。
这样,短请求一结束,新请求下一步就能补进去,GPU 上的批始终保持满载。这个思路最早由 2022 年的 Orca 论文提出,叫“迭代级调度”(iteration-level scheduling),现在所有主流推理引擎都采用它。
下面用两个工作位模拟:A 只需 2 步,B 需要 6 步。拖到最后,对比两条时间线:
观察批处理的空位全部请求在第 0 轮到达;每步成本相同
整批结束后补入
轮次12345678910111213A · 2B · 6C · 3D · 2E · 4
完成需 13 轮 · 序列槽位利用率 65%
每轮补入新请求
轮次12345678910111213A · 2B · 6C · 3D · 2E · 4
完成需 10 轮 · 序列槽位利用率 85%
灰色虚线为尚未推进的工作。两种方法都完成 17 个输出 token;总轮数差别来自空位能否及时补入。
这不是 GPU 利用率或性能预测:没有计算 prefill、KV 限制、不同 batch 的耗时及抢占。真实连续批处理在迭代边界重组执行集合。
两种方案完成的总工作量相同,连续批处理让新请求更早开始,灰色的空等格子少了很多。真实引擎的“工作位”不是固定的两个,批的大小每一步都可以变化,上限由显存和配置决定。
调度器每一步在想什么
推理引擎的调度器每一步都要回答:这一步让谁参与计算,各算多少个 token。它受三个约束:
| 约束 | 含义 | 常见参数 |
|---|---|---|
| 同时运行的请求数 | 一步里最多有多少个请求 | max_num_seqs |
| 一步的 token 预算 | 一步里最多处理多少个 token(Prefill 的 token 也算在内) | max_num_batched_tokens |
| KV 块 | 空闲块够不够新请求做 Prefill、老请求追加 | 由显存决定 |
第二个约束很重要。一个 Decode 请求每步只处理 1 个 token,一个新请求的 Prefill 却可能一次处理几千个。token 预算限制了每一步的总计算量,避免某一步因为塞进了太多 Prefill 而耗时过长。
这三个数字和“系统里有多少个连接”是不同层次的东西:
| 层次 | 例子 | 决定什么 |
|---|---|---|
| API 连接 | 1000 个用户同时打开了页面 | 系统要接住多少请求、排多长的队 |
| 运行中的请求 | 这一步有 200 个请求在生成 | GPU 这一步同时推进多少个序列 |
| 本步 token 数 | 200 个 Decode + 1 个 2000 token 的 Prefill | 这一步的计算量和耗时 |
接住 1000 个连接,不等于 GPU 同时在算 1000 个请求。多出来的请求在队列里等,队列变长只会让等待变久,不会增加算力。
Prefill 和 Decode 怎样排
新请求要先做 Prefill,正在运行的请求要做 Decode,同一步里两者怎样安排?引擎有不同的策略。
Prefill 优先:只要有等待的请求能放进来,就先做它们的 Prefill,这一步不做 Decode。nano-vllm 就是这样:调度器先尝试安排等待队列,只要安排上了任何一个,这一步就是纯 Prefill 步。好处是新请求的 TTFT 短,批也能更快填满。坏处是正在生成的请求要停下来等,一个长输入的 Prefill 可能让所有人的出字卡顿几百毫秒。
混合调度:同一步里既有 Decode,也有新请求的 Prefill(或者 Prefill 的一部分),在 token 预算内拼在一起。vLLM V1 采用这种方式,配合第 11 篇的分块预填充,既不让新请求等太久,也不让正在生成的请求卡顿。
这两种策略的差别,正好体现了第 5 篇说的吞吐与延迟的矛盾。
显存不够时:抢占
PagedAttention 按需分配块,请求开始时不预留全部空间。这就有一种可能:很多请求同时在生成,块一块块被用掉,到某一步,有个请求要追加新块,却没有空闲块了。
这时调度器必须腾出空间,办法是抢占(preemption):挑一个正在运行的请求,暂时把它赶出去。有两种做法:
- 重算(recompute):直接释放它的全部 KV 块,把它放回等待队列。以后轮到它时,把“原始输入 + 已经生成的内容”当作新的输入,重新做一次 Prefill。
- 换出(swap):把它的 KV 块复制到 CPU 内存,以后再复制回来。
重算浪费了之前的计算,但实现简单,而且 Prefill 是计算受限的,重做一遍比想象中快。换出要占用 CPU 内存和 PCIe 带宽。vLLM 的 V1 引擎只使用重算方式;nano-vllm 也只有重算。
被抢占的通常是最后加入的请求,它已经生成的内容最少,重算的代价最小,也符合先来先服务的公平原则。nano-vllm 的 Decode 调度把这个逻辑写得很直白:
python
while self.running and len(scheduled_seqs) < self.max_num_seqs:
seq = self.running.popleft()
while not self.block_manager.can_append(seq):
if self.running:
self.preempt(self.running.pop()) # 抢占队尾最后加入的请求
else:
self.preempt(seq)
break
else:
seq.num_scheduled_tokens = 1
seq.is_prefill = False
self.block_manager.may_append(seq)
scheduled_seqs.append(seq)对被抢占的用户来说,表现就是回答写到一半,突然停住好几百毫秒,然后继续。这是 P99 延迟变差的常见原因之一。
抢占偶尔发生是正常的,频繁发生说明配置和负载不匹配。引擎的监控指标里通常有抢占次数。常见的缓解办法是:降低同时运行的请求数上限,提高显存利用率,缩短允许的最大上下文,或者用 KV 量化(第 14 篇)让每个 token 占得更少。
排队、优先级与回压
调度不只是 GPU 里的事。高峰期请求持续涌入,超过了系统的处理能力,队列会越来越长,每个人的 TTFT 都在变差。这时有几种手段:
- 准入控制:在入口处就限制,比如超过一定队列长度直接返回“服务繁忙”,或者拒绝超过长度上限的输入。明确的拒绝比让用户等一分钟再超时更友好。
- 优先级:给交互式请求更高优先级,后台批量任务让路。vLLM 支持按优先级调度。
- 回压:让上游(网关、应用)感知到下游已经饱和,主动减速或分流到其他实例。
- 及时取消:用户关闭页面后,请求要立刻从队列和批中移除,释放 KV。
公平与命中率不能只顾一头
后面讲前缀缓存时会看到,有些请求能复用别人算过的 KV,处理起来更便宜。如果调度器总是优先处理这类请求,整体吞吐会更高,但无法命中缓存的请求可能一直排在后面,等很久。SGLang 提供了“最长前缀匹配优先”(lpm)的调度策略,但默认仍是先来先服务;即使选了 lpm,等待的请求超过一定数量时也会退回先来先服务,避免排序本身的开销和饿死问题。
所以调度器的目标不是单一的“吞吐最高”,而是在满足延迟目标的前提下尽量提高吞吐,也就是第 5 篇的 Goodput。
静态批处理要等整批结束才能换人,短请求陪跑、新请求干等;连续批处理以“一步”为单位重新组队,有人结束立刻补位。调度器在请求数、token 预算和 KV 块三个约束下挑选请求,显存不够时用重算或换出的方式抢占后来者。
当大家都带着同一份制度提问时,还有一类浪费:每个人的 Prefill 都在重复计算同样的内容。下一篇讲怎样把算过的前缀留下来给别人用:前缀缓存与 RadixAttention。
