十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

TensorRT部署YOLO12:batch size对GPU吞吐量影响实测

TensorRT部署YOLO12:batch size对GPU吞吐量影响实测 之前有个朋友私信我说同样的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之后折算出来的这两种场景下数字的含义完全不同。如果只是batch1的单帧延迟那它本质上衡量的是“一张图从进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都是单独构建的enginewarmup 20轮正式测试200轮取平均。为了减少偶然性每个点我跑了三轮取中间值。batch size单batch平均耗时(ms)折算吞吐量(FPS)显存占用(GB)12.833532.124.714252.448.124923.0814.985344.11628.705576.33259.6353710.6这里解释一下表格里两个值得盯住的列。单batch平均耗时是“一次推理跑完batch张图的总时间”折算吞吐量是用batch size除以单batch耗时再乘1000换算来的每秒处理图片数。比如batch16时一次推理28.7毫秒处理16张图折算下来每秒能处理557张。3.2 关键发现吞吐量并不是越大越涨数据贴出来之后几个结论非常明确。第一从batch1到batch16吞吐量从353一路涨到557 FPS提升幅度接近58%。这意味着什么意味着如果你线上一直用batch1显卡有一大半的算力是闲着不干活的。在TensorRT部署里把batch size从1调到甜点区是很典型的高性价比优化代码逻辑都不用动只是把batch参数和buffer大小改对吞吐就上去了。第二提升幅度是边际递减的。看相邻档位的增幅从1到2提升了约20%2到4提升了约16%4到8只提升了约8%而8到16更是只有4%出头的提升。这个趋势说明当batch小的时候GPU计算单元利用率低每次增大batch都是雪中送炭当batch到8之后SM基本已经跑得比较满了再加图进去只是让每张图分摊到的计算资源略微变少吞吐量增长也就钝了。第三也是最值得注意的batch32时吞吐量不升反降从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的固定开销都存在。你调一次enqueueV2GPU就要经历一次任务下发。batch1时每张图的kernel启动成本完全无法摊薄batch16时同样一次启动成本可以分摊到16张图上GPU的有效工作时间占比自然就上来了。用流水线类比一下就很好懂工人从传送带上拿零件加工如果传送带一次只送一个零件工人每次都要停下来等下一个如果你一次把16个零件排成一排送过来工人可以连续加工很久。这也是小batch区域吞吐量大幅上升的核心原因。4.2 计算强度与访存带宽的限制再往深一层GPU的吞吐量理论上由两个瓶颈卡着要么算不过来要么数据喂不过来。这在体系结构里叫Roofline模型。判断一个算子到底被哪个瓶颈卡住看计算强度——单位字节访存对应的浮点计算次数。YOLO12这种卷积主导的网络整体上属于计算密集型卷积层动辄几十上百的TOPS计算量计算强度很高GPU算力可以发挥得很充分。但网络里不是所有算子都这么“友好”特征图拼接Concat、上采样Resize还有检测头的reshape操作这些层访存量大但计算量小计算强度很低。batch size变大后计算密集的卷积层继续能吃到算力红利但访存密集的层会逐渐逼近显存带宽上限。data表格里8到16的增幅放缓就是这部分访存瓶颈开始出头了。4.3 为什么batch太大反而回落batch32吞吐量下降这跟很多人的直觉相悖但背后有几个很现实的因素。首先是L2缓存命中率下降。GPU的L2缓存大小是固定的batch16的时候一个batch的中间特征加上权重还能有比较高的命中率batch32时单次推理需要搬运的数据块大幅增大L2缓存装不下开始频繁访问显存。显存带宽虽然很宽但相比L2带宽还是差了一个数量级一旦从缓存层打到显存层整体访存速度就往下跌。其次是显存占用压力。batch32时接近10.6GB接近12GB总显存。这个时候如果你系统里还开着桌面显示、或者其他进程占用显存分配器可能要用到比较保守的内存池策略甚至出现页迁移这些都是隐藏开销。第三TensorRT autotuning时大batch往往会选中一些更“激进”的kernel实现——比如更强的pipeline调度、更大的平铺块。这些kernel理论上更高效但它们的启动同步开销也更大。单batch总耗时被拉长到59毫秒之后任何一点开销被放大收益就被吃掉了。一句话总结batch size的甜点区是计算资源利用率、访存带宽、L2命中率和显存容量四个方面博弈出来的平衡点。5. 工程落地不同场景下batch size应该怎么选5.1 在线实时服务延迟约束优先别只盯着吞吐量如果你做的是在线服务比如安防平台的实时检测、直播画面的内容审核那结论不是“选吞吐量最高的batch16”而是要优先看延迟约束。注意表格里那一列单batch平均耗时batch16时一次推理要28.7毫秒才能返回。如果你的业务要求单次请求的p99延迟在20毫秒以内batch16直接不合格因为用户不可能等你去凑够16张图再一起推理。在延迟敏感场景下我的经验是按照这条链路去选参数先确定业务的延迟上限。比如要求p99小于30毫秒。从batch1开始逐档看单batch耗时是否满足延迟预算。在满足延迟预算的最大batch里挑吞吐量最高的。按这个逻辑如果延迟预算在30毫秒batch16的28.7毫秒刚好卡在规定内可以用如果预算只有20毫秒那就只能降到batch8。如果延迟预算在10毫秒以内老老实实batch2以下然后靠后续说的多stream方案补吞吐。5.2 离线批处理直接压甜点区离线场景相对简单。比如离线视频抽帧检测、批量图片质检、数据清洗跑模型没有实时延迟约束追求的就是单位时间处理张数最大化。这时候直接选数据表里的吞吐量峰值点也就是batch16。如果显存还要留一些给其他任务选batch8也不会损失太多毕竟从8到16吞吐只提升4%但显存占用多出了2GB多。这类场景里我还有一个建议如果单batch吞吐量已经触顶可以尝试同时开两个推理stream每个stream维持batch8。在某些卡上两个stream交替提交任务能把GPU的空隙填得更满这种方式的吞吐可能比单stream跑batch16还要高一点。能不能吃这个红利取决于你的kernel是否足够短小、stream间切换开销大不大实测为准。5.3 动态shape与请求聚合真实线上服务的请求到达是零散的很多时候根本凑不满一个大batch。比如你设了batch16但某几毫秒内只来了5个请求剩下的11个空位怎么办硬等的话先来的请求延迟就爆了。两种主流解法。第一种是动态shape。TensorRT的optimization profile允许你设置min/opt/max三档推理时每次调用可以传入不同batch。但要注意动态shape的engine在性能上通常会比静态shape的专用engine差几个百分点因为TensorRT没法像静态那样做极致的kernel特化。而且频繁变batchbuffer分配和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 显存占用不是“够用就行”batch16到batch32吞吐量几乎原地踏步显存占用却从6.3GB涨到10.6GB。这笔交易在部署上非常不划算。而且显存这东西不只是“够不够”的问题还要看运行时余量。TensorRT在推理时的显存使用分两部分模型权重常驻显存中间激活值按batch动态增长。显存占用顶到90%以上之后一旦系统有其他进程申请显存就容易触发OOM或者性能抖动。在多路并发场景下更要警惕每开一个context都会复制权重和中间buffer。假设你要开4个context做多路并行选batch8而不是batch16可能就从4x2.2GB变成4x4.3GB的差距。这种多模型、多实例部署的显存规划必须从全局角度算而不是单模型最优。6.4 不要直接照搬别人的甜点区最后强调一个容易犯的错别把任何一篇测试文章的甜点区直接抄到自己的项目里。这张表格里的8到16是我这张卡、YOLO12、FP16下的结果换成RTX 4090或者换成ViT结构甜点区可能完全不一样。影响甜点区的变量包括GPU算力与显存带宽的比例、模型计算强度、TensorRT版本、是否为该batch单独构建engine、有没有开FP16、输入分辨率。甚至同一个模型换一个输入分辨率甜点区都可能偏移。规范化做法就是我把流程再复述一遍先小batch摸延迟底然后按2的倍数往上试batch每个点单独构建engine每个点跑三轮取均值同时记录显存最后结合延迟约束选参数。这套流程跑一次大概一两个小时但换来的是靠谱的部署参数和后面排障时的基准线。再分享一个小技巧压测之前先用trtexec快速跑一把参考数据。./trtexec --modelyolov12.onnx --fp16 --minShapesimages:1x3x640x640 --optShapesimages:8x3x640x640 --maxShapesimages:8x3x640x640 --shapesimages:8x3x640x640命令行直接能看到TensorRT自己报的吞吐参考值。拿这个值和你C代码里测到的做对比如果差距超过10%那先别急着调batch优先检查代码里有没有内存拷贝、同步、buffer复用之类的问题。多一条独立的参考线后面排查问题会轻松很多。
返回列表