Skip to content

约 6 分钟阅读

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。

别急,先让缓存热一下。