ONNX Runtime 和 TensorRT 这两个名字搞推理优化的人应该都不陌生。前者是微软主导的跨平台推理引擎几乎什么硬件都能跑后者是 NVIDIA 官方为自家 GPU 定制的深度学习推理优化引擎性能天花板更高。这半年我把一个基于 PyTorch 训练的目标检测模型从 ONNX Runtime 迁移到了 TensorRT 原生推理过程谈不上轻松但收益确实实打实——单帧延迟降了不止一半显存占用也小了一圈。这篇东西就完整记录一下这次的迁移探索过程从转换链路、精度取舍到部署踩坑适合正在做推理加速或准备做模型部署的工程师参考。1. 为什么要从 ONNX Runtime 折腾到 TensorRT先说清楚我手头这个项目是什么情况一个运行在 NVIDIA 工控机上的目标检测服务输入是摄像头画面模型本身不算大但帧率要求高而且需要长期在边缘设备上跑对显存和延迟都敏感。最开始用的是 ONNX Runtime 的 CUDA Execution Provider也就是让 ONNX Runtime 把算子调度到 GPU 上执行。这个方案最大的优势是省事模型从 PyTorch 导成 ONNX 之后基本不用改代码就能跑起来跨平台支持也好不用绑定 NVIDIA 的生态。但 ONNX Runtime 在 GPU 上的优化方式相对保守它做的是通用算子调度优化不会像 TensorRT 那样针对某一张显卡的架构去深度调优。具体到我的场景里ONNX Runtime 跑同一个 FPN 结构的检测模型GPU 利用率波动明显帧间延迟的抖动也比较大。后来我换了 TensorRT 原生推理同样一张 1080Ti延迟从 18~25ms 稳定到了 8~11ms这个差距就不是换个运行库单纯的算子重排能做出来的差距了是 TensorRT 对整个计算图做了重构级别的优化。1.1 我为什么没有在 ONNX Runtime 里直接用 TensorRT Execution Provider这里有个很容易被忽略的细节ONNX Runtime 其实也支持挂载 TensorRT 作为执行后端配置一个 session options 就行。那为什么我还要绕一圈去做 TensorRT 原生因为 ONNX Runtime 挂 TensorRT EP本质上还是 ONNX Runtime 在管理整个图调度TensorRT 只是以一个插件形态参与部分算子加速。这意味着跨 EP 的算子边界处会有数据拷贝和格式转换的开销而且有些 ONNX 算子的表达方式和 TensorRT 的优化期望并不完全匹配导致部分子图没法被 TensorRT 完全接管。我这个项目里的模型有不少自定义的后处理操作如果用 ONNX Runtime 挂 TensorRT量化、层融合都受限于 ONNX Runtime 的图优化边界。而直接走 TensorRT 原生 API我可以完全控制整个引擎的构建方式从精度模式到工作空间大小到动态 shape 范围全都能手动指定。说白了ONNX Runtime 是让你快速跑起来TensorRT 原生是让你把每一份算力和显存都抠出来。对于边缘设备上长时间运行的推理服务这一步折腾是值得的。1.2 什么样的场景值得迁移什么样的场景不要碰这是我踩过一轮后总结的选型判断标准。如果你的运行环境有多样化的硬件比如既要跑 ARM 又要跑 AMD GPU那别碰 TensorRT老老实实用 ONNX Runtime或者直接用 ONNX Runtime 的 CPU 和 CUDA EP。但如果你确定生产环境就是 NVIDIA GPU而且模型结构相对固定不会频繁改动网络层对单帧延迟和吞吐量有明确压力指标显存预算紧张Tegra 系列或老架构显卡用得比较多需要用到半精度或 INT8 量化来压性能那 TensorRT 原生就是绕不过去的一条路。我这个项目恰好全中所以迁移的投入产出比很高。2. 模型转换链路从 PyTorch 到 ONNX 再到 TensorRT 引擎TensorRT 本身不直接读取 PyTorch 的权重文件最通用的路线是先把 PyTorch 模型导出成 ONNX 中间格式再通过 TensorRT 的解析器转换成引擎。很多网上的速通教程会让你用 trtexec 命令行直接一步搞定但真实项目里基本不会这么顺利尤其是模型里有动态 shape、自定义算子或者复杂的后处理逻辑时每一步都可能卡住。2.1 第一步把 PyTorch 模型导出成可用的 ONNX导出这一步决定了后面 TensorRT 能不能顺利解析非常关键。用torch.onnx.export时有四个参数是我吃过亏之后才重视起来的opset_version、dynamic_axes、input_names、output_names。opset 版本直接影响算子表达方式。TensorRT 对 ONNX 算子的支持情况是跟着版本走的opset 太新可能导致 TensorRT 解析器不认某些新算子opset 太旧又可能让一些组合算子展开成一堆细碎操作增加后续层融合的难度。我在这次项目里用的 PyTorch 2.x 导出最终固定在了 opset 16。这个版本既能完整表达模型里的常见结构TensorRT 8.x 和 9.x 的解析器对它支持也很好。dynamic_axes 这个参数决定了 ONNX 模型的输入 shape 是不是动态的。TensorRT 构建 engine 时允许指定动态 shape 范围但前提是 ONNX 图里对应维度的符号没有写死。这里有个小技巧即使你部署时只需要固定输入分辨率导出 ONNX 时也建议先导出带有动态 batch 维的版本然后在 trtexec 或者 Python API 构建时再固定具体范围。原因后面讲动态 shape 坑的时候细说。还有torch.onnx.export默认会把模型切到 eval 模式但torch.no_grad()还是要手动包一层否则即使只是导出也能把 Batchnorm 的统计量搞乱。导出完成后我强烈建议先做一遍 ONNX 简化用onnxsim或onnxruntime自带的图优化工具把常量折叠、冗余节点清理掉。这一步会直接影响后续 TensorRT 解析时的算子匹配率实测同一个模型清理前后TensorRT 构建时报告的层融合数量和 engine 体积都有可感知的差异。2.2 第二步构建 TensorRT 引擎的两种主流姿势TensorRT 引擎构建有两种主流姿势用 trtexec 命令行工具以及用 Python/C API 自己写构建脚本。trtexec 适合快速验证模型能不能被解析、不同精度配置下的性能大概什么水平它是 TensorRT 自带的一个可执行程序一行命令就能完成从 ONNX 到 engine 的转换。比如我早期验证性能上限时就跑过类似这样的命令trtexec --onnxmodel.onnx \ --saveEnginemodel_fp16.engine \ --fp16 \ --minShapesimages:1x3x640x640 \ --optShapesimages:4x3x640x640 \ --maxShapesimages:8x3x640x640但 trtexec 有个局限它按照你指定的输入输出张量来生成绑定模型的预处理、后处理、动态 shape 的精细控制都不方便在命令行里完成。所以到了正式集成阶段我改用 Python API 一步步构建。TensorRT 的 Python API 构建过程核心逻辑是这样几个步骤创建 builder配置网络定义和精度解析 ONNX设置优化 profile最后序列化保存 engine。这里重点说下优化 profile。当模型存在动态 shape 时TensorRT 要求你至少提供一个 optimization profile里面明确给出每个动态维度允许的最小、最大和常见 shape。层融合和 kernel 选择都是基于这个 profile 来做的optShape的值会影响自动调优器为哪些尺寸做针对性优化。我的经验是optShape尽量设置成实际生产环境最常见批大小和分辨率而不是为了省事直接填最大值否则构建出来的 engine 在常用尺寸上可能不是最优的。import tensorrt as trt logger trt.Logger(trt.Logger.WARNING) builder trt.Builder(logger) network builder.create_network(1 int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)) parser trt.OnnxParser(network, logger) with open(model.onnx, rb) as f: parser.parse(f.read()) profile builder.create_optimization_profile() profile.set_shape(images, (1, 3, 640, 640), (4, 3, 640, 640), (8, 3, 640, 640)) config builder.create_builder_config() config.add_optimization_profile(profile) config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 1 30) config.set_flag(trt.BuilderFlag.FP16) plan builder.build_serialized_network(network, config) with open(model.engine, wb) as f: f.write(plan)这段代码是我后来整理出来的最小可用版本。注意EXPLICIT_BATCH这个标志在 TensorRT 8 之后是必须的这是新版 API 要求显式声明 batch 维度是动态的不再像旧版那样内部隐式处理 batch 维度。2.3 网络结构对转换的影响有些模型结构天生对 ONNX 导出和 TensorRT 转换友好有些则不是。我在这次迁移中对这个问题感受极深。模型里含有大量reshape、transpose、split操作时TensorRT 解析出的层数量会明显增加这并非好事因为层之间的数据搬运可能抵消掉算子融合带来的收益。特别是检测类模型里常见的 FPN 结构多层特征图之间的 concat 和 upsample 操作会形成一种类似网状的连接结构TensorRT 对这些区域需要精心匹配融合策略处理不当就会导致某些层一直以半精度之外的低效模式执行。如果你自己的模型导出后用 Netron 查看 ONNX 图发现有很多看着就冗余的 Cast、Concat、Shape 节点先别急着上 TensorRT优先回 PyTorch 端调整导出逻辑或者在上 ONNX 简化时把这类节点去掉。这比后面在 TensorRT 里拼命调参数有效得多。图本身就是接口图不干净后面全是坑。3. 推理性能实测FP32、FP16 与精度校准的取舍TensorRT 引擎构建时最核心的一个选择就是精度配置。FP32、FP16、INT8 三档我都测过性能、精度、显存三个维度的表现差别很大。对于大多数目标检测、语义分割类模型FP16 是性价比最高的选择。它的收益来自 Volta 之后的 GPU 架构对半精度计算有专门的硬件单元吞吐通常是同架构 FP32 的两倍而模型精度损失一般能控制在可接受范围内。但 FP16 带来的风险是动态范围变小有些模型的中间激活值如果本来就分布在极端范围半精度可能导致梯度下溢或溢出。现在的网络结构基本都会用 Batchnorm 或 LayerNorm 这类归一化手段所以风险没那么夸张。但保险起见我转换后必做的一步是对比同一输入下 FP32 engine 和 FP16 engine 的输出差异把 cosine similarity 算一下如果低于 0.99 就要小心了。3.1 基准测试怎么设计才靠谱很多人测性能的时候习惯直接拿个计时器包住推理调用然后输出一个平均延迟。这种做法在真正做工程优化时不够用。我这次测性能的流程分了三步热身、分桶统计、并发验证。热身是关键的第一步TensorRT 引擎第一次推理会触发 lazy initialization显存分配、kernel 加载都在这一刻发生如果不做热身就直接计时得到的第一帧数据能比稳态慢一个数量级。我一般是预热 20 次左右再开始正式测量。分桶统计是针对延迟抖动做的跑 1000 次推理每次记录从输入拷贝到输出取回的总耗时然后按 P50、P95、P99 分位数来分析。P50 反映典型性能P95 和 P99 反映极端情况下的稳定性。我之前被一个只报平均帧率的方案坑过实际跑视频流时每几十帧就蹦一次高延迟平均帧率挺好看但用户体验就是卡顿所以分位数统计是必须的。并发验证在工控机这种也算多路摄像头并发的场景里尤为重要。我当时的测试是按 4 路输入同时推理来压测观察显存占用会不会持续爬升。TensorRT 的显存池是复用的但如果代码里每个 session 都单独创建 context 且没有人管理生命周期显存碎片会在长时间运行后暴露出来。3.2 FP16 的收益和需要注意的风险我的模型用 FP16 构建后单帧延迟相比 FP32 降低了约 40%显存占用因为半精度权重减半的关系也从 2.1GB 降到了 1.4GB 左右。但 FP16 不是白给的它要求输入数据在预处理时也能对应到合适的数据类型。ONNX Runtime 时代我输入是 float32 的 numpy 数组改成 TensorRT 后需要显式转换成 float16否则每次推理都会有一次隐式转换虽然不影响精度但会多出一次拷贝的开销。还有一处容易忽略某些算子在 FP16 下会使用不同的 kernel 实现导致输出的数值分布略微偏移。我的模型是检测模型最终输出是检测框坐标和类别概率FP16 下坐标偏移通常只有零点几个像素无伤大雅。但如果你的模型是语义分割或像素级回归任务输出对精度更敏感建议测试时用 mIoU 或像素级误差指标验收而不是只看坐标重叠度。3.3 INT8 量化到底要不要上这次迁移我也尝试了 INT8 量化说实话收益和代价并存。int8 engine 的性能确实比 FP16 又提升了一截显存占用进一步下降但前提是必须做校正。TensorRT 官方提供了一整套基于校准数据集的量化流程要求你准备一批有代表性的真实输入数据来统计各层激活值的分布然后据此选择量化尺度。我第一轮做 INT8 的时候偷懒直接拿了训练集的图片做校准数据结果跑到真实摄像头画面上检测置信度崩了一截。后面换成从实际场景中采样的图片集合才恢复正常。如果你没有精力维护一套跟线上数据分布一致的校准集建议保守一点用 FP16。INT8 更适合模型结构已经冻结、线上输入分布长期稳定的项目我一个做工业质检的朋友他们那边用 INT8 跑得很稳因为他们拍摄角度、光照条件完全固定校准数据跟线上数据基本没差异。4. 落地部署时的坑每一个都踩过模型转换完成只是第一步真正把 TensorRT 引擎集成到服务里跑起来才是踩坑的高峰期。这里我把这次迁移中碰到的几个主要问题完整记录一下都是文档里不会写得太细的地方。4.1 动态 shape 引发的工程连锁问题前面提过我在导出 ONNX 时保留了动态 batch 维构建 TensorRT engine 时设了 1 到 8 的动态范围。但是运行时你会遇到一个问题虽然 engine 支持动态 shape但每次调用enqueue_v2或execute_async_v2之前你必须通过 context 设置实际的输入 shape而且如果两次推理的 shape 不同TensorRT 内部可能需要重新绑定显存、重新选择部分 kernel这会在中间帧产生额外延迟。我的解决方案是在上层服务里做统一的输入尺寸管理。所有输入图像在进入推理前先 resize 到固定分辨率batch 大小也在批处理队列里按固定值聚合。这样表面上浪费了一点动态 shape 的灵活性但换来了延迟的稳定性和代码的简单性。对于视频流这种对帧间隔敏感的场景稳定性比灵活性更重要。4.2 显存管理不当导致的崩溃TensorRT 构建 engine 时会根据 profile 自动计算显存需求并做内存池预分配。但如果你在同一个进程里反复创建和销毁 engine或者加载多个 engine 不释放旧引擎显存碎片化问题会很快暴露。我最初的版本为了调试方便每次切换配置都重新创建 engine跑了几个小时服务突然报 CUDA out of memory排查到最后发现就是旧 engine 没有销毁CUDAGraph 和显存池没释放。正确的做法是engine 的构建和加载应该只在启动时做一次之后整个生命周期内复用同一个 execution context。如果确实需要切换不同配置应该显式删除旧对象并调用torch.cuda.empty_cache或对应的 CUDA 显存清理接口。4.3 多显卡环境下的 device 绑定工控机有时候会有集显加独显的组合或者一台机器插多张不同型号的独显。TensorRT 构建 engine 和运行时推理都必须指定统一的 CUDA device否则会出现一个莫名的非法内存访问错误。我排查过的一个案例是PyTorch 默认把张量放到了 device 0但 TensorRT engine 是通过 device 1 构建的推理时互拷数据就炸了。解决方案是在整个项目的入口处统一设置 CUDA_VISIBLE_DEVICES 环境变量并让 PyTorch、ONNX Runtime、TensorRT 三方都只看到同一张物理显卡。4.4 engine 的跨环境序列化问题TensorRT 的 engine 序列化产物是二进制 plan 文件但这个文件跟构建时的 TensorRT 版本、GPU 架构、显存配置都有耦合。换一张不同架构的显卡或者升级 TensorRT 版本旧 engine 文件基本就不能用了。所以我在部署流程里做了一个约束engine 文件必须在目标机器上当场构建或者构建时指定多架构兼容模式不要把宿主机上构建的 engine 直接拷到工控机上跑。这个坑一次就够让人记一辈子了我见过不少人在这个环节栽跟头——明明模型一样别人的机器跑得好好的自己的机器报could not find any kernel原因就是架构不匹配。5. 常见问题排查速查与经验总结TensorRT 的报错信息有时候很抽象尤其是那些纯错误码没有上下文提示的情况。以下是我这次迁移过程中整理的问题排查表和解决方案照着这个思路做能省不少时间。5.1 问题排查速查表现象可能原因排查方法处理方案engine 构建时报错 CUSOLVER 相关模型里包含矩阵求逆或求解类算子用 Netron 检查 ONNX 图中是否有相关节点在导出 ONNX 前手动改写模型把求逆操作用其他方式替代engine 能构建但运行时报 illegal memory access输入张量 shape 超出优化 profile 范围检查输入 shape 和 onnx 导出时设置的动态维度是否一致统一运行时 shape 管理确保任何时刻都不超过 maxShapeFP16 下输出精度显著下降模型中间层存在对精度敏感的算子逐层对比 FP32 和 FP16 的输出差异对敏感层设置排除精度标志只对大部分层使用 FP16同一份 engine 在不同机器上加载失败TensorRT 版本或 GPU 架构不匹配检查两边的 TensorRT 版本和 CUDA 架构代号重新在目标机器上构建 engine显存占用持续上升execution context 没有正确释放用 nvidia-smi 监控推理循环前后的显存变化每个 context 使用完显式删除避免重复创建 builder推理延迟 P99 远高于 P50动态 shape 切换或 CPU 预处理频繁分阶段计时定位瓶颈输入预处理后移或改为固定分辨率5.2 一定要在代码层面做显式管理的东西TensorRT 的 Python API 和 C API 风格差异很大。如果你和我一样先在 Python 里验证流程再迁移到 C 线上代码有一个地方容易踩坑Python 里 TensorRT 对象引用计数管理相对宽松只要对象不被垃圾回收就行但 C 里必须严格按照 builder 到 engine 到 context 的生命周期顺序创建和销毁。先销毁 context 再销毁 engine顺序搞反会导致 pending 的 CUDA kernel 执行时访问已释放的内存。另外TensorRT 8.x 之后提供的enqueueV3和 CUDAGraph 结合使用能进一步压缩延迟但引入了额外的显存管理复杂度。如果你不是对延迟有极致要求先用稳一点的enqueueV2把性能基线打出来觉得不够再上 CUDAGraph。我做这次迁移时给自己定的原则是每个阶段只引入一个变量确认新的优化项稳定了再继续下一个。5.3 回到 ONNX Runtime 和 TensorRT 的坐标系里看待这条迁移迁移完成之后再回头看ONNX Runtime 和 TensorRT 其实不是对立关系而是不同层级的工具。ONNX Runtime 处理的是模型可移植性和跨平台部署的问题TensorRT 处理的是特定硬件上算力榨取的问题。我最后生产环境里实际上两个都用了模型分析和小规模验证走 ONNX Runtime正式推理服务走 TensorRT 原生。两者共存的架构让我既能快速验证新想法又能保证线上性能稳定。这次迁移花了我大概两周时间真正用在写代码上的时间大概不到三分之一剩下的时间全花在理解报错、调精度、测边界条件上。但回头看这波折腾很值我对模型从训练到部署的全链路有了更清晰的认知后续再做模型优化或者换新架构显卡时调整起来会快很多。最后再分享一个小技巧TensorRT 构建 engine 的时候留意看一下构建日志里那些 warning别觉得不影响构建就跳过。很多时候 warning 里会提示某些算子回退到了低效实现、某些层被强制使用了 FP32 精度这些都是性能优化空间的直接线索。我自己就是根据一条detected invalid kernel的 warning 追到了模型里一个多余的 transpose删掉之后延迟又降了 2ms。这种收益不需要改模型结构只需要你愿意多看几行日志。