3017 字
约 10 分钟
15
S23-B03 · 吞吐与延迟:批处理的基本权衡

推理服务里最常被问的两个数,是"能扛多少"和"多快返回"。这两个数在物理上是对立的:想扛得多,就得把更多请求塞进同一批一起算;塞得越多,单个请求等得越久。

入门阶段最容易犯的错,是把这两件事当成两个独立旋钮——调大并发提吞吐、调小批降延迟,看起来很合理。但真实系统里它们通过同一份显存、同一条带宽、同一个调度器连在一起,动一个必然带另一个。这篇把这条耦合关系拆开,并给出找拐点的方法。


1. 批处理的收益来源与代价

收益来自硬件特性:decode 每一步都要把权重读一遍,只产出 1 个 token。如果只服务 1 条请求,读一次权重换 1 个 token;如果同时服务 32 条请求,同一次权重读取换 32 个 token。读的量不变,产出翻了 32 倍,这就是批处理的全部收益来源。

代价来自"批"的定义:一批要等齐、要一起算完、要一起出结果。

项 收益 / 代价 具体表现
权重读取 收益 摊薄到多条请求,吞吐近似线性上升(未饱和前)
算力利用率 收益 从"内存等待"变成"算力忙碌"
完成时间 代价 同批内最慢的请求决定整批完成时间
显存 代价 每条请求都要占 KV,批越大占越多
准入 代价 批满时新请求只能排队
批处理的净收益 ≈ (批内 token 产出总量) / (该批的墙钟时间)
              ≠ 单请求速度 × 批大小     # 忽略了等待与尾部延迟

2. 静态批与连续批处理

维度 静态批(static batching) 连续批(continuous batching)
组批时机 凑齐一批才开始,跑完才换批 每一步都重新组批,完成的立即退出
等待行为 后到的请求等整批跑完 有空位就补进来
木桶效应 严重:等最长的那条 大幅缓解:短请求先走
实现复杂度 低 需要引擎支持
适用 离线批处理、长度整齐的任务 在线服务,长度分布不可控

静态批的问题可以用一条时间线看清:

静态批(两条短请求陪一条长请求跑完全程):
req A(短) ────┐
req B(短) ────┤ 全部等到最长的那条结束才返回
req C(长) ────────────────┘

连续批(每条完成即退出,空位立刻补新请求):
req A(短) ────┘
req B(短) ────┐
req C(长) ────────────────┘
req D(新)      └── 补进 A 让出的位置

所以连续批处理解决的不是"算得更快",而是不让短请求陪跑。它对吞吐的提升来自减少空转(padding 与空位),对延迟的提升来自消除无谓等待。

3. batch size 与两个延迟指标的拉扯

批大小 吞吐 TTFT TPOT 显存占用
很小 低(读一次权重换 token 少) 好 好 低
适中 接近线性上升 可接受 可接受 中
很大 趋于平缓(进入算力/带宽饱和) 变差(排队 + prefill 更重) 明显变差(每步要算更多) 高,逼近 OOM

机制要说清:批变大时,单步 decode 要处理的隐藏状态变多,单步耗时上升,于是 TPOT 变差;同时批满导致新请求排队,于是 TTFT 变差。吞吐的上升却有天花板,因为带宽和算力总有一个先饱和。

单步 decode 时间 ≈ 权重读取时间 + 批内注意力与矩阵乘时间 + 固定开销
                   # 批很小时第一项主导,批很大时后两项主导

这就是"批大小有最优值"的物理原因:小批时瓶颈是权重读取(加批几乎免费);大批时瓶颈变成计算与显存(加批开始收费)。最优值就在两条曲线的交点附近,不是越大越好。

4. 并发数与吞吐不是线性关系

并发(同时处理的请求) 实际吞吐 单请求延迟 说明
低于批上限 随并发近似线性上升 基本不变 还有空位,加进来几乎免费
接近饱和 上升明显变慢 开始变差 带宽/算力开始抢
超过饱和 几乎不涨 快速恶化 排队变长,吞吐不再增加
远超饱和 可能下降(调度与换出开销) 不可用 无谓的上下文切换与重算

饱和之后的加并发不叫"提升吞吐",叫"加深队列"。区分方法:看吞吐曲线是否还在上升——平了就说明加的东西只是排队。

一个常用的换算关系(Little 定律的直觉版):

平均在系统内的请求数 ≈ 到达率 × 平均停留时间
# 想同时要"高到达率"和"短停留时间",只能提高服务能力,没有第三条路

推论很直接:如果服务率固定,到达率一超过它,队列长度就随时间线性增长,然后超时、然后失败。排队论里这叫不稳定状态,工程上叫"雪崩前的最后十分钟"。

5. 三个可调参数与它们的相互约束

参数 管什么 调大之后 与谁冲突
最大批大小(max_batch_size) 一步最多算多少条请求 吞吐升、TPOT 差 显存(KV 与激活)
最大批 token 数(max_num_batched_tokens) 一步最多算多少 token 吞吐升、prefill 更重 显存(激活峰值)、TTFT
最大并发序列数(max_num_seqs) 同时在系统里的请求上限 吞吐升到饱和、排队变长 KV 显存、延迟目标
# vLLM 侧三个上限(名字与默认值按引擎版本核对,不同版本有差异)
llm = LLM(
    model="MODEL_PATH",
    max_model_len=MAX_LEN,          # 决定单请求 KV 上限
    max_num_seqs=MAX_SEQS,          # 同时处理的序列数
    max_num_batched_tokens=MAX_TOKENS,  # 单步 token 预算,含 prefill
    gpu_memory_utilization=0.90,    # KV 可用的显存比例
)

三者的关系是互相封顶:批大小受 token 预算限,token 预算受激活显存限,并发数受 KV 显存限。调其中一个而不动另两个,往往只是把瓶颈挪到另一处。常见组合错误是:把 max_num_seqs 调得很高,但 max_num_batched_tokens 很小,结果请求全在排队,吞吐一点没涨。

6. 延迟目标的两种表述

口径 含义 用途 陷阱
P50 一半请求快于这个值 看"典型体验" 会掩盖长尾,通常显著乐观
P95 / P99 95%/99% 的请求快于这个值 容量规划、SLO 采样不足时不稳定

P99 才决定体验,原因是用户不会平均地感受延迟:一个用户只要遇到一次 30 秒的等待,就会认为服务坏了;而 P50 好看完全不影响这次遭遇。所以延迟 SLO 应该写成"P99 TTFT < X 毫秒",而不是"平均 TTFT < X/2 毫秒"。

同时要注意长尾的来源不止排队:超长 prompt、KV 换出重算、GC 式的显存整理、上游限流重试,都会制造尖峰。只看均值这些全都看不见。

7. 怎么找自己服务的拐点

方法固定四步,不要靠猜:

  1. 固定负载模型:输入长度分布、输出长度分布、到达方式(闭合/开放)先定死。
  2. 阶梯加压:并发从低到高按固定步长往上推(例如 1→2→4→8→16→32),每级跑够时间,级间留恢复间隔。
  3. 每级记录三个数:吞吐、P50 延迟、P99 延迟。
  4. 找交叉点:吞吐曲线开始变平、同时 P99 开始陡升的那一级,就是拐点。拐点之前的并发是安全区。
# 示意:只看形状不看具体数值,具体数字必须自己压出来
并发   吞吐       P99 延迟
 1    低         稳定
 4    明显上升    稳定
 8    上升变慢    开始抬头     ← 接近拐点
16    基本不涨    陡升         ← 已过拐点,加的是队列
32    可能下降    不可用

一个必须提醒的坑:闭合压测会低估排队。闭合模型里客户端只有固定线程,服务慢下来客户端也慢,压力自动变小;开放模型(按固定速率打)才暴露真实的排队增长。生产容量按开放模型的结果规划更安全。

8. 四个常见误解

误解 实际
加并发就加吞吐 饱和点之后只增加排队与延迟,吞吐不变
只报平均延迟 均值掩盖长尾;体验由 P99 决定
用一个 batch size 应付所有场景 交互式与批处理的最优值不同,甚至同一服务内按流量分时段也不同
忽略 prefill 与 decode 的混合调度 长 prompt 的 prefill 会挤占 decode 的步进节奏,造成 TPOT 抖动

9. 排错:五种吞吐/延迟异常

现象 原因 处理
吞吐低但 GPU 利用率不高 批太小、请求串行 开连续批处理,提高批上限
吞吐上不去,GPU 利用率很高 已饱和,算力或带宽打满 这是上限;要更好只能加卡或量化
TPOT 抖动大,平均值还行 prefill 插入抢占步进 限制单步 token 预算,或分离 prefill 与 decode
并发加了一倍,吞吐只涨一点 已过拐点 回退到拐点前的配置
压测很好,线上很差 闭合压测 + 长度分布失真 用真实流量重放,改开放模型压测

10. 边界:这些结论什么时候不适用

场景 说明
离线批处理 没有人在等,可以牺牲全部延迟换吞吐,拐点概念不重要
单请求、低 QPS 永远到不了饱和,批处理收益接近于零,别过度配置
输出极短(如分类) 主要成本在 prefill,decode 批处理的结论要重新推
多副本部署 单副本拐点不等于全局上限,要按总吞吐与负载均衡一起算

11. 自测

  1. 批处理的收益到底来自什么硬件特性?为什么批很小时加批"几乎免费"?
  2. 连续批处理解决的是"算得更快"还是"不让短请求陪跑"?两者对指标的影响有何不同?
  3. 为什么批大小有最优值,而不是越大越好?两条曲线的交点代表什么?
  4. 吞吐曲线变平之后再提高并发,系统处于什么状态?用户会看到什么?
  5. 为什么延迟 SLO 要写 P99 而不是平均值?长尾通常由哪些原因造成?
  6. 阶梯加压找拐点,为什么闭合模型的结果会低估真实排队?
S23-B03 · 吞吐与延迟:批处理的基本权衡
https://www.clxhxhhr.top/posts/5997/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。