ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI模型推理批量执行优化:从动态批处理到压测调优全解析

AI模型推理批量执行优化:从动态批处理到压测调优全解析

做AI推理性能优化越久,越觉得很多人把力气花错了地方。模型压缩、量化、蒸馏这些手段当然重要,但真正上线后拖垮你系统的,往往是看起来不起眼的执行层——请求一个一个来,就一个一个算,GPU明明很强,却硬生生被用成了“大型单核处理器”。我在实际项目里见过太多团队:训练的时候烧钱烧时间把模型调得很好,一搬到线上推理服务,吞吐量上不去,延迟又不稳定,最后查下来,根因几乎都在“批量执行”这一环——没有合理的批次调度、batch size随便填、排队策略混乱。这篇就把“AI模型推理的批量执行优化方案”完整展开,从批量为什么能提速,到动态批处理的核心参数怎么选,再到一套可以直接复用的压测调优流程和常见问题排查,适合正在做模型服务化落地、推理加速或者准备给推理服务做压测优化的算法工程师和后台开发。

1. 批量执行优化到底在解决什么问题

1.1 从单请求串行到批量并行的转变逻辑

模型推理的耗时不是一个均匀的整体,拆开看大致有三段:输入预处理、模型前向计算、输出后处理。前向计算内部又由大量算子组成,每个算子启动时都有固定的加载和内存搬运开销。单条请求走一遍时,这部分固定开销占总耗时的比例并不低,尤其是小模型、短输入的场景下,算子启动甚至可能占据一半以上的时间。很多刚开始做推理优化的人会盯着模型结构算FLOPs,却把每次请求重复支付的“启动成本”忽略了。

批量执行的核心逻辑,就是把多条请求合并进同一个batch,让前向计算一次性读入多份数据,用batch维度同时算完。这样固定开销被多条请求摊薄,显存访问更集中,算子之间也能复用中间结果,单位时间内能处理的请求数自然就上去了。用一个生活化的类比:快递员送件,如果单趟只送一单就回站点再出发,和我同一路线一次带十单,差距不是简单的十倍问题,而是来回路程和交接这些固定成本被分批摊掉之后,整体效率大幅提升。

很多人以为“那我把batch size调大不就得了”,真正落地时才发现请求是零散到达的,不是整齐排队等你攒。如果固定等一个batch攒满了才跑,第一条请求的延迟会被拉高;如果来一条就跑一条,又退化成串行处理。想要同时兼顾高吞吐和可控延迟,必须引入动态批处理机制,这也是整篇内容里最核心的部分。这里先按下不表,后面会用一整节讲透。

1.2 延迟和吞吐的权衡:先把这笔账算清楚

批量执行最核心的权衡发生在延迟和吞吐之间,而且这笔账是可以算出来的。假设单张卡处理一条请求需要10ms,这是batch等于1时的基线。把batch调到8之后,由于算力利用率变高,总计算时间往往不是80ms,而是30~40ms,平均到每条请求上就是4~5ms,吞吐量几乎翻倍。这就是批量执行的经济账,也是为什么几乎所有生产级推理服务都必须做批处理的原因。

但这里藏着一个容易被忽略的陷阱:动态批处理下,请求实际等待时间等于排队时间加上批量计算时间。排队时间取决于攒批策略,批量计算时间取决于batch size。如果业务是实时交互型,比如语音助手、在线对话,盲目放大batch必然引入不可接受的排队等待,用户侧体感就会变成“转圈圈”或“卡顿”。如果是离线批处理任务,比如批量审核、离线特征计算,单条延迟根本不重要,完全可以牺牲延迟去换吞吐。

所以批量执行优化的本质,是找到适合业务场景的平衡点。在线延迟敏感型任务,每批4到16往往比较合适;离线批量计算,32、64甚至更大才值得考虑。另外一个容易翻车的点:batch增大后尾部延迟(P99)通常明显上涨,因为总计算时间变长,排队最久的请求等待时间也变长。这个规律在实际压测中几乎每次都会出现,看到P99上涨不要慌,先用业务容忍阈值来判断能不能接受,而不是无条件追求低延迟。

2. 动态批处理:核心机制、参数与实现思路

2.1 动态批处理的工作流程拆解

动态批处理(Dynamic Batching)是现代推理服务框架普遍具备的能力,工作流程大致分四步:请求进入调度队列、攒批窗口等待、触发条件判断、批量前向计算与结果分发。

第一步是请求接收。外部发来的推理请求统一收进队列,这一步通常要带上原始请求id,后面返回答复时才能正确拆包。第二步是攒批窗口,调度器按时间窗口或请求数量进行等待,窗口内到的请求都算作候选。第三步是触发条件判断:当队列里凑够预设的max_batch_size,或者等待时间达到上限,就把候选请求打包成一个batch。第四步是前向计算和结果分发,一次推理跑完,按原始请求id把结果一对一返回。

工程上最关键的细节是“怎么攒批”。一种做法是固定窗口,每隔N毫秒强制触发一次,窗口内积了多少请求就处理多少;另一种是条件触发,队列里的请求数达到max_batch_size就立刻触发,不用等满窗口。成熟方案通常把两者结合:达到数量上限立即触发,达到时间上限强制触发,两者取先到者。这样高并发时能保证最大吞吐,低并发时又能守住延迟下限。

我写过一个简化版调度伪代码,可以很直观地展示这个逻辑:

class DynamicBatcher: def __init__(self, max_batch_size, batch_timeout_ms): self.queue = [] self.max_batch_size = max_batch_size self.batch_timeout_ms = batch_timeout_ms async def submit(self, request): future = asyncio.Future() self.queue.append((request, future)) if len(self.queue) >= self.max_batch_size: await self.flush() return future async def timer_loop(self): while True: await asyncio.sleep(self.batch_timeout_ms / 1000) if self.queue: await self.flush() async def flush(self): batch = self.queue[:self.max_batch_size] self.queue = self.queue[self.max_batch_size:] results = await model.batch_infer([req for req, _ in batch]) for (req, fut), result in zip(batch, results): fut.set_result(result)

两个容易被忽略的工程细节。第一,请求自身的超时时间必须大于批处理排队时间,否则请求还在队列里等着凑批,客户端那边就已经判死重试了,服务端白算一遍。第二,不同输入长度拼进同一个batch时,框架通常要做padding对齐,短样本会被补到和最长样本一样长,这会产生大量无效计算。输入长度差异大的业务,最好在入队前先做长度分桶,让相近长度的样本进同一批。

2.2 max_batch_size 到底怎么定:估算公式与实测校正

max_batch_size是动态批处理最核心的参数,决定一次前向计算最多处理多少样本。这个值不是拍脑袋定的,主要受显存、计算延迟和业务容忍度三方面约束。

先给一个显存维度的估算公式。前向推理时显存占用大致由模型参数、激活值和输入输出缓冲区三部分组成。参数显存基本固定,激活值大致和batch size成正比。粗略估算公式:

  • batch_max = 可用显存(字节)÷(单样本峰值激活值字节数 × 安全系数)

安全系数我习惯取1.5到2,因为框架、上下文缓存、临时张量都会额外吃显存。举个例子:某模型单条样本的最坏激活值约384MB,可用推理显存24GB,那么batch上限大概是24×1024÷(384×1.5)≈42,稳妥起见设定成32,把余量留给后处理和系统开销。

计算延迟也要算进去。batch size翻倍后,单样本平均计算时间会先下降后上升,原因是算力利用率从低到高再到饱和,带宽和计算密度到达瓶颈后,继续增大batch只是提高绝对耗时,单样本收益趋近于零。我见过不少团队卡在“公式算出来64,实际16就跑不满”的尴尬局面,多半是忽略了padding开销和算子并行效率。公式只是粗算,最终一定要拿压测数据校正。

2.3 排队窗口、超时与队列长度的搭配策略

排队窗口(batch timeout)决定一只batch最多等多久,直接影响尾延迟。在线实时场景,我建议窗口控制在10到25毫秒,且必须远小于请求自身的超时时间。举个例子:请求超时500ms,batch窗口定为20ms,那么排队最多积累约20ms再加上一次batch计算时间,对整体延迟的污染可控。如果窗口设成200ms,并发一低,单条请求可能在队列里白等200ms,用户直接感知到“慢”。

离线场景策略完全不同。离线批处理任务里请求几乎同时到达,窗口可以设得很大,比如一秒以上,目的是尽量把同批数据聚合,减少推理引擎空转次数。这时候关注总吞吐而不是单条延迟,batch窗口再长都没关系。

还有一个常被忽略的参数:最大排队长度。没有这个限制时,突发流量会让请求无限堆积,内存持续上涨,响应持续恶化,最终拖垮服务。我习惯给队列设置显式上限(比如2000条),超过上限直接拒绝新请求或返回降级结果,让上层重试或降级。这种“快速失败”策略在高QPS场景下,比让请求在队列里硬等十秒要健康得多。要记住,真正伤害系统的不只是慢,还有一堆半死不活的请求占着队列资源不放。

3. 显存、算子与引擎:批量化的落地约束

3.1 显存占用随 batch 变化的真实曲线

很多人以为显存只和模型参数大小有关,模型多大显存就多大,这是我在带新人时反复纠正的误区。前向推理时,每一层算子的中间激活值都要暂存在显存里,这部分对batch size是线性敏感的。以Transformer类模型为例,注意力模块的中间张量维度一般是[batch, seq_len, head_dim],batch翻倍,这部分显存占用直接翻倍,动态shape输入还会加剧这种情况。

更麻烦的是,部分推理框架对动态输入维度会做预分配。如果服务上开放了动态输入长度,框架常常按可能出现的最大shape预留显存,这时候batch和显存的关系就不再是线性,而是按“最大可能值”跳变。所以我在评估batch上限时从来不看平均激活值,而是看最坏输入长度下的峰值占用。

实际操作里看显存占用,不建议只盯监控面板的“当前显存”。推理框架内部有显存池和缓存机制,当前占用不代表真实峰值。更稳妥的做法:把batch逐渐抬高,同时固定跑一组代表性样本,观察显存监控的稳定读数,连续三轮都稳定不溢出,才认为这一档batch是安全的。

3.2 算子级别的批量收益并不均匀

批量执行的收益并非在所有算子上均匀分布。对矩阵乘法这类计算密集型算子,批量收益最明显——batch维合并后等价于一次更大的矩阵乘,GPU计算密度显著提高。对层归一化、激活函数这类逐元素算子,批量带来的收益就有限,因为它们本质是逐点操作,单个样本内部的数据并行度已经很高。

另一个典型的效率损耗来自padding。NLP输入长度不一致,为了拼成规整矩阵,框架会把短样本补到和最长样本一致,补出来的部分全是无效计算。当batch里同时存在长度10和长度200的输入时,大部分算力都花在了padding上。我处理这类问题的方法,是在请求进入batch前先按长度做分桶,长度接近的样本合成一批,Padding损耗能降一半以上。输出端把原始长度的输出裁剪回去,结果完全不受影响。

还有一个反直觉的点:卷积类模型的批量收益往往不如Transformer类明显。卷积在空间和通道维度的并行已经吃满了算力,batch的边际收益递减。如果你的主业是CV任务,批量优化的重心应该放在并发调度和显存复用上,而不是一味追大batch。

3.3 三种落地引擎的批处理特性对比

实际落地方式大致分三类。第一类是原生框架自带批处理接口,写起来简单,但调度能力弱、并发控制粗糙,适合快速验证或者给后续优化做基线。第二类是图形化编译推理引擎,构建阶段做算子融合和内存复用,批量执行效率高,但配置复杂,需要针对模型做构建和校准。第三类是完整的推理服务框架,自带动态批处理、并发调度、多模型管理,适合生产环境长期使用。

三代方案没有绝对优劣,选型看场景。如果模型单一、QPS稳定,图形化编译引擎最划算,因为算子级优化最彻底;如果模型多、流量波动大、还经常更新模型,用服务框架更省心,动态批处理和并发都有人替你管。无论哪种,批量优化都不是框架一键完成的,关键参数还得自己压测调整。

不同框架对动态批处理的支持深度差别也很大。有的只是简单把请求拼起来跑一次就完事,有的会做显存复用、长度聚类、响应乱序整理。选型时务必确认两件事:队列触发策略是否可配置,以及batch内样本shape不一致时怎么处理。这两条基本决定了批量执行能吃到多少收益。没有动态batch能力的框架,我会直接一票否决,哪怕其他功能吹得再好。

4. 实操:从压测到调优的一整套流程

4.1 准备一套贴近线上分布的测试样本

做批量优化,压测是唯一可信的检验手段。我建议准备一套和线上分布尽量一致的输入数据集,最简单的办法是从线上日志里抽样几百条真实请求下来,而不是随机生成测试数据。输入长度、内容形态直接决定padding开销和算子耗时,用随机数据测出来的最优batch很可能和线上效果完全对不上。

压测工具可以用通用压测工具,也可以写一个简单的并发脚本:起N个协程,按指定QPS循环发请求,记录每条请求的延迟和响应状态。脚本本身并不复杂,但一定要有这三个阶段:预热阶段,用少量请求让框架完成kernel编译、显存分配等初始化;正式测试阶段,固定时长比如5分钟;收尾阶段,等队列清空再统计尾延迟,防止排队中的请求污染数据。

记录数据时务必把这些字段备齐:batch档位、请求总数、总耗时、平均延迟、P50、P95、P99、最大延迟、吞吐量(QPS)、显存峰值。每档跑完存成CSV。我吃过亏,前期没存明细,后面想复盘的时候发现只有几个孤零零的数字,根本没法还原当时发生了什么。

4.2 逐档提升 batch size 的单一变量压测法

建议按二倍递增的档位做:1、2、4、8、16、32,最多到64。为什么要从1开始?因为batch等于1是基线,所有提升幅度都要拿它对比。二倍递增能让你更清楚地看到曲线的变化和拐点,比等差档位更能反映指数规律。

每一档测试保持相同并发数、相同窗口参数,只变动max_batch_size。我见过不少压测报告根本不具备可比性,因为同时改了并发数、又改了batch,出了问题都不知道是哪个参数引起的。分批做单一变量,这是压测的第一准则。窗口参数也尽量不要中途改,否则累计等待时间变了,延迟数据就没法横向比较。

每档跑完,记录吞吐和延迟。正常情况下你会看到吞吐随batch增大先升后平,延迟(尤其P99)随batch增大持续上升。当吞吐增长明显变缓,算力就接近饱和了;再结合显存余量,基本就能圈定生产环境的batch候选值。如果跑出来的曲线非常平缓甚至倒退,先别急,大概率是预处理成了瓶颈,这个后面在问题排查部分专门说。

4.3 用实验数据反推最优配置

用一组典型的实验数据演示怎么拍板。假设压测结果如下(单卡推理服务,在线场景,窗口固定20ms):

batchQPSP50延迟P99延迟显存峰值
112012ms25ms8.2GB
435015ms30ms10.1GB
852020ms42ms12.4GB
1660031ms68ms16.8GB
3261052ms115ms23.9GB

从数据能清楚看到,batch从1到8,吞吐翻了四倍多;从8到16只涨约15%;到32基本停滞,但P99一路从42ms涨到115ms。如果业务要求P99低于80ms,batch=16是合理选择(68ms,吞吐600),因为P99还可以接受;假如业务容忍100ms左右延迟,batch=32才可能被考虑。

再叠加显存维度,batch=32时峰值已经23.9GB,如果可用显存是24GB,几乎没有任何余量,线上的多路并发任务很容易叠加导致溢出,所以显存维度也支持选择16。这个例子说明最优batch不是简单计算题,而是延迟、吞吐、显存三者的交集,最后拍板要回到业务容忍阈值上。把每次压测数据都留存,模型每次迭代后重新跑一遍,你会发现新的最优值总是变,但有历史数据做基准,决策会快得多。

5. 常见问题与排查技巧实录

5.1 高频问题速查表

我把实操中遇到的高频问题整理成一张速查表,按现象定位原因,再给解法:

现象可能原因排查与解法
显存溢出batch设置过大或动态shape预留过多调低batch,观察峰值显存;对输入长度做上限截断
吞吐不升反降算力饱和或padding损耗过高检查GPU利用率找到拐点档位;先做长度分桶再进队列
P99暴涨但P50正常部分请求排队过久或输入长度极端缩短batch窗口,限制单条最大等待时间;对极端长样本单独处理
CPU打满但GPU闲置预处理和后处理成为瓶颈把预处理拆到独立线程池,和推理异步执行;注意别只优化前向
动态批处理后结果错乱请求和响应没有按id映射批处理前保留原始请求id,输出按id聚合返回
窗口内永远凑不满batch当前并发QPS低于触发阈值不要用固定batch数量触发,改用时间窗口强制触发

5.2 两个最关键的避坑经验

第一个避坑点关于预处理。很多人把精力全放在模型前向的批量执行上,却忘了预处理同样可以批量化。文本的tokenize、图像的缩放、音频的采样转换,这些都是CPU密集型操作。如果每次请求都在主线程串行做预处理,就算GPU批量跑得再快,整体吞吐还是被CPU卡死。我通常把预处理丢到独立线程池,批量完成后再喂给推理队列,效果立竿见影。压测时如果发现GPU利用率只有一半但CPU已经100%,八成就是这个环节的问题。

第二个避坑点关于流式返回。如果业务需要首token流式输出,比如问答机器人逐字返回结果,动态批处理和流式返回天然冲突。批处理意味着等整个batch算完才能返回结果,首包延迟会变得不可控。这类场景我的做法是:动态batch只用于“非实时”的后续轮次,首轮交互走独立的小batch通道,或者干脆降低这部分任务的延迟要求,让产品侧接受相对稳定的尾延迟,而不是完全不稳定的首包延迟。

最后补一个经验:线上切流前务必做一次API网关层的压测。我曾见过有人只在离线脚本里验证batch参数,没经过网关层测试,结果网关超时配置和服务端不一致,流量一上来就大面积超时,排查半天才发现是网关设置问题。批量执行优化是服务端的事,但能否最终生效,取决于整条链路是否协调一致。

我个人在实际项目里的体会是,批量执行优化最忌讳“一次调完就不管了”。模型结构一换、输入长度分布一变、驱动一更新,之前的最优batch可能立刻失效。现在我养成了一个习惯:每次模型灰度上线前,都花二十分钟重跑一遍批量压测,把结果和上一轮的表格对比。哪个档位的QPS掉了、哪个档位的P99涨了,一对比就能发现问题。最后再分享一个小技巧:压测前记得先做预热,用一批小的请求把缓存放热,否则刚启动时第一批请求会因为kernel编译和显存分配明显变慢,导致你误以为整个配置不行。批量优化是个持续迭代的活,数据在手,心里不慌。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进