
最近我把一条视频智能分析链路的推理后端从 ONNX Runtime 一步步迁到了 TensorRT 原生整个过程的收获比我预想的大很多。起因是一个很实际的需求在 T4 上用 YOLO 做 640 分辨率的检测视频流是 1080p 25 帧甲方直接问我“这卡能扛住多少路”。我一开始觉得先用 ONNX Runtime 的 GPU 推理把服务跑起来再优化也不迟结果实测下来发现瓶颈根本不在“能不能跑”而在“能跑多快、稳定扛多少路”。这篇就当一次完整复盘把我这次从模型导出、engine 转换、动态 shape 调参、算子兼容性排查到多路并发估算的整个过程都记下来同时也聊聊我在这个过程中踩过的坑。适合正在从 ONNX Runtime 迁移到 TensorRT、第一次碰 TensorRT 部署流程、或者正被多路视频流吞吐问题困扰的朋友。1. 为什么绕了一大圈还是要回到 TensorRT1.1 场景需求25 帧视频流背后的硬预算先说需求里那个“1080p 25 帧”意味着什么。25 帧每秒换成单帧时间预算就是 1000ms / 25 40ms。也就是说每一帧从视频解码、缩放、推理、后处理到输出结果整条链路最多只能用 40ms。听起来好像很宽裕但如果你同时处理多路就不是单帧延迟的问题了而是吞吐和延迟互相挤兑的问题。我一开始想得很简单ONNX Runtime 在 GPU 上也有 CUDA EP直接把 PyTorch 模型导成 ONNX然后扔给 onnxruntime-gpu 跑就行。这在单路、帧率不高的场景下确实没问题。但一旦你把多路视频流丢进去每一路都排队等 GPU问题就来了。我手头那张 T4 上先用 ONNX Runtime 的 CUDA EP 跑一个 YOLOv5s 640 输入单帧推理大概在 10ms 上下。看着好像挺快但如果你要支持 6 路每路之间就得排队再算上解码和前后处理40ms 预算很快就被吃光。这还没算 GPU 显存里各种中间 tensor 的分配开销。所以没过多久我就意识到 ONNX Runtime 只是一个“通用”方案正向 TensorRT 原生这条路不太能绕开。1.2 ONNX Runtime 的定位通用与极致之间是冲突的ONNX Runtime 最大的优势是跨框架、跨硬件。它本质上是一个“通用运行时”各种框架导出的 ONNX 模型都能跑CPU、GPU、NPU 都能跑。但“通用”这个词背后藏着性能和效率的代价。比如 TensorRT 会在构建 engine 时做层融合把 Conv BatchNorm ReLU 合并成一个算子减少 kernel 启动次数和显存读写ONNX Runtime 却要保持算子级别的通用语义它必须为各种平台留出兼容路径。我实测过同一张 T4、同一个模型ONNX Runtime CUDA EP 推理耗时大约 10msTensorRT FP16 engine 能压到 4ms 左右。性能差距不是某个算子跑得慢而是执行编排方式完全不一样。TensorRT 构建 engine 时会生成一个高度定制化、算子融合过的执行计划而 ONNX Runtime 更接近“把 ONNX 图按顺序跑一遍”。如果你的业务和竞对差别就差在这几毫秒上选择一点也不难做。1.3 为什么不用 ONNX Runtime 自带的 TensorRT EP可能有朋友会问onnxruntime-gpu 里不是也有 TensorRT EP 吗直接在 ORT 后端上挂 TensorRT 不就好了这条路我也试过但最后放弃了。ORT 里的 TensorRT EP 相当于“ONNX Runtime 有选择地让 TensorRT 接管部分算子”它没有完全暴露 TensorRT 的底层能力。你最依赖的动态 batch 优化、多 stream 调度、workspace 显存池控制在 ORT 里都隔了一层。TensorRT 原生部署就不一样了engine 怎么构建、batch 范围怎么设、显存复用策略怎么配、上下文和 CUDA stream 怎么管理全部自己掌控。虽然上手成本高一点但对多路视频流这种要精确管理吞吐和显存的场景这个掌控感很有价值。如果你只是快速验证想法ORT 的 TensorRT EP 够了如果是生产环境且性能敏感我建议一步到位走原生。2. 从 PyTorch 导出 ONNX这一步做不好后面全是坑2.1 导出前的准备别急着执行 torch.onnx.export转换链的第一步看起来很简单torch.onnx.export一行代码但这一步如果草草了事后面 TensorRT 转换时各种 Unsupported Op、维度不匹配都会冒出来。先说导出前的动作最关键的是这几件事。model.eval()和torch.no_grad()必须放进导出代码里这个大家基本都知道但容易忽略的是模型里的 batch norm 层。某些 BN 实现的 running mean/var 在model.trainingFalse之前如果没有收敛好导出的 ONNX 里就会带着训练态的行为痕迹推理效果可能悄悄变差。我觉得更值得注意的坑是输入示例。ONNX 导出时会根据输入示例追踪整个计算图如果你的示例张量 shape 或数值范围跟真实业务不一致ONNX 里某些算子可能会按“最不利”的方向展开。所以导出前最好用真实预处理后的一张图做输入示例而不是随便torch.randn。2.2 dynamic_axes 到底怎么设如果你的部署场景只有固定 batch1完全可以不设 dynamic axesengine 也会相对小一些。但多路部署时我更建议把 batch 维度设成动态。以 YOLO 为例输入一般是[N, 3, 640, 640]我不想把 H/W 也放开因为 H/W 动态会显著增加 engine 构建难度和显存碎片。只把 N 放开就行。import torch # 把 batch 维度标记为动态 dynamic_axes {images: {0: batch}} torch.onnx.export( model, (dummy_input,), yolov5s.onnx, input_names[images], output_names[output], dynamic_axesdynamic_axes, opset_version13, do_constant_foldingTrue, )opset 不是越高越好我就遇到过 opset 17 导出的模型里某些算子 TensorRT 8.6 还不支持降到 13 反而顺利通过。这个版本关系我在第三个部分还会细说。2.3 导出后必须验证哪怕是企业级模型也一样导出完必须跑一遍 onnxruntime 验证输出差异这一步能帮你区分“模型转换坏了”和“后续部署坏了”。我习惯用onnxruntime.InferenceSession加载导出的模型拿同一张输入图与原 PyTorch 模型输出做对比rtol 和 atol 都放宽到 1e-3 级别确保形状和数值大致一致。如果模型是 YOLO 这类检测模型还要注意一个点官方仓库导出的 ONNX 里后处理有时候是保留的比如 decode部分 NMS有时候是不保留的输出只有原始预测头。TensorRT 转换时我更推荐导出带原始输出的版本也就是把 decode、NMS 这些后处理留到业务代码里。原因很简单NMS 里很多算子例如 NonMaxSuppression在 TensorRT 里处理起来比较麻烦而且把后处理从 GPU 拿回 CPU 时动态输出 shape 也会扰乱 engine 构建。先导个干净的模型后处理自己用 CUDA 或推理框架的插件处理都会省心很多。3. ONNX 转 TensorRT enginetrtexec 和参数取舍3.1 先跑通 trtexec再谈 Python APITensorRT 构建 engine 有三种常见方式直接用命令行工具 trtexec用 Python API 写转换脚本或者用 C API 做生产集成。我最建议第一次接触 TensorRT 的人先跑 trtexec因为它把所有参数暴露在命令行里调试非常快而且能直接看构建进度和性能统计。TensorRT 安装目录一般在/opt/TensorRT-x.x.x.xtrtexec 就在bin目录下。/opt/TensorRT-8.6.1.6/bin/trtexec \ --onnxyolov5s.onnx \ --saveEngineyolov5s.engine \ --fp16 \ --minShapesimages:1x3x640x640 \ --optShapesimages:4x3x640x640 \ --maxShapesimages:8x3x640x640 \ --memPoolSizeworkspace:2048跑完以后trtexec 会打印出一堆统计信息GPU Compute Time、Host Latency 这些很有价值。但别被输出里的数值完全洗脑那是在理想循环条件下测的真实业务吞吐还得另行实测。这个我放在第五部分展开。3.2 精读核心参数min/opt/max 与 FP16 的关系这几个参数建议群里未来会经常被问到我比较有耐心这里就多讲一点。--minShapes、--optShapes、--maxShapes是给 engine 指定动态 shape 范围的TensorRT 构建时不是对所有 shape 平均优化它会在optShapes指定的维度附近做最佳 kernel 选择。比如你实际部署时每个 batch 大多是 4~6那optShapes就设成 4 或 6别设成 1否则真正跑 4 路时性能会打折。--fp16是一步见效的优化开关。FP16 推理能把带宽压力减半kernel 计算也更快。但前提是模型对精度不是特别敏感。检测模型一般问题不大分割、回归类模型就得谨慎一点。建议是先跑 FP16如果精度可接受就保持不可接受再退回 FP32或者用 INT8 量化但要加校准集。--memPoolSizeworkspace:2048是限制 TensorRT 构建时使用的显存 workspace 上限单位 MB。显存不够时优先压这个宁愿多花一点构建时间也别让运行时 OOM。我实测下来如果显存只有 16GmaxShapes 又开得太大构建阶段就可能直接把显存打爆。注意这里写的是新版参数名老版本叫--workspace不同版本命名有差异记得看你的 trtexec--help。3.3 TensorRT 安装与版本对齐最容易翻车的那一步网上 tensorrt 安装教程非常多但这个库的安装本质是“版本对齐”。我这次踩过最典型的坑是宿主机 CUDA 12.2但按照老教程装了配套 CUDA 11.x 的 TensorRT结果运行时报engine error: driver API之类的错。TensorRT 编译时写死了依赖的 CUDA runtime 和 cuDNN 版本你推理机器上的驱动可以向后兼容但 runtime 库版本最好和构建时一致。所以给个务实建议先确定目标机器的 CUDA 版本再按 CUDA 大版本选择对应的 TensorRT 安装包。如果只是 Python 环境使用可以只装pip install tensorrt对应的 Python wheel但 C 部署或使用 trtexec 时系统安装包也建议一起装好否则找不到libnvinfer.so。我在安装完以后会跑一个小验证python -c import tensorrt as trt; print(trt.__version__)能正常打印出来再继续下一步。别在版本问题上浪费太多时间。4. 动态 shape、FP16 精度与算子兼容性排查4.1 动态 shape 的隐藏成本我之前在 batch 维度开了动态H/W 固定这个过程还算顺利但即使这样动态 shape 也带来了两个隐藏成本一是 engine 构建时间明显变长二是显存预留会按maxShapes来规划不是按你经常使用的 shape 来规划。也就是说如果你把 maxBatch 设成 32TensorRT 在显存分配时很可能按 32 的规模预留平时跑 batch 8 照样占掉一大块显存。我在部署时经常做的事是先做一轮“显存摸底实验”。分别构建 maxBatch8、16、32 的 engine加载到 GPU 上用nvidia-smi看实际占用再结合业务需求选合适的最大值。很多人在这一步只关注推理速度忽略了显存规划结果生产上线第一天就显存溢出。4.2 FP16 转完精度漂了怎么办YOLO 检测模型通常对 FP16 不算敏感但也不是所有子模块都一样。置信度阈值附近那些框FP16 计算时 IoU 和 score 的精度变化可能让 NMS 结果产生细微差异表现出来就是某个目标偶发漏检或框偏移。我排查这类问题有一个固定套路先做小样本对比拿同一段视频分别跑 FP32 engine 和 FP16 engine统计检测框数量、类别置信度分布再画 PR 曲线对比。如果整体 mAP 掉点在 0.5% 以内通常可以接受如果掉得明显检查网络里有没有 LayerNorm、Sigmoid 这类对精度敏感的位置。也可以考虑对输出置信度阈值做微调很多时候不是 FP16 不行而是一个地区阈值的适配问题。如果 FP16 实在不行还有一个折中方案用 Polygraphy 给模型做逐层 FP16 对比找少数落差的层强制保持 FP32。这算进阶玩法但对精度敏感的模型很有效。4.3 Unsupported Operation 的排查流程转到 TensorRT 时常见的报错是[E] [TRT] ... unsupported op。我的处理顺序是这样的先看报错列出的算子名然后在 TensorRT 的官方算子支持表里查版本如果查得到但就是不支持可能是输入 shape 动态范围导致 TensorRT 无法推导出合适的 kernel这时优先调整动态范围。如果算子真不支持先上onnx-simplifier简化模型很多显式Constant - Gather之类的小算子会被折叠掉可以减少不支持算子的出现概率。最后实在不行才考虑写 Plugin。写插件代价较高建议非核心算子不要轻易走到这一步。还有一个缓兵之计把 ONNX 的 opset 版本往低调。有些高版本 opset 里的算子 TensorRT 还没覆盖而同样功能在低版本 opset 里可能被表示成几个通用算子TensorRT 反而能接受。我手上的模型最后是从 opset 17 降到 13 才顺利转完的。4.4 FastSAM 这类分割模型也能走同一套链路如果你手里不是 YOLO 而是 FastSAM 那种分割模型加速思路完全一样。FastSAM 用 ViT 提取特征输出是 mask 分支转 ONNX 时输出维度会大不少engine 构建的显存和耗时都会更高。这个模型用 C 部署时也更要注意输出 buffer 的管理mask 是[batch, 1, H, W]级别的张量copy 回 CPU 的耗时可能超过你省下来的 GPU 加速时间。我建议把 mask 的降采样或者阈值化尽量挪到 GPU 侧的 kernel 里做完只把最终小图传回 CPU。这一条对检测里多个输出头的处理同样适用。5. 部署侧的真实瓶颈从单帧延迟到多路吞吐5.1 Benchmark 的正确姿势预热、流水线、端到端很多人在这一步被 trtexec 的输出迷惑觉得单帧 1ms那是不是可以轻松支持 40 路不是。trtexec 测的是“连续喂同尺寸数据”时的稳态吞吐实际业务里视频流解码、搬数据、排队、前后处理全是开销尤其是 Unity 那种多路并发下CPU 和 PCIe 拷贝会成为新的瓶颈。我习惯的 benchmark 方式是部署成一个内部 HTTP 服务把一帧真实视频帧发送进去测量端到端延迟和吞吐。并且一定要先跑 200 帧预热把 CUDA context 和显存页面的初始化成本磨平再开始统计。每次调整后都要记录解码耗时、预处理耗时、推理耗时、NMS 耗时、拷贝耗时这五项不然你根本不知道瓶颈在哪。5.2 T4 实测能扛多少路一份估算方法回到开头的问题“T4 上 1080p 25fps、YOLO 640 检测能支持多少路”。我没法给你一个通吃的精确数值因为模型大小、TensorRT 版本、是否开 FP16、后处理写得好不好都会影响但我可以给你一个非常可靠的估算框架。先算单帧预算25fps 意味着每帧 40ms。假设视频解码用硬解耗时 3msletterbox 缩放加去归一化 2msNMS 加框过滤 3ms。留给推理的预算大约 32ms。我这边的 T4 环境 TensorRT 8.6 FP16 YOLOv5s单帧推理稳定在 4ms 出头。理论上并行路数约 32 / 4 8 路。但实际部署还得留余量因为显存碎片、GPU 调度抖动、峰值流量都会侵蚀这点余量。保守一点按 70% 利用率算5~6 路比较稳。如果你换成 YOLOv5n 这类更轻量的模型单帧可能压到 2ms那支持路数能再多一些但要注意检测精度下降对业务的影响。如果输入分辨率从 640 提到 1280推理耗时可能翻 3 倍以上路数要骤减。所以“多少路”永远是一个依赖具体配置的工程问题而不是一个固定答案。5.3 多流并发别把每个线程当成一个 engine多路视频流部署时最常见的错误方案是“一路一个 engine”因为简单直观。但 T4 显存只有 16GYOLO engine 一个就占几百 MB 甚至更多开了十几路 VRAM 直接吃紧而且多个 engine 之间的 CUDA context 切换开销也会让性能变得很难看。我最终的做法是一个进程里只创建一个 engine但创建多个 execution context再配合多条 CUDA stream。每路视频流对应一个 stream帧数据到了以后先做解压和预处理CPU 或 GPU 侧然后以异步方式提交到对应的 stream 上。这样能最大化重叠计算和数据传输而不是让各路在 GPU 上串行排队。如果使用 Python 绑定还要注意 GIL 对多线程调度的影响更激进的做法是把预处理完全下沉到 GPU 端 kernel或者干脆换 C 部署这就是热词里有人搜 FastSAM C TensorRT 的背景之一。6. 踩过的坑与问题速查以及一点经验收尾6.1 高频问题速查表这一part我整理了这次从 ONNX Runtime 迁到 TensorRT 过程中遇到的高频问题每条都是我用真金白银试出来的写成表方便你以后排查。症状可能原因处理方式engine 加载后第一次推理巨慢CUDA context 初始化、kernel 编译启动后先预热 20~50 次推理动态 batch 下显存占用超预期maxShapes 开得过大显存按最大值预留降低 maxShapes实测占用再定上限FP16 转换后检测偶发丢框置信度阈值附近精度差异导致 NMS 结果变化跑 PR 对比微调阈值必要时逐层回退 FP32ONNX 转 engine 报 Unsupported Op算子超出 TensorRT 支持范围简化 ONNX、降低 opset 版本、更新 TensorRT最后才写 Plugin同一模型 Python 与 C 推理结果不一致后处理逻辑或 buffer 管理不一致统一后处理接口输出先 dump 对比多线程调用 engine 崩溃多个线程共享同一个 engine 但各自创建 context 后资源没管理好一个 engine 对应多个 context别在并发路径上创建销毁6.2 一点经验收尾最后分享一个我这次项目里觉得非常受用的小技巧保存 engine 文件的同时把当时的构建配置一起保存下来。像是 FP16 开关、minShapes/optShapes/maxShapes、输入输出名字、NMS 阈值、输入分辨率这些全部写到一个 meta.json 里。为什么要这么做因为 TensorRT engine 是和一个确切配置绑定的今天你构建的 engine过两周可能已经忘了当时用的是哪个版本的 TensorRT 或者哪个优化策略。出了问题想复现就非常痛苦。我个人还有一个小习惯就是每次性能对比都建一个“矩阵表”横轴是方案ONNX Runtime CPU / ONNX Runtime GPU / TensorRT FP32 / TensorRT FP16纵轴是单帧延迟、显存占用、是否支持动态 batch、构建耗时、精度对比。所有决策都在这个表上做而不是凭感觉。每次把一个配置跑完之后把数据补进表里后面迁移到新模型时就有一个很扎实的参考基准。如果你也正在做模型加速可以先把 ONNX Runtime 当作一个 baseline它的价值在于帮你确认上游模型行为正确然后你再拿 TensorRT 去逐项压榨性能。尤其记住模型转换这层只是开始真正拉开差距的往往是多流调度、前后处理下沉和显存规划这些部署细节。希望这篇记录能帮你少走点弯路也欢迎你在自己的环境下多跑几组数据不同 GPU、不同 TensorRT 版本之间差异真的很大量出来的数字永远比网上看来的靠谱。