PyTorch模型转TensorRT全链路实战:ONNX导出、引擎构建与精度调优
模型训练完之后真正让人头疼的往往不是网络结构本身而是怎么把它塞进推理引擎里跑出该有的速度。我最近刚把一个 PyTorch 模型从.pth一路折腾到 TensorRT 的.trt引擎中间踩的坑比训练本身还多。这篇就把整条链路——PyTorch 导出 ONNX、ONNX 校验与简化、TensorRT 环境安装、trtexec 与 Python API 两条构建路径、精度对齐和性能调优——完整捋一遍。不管你是刚接触部署的新手还是已经跑通过一次但被精度问题折磨过的老手下面这些内容应该都能帮你少走几个晚上的弯路。1. 先把整条链路想清楚为什么是 PyTorch → ONNX → TensorRT1.1 三个环节各自解决什么问题很多人一上来就问“能不能直接从 PyTorch 转到 TensorRT”答案是能但通常不推荐作为第一步。原因在于这三者的定位完全不同。PyTorch 是训练框架它的计算图是动态的带着大量 Python 层面的控制流、autograd 元信息、以及训练专用的算子比如 Dropout 在训练和推理时行为不同。TensorRT 是推理引擎它只关心前向计算而且要求计算图是静态的、算子集合是固定的。这两者之间需要一个“中间语言”来过渡ONNX 就是这个中间语言。ONNX 的价值在于它是一个开放的、框架无关的模型表示格式。PyTorch 官方提供了torch.onnx.export把动态图 trace 或 script 成静态图TensorRT 官方提供了 ONNX Parser把 ONNX 图翻译成自己的网络定义。中间隔一层 ONNX好处是导出环节的问题和构建引擎环节的问题可以分开排查。如果直接走 PyTorch → TensorRT 的路径比如通过torch-tensorrt一旦出错你很难判断是导出阶段的问题还是引擎构建阶段的问题。所以这条链路的分工是PyTorch → ONNX负责把训练图“冻结”成推理图去掉训练专用逻辑固定输入输出形状或标记动态维度。ONNX → TensorRT负责算子融合、精度校准FP16/INT8、kernel 自动调优最终生成针对特定 GPU 架构优化的.trt引擎文件。1.2 什么情况下值得走这条路不是所有模型都值得上 TensorRT。我的经验判断标准是模型在 GPU 上推理且对延迟敏感比如实时视频处理、在线服务模型结构相对稳定不会一天改三次batch size 和输入分辨率基本固定或者变化范围可控愿意花时间做精度对齐。如果模型本身很小比如几层全连接或者推理频率极低那用 ONNX Runtime 甚至原生 PyTorch 就够了上 TensorRT 的收益可能还不如你调试花的时间。TensorRT 的收益主要来自三块层融合把 ConvBNReLU 合成一个 kernel、精度降低FP32→FP16→INT8、以及针对特定 GPU 的 kernel 自动调优。模型越大、层越多收益越明显。1.3 版本兼容性是这条链路最大的隐形杀手在动手之前我必须强调一件事PyTorch、ONNX、onnxruntime、TensorRT、CUDA、cuDNN 这几个东西的版本是强耦合的。我见过太多人卡在“导出成功但 TensorRT 解析失败”或者“解析成功但输出全是 NaN”最后发现是版本不匹配。一个相对稳妥的组合思路是先确定你的 GPU 驱动支持的 CUDA 版本上限然后选一个 TensorRT 版本再反推它支持的 ONNX opset 和 PyTorch 版本。TensorRT 每个版本对 ONNX opset 的支持是有上限的比如某些版本最高支持到 opset 17你导出时用了 opset 18 的新算子Parser 直接报 unsupported。提示导出 ONNX 时不要盲目用最新 opset。先查清楚你目标 TensorRT 版本支持的 opset 范围再在torch.onnx.export里用opset_version参数指定。2. PyTorch 导出 ONNX那些文档里不会写的细节2.1 导出前的模型准备eval 模式和设备选择导出前第一件事把模型切到 eval 模式model.eval()。这一步看起来简单但影响很大。BatchNorm 在 train 和 eval 模式下行为不同Dropout 也是。如果你忘了切 eval导出的 ONNX 里 BN 层会带着训练时的统计逻辑推理结果直接偏掉。第二件事是设备。我建议在 CPU 上导出或者至少保证导出时用的 device 和后续推理一致。有些算子在不同 device 上的导出行为不一样尤其是涉及自定义算子的时候。导出时给一个 dummy input这个 input 的 shape 决定了 ONNX 图的输入 shape。import torch model.eval() dummy_input torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, model.onnx, opset_version13, input_names[input], output_names[output], dynamic_axes{ input: {0: batch_size}, output: {0: batch_size} }, do_constant_foldingTrue )这里几个参数值得展开说。do_constant_foldingTrue会在导出时把能算的常量提前算掉减小图体积一般建议开。dynamic_axes是动态维度的关键如果你希望 batch size 可变就必须在这里标记。但要注意标记了动态维度之后TensorRT 构建引擎时需要配置 optimization profile否则默认按固定 shape 处理。2.2 trace 和 script 的选择逻辑torch.onnx.export默认走 trace 模式也就是喂一个 dummy input 跑一遍记录实际执行的操作。trace 的优点是简单缺点是它记录的是“这一次执行路径”。如果你的模型里有if分支依赖输入值比如根据输入内容走不同分支trace 只会记录走的那一条其他分支丢失。遇到这种情况有两个选择一是用torch.jit.script把模型转成 TorchScript 再导出script 模式能保留控制流二是改写模型把数据依赖的控制流改成算子形式比如用torch.where替代 if。我的经验是能改写就改写因为 script 模式对某些 Python 语法支持有限而且导出的图往往更复杂TensorRT 解析时更容易出问题。2.3 导出后的第一道校验onnx.checker 和 onnxruntime 对齐导出成功不代表导出正确。我养成的习惯是导出后立刻做两件事第一用onnx.checker.check_model做结构校验能抓出图结构层面的明显错误。import onnx onnx_model onnx.load(model.onnx) onnx.checker.check_model(onnx_model)第二用 onnxruntime 跑一遍和 PyTorch 的输出做数值对比。这一步是精度对齐的第一道防线如果这里就对不上后面 TensorRT 更不可能对。import onnxruntime as ort import numpy as np ort_session ort.InferenceSession(model.onnx) ort_inputs {ort_session.get_inputs()[0].name: dummy_input.numpy()} ort_outputs ort_session.run(None, ort_inputs) with torch.no_grad(): torch_outputs model(dummy_input).numpy() diff np.max(np.abs(ort_outputs[0] - torch_outputs)) print(max diff:, diff)一般来说FP32 下这个 diff 应该在 1e-5 到 1e-4 量级。如果 diff 到了 1e-2 甚至更大说明导出过程有问题常见原因是某些算子被替换成了近似实现或者 eval 模式没切对。2.4 onnx-simplifier不是万能但常常有用onnx-simplifier这个工具能做一些图优化比如常量折叠、冗余算子消除、把一些复杂算子拆解成基础算子。它的价值在于有些 PyTorch 导出的图里会带一些 TensorRT 不支持的算子组合simplifier 有时能帮你化简掉。pip install onnx-simplifier python -m onnxsim model.onnx model_sim.onnx但要注意simplifier 不是万能的而且它本身也可能引入问题。我的做法是先不简化直接试 TensorRT 解析如果报 unsupported operator再试简化。简化后一定要重新做一次 onnxruntime 对齐确认数值没变。3. TensorRT 环境安装比想象中更需要耐心3.1 安装方式的取舍pip 还是 tar 包TensorRT 的安装方式主要有几种pip 安装、tar 包解压、以及通过系统包管理器。我个人的偏好是 tar 包原因是可控性强能清楚知道每个库文件在哪出问题好排查。pip 安装最省事但有时候会和系统里已有的 CUDA 库产生冲突。pip 安装大致是这样pip install tensorrt但要注意pip 上的 TensorRT 包通常只包含 Python 绑定和部分库完整的推理库比如libnvinfer.so可能需要额外安装。而且 pip 包的版本和你的 CUDA 版本必须匹配装错了会在 import 时报找不到符号。tar 包方式则是下载对应 CUDA 版本的 TensorRT 压缩包解压后把lib目录加到LD_LIBRARY_PATH把python目录下的 whl 装进环境。这种方式的好处是版本对应关系一目了然。3.2 环境变量配置LD_LIBRARY_PATH 是重灾区TensorRT 运行时依赖一堆动态库如果LD_LIBRARY_PATH没配好会出现ImportError: libnvinfer.so.X: cannot open shared object file。我的做法是在激活环境后显式设置export LD_LIBRARY_PATH/path/to/tensorrt/lib:$LD_LIBRARY_PATH export PATH/path/to/tensorrt/bin:$PATH把bin也加进去是为了能用trtexec这个命令行工具后面构建引擎会频繁用到它。注意LD_LIBRARY_PATH的顺序很重要。如果系统里同时存在多个版本的 CUDA 或 TensorRT 库排在前面的会优先被加载。建议把 TensorRT 的路径放在最前面避免加载到旧版本。3.3 验证安装是否成功装完之后别急着跑模型先做最小验证import tensorrt as trt print(trt.__version__) logger trt.Logger(trt.Logger.WARNING) builder trt.Builder(logger) print(builder created:, builder is not None)如果这几行能跑通说明 Python 绑定和底层库都加载正常。如果import tensorrt就报错八成是LD_LIBRARY_PATH或者 CUDA 版本的问题。这时候可以用ldd检查具体缺哪个库ldd $(python -c import tensorrt; print(tensorrt.__file__))它会列出所有依赖库的解析情况缺哪个一目了然。3.4 一个容易被忽略的点CUDA 版本与 TensorRT 的对应TensorRT 每个版本都明确声明支持的 CUDA 版本范围。比如某个 TensorRT 版本可能只支持 CUDA 11.x你系统里装的是 CUDA 12.x那即使装上了运行时也可能出各种诡异问题。查对应关系最靠谱的方式是看官方 release notes 里的 compatibility matrix而不是凭感觉。我踩过一次坑系统 CUDA 是 12.1装了个只支持到 11.8 的 TensorRTimport能过但一构建引擎就 segfault。后来换成匹配版本问题消失。所以这一步千万别省。4. 用 trtexec 构建引擎最快跑通第一条链路4.1 trtexec 是什么为什么先用它trtexec是 TensorRT 自带的命令行工具能把 ONNX 直接转成.trt引擎还能顺便跑性能测试。我强烈建议第一次转换先用它原因是它把构建引擎的所有步骤都封装好了你不需要写一行 Python 代码就能验证“这个 ONNX 到底能不能被 TensorRT 解析”。如果 trtexec 都过不了那写 Python API 也是白搭。基本用法trtexec --onnxmodel.onnx --saveEnginemodel.trt --fp16这一行做了几件事加载 ONNX、构建网络、应用 FP16 精度、序列化引擎到文件。如果 ONNX 里有 TensorRT 不支持的算子这一步会直接报错并告诉你哪个算子不支持。4.2 动态 shape 的 optimization profile 配置如果你的 ONNX 标记了动态 batchtrtexec 需要你指定 shape 范围否则它默认按固定 shape 处理。配置方式是通过--minShapes、--optShapes、--maxShapestrtexec --onnxmodel.onnx \ --saveEnginemodel.trt \ --fp16 \ --minShapesinput:1x3x224x224 \ --optShapesinput:8x3x224x224 \ --maxShapesinput:16x3x224x224这里三个 shape 的含义要理解清楚min和max定义了引擎支持的输入范围opt是“最优”shapeTensorRT 会针对这个 shape 做 kernel 调优。所以opt应该设成你实际推理时最常用的 batch size。如果你实际跑 batch8 居多就把 opt 设成 8这样性能最好。提示min、opt、max三个 shape 的维度数必须一致而且opt必须落在min和max之间。设错了 trtexec 会直接报错。4.3 精度选项FP32、FP16、INT8 怎么选--fp16是最常用的选项绝大多数模型在 FP16 下精度损失可以忽略速度提升明显。--int8则需要校准calibration因为 INT8 的量化范围需要根据实际数据分布确定。校准需要一个校准数据集配置起来更复杂。我的建议是先用 FP32 跑通确认精度没问题再切 FP16对比精度和速度如果 FP16 还不够快再考虑 INT8。不要一上来就 INT8否则精度出问题你都不知道是量化的问题还是导出本身的问题。4.4 解读 trtexec 的输出trtexec 跑完会打印一堆信息其中最有价值的是这几块Layer 信息告诉你哪些层被融合了哪些层用了什么精度。Throughput吞吐量单位是 qpsqueries per second。Latency延迟分 min/median/max/percentile。GPU Compute Time纯计算时间不含数据传输。我一般重点看 median latency 和 throughput。如果发现某个层没被融合比如出现了独立的 BN 层可能是导出时 BN 没被折叠可以考虑在导出前手动 fold BN。5. Python API 构建引擎需要精细控制时的选择5.1 什么时候必须用 Python APItrtexec 够用但有些场景必须用 Python API需要在构建时动态决定精度策略比如某些层 FP16某些层 FP32需要自定义插件custom plugin需要把引擎构建集成到服务里而不是手动跑命令行需要更细粒度地控制 optimization profile。Python API 的流程是创建 logger → 创建 builder → 创建 network → 用 parser 解析 ONNX → 配置 builder config → 构建并序列化。5.2 完整构建代码与逐行解释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: if not parser.parse(f.read()): for i in range(parser.num_errors): print(parser.get_error(i)) raise RuntimeError(ONNX parse failed) config builder.create_builder_config() config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 1 30) config.set_flag(trt.BuilderFlag.FP16) profile builder.create_optimization_profile() profile.set_shape( input, min(1, 3, 224, 224), opt(8, 3, 224, 224), max(16, 3, 224, 224) ) config.add_optimization_profile(profile) serialized_engine builder.build_serialized_network(network, config) with open(model.trt, wb) as f: f.write(serialized_engine)几个关键点展开说。EXPLICIT_BATCH这个 flag 现在基本是必须的它表示网络使用显式 batch 维度。set_memory_pool_limit设置 workspace 大小workspace 是 TensorRT 构建时用来做 kernel 调优的临时内存太小会导致某些 tactic 无法使用太大浪费显存。1GB 是个常见的起点如果构建时报 workspace 不足就往上加。parser.parse返回 False 时一定要遍历parser.num_errors把错误打出来否则你只知道失败不知道原因。这是排查 ONNX 兼容性问题最重要的信息来源。5.3 构建失败的常见原因与排查顺序构建失败时我一般按这个顺序排查看 parser 错误如果是 unsupported operator说明 ONNX 里有 TensorRT 不支持的算子需要换实现或写插件。看 shape 问题如果报 shape 相关错误检查 dynamic_axes 和 optimization profile 是否匹配。看 workspace如果报内存不足加大 workspace。看精度如果 FP16 构建失败但 FP32 成功可能是某些层不支持 FP16可以设置config.set_flag(trt.BuilderFlag.OBEY_PRECISION_CONSTRAINTS)或者逐层指定精度。5.4 引擎序列化与反序列化构建好的引擎可以序列化到磁盘下次直接反序列化加载省去构建时间。构建一个复杂引擎可能要几分钟甚至更久所以生产环境一定是构建一次、多次加载。runtime trt.Runtime(logger) with open(model.trt, rb) as f: engine runtime.deserialize_cuda_engine(f.read())要注意引擎是和 GPU 架构绑定的。在 A 卡上构建的引擎拿到 B 卡上可能无法加载。所以部署时要确保构建环境和运行环境的 GPU 架构一致或者干脆在目标机器上构建。6. 精度对齐从 FP32 到 FP16 的数值验证6.1 为什么精度对齐是必须的TensorRT 会做算子融合和精度转换这些操作会引入数值差异。FP16 的精度损失是必然的关键是要控制在可接受范围内。如果你的模型对数值敏感比如涉及 softmax 后的概率、或者小目标的检测精度对齐没做好线上表现可能和离线差很多。6.2 三层对齐法PyTorch vs ONNX vs TensorRT我的做法是做三层对比第一层PyTorch 输出 vs ONNX Runtime 输出验证导出正确性第二层ONNX Runtime 输出 vs TensorRT FP32 输出验证 TensorRT 解析正确性第三层TensorRT FP32 输出 vs TensorRT FP16 输出验证精度损失可接受。每一层都算 max abs diff 和相对误差。经验阈值是第一层 diff 1e-4第二层 diff 1e-3第三层 diff 1e-2。超过这些阈值就要警惕。6.3 用 polygraphy 做自动对比手动写对比代码比较繁琐NVIDIA 提供了polygraphy这个工具能自动做多层对比polygraphy run model.onnx \ --trt \ --fp16 \ --onnxrt \ --val-range input:0:1 \ --atol 1e-2 --rtol 1e-2它会同时跑 ONNX Runtime 和 TensorRT对比输出并报告差异。--atol和--rtol是绝对和相对容差。这个工具在排查精度问题时非常高效强烈推荐。6.4 精度不达标时的调整手段如果 FP16 精度不达标可以尝试部分层保持 FP32对数值敏感的层比如最后的分类头单独设成 FP32。关闭某些融合有些融合会引入误差可以尝试禁用。调整校准如果是 INT8重新做校准用更有代表性的校准数据。我遇到过一次某个模型的 FP16 输出在边界样本上偏差很大最后发现是某一层的 LayerNorm 在 FP16 下精度不够把那一层单独设成 FP32 后问题解决。7. 性能调优与部署时的实际考量7.1 batch size 与吞吐的权衡batch size 不是越大越好。大 batch 能提高 GPU 利用率但会增加延迟。在线服务通常追求低延迟所以 batch 要小离线批处理追求吞吐batch 可以大。我的做法是测几个不同 batch size 下的延迟和吞吐画一条曲线找拐点。用 trtexec 可以方便地测trtexec --loadEnginemodel.trt --batch8 --iterations1007.2 workspace 大小对构建的影响workspace 太小会导致 TensorRT 无法使用某些高性能 tactic构建出的引擎性能可能不是最优。但 workspace 太大也没必要因为它只在构建时占用。我的经验是给 1-2GB如果构建日志里出现“some tactics were skipped due to workspace limit”就往上加。7.3 引擎缓存与版本管理生产环境里引擎文件应该和模型版本、TensorRT 版本、GPU 型号绑定管理。我见过因为引擎文件和 TensorRT 版本不匹配导致加载失败的案例。建议在引擎文件名里带上这些信息比如model_v1.2_trt8.6_sm86.trt一目了然。7.4 一个实际部署中的小技巧如果服务需要频繁重启每次重启都反序列化引擎会浪费时间。可以在服务启动时一次性加载引擎并创建 execution context然后复用。execution context 是线程不安全的多线程场景下每个线程需要独立的 context但可以共享同一个 engine。context engine.create_execution_context() context.set_input_shape(input, (8, 3, 224, 224))设置 input shape 这一步在动态 shape 场景下必须做否则 context 不知道按哪个 shape 分配内存。8. 我踩过的几个典型坑与排查思路8.1 导出成功但 TensorRT 报 unsupported operator这是最常见的坑。原因通常是 PyTorch 某个算子导出成了 ONNX 里的某个 op而 TensorRT 不支持这个 op。排查方法是看 parser 的具体报错它会告诉你哪个节点、哪个 op 不支持。解决办法有几种换一个等价的算子实现、用 onnx-simplifier 化简、或者写自定义插件。我有一次遇到Resize算子不支持某个 mode最后把模型的 resize 逻辑改成了Interpolate的另一种模式才通过。8.2 动态 shape 下输出 shape 不对动态 shape 场景下如果 optimization profile 配置不对或者 ONNX 里的 shape 推导有问题输出 shape 可能和预期不符。排查方法是先用固定 shape 跑通确认逻辑正确再逐步引入动态维度。8.3 FP16 下出现 NaNFP16 的数值范围比 FP32 小很多某些中间结果可能溢出成 NaN。排查方法是逐层对比 FP32 和 FP16 的输出找到第一个出现 NaN 的层。解决办法是把这个层设成 FP32或者对输入做归一化。8.4 引擎加载失败版本或架构不匹配前面提过引擎和 GPU 架构绑定。如果加载时报错先确认构建引擎的 GPU 和运行引擎的 GPU 是不是同一架构。另外TensorRT 版本也要一致不同版本的引擎格式可能不兼容。排查这类问题的顺序是先看错误信息里的关键词是 version 还是 architecture再对照构建和运行环境。我一般会在构建脚本里把 TensorRT 版本、CUDA 版本、GPU 型号都打印出来方便对比。8.5 一个关于 workspace 的隐蔽坑有一次构建引擎时没报错但性能比预期差很多。后来看构建日志发现因为 workspace 设得太小TensorRT 跳过了一些高性能 tactic。把 workspace 从 256MB 加到 2GB 后性能提升了将近 40%。这个坑很隐蔽因为构建是成功的只是性能没到最优。所以构建日志一定要看尤其是关于 tactic 的部分。9. 收尾一些个人经验整条链路走下来我最大的体会是版本管理和精度对齐是这条路上两个最耗时的环节但也是最值得提前投入的。与其出了问题再回头查版本不如一开始就把版本对应关系理清楚把每一层的数值对比脚本写好。另外trtexec 和 polygraphy 这两个工具真的能省很多时间。前者帮你快速验证 ONNX 能不能转后者帮你快速定位精度问题。我现在的习惯是任何新模型先用 trtexec 跑一遍看能不能过再用 polygraphy 做精度对比两个都过了再写正式的部署代码。最后分享一个小技巧把整个转换流程写成一个脚本从导出 ONNX 到构建引擎到精度验证一条龙跑完每次模型更新直接重跑。这样既避免了手动操作的遗漏也方便做版本对比。我现在的脚本里会记录每次转换的 PyTorch 版本、ONNX opset、TensorRT 版本、GPU 型号和精度对比结果出问题时回溯起来非常方便。