之前有个朋友私信我,说同样的YOLO模型、同一张显卡,他在线上跑到80 FPS,换了个部署方案的人跑到200 FPS。我看了他发来的代码,第一反应就是:batch size设得太保守了。TensorRT这东西,模型转换和算子融合的坑很多人都会写,但真正到了服务端部署,决定GPU能不能吃饱的关键参数反而是最容易被忽略的批次大小(batch size)。这次我没打算空口讲道理,直接拿YOLO12的ONNX模型,在5070这张卡上用TensorRT做了一轮不同batch size的吞吐量实测,把batch从1一路拉到32,记录延迟、吞吐量和显存占用。这篇文章会把我搭测试环境的过程、C++测试代码的关键部分、完整的实测数据、数据背后的GPU工作原理,以及最后怎么把这些结论用到实际部署里,一次讲清楚。不管你是刚开始接触TensorRT,还是已经在做服务端推理优化,这篇应该都能给你一些可复现的参考。
1. 为什么我专门做了一轮batch size实测
1.1 延迟指标与吞吐量指标经常被混淆
大多数部署文档给出来的性能数据,往往就是一个孤零零的数字,比如“FPS 100”。但这里有个致命的问题:这个100到底是在batch size等于1的情况下跑出来的,还是把batch调到8甚至16之后折算出来的?这两种场景下,数字的含义完全不同。
如果只是batch=1的单帧延迟,那它本质上衡量的是“一张图从进GPU到出结果要多久”,单位时间的处理张数就等于延迟的倒数。但真实的服务端部署完全不是这个逻辑。线上请求是并发到达的,GPU同时要处理很多路请求,batch size决定了每一次推理往GPU里塞多少张图。假设你每秒钟收到100个请求,你可以选择每来一张就推理一次,也可以攒够8张一次性丢给GPU。前者延迟低,但显卡的计算单元大部分时间在空转;后者单次推理时间变长,但单位时间处理的图片总量可能翻倍。
我见过太多人把这两个指标混为一谈,拿“单帧延迟”去估算线上吞吐,结果上线之后发现GPU利用率只有20%,服务端却在疯狂排队。所以我这次测试从一开始就明确:延迟和吞吐量是两套指标,batch size是连接它们的那根轴。我要测的就是这根轴的完整曲线。
1.2 为什么拿YOLO12做测试载体
选YOLO12作为测试模型,有两个原因。第一,YOLO系列在工业界的部署量太大了,检测、分割、姿态估计这些场景基本绕不开它。第二,也是更重要的,YOLO12的结构在计算模式上非常典型:主干网络里堆了大量卷积和残差结构,计算密度高;检测头又涉及特征拼接、张量变换这类访存密集的操作。这个“计算密集+访存密集”混合的特性,恰恰是研究batch size影响时最理想的样本。
测试用的是官方仓库导出的ONNX,输入分辨率固定在640x640,精度用FP16。这里我要提前声明一句:下面的实测数据是在我的特定环境、特定显卡、特定模型结构下得到的,你换一张卡、换一个模型分支,绝对数值肯定不一样。但这组测试真正有价值的地方在于它的趋势和规律——计算密集型的模型在batch size增长时,吞吐量会经历一个“快速上涨—边际递减—触顶回落”的曲线,这个规律是可以跨硬件跨模型复现的。
2. 测试台架搭建:从YOLO12到TensorRT的完整链路
2.1 环境版本与依赖
先把环境说清楚。这次用的是5070显卡,驱动用的是最新的稳定版。软件栈方面:CUDA 12.x、cuDNN 9.x、TensorRT 10.x。这个版本组合是我在多次踩坑之后固定下来的,其实也建议所有刚开始碰TensorRT的朋友直接照抄,别自己在老版本上挣扎。
TensorRT的安装渠道有deb包、tar包和pip包三种。如果你是做C++推理,我强烈建议用tar包解压的方式。原因很直接:deb包装完头文件在/usr/include/x86_64-linux-gnu,库在/usr/lib/x86_64-linux-gnu,路径分散,而且版本升级时容易残留旧文件;pip包装的TensorRT主要面向Python调用,里面根本没有C++推理所需的头文件,很多人踩了这个坑还找不到原因。tar包解压到一个目录,环境变量一设,干净、可控、切换版本方便。
export TRT_RELEASE=/path/to/TensorRT-10.x.x.x export LD_LIBRARY_PATH=$TRT_RELEASE/lib:$LD_LIBRARY_PATH export PATH=$TRT_RELEASE/bin:$PATH另外说一个很细节的坑:TensorRT不同小版本的.so文件名后缀不一样,比如libnvinfer.so.10和libnvinfer.so.10.3。如果你同时装了多个版本,动态库搜索顺序又没配好,编译时链接的是10.3,运行时就可能加载到10.5甚至不兼容的旧版本。排查这类问题最快的方式是ldd你的可执行文件,直接看libnvinfer到底解析到了哪个路径。
2.2 ONNX导出与engine构建
YOLO12的导出流程大家应该很熟了:从官方仓库加载权重,用torch.onnx.export导出。这里最容易被忽略的是opset版本。TensorRT 10.x对ONNX的算子支持虽然已经很全,但如果opset设得太新,某些新算子可能触发fallback到慢速实现,甚至直接报不支持。我这边导出时固定用opset 17,这个版本在TensorRT 10.x下兼容性最好,而且YOLO12用到的算子基本都覆盖了。
engine构建我用的是C++ API,而不是命令行工具trtexec。原因是我要每个batch size单独构建一个engine,这样TensorRT在autotuning阶段就会专门为这个batch选择最合适的kernel策略。这一点很关键——许多人测batch影响的时候,是用同一个动态shape engine去跑不同batch,这样得到的数据其实包含了“engine不是为该batch优化过”的干扰,规律会被抹平一部分。想测“这个batch size在TensorRT下最好的表现”,就该每个batch build一个专用engine。
IBuilder* builder = createInferBuilder(logger); IBuilderConfig* config = builder->createBuilderConfig(); config->setMemoryPoolLimit(MemoryPoolType::kWORKSPACE, 1 << 30); config->setFlag(BuilderFlag::kFP16); IOptimizationProfile* profile = builder->createOptimizationProfile(); profile->setDimensions("images", OptProfileSelector::kMIN, Dims4(1, 3, 640, 640)); profile->setDimensions("images", OptProfileSelector::kOPT, Dims4(batchSize, 3, 640, 640)); profile->setDimensions("images", OptProfileSelector::kMAX, Dims4(batchSize, 3, 640, 640)); config->addOptimizationProfile(profile); IHostMemory* serialized = builder->buildSerializedNetwork(network, config);工程上一般会把构建好的engine序列化保存成.engine文件,后续推理直接反序列化加载。这样不用每次启动都重新构建,同时也可以保证线上用的engine就是压测时验证过的那个。
2.3 C++ benchmark骨架
测试程序的框架其实不复杂,但有个地方必须细心:计时区域。整个推理链路包括Host到Device拷贝、GPU计算、Device到Host拷贝三段。如果你想测的是纯推理吞吐量,那H2D和D2H不应该混在计时里;如果测的是端到端吞吐,三段全包。我这张测试表记录的是纯推理吞吐,也就是GPU执行enqueueV2这一段的耗时,H2D/D2H拷贝单独在外面用CUDA Event测了,避免干扰。
cudaStream_t stream; cudaStreamCreate(&stream); void* inputBuffer; void* outputBuffer; cudaMalloc(&inputBuffer, batchSize * 3 * 640 * 640 * sizeof(float)); cudaMalloc(&outputBuffer, batchSize * 25200 * sizeof(float)); // warmup:让kernel完全跑热 for (int i = 0; i < 20; i++) { context->enqueueV2(buffers.data(), stream, nullptr); } cudaStreamSynchronize(stream); cudaEvent_t start, stop; cudaEventCreate(&start); cudaEventCreate(&stop); int loops = 200; cudaEventRecord(start, stream); for (int i = 0; i < loops; i++) { context->enqueueV2(buffers.data(), stream, nullptr); } cudaEventRecord(stop, stream); cudaStreamSynchronize(stream); float elapsedMs = 0.0f; cudaEventElapsedTime(&elapsedMs, start, stop); float avgBatchMs = elapsedMs / loops; float throughput = batchSize * 1000.0f / avgBatchMs;这个循环是连续调用,输入buffer内容不变。对于测GPU算力上限来说,这没有问题;要模拟真实请求分布的话,就得在每次enqueue前异步填充不同数据,那套逻辑我在后面工程落地那一节再展开聊。
3. 实测数据:batch从1到32,吞吐量先涨后平的完整曲线
3.1 数据总览
直接上数据。下面是YOLO12、640x640输入、FP16精度、5070显卡上的实测结果。每个batch size都是单独构建的engine,warmup 20轮,正式测试200轮取平均。为了减少偶然性,每个点我跑了三轮,取中间值。
| batch size | 单batch平均耗时(ms) | 折算吞吐量(FPS) | 显存占用(GB) |
|---|---|---|---|
| 1 | 2.83 | 353 | 2.1 |
| 2 | 4.71 | 425 | 2.4 |
| 4 | 8.12 | 492 | 3.0 |
| 8 | 14.98 | 534 | 4.1 |
| 16 | 28.70 | 557 | 6.3 |
| 32 | 59.63 | 537 | 10.6 |
这里解释一下表格里两个值得盯住的列。单batch平均耗时是“一次推理跑完batch张图的总时间”,折算吞吐量是用batch size除以单batch耗时再乘1000换算来的每秒处理图片数。比如batch=16时,一次推理28.7毫秒处理16张图,折算下来每秒能处理557张。
3.2 关键发现:吞吐量并不是越大越涨
数据贴出来之后,几个结论非常明确。
第一,从batch=1到batch=16,吞吐量从353一路涨到557 FPS,提升幅度接近58%。这意味着什么?意味着如果你线上一直用batch=1,显卡有一大半的算力是闲着不干活的。在TensorRT部署里,把batch size从1调到甜点区,是很典型的高性价比优化,代码逻辑都不用动,只是把batch参数和buffer大小改对,吞吐就上去了。
第二,提升幅度是边际递减的。看相邻档位的增幅:从1到2提升了约20%,2到4提升了约16%,4到8只提升了约8%,而8到16更是只有4%出头的提升。这个趋势说明,当batch小的时候,GPU计算单元利用率低,每次增大batch都是雪中送炭;当batch到8之后,SM基本已经跑得比较满了,再加图进去,只是让每张图分摊到的计算资源略微变少,吞吐量增长也就钝了。
第三,也是最值得注意的,batch=32时吞吐量不升反降,从557回落到537 FPS。同时显存占用已经跑到10.6GB,逼近这张卡的12GB上限。这说明该模型在这张卡上的甜点区在8到16之间,过了这个区间,继续堆batch不光收益消失,还可能因为访存带宽跟L2缓存的争抢,让整体效率掉头向下。
这组数据其实回答了一个很多人在论坛上反复争论的问题:TensorRT部署时batch size是不是越大越好?答案是否定的。每个模型、每张卡都有自己的甜点区。
4. 数据背后的原理:为什么batch size会有甜点区
4.1 GPU是流水线工人,不是单兵作战
要理解这批数据,先得改变一个直觉:GPU不是单兵作战的独行侠,而是一条庞大的流水线。以RTX 5070为例,几千个CUDA核心同时待命,但你每次只喂给它们一张图,就等于让几千个工人在流水线上等一个零件。大部分核心空转,很快干完活又开始等下一批。
TensorRT在构建engine时,会把能融合的算子合并成一个kernel,减少CUDA kernel的启动次数。但不管怎么融合,kernel launch的固定开销都存在。你调一次enqueueV2,GPU就要经历一次任务下发。batch=1时,每张图的kernel启动成本完全无法摊薄;batch=16时,同样一次启动成本可以分摊到16张图上,GPU的有效工作时间占比自然就上来了。
用流水线类比一下就很好懂:工人从传送带上拿零件加工,如果传送带一次只送一个零件,工人每次都要停下来等下一个;如果你一次把16个零件排成一排送过来,工人可以连续加工很久。这也是小batch区域吞吐量大幅上升的核心原因。
4.2 计算强度与访存带宽的限制
再往深一层,GPU的吞吐量理论上由两个瓶颈卡着:要么算不过来,要么数据喂不过来。这在体系结构里叫Roofline模型。判断一个算子到底被哪个瓶颈卡住,看计算强度——单位字节访存对应的浮点计算次数。
YOLO12这种卷积主导的网络,整体上属于计算密集型,卷积层动辄几十上百的TOPS计算量,计算强度很高,GPU算力可以发挥得很充分。但网络里不是所有算子都这么“友好”:特征图拼接(Concat)、上采样(Resize),还有检测头的reshape操作,这些层访存量大但计算量小,计算强度很低。
batch size变大后,计算密集的卷积层继续能吃到算力红利,但访存密集的层会逐渐逼近显存带宽上限。data表格里8到16的增幅放缓,就是这部分访存瓶颈开始出头了。
4.3 为什么batch太大反而回落
batch=32吞吐量下降,这跟很多人的直觉相悖,但背后有几个很现实的因素。
首先是L2缓存命中率下降。GPU的L2缓存大小是固定的,batch=16的时候,一个batch的中间特征加上权重还能有比较高的命中率;batch=32时,单次推理需要搬运的数据块大幅增大,L2缓存装不下,开始频繁访问显存。显存带宽虽然很宽,但相比L2带宽还是差了一个数量级,一旦从缓存层打到显存层,整体访存速度就往下跌。
其次是显存占用压力。batch=32时接近10.6GB,接近12GB总显存。这个时候如果你系统里还开着桌面显示、或者其他进程占用显存,分配器可能要用到比较保守的内存池策略,甚至出现页迁移,这些都是隐藏开销。
第三,TensorRT autotuning时,大batch往往会选中一些更“激进”的kernel实现——比如更强的pipeline调度、更大的平铺块。这些kernel理论上更高效,但它们的启动同步开销也更大。单batch总耗时被拉长到59毫秒之后,任何一点开销被放大,收益就被吃掉了。
一句话总结:batch size的甜点区,是计算资源利用率、访存带宽、L2命中率和显存容量四个方面博弈出来的平衡点。
5. 工程落地:不同场景下batch size应该怎么选
5.1 在线实时服务:延迟约束优先,别只盯着吞吐量
如果你做的是在线服务,比如安防平台的实时检测、直播画面的内容审核,那结论不是“选吞吐量最高的batch=16”,而是要优先看延迟约束。注意表格里那一列单batch平均耗时:batch=16时,一次推理要28.7毫秒才能返回。如果你的业务要求单次请求的p99延迟在20毫秒以内,batch=16直接不合格,因为用户不可能等你去凑够16张图再一起推理。
在延迟敏感场景下,我的经验是按照这条链路去选参数:
- 先确定业务的延迟上限。比如要求p99小于30毫秒。
- 从batch=1开始,逐档看单batch耗时是否满足延迟预算。
- 在满足延迟预算的最大batch里,挑吞吐量最高的。
按这个逻辑,如果延迟预算在30毫秒,batch=16的28.7毫秒刚好卡在规定内,可以用;如果预算只有20毫秒,那就只能降到batch=8。如果延迟预算在10毫秒以内,老老实实batch=2以下,然后靠后续说的多stream方案补吞吐。
5.2 离线批处理:直接压甜点区
离线场景相对简单。比如离线视频抽帧检测、批量图片质检、数据清洗跑模型,没有实时延迟约束,追求的就是单位时间处理张数最大化。这时候直接选数据表里的吞吐量峰值点,也就是batch=16。如果显存还要留一些给其他任务,选batch=8也不会损失太多,毕竟从8到16,吞吐只提升4%,但显存占用多出了2GB多。
这类场景里我还有一个建议:如果单batch吞吐量已经触顶,可以尝试同时开两个推理stream,每个stream维持batch=8。在某些卡上,两个stream交替提交任务,能把GPU的空隙填得更满,这种方式的吞吐可能比单stream跑batch=16还要高一点。能不能吃这个红利,取决于你的kernel是否足够短小、stream间切换开销大不大,实测为准。
5.3 动态shape与请求聚合
真实线上服务的请求到达是零散的,很多时候根本凑不满一个大batch。比如你设了batch=16,但某几毫秒内只来了5个请求,剩下的11个空位怎么办?硬等的话,先来的请求延迟就爆了。
两种主流解法。
第一种是动态shape。TensorRT的optimization profile允许你设置min/opt/max三档,推理时每次调用可以传入不同batch。但要注意,动态shape的engine在性能上通常会比静态shape的专用engine差几个百分点,因为TensorRT没法像静态那样做极致的kernel特化。而且频繁变batch,buffer分配和context状态切换也有额外开销。
第二种是请求队列聚合。在推理服务前端维护一个请求队列,设置一个最大batch值和等待窗口,比如5毫秒。来了请求就进队列,攒够maxBatch就立刻推理;如果5毫秒还没攒够,那就把已有请求凑成一个小batch先发出去。这样做等于把“凑批”从模型层搬到了调度层,用可控的等待时间换取吞吐。我实测下来,等待窗口设4到6毫秒,对整体吞吐的影响很小,但小batch延迟基本不会被拉爆。这套方案比频繁切换动态shape更稳,也更容易做超时控制和优先级调度。
6. 测试过程中踩过的坑,提前帮你避开
6.1 warmup没做够,数据全是虚的
第一次跑测试的时候,我为了赶时间,只warmup了2轮就开始记录数据。结果前几十次推理的耗时有明显跳变,第一次甚至接近后面均值的两倍。这是TensorRT和CUDA内部机制共同作用的结果:第一次enqueueV2时,kernel可能还在做某种形式的懒加载,cuDNN或者TensorRT的上下文缓存也要初始化,显存页面首次触碰会有Page Fault。
正确的做法是:warmup轮数至少给到20轮,让kernel彻底跑热,模型加载、context初始化这些一次性开销全部在warmup阶段消耗掉,然后再计时。如果是端到端的完整流水线测试,还要额外注意CPU侧的数据预处理管线也会有个“预热”过程,别把第一次数据预处理的时间算进推理里。
6.2 计时要敢用CUDA Event,别用CPU时钟
CPU侧打时间戳在CUDA场景下基本是废的。原因很简单:enqueueV2是一个异步调用,函数返回时kernel可能还没真正开始执行。你把CPU时间戳打在函数调用前后,测出来的其实是“提交任务的时间”,而不是“GPU执行完的时间”,两者差距在系统负载高的时候非常离谱。
正确姿势就是用CUDA Event。在stream里记录start事件,提交所有推理任务,再记录stop事件,最后同步等待,用cudaEventElapsedTime取时间。这个时间才是GPU端真实执行耗时。如果你想进一步排除CPU提交端的干扰,还可以把cudaEventRecord(start)放在第一次enqueue之前,然后连续enqueue 200次,最后统一record stop。循环里的提交时间是流水线化的,测的是稳定的GPU执行吞吐,而不是单次启动的延迟抖动。
提示:如果你要对比“端到端”和“纯推理”两种指标,一定要在文章和汇报里写清楚测的是哪一段。两套数据差出来的量,主要就是H2D/D2H拷贝和预处理的时间,混着说等于自欺欺人。
6.3 显存占用不是“够用就行”
batch=16到batch=32,吞吐量几乎原地踏步,显存占用却从6.3GB涨到10.6GB。这笔交易在部署上非常不划算。而且显存这东西不只是“够不够”的问题,还要看运行时余量。TensorRT在推理时的显存使用分两部分:模型权重常驻显存,中间激活值按batch动态增长。显存占用顶到90%以上之后,一旦系统有其他进程申请显存,就容易触发OOM或者性能抖动。
在多路并发场景下更要警惕:每开一个context,都会复制权重和中间buffer。假设你要开4个context做多路并行,选batch=8而不是batch=16,可能就从4x2.2GB变成4x4.3GB的差距。这种多模型、多实例部署的显存规划,必须从全局角度算,而不是单模型最优。
6.4 不要直接照搬别人的甜点区
最后强调一个容易犯的错:别把任何一篇测试文章的甜点区直接抄到自己的项目里。这张表格里的8到16是我这张卡、YOLO12、FP16下的结果,换成RTX 4090或者换成ViT结构,甜点区可能完全不一样。影响甜点区的变量包括:GPU算力与显存带宽的比例、模型计算强度、TensorRT版本、是否为该batch单独构建engine、有没有开FP16、输入分辨率。甚至同一个模型换一个输入分辨率,甜点区都可能偏移。
规范化做法就是我把流程再复述一遍:先小batch摸延迟底,然后按2的倍数往上试batch,每个点单独构建engine,每个点跑三轮取均值,同时记录显存,最后结合延迟约束选参数。这套流程跑一次大概一两个小时,但换来的是靠谱的部署参数和后面排障时的基准线。
再分享一个小技巧:压测之前先用trtexec快速跑一把参考数据。./trtexec --model=yolov12.onnx --fp16 --minShapes=images:1x3x640x640 --optShapes=images:8x3x640x640 --maxShapes=images:8x3x640x640 --shapes=images:8x3x640x640,命令行直接能看到TensorRT自己报的吞吐参考值。拿这个值和你C++代码里测到的做对比,如果差距超过10%,那先别急着调batch,优先检查代码里有没有内存拷贝、同步、buffer复用之类的问题。多一条独立的参考线,后面排查问题会轻松很多。