有一次,某业务方跑来找我,说线上一个推理服务在高峰时段延迟抖动得非常厉害,QPS看着不高,但GPU利用率忽上忽下,任务堆积预警不断。我第一反应是“加卡”,但仔细翻了监控才发现,这个服务压根没做批量优化——每个请求到达后直接单条进模型,GPU计算密度低得可怜,大部分时间都在等数据搬运。后来我把批量推理的调度逻辑从排队、攒批、超时到模型shape适配完整梳理了一遍,服务吞吐直接翻了近十倍。这篇就是那次之后沉淀下来的实践记录,重点聊批量执行优化里那些容易被忽视、但真正决定成败的细节。
如果你正在维护推理服务,或者正准备把某个模型部署到生产环境,这篇文章应该能帮你少踩不少坑。我会从GPU为什么天然适合批量处理讲起,拆解动态批处理的核心调度逻辑,对比主流推理引擎的配置方式,再分享一套可以直接参考的压测和调优流程,最后聊聊生产环境里批量优化常见的翻车现场。内容偏实操,但原理部分我也会尽量讲透,因为不理解原理直接抄参数,换了场景就很容易失效。
1. 先聊清楚:为什么“一条条推理”会在生产环境被按在地上摩擦
1.1 延迟倍增的源头:GPU的执行模型偏向“批”
很多刚接触推理服务的人会觉得困惑:GPU单卡算力这么强,单条请求进来直接算不就行了,为什么非要攒一批再算?其实问题的答案藏在GPU的工作方式里。GPU本质上是高度并行的流处理器阵列,但它并不是“单条请求处理得更快”,而是“同时处理很多条请求时总吞吐更高”。把单个小矩阵乘法丢给GPU,实际计算可能只有几毫秒,但光是把数据从CPU侧拷贝到显存、启动kernel、等待同步,这套固定开销可能就要占掉大半时间。
我习惯用一个快递的类比来解释这件事:单条推理请求就像骑电动车去送一个快递,启动快、灵活,但每趟的油钱和路上时间都摊在一个包裹上。批量推理则是开一辆卡车,装满一车再出发,虽然发车前的准备更久、头几个包裹的“等待时间”更长,但摊到每个包裹上的成本低得多。GPU的可编程单元处理一批数据时,指令流是重复利用的,数据并行度越高,计算单元的空闲比例越低。这也是为什么同样的模型、同样的GPU,单条跑可能只有50 QPS,但batch size拉到32之后,吞吐能冲上500 QPS甚至更高。
不过这里面有个很关键的细节:吞吐提升了,不等于每条请求都快了。批量推理意味着后面的请求要等前面的请求凑齐才一起计算,天然会引入额外的排队延迟。所以批量优化本质上是在“用延迟换吞吐”,优化的目标不是把延迟压到最低,而是在业务可接受的延迟上限内,把吞吐尽可能拉高。
1.2 批量不是越大越好:吞吐和延迟天生是一对矛盾
我遇到过不少同学把batch size直接调到最大,觉得“反正显存够大,能塞多少塞多少”。这个思路在纯离线跑批场景可能还行,但在在线推理服务里,盲目拉大batch size经常会适得其反。
先看服务端延迟。假设batch size是16,那么第1个到达的请求最多可能要等后面15个请求到齐才能开始计算。如果请求到达速率不稳定——有的时段每秒来100个,有的时段每秒只有5个——攒批时间会剧烈波动,表现到用户侧就是P99延迟忽高忽低。更麻烦的是GPU的显存和计算资源是有限的,batch size翻倍,意味着中间激活值占用的显存可能翻倍甚至更多。一旦batch size超过某个阈值,就会出现显存溢出,或者kernel启动的时间被数据搬运时间淹没,吞吐曲线反而掉头向下。
我整理过一组在某文本分类模型上实测的数据,模型是中等规模的Transformer,GPU是常规的推理卡,输入平均长度约128 token:
| Batch Size | 单batch延迟(ms) | 吞吐(QPS) | P99延迟(ms) | 显存占用(GB) |
|---|---|---|---|---|
| 1 | 8.2 | 122 | 31 | 1.2 |
| 8 | 14.6 | 548 | 142 | 2.1 |
| 16 | 21.8 | 733 | 268 | 3.6 |
| 32 | 36.9 | 867 | 512 | 6.4 |
| 64 | 71.5 | 895 | 903 | 12.8 |
可以看到,从16到32,吞吐还在涨,但P99延迟几乎是翻着倍往上走。从32到64,吞吐提升已经很小了,延迟却涨得很难看,说明已经逼近硬件的计算和访存瓶颈。如果业务方给的延迟红线是P99不超过500ms,那这个服务最优的工作点其实在batch size 16附近,而不是吞吐最高的64。所以批量优化的第一步,永远是先明确业务指标,再去找吞吐和延迟的平衡点,而不是单纯追求“跑得满”。
2. “攒批”的艺术:动态批处理的调度策略拆解
2.1 动态批处理的完整闭环:请求进入、排队、攒批、超时触发
静态批处理最适合离线跑批:数据是现成的,一批一批按固定大小切好丢进去算就行。但线上推理服务的请求是动态到达、有先来后到的,这时候就需要动态批处理(dynamic batching)——用一个调度模块把短时间内到达的多个请求合并成一个batch,统一送进模型推理。
一个完整的动态批处理闭环通常长这样:请求进入服务后,先被丢进一个队列;调度器按策略把队列头部的若干请求取出来,凑成一个batch;凑batch的触发条件一般是“攒够最大batch size”或“到了最大等待时间”;batch构建完成后交给推理引擎执行,结果再逐个拆开返回给对应请求。
我手写过最简版本的调度逻辑,核心就是一个带超时判断的循环。伪代码大概是:
class DynamicBatcher: def __init__(self, max_batch_size=32, queue_time=0.01): self.max_batch_size = max_batch_size self.queue_time = queue_time self.queue = deque() async def submit(self, req): self.queue.append(req) return await req.future async def run(self): while True: if not self.queue: await asyncio.sleep(self.queue_time) continue batch = [] deadline = time.time() + self.queue_time while len(batch) < self.max_batch_size: if self.queue: batch.append(self.queue.popleft()) elif time.time() < deadline: await asyncio.sleep(0.001) else: break results = await model.infer([r.data for r in batch]) for req, res in zip(batch, results): req.future.set_result(res)这段代码虽然简单,但已经覆盖了动态批处理最关键的两个控制参数:最大batch size和最大攒批时间。实际生产里还会有优先级队列、大小请求分组、模型多实例轮流拉取等复杂设计,但核心骨架就是这个。
这里需要注意一点:攒批等待时间的计算方式。我上面用的是超时截止时间,也就是说第一个请求最多等queue_time这么久。如果写法不对,很容易变成每来一个请求都重新计算deadline,导致队列第一个人永远等不到出发的机会。这种细节问题排查起来很隐晦,但确实有人在这里翻过车。
2.2 攒批窗口到底设多少:一个从200ms到1200ms的实测对照
攒批窗口(也就是上面代码里的queue_time)是动态批处理里最需要耐心调的参数。设大了,请求排队时间边长,吞吐好但延迟变差;设小了,batch经常凑不满,GPU利用率上不去。我通常设几个候选值做对比测试,下面是某视觉检测服务的一组实测对照:
| 攒批窗口(ms) | 平均Batch Size | 吞吐(QPS) | P50延迟(ms) | P99延迟(ms) |
|---|---|---|---|---|
| 0 | 1.0 | 96 | 22 | 58 |
| 10 | 4.2 | 183 | 34 | 87 |
| 50 | 7.8 | 246 | 63 | 176 |
| 100 | 9.6 | 265 | 112 | 290 |
| 200 | 10.1 | 268 | 205 | 410 |
这个数据很有意思。攒批窗口从0加到50ms的过程中,吞吐涨得非常快,但延迟只是在可接受范围内缓慢上升;从100ms往200ms走,吞吐几乎不涨了,延迟却还在继续增加。这说明该服务的请求密度并不高,窗口开到100ms以上后,等再久也很难等到更多请求,纯粹是干等。
我实际配置的时候会看业务高峰时段的平均到达间隔:如果请求平均间隔是8ms,那把攒批窗口设在15~20ms比较合理;如果平均间隔是60ms,窗口设200ms也等不出什么东西。更稳的做法是让攒批窗口可动态调整——比如按滑动窗口内的真实的请求速率来推算最优等待时间,请求密集时缩短窗口,请求稀疏时适当放宽。这个逻辑自己实现起来不算复杂,但如果没有历史流量数据支撑,一开始还是用固定窗口跑一段,摸清到达规律再改动态策略更稳妥。
2.3 模型层面要为批处理做的配合:固定shape与padding策略
动态批处理做完调度还不够,模型本身的输入层如果没配合好,攒出来的batch照样跑不起来。最常见的问题是变长输入。像NLP服务,每条文本转成token后长度不一样,模型前向计算要求输入是规整的二维张量,这时候就必须做padding——把短的样本用特殊token补齐到同一个长度。如果每次都以batch内最长样本为基准做padding,那么一条长文本请求很可能拖慢整批其他短文本的计算速度,GPU算了很多无用的padding位。
我推荐的做法是按长度分桶加可控padding。请求进来时先按token长度分到不同的桶,比如0~64、64~128、128~256几个区间,每个桶内部再padding到桶的最大长度。这样batch内长度差异被控制在一个小区间,计算浪费少,动态批处理也能更高效。代价是需要维护多个桶的队列,调度逻辑会比单一队列复杂一点,但收益非常明显。
这里还有个容易被忽略的点:很多框架自带的动态批处理模块只做“张量拼接”,不做pad截断。如果batch里有一条特别长的序列,整批计算的复杂度会骤增,甚至让吞吐直接对半砍。我在某个文本生成服务里就遇到过这种问题,后来在进队列之前对超长输入做了截断处理,把长度超过上限的请求直接降级到单条走慢路径,才把P99拉回稳定区间。截断策略记得要和业务方确认好,不能机械地丢内容,否则影响的是模型效果。
3. 落地到推理引擎:vLLM、Triton、TensorRT的批量调度配置
3.1 vLLM的Continuous Batching为什么更“聪明”
如果你在部署的是自研模型或开源大模型,大概率会接触到vLLM。除了显存管理和PageAttention之外,vLLM最受关注的特性是Continuous Batching(连续批处理)。传统动态批处理是“攒一批算一批,算完再攒下一批”,batch内部所有序列同步前进。Continuous Batching的思路是:batch里的序列各自推进,每步生成结束后,已经结束的序列立刻从batch中移出,新到达的序列可以马上插入进来,不需要等整批一起完成。
打个比方,传统批处理就像绿皮火车,所有乘客必须在同一站下车;Continuous Batching则更像城市公交,每到一站有人下、有人上,车辆始终满载运行。对于LLM这类生成式的推理场景,用户请求的生成长度差异极大,有的生成10个token就结束,有的要生成2000个,Continuous Batching能显著减少计算空档,提高GPU利用率。
如果你们团队没有特殊的调度需求,LLM服务直接用vLLM,把--max-num-seqs和--max-model-len配合好,基本能拿到一个很不错的开工配置。需要注意,max-num-seqs不是你设得越高越好。它受限于显存和模型大小,设太高会导致KV cache不够用,请求吞吐没涨,排队延迟反而飙升。我一般先按“可用显存除以单条序列KV cache占用”的公式估算一个初值,再通过压测修正。
3.2 Triton Dynamic Batcher的关键参数:Max Batch Size与Queue Delay
NVIDIA的Triton Inference Server是我在混合模型场景里用得最多的框架。它的Dynamic Batcher模块把攒批逻辑做成了开箱即用的配置项,支持最大batch大小、队列延迟、优先队列等多个参数。
一个典型的Triton配置长这样:
model_loop: batcher: kind: DYNAMIC max_batch_size: 32 preferred_batch_size: [8, 16] max_queue_delay_microseconds: 15000preferred_batch_size参数我特别想强调一下。某些模型对batch size有性能陷阱,比如16是整数倍对齐,跑起来比24快得多。Triton允许你设置一个偏好列表,调度器会优先凑成这些“好”的batch大小,而不是死等最大batch。这在实际调优时很有用,尤其是模型kernel做过长度对齐优化的场景。
另一个值得说的是max_queue_delay_microseconds的单位是微秒。同样一个值,有人填成15,以为单位是毫秒,结果攒批窗口变成15微秒,相当于没有等待,动态批处理直接失效,GPU利用率掉回单条水平。这种单位换算翻车案例在社区里见过不止一次,建议配置里写单位注释或者用变量替换。
如果你用Triton的Python后端做自定义推理逻辑,还能在execute函数里拿到请求batch的元数据,实现更精细的控制。比如不同batch大小走不同的预处理路径、动态决定是否转发给更高算力实例,这些都是Triton的强项。
3.3 自研批处理时的妥协与替代路径:当业务形态很特殊
框架虽然好用,但总有业务形态逼着你去自研调度。比如模型输入本身就不是规整张量,而是带复杂结构的变长数据;或者推理链路里除了模型推理还包含大量业务预处理,必须和批次逻辑耦合在一起。
自研批处理的第一个妥协方案是退化到“微批”。如果模型对batch size极其敏感,那就不追求攒大batch,而是固定小batch(比如4或8),用多进程并发跑多个微批。这样既利用了GPU的并行能力,又避免了单batch过大带来的调度延迟。我自己在某个多模态服务里就是这么干的,实测吞吐比单条翻了三倍,延迟上涨幅度却在业务接受范围内。
第二个替代路径是手动合并计算。有些模型虽然输入是变长的,但内部可以拆成多个独立的小算子。把多个请求的embedding查表、prefill计算合并在一起做,到某些串行环节再拆开。这种方案对模型的侵入性很强,一般只有团队真正理解模型结构、且框架控制不了的时候才会去碰。做之前一定要算清楚收益:如果手动拼batch节省的时间还抵不上拆解和拼接的开销,那还不如直接用现成框架。
4. 一次完整的性能摸底:我压测推理服务时的标准动作
4.1 压测前必须想清楚的三个问题:业务QPS、延迟上限、显存余量
很多人在调推理服务时一上来就开压测工具,跑出一堆数字却不知道怎么用。我习惯先回答三个问题,再动手。
第一,业务方对QPS的硬性要求是多少?这个数字决定了优化目标的方向。如果目标只有100 QPS,当前已到90,那微调参数就够了;如果目标是1000 QPS,那可能要从架构层面重新考虑。
第二,用户可接受的延迟上限是多少?不能只看平均延迟或P50,要看P95和P99。在线服务里,尾延迟往往才是用户流失的元凶,而批量优化最容易被牺牲的就是尾延迟。
第三,显存余量有多少?批量推理的瓶颈很多时候不是计算,而是显存。中间激活值、KV cache、输入tensor拷贝都会随着batch增大而快速膨胀。不摸清显存余量,压测到一半就OOM的情况会经常发生。
我压测前还会顺手清一遍机器的干扰项:确认没有别的服务在抢GPU、CPU核心数给够、网络带宽没有被打满。这些外部因素会污染测试数据,让你以为是自己配置的问题,实际是环境问题。
4.2 从吞吐曲线找拐点的实操方法
压测工具我常用两类:对HTTP接口用wrk或hey这类通用压测工具,对gRPC或自定义协议用脚本模拟真实请求。核心思路是逐步加压,观察曲线。
以wrk为例,如果服务是HTTP/1.1接口,命令大致长这样:
wrk -t 4 -c 50 -d 60s --latency http://127.0.0.1:8000/infer-c 50代表50个并发连接。我一般会按20、50、100、200、400这样逐级增加并发,每一档跑60秒,记录吞吐和延迟分布。这样跑下来最直观的产出是一张表格:
| 并发数 | 吞吐(QPS) | P50(ms) | P99(ms) | GPU利用率(%) |
|---|---|---|---|---|
| 20 | 132 | 41 | 98 | 42 |
| 100 | 246 | 102 | 214 | 73 |
| 200 | 251 | 195 | 402 | 74 |
| 400 | 247 | 380 | 860 | 73 |
从这张表能很清楚地看到拐点:并发从100加到200,吞吐基本没涨,P99却快翻倍了,GPU利用率也没继续上升。说明系统已经在排队而不是在计算,再加并发只会让延迟更差。这个并发数就是该配置下的最优工作点。
如果需要更细的数据,可以配合nvidia-smi dmon采样GPU利用率,或者在服务端记录每次推理的实际batch size和排队时间,画成时间序列曲线。很多时候吞吐没涨但延迟涨了,就是攒批窗口设太大导致的“虚空等待”。
4.3 典型案例:NLP文本分类服务从20QPS到200QPS的调优过程
这个例子属于我压箱底的素材。某个文本分类服务上线初期,单卡GPU的吞吐只有20 QPS左右,业务方天天催。我上去看了下,问题非常典型:请求走的是单条推理,模型输入还是变长没有padding处理的原始token序列,GPU利用率不到20%。
第一步,我先做了一次基线压测,摸清单条推理的延迟分布:P50约35ms,P99约90ms。这个延迟其实不算差,但GPU利用率低,说明算力闲置严重。
第二步,我把Triton接入,配置了Dynamic Batcher,max_batch_size设为32,max_queue_delay_microseconds从0开始逐步上调。结果攒批窗口到10ms时,吞吐就翻到了90 QPS;窗口到30ms时,吞吐继续爬到150 QPS。
第三步,我针对模型做了输入分桶和padding优化,把batch内的长度差距压小。这一步做完,同样的配置下吞吐直接到了210 QPS,P99延迟控制在350ms以内。整体的收益主要来自两块:批处理把GPU利用率从20%拉到70%以上;分桶padding把无效计算比例从40%降到不足10%。
这个案例给我的体会是:批量优化的天花板往往不在调度模块,而在模型输入的处理方式。调度解决的是“车什么时候发、装多少人”,模型输入格式解决的是“每个人上车占多大地方”。两个都优化到位,吞吐才能成倍往上翻。
5. 生产环境里的暗礁:批量优化的稳定性兜底
5.1 超时风暴与队列膨胀:当上游批量重试叠加时会发生什么
动态批处理把请求先攒在队列里,这个队列如果被击穿,问题会被无限放大。最典型的场景是上游超时重试。假设服务承诺P99=200ms,攒批窗口50ms,排队请求一多,部分请求超过200ms后,上游SDK自动重发请求。重发的新请求又进入同一队列,和旧请求一起排队,队列越来越长,延迟越来越高,结果触发更大规模的重试——这就是经典的“超时风暴”。
我经历过一次线上事故,某业务方请求数从正常500 QPS飙到3000 QPS,我们服务的延迟从P99 300ms直接干到3秒,所有实例都在处理重复请求,真正的新请求反而进不来。事后看监控,队列深度涨到了几万,batch size倒是维持住了,但每个请求的排队时间已经远远超过攒批时间,动态批处理已经完全退化成“堆队列”。
应对手段分两层。第一层是队列有界。给积压队列设置硬顶,比如2000条,满了直接拒绝新请求并返回忙错误,让上游走自己的降级逻辑,而不是无限堆下去。第二层是重试去重,给每个请求分配唯一ID,队列里同一个ID只保留最新的一个,系统性减少重复请求。这两招不解决慢的问题,但能防止慢演变成雪崩。
5.2 显存OOM与动态拼接失败:常见故障复盘
批量优化的第二个暗礁是显存溢出。动态batch size看着很美,但模型在batch大小变化时,显存占用不是线性增长,而是跳跃式增长。尤其是Transformer结构,批量推理时要为每个样本维护中间状态,batch翻倍,激活内存可能变四倍。
我踩过一次很具体的坑:某模型在batch size 16时跑得好好的,我把max batch size调到32后发现偶发OOM。排查了半天,发现变量不在batch本身,而是某个输入序列特别长,激活值暴增,直接把显存顶爆了。后来我把max_batch_size调回16,同时限制单条输入的最大长度、超长的走单条推理降级,才彻底解决问题。
另一个常见的“动态拼接失败”发生在用PyTorch手动实现批处理的场景。不同长度token的tensor直接torch.stack会直接报错,必须用pad_sequence或对齐到同一长度。但pad_sequence默认按batch内最长样本pad,长尾样本会拖垮整批效率。所以我前文说的分桶策略不只是性能优化,也是一种规避隐式OOM的手段——桶内最大长度可控,显存占用更可预期。
5.3 让体系可观测:监控批量大小、队尾延迟与GPU利用率
批量优化上线后,必须要有配套的可观测体系,否则出问题就是黑盒。我建议至少监控几个核心指标,缺一不可:
- 实际batch size分布:如果长期达不到max batch size,说明流量密度不够,攒批窗口可能偏短;如果长期贴着max跑,说明流量过载,需要考虑扩容或限流。
- 队列深度和队尾等待时间:这两个直接反映调度压力。队尾延迟一旦持续增长,说明消费速度跟不上生产速度,需要马上处理。
- GPU利用率与显存占用:利用率长时间低于30%,说明批量策略可能没生效;显存占用突增且没有回落,大概率是动态shape导致的内存碎片问题。
- 按请求路径拆解的延迟:至少分成排队时间、预处理时间、推理时间三部分,否则你无法判断延迟恶化是调度问题还是模型问题。
这些指标我习惯同步到监控平台上,做了几个基础告警:队尾延迟超过阈值、实际batch size长期为1、GPU利用率低于某个值持续5分钟。有了这套东西,后续调参才有依据,而不是每次都靠“盲试”。
6. 批量调优的几条个人体会
批量推理优化做到最后,回头看最核心的其实不是哪个框架、哪个参数,而是对“延迟换吞吐”这个本质的理解。每个服务的最优batch size、攒批窗口、padding策略都不一样,但分析思路是共通的:先明确业务指标,再压测找拐点,最后做可观测兜底。
有个小技巧可以分享:压测时别只测常态流量,也要测突发流量。批量优化的参数在均匀流量下表现很好,但在突发流量下队列深度会瞬间打满,造成P99高企。所以我在压测场景里会加一段“脉冲式”请求模型,专门观察动态批处理在流量抖动时的表现。如果你的调度器在突发流量下能自动收缩攒批窗口,而不是傻傻地等人凑满batch,那才算是真正可用的方案。
最后再提醒一句:模型推理的批量执行优化是个持续迭代的活,不是上线时调一次就能一劳永逸。流量模型会变、业务延迟要求会变、模型本身也可能升级,每变动一个外部条件,最优参数都可能漂移。把监控指标体系建好,把调优流程沉淀成文档,下一次再遇到瓶颈时,你需要的只是按部就班地跑一遍压测、看拐点、调参数,而不是重新从零开始摸索。