摘要
大模型推理服务做批处理,几乎所有资料都在说"批越大吞吐越高"。但把服务真正推到线上会发现:低并发时凑批只是在攒延迟,高并发时长尾请求又让批里一半槽位在空转。我用一段可运行的离散事件仿真代码,把静态批处理和连续批处理(continuous batching)放在同一个理想化算力模型下对比,负载率从 0.2 req/s 扫到 1.5 req/s,每种配置跑 3 个随机种子。结论是三句话:批处理的收益只在"算力被填满"时成立;静态批的 padding 空转实测占 GPU 实际周期的 35%~64%,而连续批的浪费体现为引擎空闲,在 1.5 req/s 已降到 0%~1.8%;负载超过 1 req/s 后静态批的中位首 token 延迟会失控到几十秒量级。下面给出完整模型假设、代码、数据和六条避坑清单。
一、为什么值得较真
在线生成服务的成本结构很特殊:一次请求的耗时由"要生成多少个 token"决定,而不是由请求大小决定。这就带来两个方向相反的力:
- 批内并发可以把权重复用率拉满,单 token 成本随批大小下降;
- 批内长度不齐,会让先完成的请求白白占着槽位,或者让整批等待最长的那个。
工程上对应的两条路线:静态批(凑够 N 个请求一起发车,整批同生共死)和连续批(每步重新组批,完成的立刻退出、新到的立刻补位)。后者是现在主流推理引擎的默认形态。我的问题是:这两条路线的差到底出现在哪个负载区间,量级有多大。
二、实验模型与代码
说明在前:这是仿真,不是真机 GPU 压测。目的是剥离硬件细节,把"组批策略本身"的代价量化出来。模型参数全部显式写死在代码里,任何人换一套参数都能复算。
单步耗时采用线性算力受限模型:
T(B) = T_BASE + T_TOKEN × B # T_BASE=15ms 空批固定开销, T_TOKEN=1.2ms 每请求每步增量三条规则决定了两者的差异:
- 静态批:从队首请求到达起,最多等 150ms 或凑满 B 个就发车;发车后整批并发跑
max(批内输出长度)步,短请求提前生成完也要占着槽位空转; - 连续批:每步结束结算一次,输出耗尽的请求立刻退出释放槽位,已到达的新请求下一步立刻补位,并发上限 32;
- 到达过程为泊松,输出长度用直方分布(60/120/200/320/500 五档,权重 0.25/0.30/0.25/0.15/0.05,期望 174 token,实测样本均值 171~181 token/req)。
两个指标口径必须分开,否则会被数字骗:
- 有效吞吐:用户真正拿到的 token/s;
- 算力吞吐:GPU 实际处理的 token/s,静态批包含 padding;
- 两者的差就是浪费。空转槽位%= 1 − 有效/算力。
importrandom T_BASE_MS,T_TOKEN_MS,MAX_BATCH=15.0,1.2,32LEN_LEVELS=[60,120,200,320,500]# 输出长度档位LEN_WEIGHTS=[0.25,0.30,0.25,0.15,0.05]# 对应概率TIMEOUT_MS=150.0# 静态批凑批超时N_REQ=300# 每轮请求数defgen_workload(qps,seed,n=N_REQ):"""泊松到达 n 个请求:[(到达时刻ms, 输出token数)]"""rng=random.Random(seed)reqs,t=[],0.0for_inrange(n):t+=rng.expovariate(qps/1000.0)# 指数分布到达间隔reqs.append((t,rng.choices(LEN_LEVELS,LEN_WEIGHTS)[0]))returnreqsdefstep_time(b):"""批大小为 b 时的单步耗时(理想化线性模型)"""returnT_BASE_MS+T_TOKEN_MS*bdefsimulate_continuous(reqs):pending,active=list(reqs),[]# active: [到达时刻, 剩余token, 是否已出首token]lat,ttft=[],[]now=busy_ms=wall_ms=0.0useful=padded=0whilependingoractive:# 已到达且批未满的请求立即补位added=0whilependingandpending[0][0]<=nowandlen(active)+added<MAX_BATCH:arr,out=pending.pop(0)active.append([arr,out,False])added+=1ifnotactive:# 引擎无事可做,时间跳到下个请求到达gap=pending[0][0]-now wall_ms+=gap# 空闲只计入墙钟,不计入 busynow=pending[0][0]continueb,dt=len(active),step_time(len(active))busy_ms+=dt wall_ms+=dt padded+=b# 本步 GPU 实际算 b 个位置drop=[]fori,rinenumerate(active):arr,got=r[0],r[2]ifnotgot:r[2]=Truettft.append(now+dt-arr)# 首 token 在本步末尾产出r[1]-=1useful+=1# 消耗 1 个有效 tokenifr[1]<=0:drop.append(i)lat.append(now+dt-arr)foriinsorted(drop,reverse=True):active.pop(i)# 完成即退出,槽位下一步就能给别人用now+=dtreturnuseful,padded,busy_ms,wall_ms,ttft,latdefsimulate_static(reqs,B):pending=list(reqs)lat,ttft=[],[]now=busy_ms=wall_ms=0.0useful=padded=0whilepending:start=max(now,pending[0][0])ifstart>now:wall_ms+=start-now# 等请求的空转deadline=start+TIMEOUT_MS batch=[pending.pop(0)]whilependingandlen(batch)<Bandpending[0][0]<=deadline:batch.append(pending.pop(0))# 超时或凑满即发车b=len(batch)max_out=max(r[1]forrinbatch)# 整批等最长的那个dt,dt_total=step_time(b),step_time(b)*max_out busy_ms+=dt_total wall_ms+=dt_total padded+=b*max_out# 含 padding 的算力口径forarr,outinbatch:ttft.append(start+dt-arr)lat.append(start+dt_total-arr)# 整批同一时刻返回useful+=out now=start+dt_totalreturnuseful,padded,busy_ms,wall_ms,ttft,lat主流程按负载率扫描,每个配置跑 3 个种子(101/202/303)取区间。完整脚本含指标汇总与百分位统计,本机 Python 3.11 直接运行,单轮全量扫描几十秒内跑完。
三、实测数据
300 个请求/轮 × 3 轮,全部跑完(含清空积压)。表中区间为 3 轮的最小值~最大值。
| 负载率 | 策略 | 有效吞吐 tok/s | 算力吞吐 tok/s | 空转槽位 | TTFT p50 ms | TTFT p95 ms | 引擎空闲 |
|---|---|---|---|---|---|---|---|
| 0.2 | 连续批 | 32~38 | 32~38 | 0.0% | 16 | 32~33 | 53%~59% |
| 0.2 | 静态 B=8 | 32~38 | 35~43 | 8%~14% | 16~178 | 3789~4527 | 45%~53% |
| 0.6 | 连续批 | 95~113 | 95~113 | 0.0% | 25~27 | 36~38 | 13%~20% |
| 0.6 | 静态 B=8 | 95~112 | 145~215 | 35%~48% | 1697~4033 | 7109~11431 | 1%~9% |
| 0.8 | 连续批 | 127~150 | 127~150 | 0.0% | 28~30 | 39~42 | 5%~11% |
| 0.8 | 静态 B=8 | 124~143 | 233~296 | 47%~54% | 3745~12099 | 11606~27865 | 0%~2% |
| 1.0 | 连续批 | 158~187 | 158~187 | 0.0% | 29~31 | 41~44 | 2%~7% |
| 1.0 | 静态 B=8 | 143~155 | 295~315 | 48%~54% | 6518~33776 | 16062~80758 | 0%~1% |
| 1.5 | 连续批 | 234~277 | 234~277 | 0.0% | 31~36 | 46~52 | 0%~1.8% |
| 1.5 | 静态 B=8 | 152~157 | 317~320 | 51%~52% | 63496~74929 | 99958~151234 | 0%~0.5% |
| 1.5 | 静态 B=32 | 207~215 | 526~571 | 59%~64% | 9393~27303 | 21128~54155 | 0%~0.7% |
同一模型下的单步耗时(决定批大小的边际收益):
| 批大小 B | 单步耗时 | 理论 token 成本 | 相比 B=1 |
|---|---|---|---|
| 1 | 16.2 ms | 16.2 ms/token | 基准 |
| 4 | 19.8 ms | 4.95 ms/token | 省 69% |
| 8 | 24.6 ms | 3.08 ms/token | 省 81% |
| 16 | 34.2 ms | 2.14 ms/token | 省 87% |
| 32 | 53.4 ms | 1.67 ms/token | 省 90% |
四、数据读出的四个结论
1. 低负载时凑批是纯亏。0.2 req/s 时两种策略的有效吞吐完全一样(32~38 tok/s),但静态批的 TTFT p95 是 3789~4527ms,连续批是 32~33ms,差两个数量级。原因很直白:请求稀疏时,150ms 超时窗口几乎每次都触发,批实际只有 1~2 个请求,却额外付出了等待;而排队一旦形成就是乘法放大。
2. 批处理的收益来自填满算力,不来自"批"这个形式。连续批的算力吞吐 = 有效吞吐(空转槽位恒为 0%),因为完成即退出;静态批在 0.8~1.5 req/s 有 47%~64% 的 GPU 周期在算 padding。也就是说:同样一张卡,静态批里近三分之二的算力花在没用的位置上。
3. 批大小不是自由变量,它被输出长度分布锁死。从 B=8 到 B=32,单 token 理论成本只能从 3.08ms 降到 1.67ms(省 81%→90%),边际收益递减;但 1.5 req/s 时静态批的空转槽位从 51% 涨到 64%。真机上同理:vLLM 的max_num_seqs决定同时驻留的序列数,max_num_batched_tokens决定单步 token 总量,后者在启用 chunked prefill 时还会切分过长的 prompt——这两个参数的实际效果就是本文表格里的 B 与长度截断。
4. 静态批的失效是突然的。0.8→1.0→1.5 req/s,静态 B=8 的 TTFT p50 从 3.7~12s 跳到 6.5~33.8s 再到 63.5~74.9s;连续批同期是 28~30 → 29~31 → 31~36ms。连续批把系统推到算力上限(模型算出约 3.44 req/s)之前,延迟几乎是平的。
五、避坑指南
- 不要用"平均输出长度"估容量。直方分布下我设的期望是 174 token,实测样本均值 171~181;换成对数正态(σ=0.6)时样本均值会漂到 300 以上,同一套代码里 0.5 req/s 就已经饱和、TTFT p95 冲到十万毫秒级。做容量规划请用 p95 长度而不是均值——第 1 版实验就是吃了这个亏,负载全部落在饱和区,延迟数字毫无意义。
- 凑批超时不要照抄别人的 150ms。它应该显著小于你的 TTFT 预算。交互对话给 150ms 会常态性拉高首 token;离线批任务可以放到秒级,因为没人在等。
- 连续批的收益在低并发时是零。我的表里 0.2 req/s 两行有效吞吐完全相同。别为了"上连续批"重构服务,先看负载是否长期处在算力不饱和区。
- 长短请求混跑要分池。静态批空转槽位 35%~64% 全部来自批内长度差;把长输出任务(报告生成、推理链)与短输出任务(分类、抽取)拆到不同实例,等价于压低 max_out。
- 队列等待要和客户端超时对齐。排队时间计入 TTFT,1.5 req/s 静态批的 TTFT p50 达 63~75s,而多数 SDK 默认超时是 30~60s,会大量超时后重试,进一步加剧积压——重试风暴的量化影响值得单独压测,本文不复述其数据。
- 上线前先在本地跑一遍这个仿真。代码不足 150 行,几十秒出结果,能在买卡/扩容之前就把"B 调到多少"“要不要连续批”"单实例能扛多少 req/s"三个问题给出量级判断。它不是真机数据,但把方向性错误挡在压测之前。
六、结论
- 批处理的价值在于把固定开销摊薄、把算力填满;填满才是目的,凑批只是手段。
- 同一模型下,静态批在 0.8~1.5 req/s 有 47%~64% 的 GPU 周期花在 padding 上,而连续批把这部分浪费压到 0,代价是引擎空闲时间(1.5 req/s 时仅 0%~1.8%)。
- 负载超过 1 req/s(相对这台理想卡"算力上限 599 tok/s ÷ 平均 174 tok/req ≈ 3.44 req/s")后,静态批的排队延迟呈非线性爆炸,连续批能维持 p50 几十毫秒量级直到接近算力上限。
- 换到真机,绝对数字会变,但三条关系不变:均值长度决定容量上限、长度方差决定浪费比例、凑批等待直接进 TTFT。
数据来源:demos/batching_sim.py与运行留档demos/batching_sim.transcript.txt(本机 Windows + Python 3.11 实测输出,PYTHONIOENCODING=utf-8)。