连续批处理如何把推理 GPU 的空转时间填满
推理压测里有一种很常见的现象:显存已经占得很高,请求也一直在排队,但 GPU 的计算单元并没有持续吃满。继续加机器只能线性增加成本,单卡吞吐却没有明显改善。
这时先别急着换模型或堆硬件。问题可能出在批处理的调度粒度——服务仍在用“凑齐一批、一起算完、再放下一批”的静态批处理,而自回归解码会让一批请求互相等待。连续批处理(continuous batching,也叫 iteration-level scheduling 或 in-flight batching)要解决的正是这一层浪费。
静态批处理把 GPU 闲置在了哪里
大模型推理通常分两个阶段:prefill 把整段 prompt 并行处理并建立 KV cache,计算密度较高;decode 每次为每个请求生成一个 token,需要串行推进许多步,往往更受显存带宽和调度效率限制。
静态批处理会把 N 个请求凑成一批,一起做 prefill,再一起逐步 decode,直到最慢的请求也生成完才释放整批。假设同一批里有的请求生成 20 个 token 就结束,有的需要 800 个,短请求完成后仍要等待长请求。请求长度差异越大,“陪跑”与 padding 带来的浪费越明显。
因此,“显存占满”并不等于“GPU 被有效利用”。显存可能被 KV cache 和预留空间占住,实际计算单元却在等待批内最慢序列或处理无效 padding。
迭代级调度:把调度粒度从“一批”降到“一步”
连续批处理的核心来自 OSDI 2022 的 Orca:不再以整批请求为调度单位,而是每完成一次前向迭代就重新调度。某个请求生成结束符后立即退出并释放槽位,等待队列中的新请求在下一步补入,批成员可以随每次迭代变化。
Orca 在论文的 GPT-3 175B 实验中,相对当时的 FasterTransformer,在相同延迟水平下报告了最高 36.9 倍的吞吐提升。这个数字不能直接搬到其他模型和硬件上,但它说明迭代级调度可能消除大量结构性空转。
后续 vLLM 把调度器与 PagedAttention 的 KV cache 管理结合。vLLM 论文报告,在相同延迟水平下,相对 FasterTransformer 和 Orca,常见模型的吞吐提升为 2 到 4 倍。收益会随模型大小、请求长度分布、并发度和解码策略变化,所以生产验收应看自己的压测结果,而不是照抄论文倍数。
这里还有一个容易忽略的配套设计。新请求可能在做 prefill,旧请求则在做 decode。Orca 的 selective batching 会对适合展平的算子批量计算,对需要按请求隔离的注意力算子分别处理。
PagedAttention 则把每个请求的 KV cache 切成固定大小的块,按需从全局池分配,块大小由具体实现和配置决定。它避免为每个请求预留一整段连续显存,让请求频繁进入、退出时,KV cache 能更细粒度地回收和复用。
如果正准备把连续批处理的推理服务推上线,可以先用一份覆盖吞吐、尾延迟与显存水位的上线前检查清单核对关键项,再进入压测,而不是等线上 OOM 或 p99 抖动后才补门禁。下面继续看实现骨架和边界。
落地:一个连续批处理调度循环长什么样
抛开框架封装,连续批处理可以抽象成一个每步执行的调度循环:
# 示意代码:每次前向迭代执行一次defschedule_step(running,waiting,kv_pool,token_budget):# 1) 回收已完成请求,释放 KV 块forreqinlist(running):ifreq.finished:kv_pool.free(req.kv_blocks)running.remove(req)emit_response(req)# 2) 显存与 token 预算允许时补入新请求while(waitingandkv_pool.can_allocate(waiting[0])andcurrent_tokens(running)<token_budget):req=waiting.popleft()req.kv_blocks=kv_pool.allocate(req)running.append(req)# 3) 拼成一次前向计算,各请求推进一步logits=model.forward(build_batched_input(running))forreqinrunning:req.append_token(sample(logits[req.id]))returnrunning,waiting第 2 步必须同时受显存预算和 token 预算约束。前者防止 KV cache 被盲目塞满导致 OOM,后者限制一次迭代处理的 token 总量,避免长 prefill 把单步耗时拉高。
以 TensorRT-LLM 为例,当前 API 提供max_batch_size、max_num_tokens和enable_chunked_prefill等参数。下面只是配置结构示意,数值必须通过目标模型、硬件和流量分布压测后确定:
max_batch_size:64max_num_tokens:8192enable_chunked_prefill:true整体流程可以画成:
静态批处理与连续批处理的差异可以这样看:
| 维度 | 静态批处理 | 连续批处理 |
|---|---|---|
| 调度粒度 | 整批请求 | 单次迭代 |
| 短请求退出 | 等最慢请求一起退 | 完成后立即退出 |
| 新请求进入 | 等下一整批 | 下一步即可补入 |
| padding | 通常补齐到批内最长 | 显著减少无效 padding |
| GPU 空转 | 容易陪跑最长序列 | 空槽可及时复用 |
| KV cache | 连续预留易产生碎片 | 分块按需分配 |
边界与取舍:prefill 会影响正在进行的 decode
连续批处理不是没有代价。一个带长 prompt 的新请求进入后,如果整段 prefill 在一次迭代中完成,这一步会明显变重,同批正在 decode 的请求也要等它,表现为 token 间延迟突然上升。
因此,追求新请求的首 token 延迟(TTFT)可能伤害正在生成中的 token 间延迟(ITL/TBT)。只看总吞吐,往往会把这个问题藏起来。
Sarathi-Serve 提出的 chunked prefill 会把长 prompt 切成若干块,分散到多次迭代中处理,再用 stall-free 调度让已有 decode 尽量持续推进。NVIDIA TensorRT-LLM 也提供enable_chunked_prefill,但官方文档明确提醒:开启并不等于已经得到最优性能,仍需结合输入与输出长度调参。
真正要调的是一组互相牵制的旋钮:
- token 预算调大,单步可容纳更多 prefill,吞吐可能提高,但单步耗时和 TBT 可能变差;
- token 预算调小,decode 更平稳,但长 prompt 的 TTFT 可能上升;
- 批上限越高,并发能力越强,但 KV cache 水位和 OOM 风险也更高;
- 上下文越长,单请求占用的 KV cache 越多,可同时容纳的请求越少。
所以连续批处理不是“打开一个开关就会更快”,而是把调度权交给了服务端。业务究竟优先吞吐、TTFT 还是 p99 TBT,必须先写进 SLA,再通过稳定流量分布的压测确定参数。
技术结论
连续批处理提高吞吐,并不是让单次矩阵计算变快,而是把自回归解码中的等待与空槽重新利用起来:调度粒度从整批降到单步,短请求即时退出,新请求及时补位,再由 selective batching 与分块 KV cache 管理托底。
论文结果说明收益可能很大,但任何倍数都只对对应的模型、硬件、并发和长度分布负责。上线验收至少要同时记录吞吐、TTFT、p99 TBT、KV cache 水位和 OOM 次数,并固定压测流量分布。只有这些指标一起过线,连续批处理才算真正把 GPU 的空转时间填满,而不是把延迟问题从一个队列挪到另一个队列。