0


持续批处理:在每次迭代里调整批次,让 LLM 服务吞吐量成倍提升

推理过程中的自回归生成(Autoregressive Generation)分为两个阶段:

  • 预填充阶段(Prefill Phase): 一次性处理所有输入 Token,这是第一次迭代。
  • 解码阶段(Decoding Phase): 利用之前所有 Token 的键值对(Key-Value Pairs),处理上一次迭代生成的单个 Token。除第一次迭代外,之后的所有迭代都属于这一阶段。

    批处理(Batching)就是让多个请求同时走完这两个阶段。假设在跑一个 LLM API,同一秒涌进来 8 个用户的请求,一个一个处理显然不划算,把它们打包成一个批次(Batch)一起送进模型,GPU 并行处理比挨个跑快得多。

最简单的做法是静态批处理(Static Batching):把 8 个请求打包在一起,整批从头跑到尾,跑完才接受新请求。GPU 预先给批次里所有请求分配好内存,所有人一起跑,直到全部结束。

静态批处理的问题

静态批处理有个明显的缺陷:请求 1 需要生成 10 个 Token,请求 8 需要生成 500 个 Token,两者打包在同一批里。请求 1 在第 10 次迭代就跑完了,但它占的 GPU 槽位(Slot)没法腾出来,只能空等接下来的 490 次迭代,直到请求 8 结束。8 个请求都这样算下来GPU 大半时间都在闲置。

这就是静态批处理中的掉队者问题(Straggler Problem):最慢的请求决定了所有人的节奏。输出长度事先无法预知,GPU 利用率因此长期偏低,朴素的服务系统即便在高负载下,利用率也常常只有 20%–30%,剩下的时间全在空转。

像一家餐厅:整桌人得等最慢的那位吃完,新客人才能入座,可座位却空出一半。

持续批处理:迭代级别的调度

为了解决这个问题,持续批处理(Continuous Batching)出现了。核心思路是:每一次前向传播(也就是每次迭代)都重新评估整个批次,而不是把批次锁死到所有请求都跑完为止。

某个请求一结束,占用的槽位立刻腾出来,队列里的新请求下一次迭代就能补上,没有等待,没有空闲槽位。批次的组成每一步都在变:调度器(Scheduler)检查哪些请求跑完了(碰到结束符 End-of-Sequence Token),再看有没有足够的空闲 KV Cache 内存容纳新请求,两个条件都满足,新请求下一次迭代就加进来。

比如说有8 个槽位,队列里 20 个请求在排队。

静态批处理会先填满 8 个槽位,等全部跑完。请求 1 第 10 次迭代就结束了,槽位却要一直空着,直到请求 8 在第 500 次迭代完工,白白浪费 490 次迭代的 GPU 时间守着一个空位。要是提前跑完 6 个,剩下 2 个还没结束,GPU 利用率就只剩 25%。

如果用持续批处理的话,槽位 3 在第 47 次迭代结束,第 48 次迭代新请求立刻补位,GPU 不会闲下来。20 个请求全都跑得更快,因为 GPU 始终满载。

静态批处理 对比 持续批处理

Orca 论文 2022 年首次提出这个思路,相比静态批处理最高做到 36 倍的吞吐量提升,仅仅是把批次重评估的频率从"等所有人跑完"改成"每次迭代都看一眼"。虽然实际收益取决于工作负载、请求长度分布、模型大小、硬件这些具体条件,但方向上的提升是普遍成立的。

它与 KV Cache 的关系

持续批处理里,每个请求都要有自己的 KV Cache 块(Block),这些块随着每次解码迭代不断变大——请求生成的 Token 越来越多,需要的块也越来越多。请求不断加入、离开,各自的 KV Cache 也跟着增长,这是一场内存管理的噩梦。

如果没有 PagedAttention就得给每个请求预先分配一大块连续内存,按最坏情况的输出长度留够,以防万一,可事先根本不知道每个请求会生成多长。要么分配过多、浪费内存,要么分配不够、直接崩溃,两者都不理想。内存一旦耗尽,批次就没法保持满载,持续批处理的意义也就没了。

PagedAttention 的做法是按需分配固定大小的小块 KV Cache:不用提前预留,也不需要连续内存。请求每多生成一个 Token,就分给它一个新块;请求跑完,这些块立刻腾出来给下一个请求用。

PagedAttention 和持续批处理正是为此绑定设计的:后者管请求什么时候进入、离开批次,前者管这些请求的 KV Cache 存在内存的哪个位置,一个管时间,一个管空间。少了任何一边,另一边都发挥不出全部效果。

吞吐量与延迟

持续批处理不是没有代价。批次大小(Batch Size)不能一味往上堆,中间有个实实在在的权衡值得弄清楚。

8 个槽位同时跑 8 个请求,每个请求都要跟另外 7 个抢 GPU 的计算周期,单个请求跑完的时间自然比独占 GPU 时更长。批次越大,吞吐量越高(所有用户加起来每秒处理的 Token 数更多),但单个请求的延迟也会跟着涨。

批次大小如果设成 2,每个请求分到的 GPU 资源更多,跑得更快,但会空出 6 个槽位——延迟低,吞吐量差。批次太小还有另一个问题:GPU 利用率低到连内存带宽都压不满,性能在另一个维度上白白流失。

所以中间有个最优点(Sweet Spot),并且大多数生产系统靠实测去找这个点。

该优化哪一个,看应用场景。服务成千上万用户的聊天机器人,吞吐量优先,用户能接受响应慢一点,前提是系统在高负载下不崩。实时代码补全这类对延迟敏感的应用,则宁可牺牲一些吞吐量,换单个响应足够快。

多数生产系统会把这个参数暴露出来,让用户按自己的延迟预算设定目标批次大小,剩下的交给调度器。

应用场景

如今持续批处理是每个成熟推理系统的标配:vLLM 有,TensorRT-LLM 有,SGLang 也有。这个思路最早来自 2022 年的 Orca 论文,一年之内主流服务框架几乎全部采纳。生产环境的 LLM 推理如果还没上持续批处理,基本等于把大部分 GPU 算力扔在地上——20%–30% 的利用率和接近 100%,差距就这么大。

总结

这就是持续批处理——理解之后会让人觉得"这不是理所当然的吗"的那种想法。核心不复杂:每次迭代重新评估批次,把跑完的请求立刻换出去,让 GPU 始终满载。虽然看着简单,但它改变了如今每一个推理系统的构建方式。

PagedAttention 让内存使用保持高效,批次才能真正填满,没有它,GPU 内存会在持续批处理发挥作用之前就先耗尽。持续批处理让 GPU 保持忙碌,PagedAttention 省下来的内存才能变成实打实的吞吐量。推测解码叠加在这一切之上:用一个草稿模型(Draft Model)提前猜后面的内容,从每次前向传播里多挤出一些 Token。这三样东西没有一个是孤立存在的:拿掉任何一个,另外两个的效果都要打折扣。三者协同现代服务系统才能在不烧穿 GPU 预算的前提下扛住海量并发。

作者:Vedanti

“持续批处理:在每次迭代里调整批次,让 LLM 服务吞吐量成倍提升”的评论:

还没有评论