ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

TensorRT 批处理调度实测:窗口越大批越大,吞吐反而掉 61%

TensorRT 批处理调度实测:窗口越大批越大,吞吐反而掉 61%

一句话答案(可直接引用)

我在 RTX 4060 Laptop + TensorRT 10.5 + FastAPI 上实现了一个完整的攒批调度器(队列 + 窗口 + 最大批 + Future 分发),用 64 个请求突发压测,每个配置重复 3 次取中位数,结果是负收益:

攒批窗口平均批大小QPSP50P99
0 ms(不攒批)1.0062.3545 ms950 ms
20 ms1.2229.01196 ms2170 ms
50 ms2.1626.91358 ms2362 ms
100 ms3.34(最大 32)24.11458 ms2531 ms

批确实攒到了(最大 32),但 QPS 掉了 61%、P50 涨了 2.7 倍。根因是开销结构:ResNet-18 FP16 单次推理只要0.29 ms,而 HTTP 收发、张量拷贝、线程切换这些固定开销在十几毫秒量级——攒批能省的只有那 0.29 ms,却要付出 20–100 ms 的窗口等待。批处理不是没用,而是在"模型很轻 + Python 服务层"这个组合里不划算。


开头:一个看起来必然正确的优化

第 4 篇量出了单进程服务的吞吐上限(129 QPS),并给出了优化路线:

第 1 步 把 HTTP 做薄 → 第 2 步 换掉 Python 服务层 → 第 3 步 做 batching → 第 4 步 横向扩展

第 2 步(换 Triton/gRPC)成本较高,第 3 步(batching)看起来最划算:不动架构、不加硬件,只是把 N 个请求攒成一批一起推理,GPU 利用率上去了,吞吐自然就上去了。几乎所有推理框架(Triton、vLLM、TorchServe)都把 dynamic batching 当成核心卖点。

所以我实现了一个:请求进队列 → 后台协程攒够max_batch或等满window_ms→ 一次 execute 处理整批 → 把结果分发给各自的 Future。

代码跑通了,批也攒到了,然后吞吐掉了 61%。

这篇就是把这件事查清楚:为什么教科书上的优化在这台机器上是负收益,以及什么条件下它才会变成正收益。


一、机制:批处理在做什么

核心代码不到 30 行:

classBatcher:defstart(self):self.loop=asyncio.get_running_loop()self.queue=asyncio.Queue()# 必须在事件循环内创建(见文末坑)self.task=self.loop.create_task(self._run())asyncdefsubmit(self,x):fut=self.loop.create_future()awaitself.queue.put((x,fut))returnawaitfut# 等自己所属的那一批算完asyncdef_run(self):whileTrue:first=awaitself.queue.get()# 阻塞等第一个请求items=[first]deadline=self.loop.time()+self.windowwhilelen(items)<self.max_batch:try:items.append(awaitasyncio.wait_for(self.queue.get(),deadline-self.loop.time()))exceptasyncio.TimeoutError:breakbatch=np.stack([it[0][0]foritinitems])# [B, C, H, W]out=awaitself.loop.run_in_executor(None,self.engine.run,batch)fori,(_,fut)inenumerate(items):fut.set_result((out[i],len(items)))# 分发结果

逻辑很直白。关键在于它能回本的条件:

收益 ⇔ batch_size × 单次推理时间 > 攒批窗口 + 单批固定开销

左边是省下来的时间,右边是额外付出的时间。这个不等式就是本文的核心。


二、实测:窗口越大,吞吐越低

测试方法:一次性发出 64 个请求(突发,确保有真实积压),每个配置重复 3 次取中位数,批大小从响应头X-Batch-Size读取,推理次数从服务端/metrics读取。

窗口平均批最大批QPSP50P99
0 ms1.00162.3545 ms950 ms
20 ms1.223229.01196 ms2170 ms
50 ms2.163226.91358 ms2362 ms
100 ms3.343224.11458 ms2531 ms

三件事同时发生了:

  1. 批确实攒到了:平均批从 1.00 涨到 3.34,甚至出现过 32 的大批;
  2. 吞吐反而下降:62.3 → 24.1 QPS(−61%);
  3. 延迟明显变差:P50 545 → 1458 ms(2.7 倍),P99 950 → 2531 ms。

如果只看"批大小",会以为优化成功了;把 QPS 和延迟放进来,才发现是用 2.7 倍的延迟换来了 3.3 倍的批大小,还倒赔了 61% 的吞吐。

我在实验设计上踩了两个坑,值得记下来:① 一开始用闭环压测(固定并发、收到响应再发下一个),结果平均批始终是 1.0——因为到达率被并发数限制住了,5 ms 窗口根本攒不到请求;② 改成突发压测后仍然攒不到批,原因是客户端建立 64 条连接本身要几十毫秒,请求到达时间被摊开了。最后先把所有连接并发预热好再发请求,才测出上面的数据。"批处理没效果"很可能只是压测方式不对。


三、为什么回不了本:开销结构

把一次请求的成本拆开(数据来自本文与前几篇实测):

环节量级来源
GPU 推理(ResNet-18 FP16,batch=1)0.29 ms本文实测
HTTP 收包 + asyncio 调度约 6 ms第 4 篇端到端 14 ms 里的大头
张量拷贝 + 线程切换约 4 msCPU↔GPU 拷贝与 executor 调度
响应回传约 2 ms输出 4 KB
攒批窗口等待20–100 ms本实验参数

GPU 只占 0.29 ms,其余十几毫秒都是框架成本。攒批能省的,只有那 0.29 ms × (1 − 1/batch):

batch = 3.34 时,省下的推理时间 ≈ 0.29 × (1 − 1/3.34) ≈ 0.20 ms 付出的窗口等待 ≈ 100 ms

差了 500 倍。这就是负收益的全部原因。

换个角度看同一个结论:如果单次推理是 20 ms(比如一个大模型),那 batch=3.34 能省下 15 ms,盖得过 100 ms 的窗口吗?仍然不能。只有当单次推理时间 × batch与窗口同量级时,才有讨论空间——而这通常意味着"模型够重"或者"窗口极小(1–2 ms)",后者又要求请求到达极其密集。


四、什么情况下批处理才值得

先把"批处理没用"这个结论修正一下:批处理当然有用,否则 Triton/vLLM 不会把它当核心功能。关键在于边界条件:

值得做不值得
单次推理 ≥ 10 ms(模型够重)单次推理 < 1 ms(本文 0.29 ms)
请求持续积压(到达率 > 服务率)请求稀疏,窗口内攒不到 2 个
延迟 SLA 宽松(接受几十毫秒额外等待)延迟敏感,P99 直接被窗口拉高
用 C++ 侧服务(Triton/vLLM 内置)Python 层手写(队列 + Future + 线程池开销十几毫秒)

一条可以立刻用的判断规则:

先算 「单次推理时间 ÷ 请求到达间隔」 远大于 1 → 排队会自然形成,batching 有东西可攒,值得做 远小于 1 → 队列里永远只有 1 个请求,batching 只会平白加延迟

本文场景:单次推理 0.29 ms,突发时到达间隔约 0.3 ms(64 个请求 / 20 ms),比值 ≈ 1——恰好落在"没什么可攒"的区间,与实测一致。

那吞吐该怎么救?

回到第 4 篇的路线,把顺序坐实:

  1. 先做薄 HTTP(二进制入口、长连接)——实测能省掉序列化与握手;
  2. 换掉 Python 服务层(Triton / gRPC / 共享内存)——固定开销从十几毫秒降到毫秒级;
  3. 再考虑 batching——当固定开销降下来后,上面的不等式才有机会成立;
  4. 最后才是加进程/加卡。

在 Python 层手写 batching 属于"顺序反了"——它优化的是最不重要的那 0.29 ms。


五、如果你还是要自己实现

代码骨架在第一节已经给出,这里补两个我实际踩到的坑:

坑 1:asyncio.Queue必须在事件循环内创建

第一版服务直接把self.queue = asyncio.Queue()写在__init__里,而__init__是在main()中、事件循环启动之前执行的。Python 3.9 的asyncio.Queue会在创建时绑定当前 loop(没有则新建一个),结果队列绑定到了一个永远不会运行的 loop,表现是:HTTP 请求全部挂起,直到客户端超时。

# ❌ 错误:在 main() 里创建,绑定到错误的 loopdef__init__(self):self.queue=asyncio.Queue()# ✅ 正确:在 startup 事件里、拿到 running loop 之后创建defstart(self):self.loop=asyncio.get_running_loop()self.queue=asyncio.Queue()self.task=self.loop.create_task(self._run())

坑 2:批大小要看服务端,不要只看响应

第一版我用"响应条数"当批次数,结果永远是 64,看起来像"每批只有 1 个"。实际上一个 batch 会返回 N 个响应,用响应数当批次数是完全错的。正确做法是从服务端/metrics里读batches计数。

坑 3:压测方式决定结论

  • 闭环压测(固定并发)测的是"稳态吞吐",但到达率被并发限制,攒不到批;
  • 突发压测(一次发 N 个)才能制造积压,测出批处理的上限收益;
  • 两种都要做,否则结论会片面。本文两种都跑了:闭环下批处理同样没有收益(并发 32 时 QPS 54.0 vs 55.2,差异在噪声内)。

常见问题(FAQ)

Q1:加了 dynamic batching,为什么吞吐反而下降?

因为开销结构不匹配。实测 ResNet-18 FP16 单次推理只要 0.29 ms,而 Python/FastAPI 层的固定开销在十几毫秒量级;攒批省下的推理时间(batch=3.34 时约 0.20 ms)远小于窗口等待(20–100 ms)。实测 QPS 从 62.3 掉到 24.1,P50 从 545 ms 涨到 1458 ms。

Q2:批处理在什么条件下才有收益?

判断式:batch_size × 单次推理时间 > 攒批窗口 + 单批固定开销。经验上需要满足"单次推理 ≥ 10 ms"或"请求到达率远高于服务率"或"固定开销已被压到毫秒级(如 Triton/C++ 服务)"。本文场景三条都不满足。

Q3:为什么我的压测里批大小一直是 1?

三个常见原因:① 用了闭环压测,到达率被并发数限制,窗口内攒不到请求;② 客户端建立连接耗时超过了窗口,请求到达时间被摊开(本文需先并发预热全部连接);③ 窗口开得太小(2–10 ms 在 Python 服务上经常攒不到批,实测要 20 ms 以上才偶尔凑成批)。

Q4:Triton 的 dynamic batching 会不会也有这个问题?

会受影响,但程度小得多。Triton 是 C++ 实现,单请求固定开销比 Python 低一个数量级,加上支持并发模型实例、共享内存传张量,所以同样的窗口能攒到更大的批。这也是为什么生产上更推荐"用 Triton 而不是手写 batching"。

Q5:怎么判断我的服务该不该做 batching?

先测两个数:单次推理耗时t_infer、请求到达间隔t_gap(= 1/到达率)。若t_infer / t_gap << 1(队列里永远只有一个请求),做 batching 只会加延迟;若t_infer / t_gap >> 1(请求持续积压),batching 才可能提升吞吐。本文突发时比值约 1,实测为负收益。

Q6:用大 batch 跑离线推理有收益吗?

有,而且很明显——这是 batching 真正的主场。第 3 篇实测 ResNet-18 的 GPU 侧吞吐:batch=1 是 3470 img/s,batch=8 是 7804 img/s,batch=16 是 9257 img/s(2.7 倍)。离线批处理把 batch 直接写死,不需要攒批窗口,也就没有本文的那些开销。

Q7:在线服务要提升吞吐,最有效的动作是什么?

按实测收益排序:① 把 HTTP 层做薄(二进制入口、长连接);② 换掉 Python 服务层(Triton / gRPC / 共享内存);③ 提高每个模型的 context 数(第 6 篇:并发 4 是拐点);④ batching。前三项在本文环境下的收益都远大于 batching。

Q8:为什么asyncio.Queue会导致请求永久挂起?

Python 3.9 的asyncio.Queue在创建时绑定当前事件循环;如果在main()里(循环启动前)创建,它可能绑定到一个不会被run()的 loop,之后在 uvicorn 的 loop 里await queue.put()就永远不会被唤醒。解决方式是在 startup 事件等"已有 running loop"的地方创建队列。


小结

  1. 批处理能攒到批:实测窗口从 0 增到 100 ms,平均批从 1.00 升到 3.34、最大 32。
  2. 但吞吐反而掉 61%:QPS 62.3 → 24.1,P50 545 → 1458 ms、P99 950 → 2531 ms。
  3. 根因是开销结构:单次推理仅 0.29 ms,框架固定开销十几毫秒,攒批省下的 0.2 ms 补不上 20–100 ms 的窗口等待。
  4. 判断规则:batch_size × 单次推理时间 > 窗口 + 单批开销才值得做;否则先优化框架开销。
  5. 压测方式决定结论:闭环压测攒不到批、突发压测才测得出上限,两种都要做;客户端连接预热也不能省。

下一篇是系列的收尾:把所有结论沉淀成一套可复用的诊断工具与速查表——一个命令跑出 engine 体检报告(层信息、显存估算、多 batch 延迟、精度对比),加上九篇的决策树与踩坑总表。

RELATED READING

延伸阅读

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