☰
PyTorch模型转ONNX再到TensorRT的部署全流程实战
2026/10/20 3:16:30 网站建设 项目流程

几年前我第一次把训练好的PyTorch模型往推理环境里搬,试了各种方案,最后落在ONNX转TensorRT这条路上。折腾过的人都知道,光把模型训出来根本不叫完事,能稳定高效地跑在生产环境里才是真功夫。这几年模型部署的需求越来越多,ONNX作为通用中间格式,TensorRT作为GPU上的推理加速引擎,这对组合几乎成了深度学习落地链路上的默认选项。

如果你手头有个训练好的模型,想在显卡上榨干推理性能,或者正在被“模型能跑但太慢”“换了个环境就报错”之类的问题困扰,这篇文章就是冲着你来的。我会把从PyTorch转ONNX、ONNX结构检查、再转TensorRT、动态shape处理到精度调优这条链路上所有值得说的细节都过一遍,包括我踩过的坑和最后沉淀下来的通用流程,尽量让后来的人少走弯路。

1. ONNX与TensorRT到底解决什么问题

1.1 先搞清模型格式和推理引擎是两回事

很多刚接触部署的同学容易把ONNX和TensorRT混在一起,以为“转成ONNX就等于加速了”,其实完全不是一回事。

ONNX是一种模型描述格式,它定义了一套标准化的计算图表示方式。你可以把它理解成一份“通用说明书”:不管你的模型是用PyTorch、TensorFlow还是别的框架训练的,只要导出成ONNX,这份说明书就能被各种推理框架读懂。它解决的是“模型怎么表达”的问题,解决不了“怎么跑得快”的问题。

TensorRT是运行在GPU上的推理优化引擎。它读入模型的计算图之后,会把算子进行融合、精度调整、内核自动调优,最终生成一个针对特定GPU型号高度定制的执行计划。它解决的是“怎么在GPU上最高效地执行”的问题。同样一个模型,在TensorRT上跑和在PyTorch的CPU推理模式下跑,性能差距可能是一个数量级。

所以标准链路是:训练框架 → ONNX(统一描述) → TensorRT(优化执行)。前者负责互通,后者负责加速,两者分工明确。

1.2 为什么最后选了这对组合

训练生态里PyTorch占了绝大多数,而PyTorch官方对ONNX导出的支持已经相当成熟,大部分算子都能正确映射。反过来,TensorRT对ONNX的解析路径也是官方主推的部署方式之一,再加上整个社区里关于ONNX转TensorRT的踩坑记录非常多,遇到问题基本都能搜到解决方案。

相比之下,如果你直接用PyTorch转其他推理框架,或者跳过ONNX直接自定义导出格式,要么是算子覆盖不全,要么是性能调优成本高,遇到问题还很难排查。ONNX相当于一个标准中转站,让问题被隔离在“导出”和“转换”两个阶段分别处理,排查起来清晰得多。

2. 从PyTorch到ONNX:实操全过程

2.1 导出前的准备和基础参数

在动手导出之前有一件很重要的事:把模型切到eval模式。这一点经常被忽略,如果模型里有Dropout或BatchNorm,train模式下导出的计算图会把随机行为和归一化统计量一起固化进去,导致推理结果异常。

PyTorch导出ONNX的核心API就是torch.onnx.export,一个最小可跑的示例大概长这样:

import torch # 假设model是已经训练好的模型 device = torch.device("cuda") model = model.to(device).eval() dummy_input = torch.randn(1, 3, 224, 224, device=device) torch.onnx.export( model, dummy_input, "model.onnx", opset_version=17, input_names=["input"], output_names=["output"], )

这里有几个参数值得多说一句。dummy_input是必须的,它用来trace模型的计算图,所以要保证shape和实际推理时的输入一致。opset_version决定了导出使用的算子集版本,通常建议选一个较新的稳定版本,但别盲目追新,因为TensorRT解析ONNX时对太高版本的算子支持不一定跟得上。我们一般在16到19之间比较稳妥,具体看你的TensorRT版本支持范围。

input_names和output_names给模型输入输出起了名字,后面转TensorRT时要通过名字绑定输入输出张量,所以起得清晰有意义会方便很多。

2.2 dynamic_axes动态轴配置

实际部署里很少固定batch size,比如在线服务可能一次只来一个请求,离线批量处理又希望尽量加大batch。如果导出时把batch维度固定成1,后面想换批量大小就得重新导出,非常麻烦。

启动动态轴的方法是dynamic_axes:

torch.onnx.export( model, dummy_input, "model_dynamic.onnx", opset_version=17, input_names=["input"], output_names=["output"], dynamic_axes={ "input": {0: "batch_size", 2: "height", 3: "width"}, "output": {0: "batch_size"}, } )

dynamic_axes里的键是张量名字,值是一个字典,字典的键是维度下标,值是给该维度取的别名。配置完之后,这个ONNX模型就支持任意batch、任意高宽的输入了。

但动态轴不是免费的午餐,后面转TensorRT时要给动态维度指定范围,而且范围设定会影响优化效果。这一点后面在TensorRT章节细说。

2.3 导出报错的常见原因和处理思路

导出ONNX时最常见的报错是“算子不支持”,比如某个自定义操作或者较新的PyTorch算子没有对应的ONNX映射。遇到这种情况我的处理优先级是:

  1. 换opset_version,有时候高版本opset包含了新的算子映射。
  2. 看模型里是不是有非常规操作,比如复杂的控制流,尝试把它改写成标准的张量运算。
  3. 实在不行,用torch.onnx.register_custom_op_symbolic为自定义算子注册映射,或者干脆把这块逻辑拆出来放到前处理/后处理里。

另外导出的模型有时候会报“output mismatch”,即PyTorch前向结果和导出的ONNX结果不一致。这种问题大多是某些算子在这两种环境下浮点行为略有差异,或者动态轴配置导致导出时构建了错误的计算图。验证手段是在导出后用onnxruntime跑一遍同样的输入,对比输出差异,判断误差是否在可接受范围内。

3. ONNX模型检查与结构优化

3.1 导出之后先别急着转TensorRT

从PyTorch导出的ONNX往往带有不少冗余信息。比如某些维度标记不明确、中间常量节点过多、甚至会出现一些为了兼容而保留的空操作。这些节点TensorRT虽然能解析,但会拖慢转换速度,甚至影响最终推理性能。

所以我有个习惯:任何ONNX模型在转TensorRT之前,都会先做一次结构检查和精简。

检查用Python脚本加载ONNX模型,看整体结构:

import onnx model = onnx.load("model.onnx") onnx.checker.check_model(model) print(onnx.helper.printable_graph(model.graph))

onnx.checker负责验证模型合法性,printable_graph能输出计算图的可读结构,方便你对模型有个直观认识。如果发现shape inference缺失,还可以用onnx.shape_inference.infer_shapes补全张量维度信息。

3.2 用onnxsim给模型做减法

结构精简我一般直接用onnx-simplifier这个开源库,它会把模型里的常量折叠、冗余节点消除、算子替换等优化统一做掉。

安装和命令行用法都很简单:

pip install onnxsim python -m onnxsim model.onnx model_sim.onnx

onnxsim执行完会输出原始节点数和简化后的节点数,通常一个训练好的ResNet模型能精简掉一部分冗余节点。我第一次用的时候把两千多个节点压到了一千多,后面转TensorRT的解析速度快了很多。

但这里有一个要注意的点:onnxsim在某些模型上可能会触发输出精度微小的变化。它在做常量折叠时如果遇到浮点计算,理论上相同操作会得到相同结果,但实际浮点舍入顺序可能导致极小的误差。简化完最好也跑一遍一致性对比,误差在1e-4以内基本都可以接受。

3.3 结构优化的一些额外手段

除了onnxsim,还有几个工具可以按需使用。onnx-graphsurgeon允许你对计算图做更精细的手术,比如手动删除某个不需要输出的子树、修改某个节点的属性。TensorRT官方对graphsurgeon支持得很好,用它能做一些onnxsim做不了的自定义优化。

我通常只在onnxsim做不到的情况下才上graphsurgeon。比如有些模型前处理里带了归一化操作(减均值除方差),这些东西完全可以落在推理前的前处理代码里,而不是浪费在TensorRT的图里解释执行。用graphsurgeon把这类算子从模型里摘掉,虽然省不了太多时间,但能让整个更干净。

4. TensorRT转换部署:主路线的选择

4.1 三种落地方式怎么选

TensorRT提供的主流部署方式有三种:trtexec命令行工具、Python API、C++ API。

trtexec适合做快速验证和参数压测,一行命令就能从ONNX生成engine文件,还会自动输出性能benchmark数据。Python API适合在Python环境下做集成,尤其是模型推理部分已经用Python写好,不想为了部署单独引入C++代码的情况。C++ API适合对延迟和吞吐要求极高的线上服务,省掉了Python解释开销,又能更精细地控制显存和stream,但开发成本也更高。

我自己在生产里见过很多项目,Python API足够覆盖大部分需求,只有到极致优化阶段才会切换到C++。所以我的建议是:先用trtexec验证可行性,再用Python API做业务集成,最后根据瓶颈决定要不要上C++。

4.2 trtexec命令实战

用trtexec从ONNX生成engine再简单不过:

/opt/tensorrt/bin/trtexec \ --onnx=model_sim.onnx \ --saveEngine=model.engine \ --fp16 \ --minShapes=input:1x3x224x224 \ --optShapes=input:8x3x224x224 \ --maxShapes=input:16x3x224x224

这里面的参数解释一下。--fp16表示允许使用FP16精度,这样很多算子会走半精度计算,速度通常能提升一倍以上。--minShapes、--optShapes、--maxShapes分别指定动态轴取值的下限、最优值和上限。TensorRT会按照最优值做kernel选择,所以optShapes最好设定成线上最常见的shape。

trtexec跑完会在终端输出Throughput、Latency等指标。首次见到这些数字的同事往往会很兴奋,但先别急着上线,因为benchmark用的输入是随机生成的,和真实业务数据分布有差异,真实延迟还得在服务里再测一遍。

4.3 Python API构建engine

如果你的项目要在一个Python服务里加载模型,那用Python API构建engine会更顺手。核心流程是:创建builder、读取ONNX、构建网络、配置优化参数、序列化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_sim.onnx", "rb") as f: ok = parser.parse(f.read()) if not ok: print("Failed to parse ONNX") print(parser.get_error(0)) config = builder.create_builder_config() config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 1 << 30) # 1GB workspace profile = builder.create_optimization_profile() profile.set_shape("input", (1, 3, 224, 224), (8, 3, 224, 224), (16, 3, 224, 224)) config.add_optimization_profile(profile) engine = builder.build_serialized_network(network, config) with open("model.engine", "wb") as f: f.write(engine)

构建engine的过程比较耗时,尤其模型又大又复杂的时候,所以实际项目里不会每次启动服务都现场构建,而是把engine序列化保存到硬盘,下次加载直接反序列化,秒级完成。

engine文件绑定的是GPU型号和TensorRT版本,换卡或者升级库之后,最好重新生成。这个我在生产环境里吃过亏:同一个engine文件在A卡上跑得好好的,换到B卡上直接报错,排查了半天才发现是硬编码了计算能力。

加载engine做推理的流程是反序列化engine → 创建execution context → 分配输入输出显存 → 用缓冲区指针执行enqueueV3。动态shape的输入要先通过context.set_input_shape绑定维度。

engine = runtime.deserialize_cuda_engine(engine_bytes) context = engine.create_execution_context() # 假设是批量动态 context.set_input_shape("input", (1, 3, 224, 224)) # 分配device buffer d_input = torch.empty(shape, dtype=torch.float32, device="cuda") d_output = torch.empty(output_shape, dtype=torch.float32, device="cuda") context.execute_v2([d_input.data_ptr(), d_output.data_ptr()])

5. 动态形状、精度校准这些绕不开的硬骨头

5.1 FP16不是什么时候都能直接开

trtexec里加--fp16很爽快,但有些模型直接开FP16会出问题。尤其是检测、分割这类对空间细节敏感的任务,FP16下某些层的相对误差会被放大,最终输出可能完全不可用。

我的操作习惯是:先用FP16构建一个engine,拿一批真实测试数据跑一遍,对比FP32的原始模型输出。如果平均误差在可接受范围内,再决定是否正式使用。误差对比有个简单做法,算输出Tensor的平均相对误差:

import numpy as np def relative_error(fp32_out, fp16_out): return np.mean(np.abs(fp32_out - fp16_out) / (np.abs(fp32_out) + 1e-6))

如果整个输出的平均相对误差在1e-3以下,通常问题不大。但对于一些本身对误差特别敏感的模型,即便平均值不大,个别像素点也可能偏差明显,最好在业务指标层面再做一轮验证。

5.2 INT8量化准备

INT8能带来比FP16更极致的加速,但配置成本也高,它需要一张校准数据集来计算每个张量的动态范围。TensorRT的INT8流程通常分四步:准备校准数据、创建校准器、设置INT8标志、构建engine。

最省事的做法是让ONNX转出来的FP32模型先跑一遍校准数据,在构建engine时指定一个校准器类。这个我在一些图像分类模型上做过,精度损失基本可以控制在1%以内,推理速度比FP32快三到四倍都是可能的。

但INT8的坑也比FP16深得多,比如校准数据的选择直接决定量化效果,如果校准数据集和线上数据分布差异大,INT8模型在真实数据上可能表现得很差。所以如果没有时间做细致调优,我一般建议团队先上FP16,后续有性能瓶颈再评估INT8。

5.3 多profile与动态shape优化

动态shape的优化范围和性能是直接相关的。范围越大,TensorRT能做的kernel选择就越保守,性能往往越差。所以一个常见的优化手段是不要给动态维度留一个过宽的范围,而是固定业务最常用的若干组形态。

如果你的业务存在几种截然不同的输入形态,比如检测任务里图像有4K和1080P两种分辨率,可以尝试给同一个engine添加多个optimization profile。在运行时根据当前请求的输入尺寸切换profile,既照顾了不同场景,又避免单个profile范围过大导致的性能损耗。

# 创建两个profile的示意 profile_1 = builder.create_optimization_profile() profile_1.set_shape("input", (1,3,2160,3840), (1,3,2160,3840), (1,3,2160,3840)) config.add_optimization_profile(profile_1) profile_2 = builder.create_optimization_profile() profile_2.set_shape("input", (1,3,1080,1920), (1,3,1080,1920), (1,3,1080,1920)) config.add_optimization_profile(profile_2) # 运行时切换profile context.set_optimization_profile_async(1, stream)

6. 实际部署中的典型问题和排查实录

6.1 算子解析失败

这类报错一般长这样:“Unsupported ONNX node ...”。原因往往是ONNX里存在TensorRT不支持的算子或属性组合。排查时我一般先用polygraphy工具快速定位是哪一层不兼容,然后决定替换算子还是用plugin实现。

polygraphy是TensorRT官方配套的调试工具,我很推荐每个做部署的朋友都装上。它提供了一堆子命令来跑ONNX和TensorRT的对比,比如:

polygraphy run model.onnx --trt --fp16 --onnxrt --tol 0.01

这条命令会把ONNX模型分别在TensorRT和onnxruntime上跑一遍,逐层对比输出差异,定位精度下降或者切图失败的具体节点。相比自己写脚本怼输入输出,这个工具能省太多时间。

6.2 动态shape运行时维度错误

动态shape场景下最常见的错误是构建engine时没设置profile,或者运行时没有预先指定输入shape。你可能会遇到“Binding input does not have a shape”或“suboptimal settings”这类报错。前者是忘了调用set_input_shape,后者是当前shape偏离了optShapes太多,性能会下降但不影响正确性。

还有一类诡异的错误是输出buffer维度错乱,原因是动态shape下输出Tensor的实际大小依赖输入维度,而你不能只用最大shape去分配缓冲区。这里的处理方式是通过context.get_tensor_shape在推理前获取实际的输出shape,再据此分配显存。

6.3 精度下降严重时的定位手段

如果FP16或INT8模型的精度掉得离谱,先别急着调量化策略,用polygraphy的layer-wise输出对比看看是哪几层偏差最大。我遇到过一个案例,所有误差都集中在模型最后的Detection Head附近,于是调整优化策略把这部分层保留为FP32,精度立刻恢复。

TensorRT允许在构建时通过setting per-layer precision来控制,比如禁止某些层使用FP16:

for layer in network: if layer.name.startswith("detector"): layer.precision = trt.float32 layer.set_output_type(0, trt.float32)

结合精度瓶颈分析,这种“定点回退”的手段比全模型降精度更能兼顾性能和效果。

6.4 显存占用和延迟抖动

TensorRT的workspace大小直接决定它能用多少显存来做算子调优和中间结果缓存。设得太小可能导致构建失败,或生成性能较差的kernel;设得太大又会让多个并行实例互相抢显存。我一般从模型本身的FP32大小出发,给它配2倍到4倍的显存空间,再根据整机显存容量压到合适范围。

延迟抖动是另一个容易被忽视的问题。GPU上多个推理请求共用同一块显存,如果代码里反复创建cuda context或做显存碎片化分配,很容易出现偶发的延迟尖刺。稳妥做法是推理线程只初始化一次context和buffer,后续请求复用,避免任何形式的显存重复分配。

7. 工程化流程建议:从训练到上线的一条龙

7.1 转换流水线怎么设计

部署久了你会发现,最耗时间的往往不是“怎么转”,而是“怎么保证每次转换都可靠”。我沉淀下来的转换流水线大概是这样的:

训练结束 → 固定模型参数和结构 → 导出ONNX → 检查结构并精简 → 用onnxruntime跑一致性对比 → trtexec构建FP32/FP16/INT8候选engine → 用真实测试集做精度和性能双验证 → 线上服务集成engine → 持续监控。

每一步都有对应的检查脚本,任何一步失败就终止,不往下走。这样能保证交付到线上的engine一定是经过全链路验证的,而不是“能跑就行”。

整个流程里,最容易被跳过的环节是用真实数据验证精度。很多人用随机噪点跑一下觉得输出形状和预期一样,就以为没事了,结果上线后发现结果一塌糊涂。真实数据集和随机输入的分布差异,在FP16/INT8下会通过数值误差暴露出来,所以这一步真的省不得。

7.2 校验环节最容易忽略的细节

做一致性对比时,输入数据要覆盖边界情况。比如动态shape模型,要分别测最小shape、典型shape、最大shape下的输出。一方面确认维度正确,另一方面确认数值误差在每种shape下都在容忍范围内。只测一种shape给出的结论往往不可靠。

另外,用onnxruntime做基准对比时要注意onnxruntime和PyTorch的算子实现也有差异,两者误差本身就存在,所以在判定ONNX导出是否合格时,我一般把threshold放宽到1e-4级别,而在TensorRT的FP16对比里,threshold放宽到1e-2级别。不同环节的判定标准不同,死磕同一套精度只会浪费时间。

7.3 监控与版本管理

engine文件不像普通的模型权重那样跨平台通用,它跟GPU架构强绑定。所以代码仓库里我会同时维护原始PyTorch权重、简化后的ONNX文件和构建engine的脚本,engine文件本身不入库,只把它作为构建产物。任何一次GPU驱动升级、TensorRT版本升级或模型更新,都触发一次重新构建而不是复用旧engine。

在服务上线后,我会把推理耗时和显存占用都接到监控里。TensorRT本身提供了一个事件探针机制,可以拿到精确的kernel执行时间,这个数据比日志里手记前后时间戳要准得多,排查延迟问题的时候非常有用。

一点个人体会

这条链路走到今天,最核心的体会是:模型转换不是一步到位的魔法操作,而是一条需要反复校验的流水线。PyTorch导出ONNX时的参数配置,ONNX简化时的精度验证,TensorRT构建时的精度策略选择,每一层都可能有隐形的坑。但只要你养成了“每个环节都做一致性验证”的习惯,绝大多数问题都能在自己可控的范围内提前暴露,而不是等上线了才被人追着问“为什么结果不对”。

另外一个小建议:尽量让组内的同学都熟悉polygraphy和onnxsim这两个工具,这两个东西真的能节省大量手动argue模型的时间。遇到精度问题、算子问题、性能问题,第一步先用polygraphy跑个对比,往往比盯着报错日志猜半天下手更快。动手久了,你会越来越有感觉,最后基本扫一眼报错信息就能猜出问题出在哪一层。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询