GPU 擅长的是大规模并行计算,但自回归解码每一步只算一个 token——这时瓶颈完全不在算力,而在memory bandwidth(显存带宽):为了算这一个 token,要把整个模型的权重从显存读进计算单元一遍。读几十 GB 权重、只做一个 token 的计算,GPU 的算力单元大部分时间在等数据。
批处理正是针对这个结构:权重只读一遍,同时为 batch 里的所有序列各算一个 token。读权重的成本被摊薄到整批请求上。这就是为什么在解码阶段,把 batch size 从 1 提到 32,吞吐几乎线性上升而单请求延迟只略微变差——你在填满本来就空转的算力。
最朴素的做法叫 static batching(静态批处理,也叫 request-level scheduling):攒够一批请求,一起送进去,等整批全部生成完毕,再取回结果、接下一批。问题出在 LLM 的一个特性上——你事先不知道每条请求要生成多少个 token。
于是必然发生这件事:批里有的请求生成 20 个 token 就结束了,有的要生成 800 个。先结束的那些请求,它们在批中的槽位并不会被释放,只能陪着最长的那条一直空转到最后。批越大、长度方差越大,浪费越严重。更糟的是,排队中的新请求必须等整批做完才能开始,队首阻塞(head-of-line blocking)直接推高了 P99 延迟。
continuous batching(连续批处理,论文中称 iteration-level scheduling,迭代级调度)的改动只有一句话:调度器不再以「一个请求」为单位,而是以「一次 forward 迭代」为单位。每完成一次迭代,调度器就重新审视当前批次——已经生成完 EOS 的序列立即离场并归还资源,等待队列里的新请求马上补进来。
这个改动之所以能落地,还需要解决一个技术障碍。批处理要求张量形状一致,但批里各条序列的长度各不相同,attention 的计算无法简单地堆在一起。Orca 的答案是 selective batching(选择性批处理):只对与序列长度无关的算子做批处理(线性层、LayerNorm 等——它们逐 token 独立计算),而 attention 部分按序列单独处理。这样批处理的收益(摊薄权重读取)几乎全部保留,同时允许批内序列长度自由不同。
连续批处理和 1.2 的 PagedAttention 是天生一对:迭代级调度让请求频繁地进出批次,如果显存仍按最坏情况连续预分配,腾出的槽位根本没法被灵活复用。分页负责「显存能随时按需分配和归还」,连续批处理负责「一有空位就立刻填上」——两者叠加才是现代推理框架的默认形态。
连续批处理不是免费的。新请求加入时要做 prefill(一次性处理它的全部输入 token),这是一个计算量大得多的操作。当它和其他请求的 decode 步骤挤在同一次迭代里,批内所有正在解码的请求都会被拖慢——用户看到的是「输出突然卡顿一下」。
这就是 2.1 节 要讲的 TTFT 与 TPOT 之间的张力:优先安排 prefill,新请求首字更快、老请求吐字变卡;优先保护 decode,反之。业界常见的折中是 chunked prefill(分块预填充)——把长 prefill 切成小块,分摊到多次迭代中,用少量 TTFT 换取平稳的 TPOT。没有哪个选择是对的,只有和你的 SLA 匹配的选择。
首尔大学与 FriendliAI 团队提出迭代级调度 + 选择性批处理,并与当时的主流实现 NVIDIA FasterTransformer 做了对比。在 GPT-3 175B 模型上:
务必连同条件一起记住这个数字:它是「把延迟固定在 190ms 这一水平时,两者各能做到多少吞吐」的比值,在 GPT-3 175B 这一特定模型上测得,基线是 2022 年的 FasterTransformer。换一个模型规模、换一个延迟目标、换一份请求长度分布,倍数就完全不同——真正可迁移的结论不是「36.9 倍」,而是「当请求的生成长度高度不均时,调度粒度的浪费可以大到一个数量级」。
今天几乎所有主流推理框架都默认开启连续批处理,所以这个提升对你多半已经内建在框架里了。它的现实意义在别处:如果你自己写推理循环、或用一个只做 static batching 的老框架,你可能正在把一个数量级的吞吐扔在地上。
默认已开启:vLLM、SGLang、TGI、TensorRT-LLM 均默认使用连续批处理,无需手动开关(其中 TGI 已进入维护模式,见附录 A)。你要调的是它的边界参数。
vLLM:--max-num-seqs 限制批内最大并发序列数;--max-num-batched-tokens 限制单次迭代的 token 预算(同时影响 prefill 切块大小)。前者控并发,后者控单次迭代的耗时上限,是 TTFT/TPOT 平衡的主要手柄。
chunked prefill:vLLM 等框架已支持并在多数版本中默认启用,用于抑制长 prefill 对解码流畅度的冲击;其思想来自 Sarathi / Sarathi-Serve 的工作。若你的场景是长输入 + 流式输出,这是首先要确认已打开的选项。
压测要点:只测平均吞吐会掩盖问题。至少要用真实的长度分布压测,并同时观察 TTFT 与 TPOT 的 P50/P99——连续批处理的收益恰恰体现在长度方差大的负载上,用等长请求压测会得到误导性的结论。
整批的持续时间由最长的那条决定 = 700 次迭代。总槽位×迭代 = 8 × 700 = 5600。
实际有效 = 30+40+50+60+700+80+90+100 = 1150。
空转比例 = 1 − 1150/5600 ≈ 79.5%。也就是说近八成的计算槽位在陪跑。
这道题的意义:注意浪费几乎完全由那一条 700 token 的长尾请求造成。这解释了为什么「聊天服务」(长度方差极大)是连续批处理收益最大的场景,而「固定长度的批量分类任务」几乎没有收益——和 1.2 的判断模式一模一样:不确定性越大,按需调度的价值越高。
因为解码是 memory-bandwidth-bound(受显存带宽限制)而非 compute-bound。每一步都要把全部模型权重读一遍,这个成本与 batch 大小无关;批内每条序列的实际计算量很小,GPU 算力有大量余量。所以增加 batch 基本只是「让同一次权重读取服务更多请求」,吞吐涨、单请求延迟几乎不变。
失效的两个边界:① 显存墙——batch 越大,KV cache 占用越多(1.2),撞到显存上限就再也加不上去;② 转为算力受限——batch 足够大后,计算量累积到能吃满算力单元,此时继续加 batch,延迟开始明显上升,吞吐增长趋于平坦。真实系统几乎总是先撞上①。
最可能是 prefill 打断 decode:有长输入的新请求加入批次时,其 prefill 计算量远大于一次 decode 迭代,导致批内所有正在流式输出的请求在那一次迭代上被拖慢,用户观感就是「卡一下」。平均吞吐看不出来,因为总 token 数没变。
验证方法:不要看平均值,看 TPOT 的分布(token 间隔时间的 P99 / 最大值),并把它与新请求到达、输入长度做时间对齐。如果卡顿时刻总是与长输入请求入批重合,即可确认。
缓解:开启 chunked prefill 把长 prefill 切块摊平;或对超长输入请求做单独的队列 / 实例隔离,避免它们污染交互式流量。这是典型的用一点 TTFT 换 TPOT 平稳性的取舍(见 2.1)。
正交但互补:连续批处理解决时间维度的浪费——槽位被已完成的请求占着空转;PagedAttention 解决空间维度的浪费——显存被按最坏情况预留却装不下数据。
只做连续批处理:调度器想让新请求立刻补位,但每条请求仍需一段按最大长度预留的连续显存,能同时驻留的序列数被死死限制住,「有空位也塞不进来」,收益大打折扣。
只做分页:显存利用率上去了,能同时驻留更多序列,但整批仍要等最长的请求结束才能换人,腾出来的显存没有调度机制去及时使用。
所以现代框架必然两者同时具备。面试时能说清「一个管时间、一个管空间,且互为前提」,就已经答到点子上了。