news 2026/10/14 1:19:10

静态批 64% 的算力在算 padding,连续批把 TTFT 钉在 46ms:同一仿真模型实测两种组批

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
静态批 64% 的算力在算 padding,连续批把 TTFT 钉在 46ms:同一仿真模型实测两种组批

摘要

大模型推理服务做批处理,几乎所有资料都在说"批越大吞吐越高"。但把服务真正推到线上会发现:低并发时凑批只是在攒延迟,高并发时长尾请求又让批里一半槽位在空转。我用一段可运行的离散事件仿真代码,把静态批处理和连续批处理(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 每请求每步增量

三条规则决定了两者的差异:

  1. 静态批:从队首请求到达起,最多等 150ms 或凑满 B 个就发车;发车后整批并发跑max(批内输出长度)步,短请求提前生成完也要占着槽位空转;
  2. 连续批:每步结束结算一次,输出耗尽的请求立刻退出释放槽位,已到达的新请求下一步立刻补位,并发上限 32;
  3. 到达过程为泊松,输出长度用直方分布(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 直接运行,单轮全量扫描几十秒内跑完。

凑满B个或超时

每步重组批

是

否

请求到达
泊松

调度器

整批发车

并发decode max批内长度 步

整批同时返回
短请求槽位空转

步内并发decode 1 token

某请求生成完?

立即退出并释放槽位

三、实测数据

300 个请求/轮 × 3 轮,全部跑完(含清空积压)。表中区间为 3 轮的最小值~最大值。

负载率策略有效吞吐 tok/s算力吞吐 tok/s空转槽位TTFT p50 msTTFT p95 ms引擎空闲
0.2连续批32~3832~380.0%1632~3353%~59%
0.2静态 B=832~3835~438%~14%16~1783789~452745%~53%
0.6连续批95~11395~1130.0%25~2736~3813%~20%
0.6静态 B=895~112145~21535%~48%1697~40337109~114311%~9%
0.8连续批127~150127~1500.0%28~3039~425%~11%
0.8静态 B=8124~143233~29647%~54%3745~1209911606~278650%~2%
1.0连续批158~187158~1870.0%29~3141~442%~7%
1.0静态 B=8143~155295~31548%~54%6518~3377616062~807580%~1%
1.5连续批234~277234~2770.0%31~3646~520%~1.8%
1.5静态 B=8152~157317~32051%~52%63496~7492999958~1512340%~0.5%
1.5静态 B=32207~215526~57159%~64%9393~2730321128~541550%~0.7%

同一模型下的单步耗时(决定批大小的边际收益):

批大小 B单步耗时理论 token 成本相比 B=1
116.2 ms16.2 ms/token基准
419.8 ms4.95 ms/token省 69%
824.6 ms3.08 ms/token省 81%
1634.2 ms2.14 ms/token省 87%
3253.4 ms1.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)之前,延迟几乎是平的。

五、避坑指南

  1. 不要用"平均输出长度"估容量。直方分布下我设的期望是 174 token,实测样本均值 171~181;换成对数正态(σ=0.6)时样本均值会漂到 300 以上,同一套代码里 0.5 req/s 就已经饱和、TTFT p95 冲到十万毫秒级。做容量规划请用 p95 长度而不是均值——第 1 版实验就是吃了这个亏,负载全部落在饱和区,延迟数字毫无意义。
  2. 凑批超时不要照抄别人的 150ms。它应该显著小于你的 TTFT 预算。交互对话给 150ms 会常态性拉高首 token;离线批任务可以放到秒级,因为没人在等。
  3. 连续批的收益在低并发时是零。我的表里 0.2 req/s 两行有效吞吐完全相同。别为了"上连续批"重构服务,先看负载是否长期处在算力不饱和区。
  4. 长短请求混跑要分池。静态批空转槽位 35%~64% 全部来自批内长度差;把长输出任务(报告生成、推理链)与短输出任务(分类、抽取)拆到不同实例,等价于压低 max_out。
  5. 队列等待要和客户端超时对齐。排队时间计入 TTFT,1.5 req/s 静态批的 TTFT p50 达 63~75s,而多数 SDK 默认超时是 30~60s,会大量超时后重试,进一步加剧积压——重试风暴的量化影响值得单独压测,本文不复述其数据。
  6. 上线前先在本地跑一遍这个仿真。代码不足 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)。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 1:19:01

合规游戏技术学习:从内存分析到反作弊原理

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:18:54

TLSR_8258蓝牙中文扫描响应包设置:编码、长度与配置实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:18:51

操作系统文件管理:物理结构与存储空间管理详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:18:31

ntfy自托管通知服务:用curl发送跨平台实时推送

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:18:30

Kubernetes 部署 Apollo 配置中心实战:架构、调优与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:18:03

SPIFFE+Modbus:工业现场零信任身份网关落地实践与踩坑记录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华