☰
TensorRT加速PyTorch模型推理:从ONNX导出到Engine部署
2026/10/17 14:47:14 网站建设 项目流程

1. 先想清楚:TensorRT到底在加速什么

1.1 PyTorch模型部署时的性能瓶颈在哪

训练好的PyTorch模型,直接丢到生产环境里跑,通常会发现速度没有想象中那么快。原因在于PyTorch本身的设计是“动态图优先”,这让它在训练时调试很灵活,可一旦到了推理阶段,每前向一次都要经过Python调度、算子分发、动态shape判断这些环节,开销很大。我在项目中测过一个语义分割模型,用PyTorch直接推理时,单张图的耗时在12毫秒左右,感觉勉强能接受,但一旦要支撑高并发请求,GPU的利用率上不去,瓶颈就暴露得非常明显。

换个角度理解:GPU是典型的并行计算设备,它喜欢的是一次性喂给它大量统一结构的数据,让它用高度优化的内核去批量处理。而PyTorch的默认执行方式,就像你每次都临时给厨房里的师傅口头报一遍菜名,师傅需要现场判断怎么做、用什么锅,自然会拖慢速度。TensorRT做的就是“提前把菜谱编译好”,把所有操作、内存布局、内核选择都预先规划死,运行时就按最优路径直接执行。

1.2 TensorRT的加速原理和边界

TensorRT是一个针对GPU推理场景的深度学习推理优化引擎,核心思路有几条:第一是图融合,把连续的卷积、批归一化、激活函数等小算子合并成一个更大的算子,减少内核启动和显存读写的次数;第二是内核自动择优,针对不同的GPU架构和输入shape,从大量内核实现里挑选延迟最低的版本;第三是显存复用,提前规划好每一层需要的临时内存,避免运行时反复申请释放。

但要注意,TensorRT不是万能的。它优化的是“推理”而不是“训练”,它要求模型结构基本固定,如果模型里包含大量动态分支、数据依赖型控制流,或者用了某些很冷门的算子,转换时可能会直接报错。网上很多教程喜欢用ResNet、YOLO系列举例,因为这些模型结构规整,容易转换成功,但换到一些结构复杂的模型(比如带Transformer块的可变形检测模型),就要额外做很多适配工作。所以“简单实用”的前提是:你的模型大部分算子能被TensorRT支持,并且你愿意接受一定的转换成本。

1.3 什么场景下值得用TensorRT

我的判断标准很简单:只要你的模型要部署到GPU上长期跑服务,就值得认真评估TensorRT。比如目标检测服务、语义分割服务、图像分类接口、视频流实时分析,这些场景模型结构相对固定,输入尺寸通常也可以约束到一个范围,正好是TensorRT最舒服的区间。

反过来,如果你的模型还在频繁迭代,几乎每周都要改结构;或者模型本身就很小,单张图推理只要1~2毫秒,再优化也压不出多少收益;又或者你使用的是CPU推理,那TensorRT就不是合适的选择。这个判断很重要,因为把一个模型转成TensorRT是要花时间的,如果模型结构不稳定,转换成本和维护成本都会变成负担。

2. 环境准备:版本搭配是第一个坑

2.1 典型的软硬件组合

说到环境,这是最容易让人劝退的地方。TensorRT对版本非常敏感,它依赖GPU驱动、CUDA版本、cuDNN版本,还要和PyTorch的导出链路配合。我曾见过有人拿着新版的PyTorch,配了一个很旧的TensorRT,结果转ONNX时倒是一路顺畅,等到用它构建engine时疯狂报错,最后排查半天发现是版本不兼容。

我当时在一个部署项目里使用过一套很稳的组合:GPU用的是常见的数据中心卡,显存24GB以上(其实8GB也能跑,但大显存会让你在做动态shape实验时舒服很多);操作系统是常见的Ubuntu类的服务器系统;驱动版本跟随CUDA的推荐版本来安装,CUDA选的是11.8(这是当时比较成熟的版本);Python版本控制在3.8到3.10之间,太新或太旧都可能在依赖编译时出现奇怪的坑。PyTorch选2.x的稳定版,TensorRT选8.5以上的版本,配套的ONNX版本保持同步更新。

2.2 安装时要注意的细节

TensorRT的安装包有几种形式:一种是tar包解压就能用,一种是deb包安装到系统路径,还有一种是通过pip安装。我比较推荐用pip方式安装对应的Python包,然后手动下载TensorRT的二进制库,把库路径添加到系统环境变量里。原因很简单:pip包方便管理Python层面的模块,但底层库的路径如果不暴露,运行时会出现“能import但无法初始化”的诡异问题。

经验之谈:把TensorRT的lib目录追加到动态库搜索路径里,比如写成export LD_LIBRARY_PATH=/你自己的路径/lib:$LD_LIBRARY_PATH,这一步很多人忽略,导致运行Python脚本时提示找不到共享库。

另外,ONNX导出时一般要配套一个onnxruntime或者onnxsim来辅助简化模型,这些工具也能通过pip直接安装。ONNX简化工具能把一些冗余的算子、多余的转换节点清理掉,显著降低后续TensorRT构建engine时的失败率,属于“小投入高回报”的环节。

2.3 五分钟跑通环境自检

环境装完之后,先别急着转模型,花五分钟写一个自检脚本,确认整条链路基本可用。这一步能省下后面大量排查问题的时间。我习惯用一个很小的随机输入模型来做全链路测试:先定义一个简单的卷积神经网络PyTorch模型,导出成ONNX,再用TensorRT加载ONNX构建engine,最后跑一次推理比对结果。这个流程如果能在几分钟内跑通,说明环境基本没问题;如果在这一步就报错,那大概率是版本或安装路径的问题,和你的具体业务模型无关。

自检脚本里也可以顺便导入TensorRT版本信息,打印出来和文档上的支持范围对一下,确保不是太老的版本。这个检查虽然琐碎,但确实能避免很多“看似模型问题其实是环境问题”的情况。

3. 简单实用的转换流程:从pt到engine

3.1 第一步:导出成ONNX模型

PyTorch模型要转成TensorRT,中间一般要经过ONNX这个中间格式。直接用PyTorch转TensorRT的接口不是没有,但成熟度、灵活度都不如“先转ONNX再转engine”这条路线。ONNX的意义在于它是一种通用计算图描述格式,把模型的计算逻辑、算子、张量shape、权重都固化下来,相当于给模型拍了一张“蓝图”。

导出ONNX的核心代码其实不长,示例如下(这里使用一个虚拟的检测模型结构,仅演示流程细节):

import torch import torch.nn as nn class DemoNet(nn.Module): def __init__(self, in_channels=3, num_classes=4): super().__init__() self.features = nn.Sequential( nn.Conv2d(in_channels, 16, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2) ) self.classifier = nn.Conv2d(32, num_classes, kernel_size=1) def forward(self, x): return self.classifier(self.features(x)) model = DemoNet() model.eval() dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, "demo_model.onnx", export_params=True, opset_version=13, do_constant_folding=True, input_names=["input"], output_names=["output"], dynamic_axes={ "input": {0: "batch_size"}, "output": {0: "batch_size"} } )

这里有几个关键点值得注意。第一,model.eval()一定要调用,否则模型里如果有dropout、batchnorm这类在训练和推理时行为不同的算子,导出的ONNX会包含训练逻辑,影响后续推理结果。第二,dummy_input的shape要和真实输入尽量一致,虽然我们后面会设置动态维度,但初始shape越接近真实数据,导出的计算图就越好优化。第三,dynamic_axes可以将batch维度设为动态,这在生产环境里非常实用,因为服务端一次可能接收1张图,也可能一次接收4张图,如果一开始就写死只有1,后面会很被动。

3.2 第二步:简化ONNX模型

导出ONNX之后,经常会出现计算图里有一些冗余的节点,比如多余的reshape、transpose、identity等。这些节点不清理的话,TensorRT在解析时可能会因为某些奇怪的模式而失败,或者生成的engine性能不如预期。此时我习惯用ONNX的简化工具跑一遍,把计算图“捋顺”。

简化的操作很简单,核心命令是(假装该工具名为onnxsim):

python -m onnxsim demo_model.onnx demo_model_sim.onnx

如果模型里有比较复杂的动态shape操作,工具可能会报“shape 不明确”的警告。这种情况下可以手动指定一个输入样例来帮助它判断,也可以接受警告继续导出,然后直接丢给TensorRT去处理,看会不会报错。我的经验是:绝大多数情况用默认参数就能完成简化,简化后检查一下输出的ONNX文件大小,通常会比原来小一点,图结构也更干净。

3.3 第三步:构建TensorRT engine

拿到简化后的ONNX,下一步就是用TensorRT把它编译成engine文件。TensorRT构建engine有两种常见姿势:一是用命令行工具“trtexec”,二是在Python里调用API。我刚开始接触时喜欢用trtexec,因为它不需要写代码,一行命令就能完成构建和性能测试,非常直观。但实际项目里engine的构建往往要嵌入到自动化部署脚本中,所以 Python API 的方式会更灵活。

下面是一个典型的Python构建engine的流程,核心思路是:创建builder(构建器)-> 创建network(计算图)-> 用ONNX解析器填充network -> 配置builder参数 -> 构建engine -> 保存engine文件:

import tensorrt as trt logger = trt.Logger(trt.Logger.ERROR) builder = trt.Builder(logger) network = builder.create_network() parser = trt.OnnxParser(network, logger) with open("demo_model_sim.onnx", "rb") as f: parser.parse(f.read()) config = builder.create_builder_config() config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 1 << 30) config.set_flag(trt.BuilderFlag.FP16) # 如果GPU支持,建议开启 # 对于动态batch,还需要添加profile并设置形状范围 profile = builder.create_optimization_profile() input_tensor = network.get_input(0) profile.set_shape(input_tensor.name, min=(1, 3, 224, 224), opt=(2, 3, 224, 224), max=(4, 3, 224, 224)) config.add_optimization_profile(profile) serialized_engine = builder.build_serialized_network(network, config) with open("demo_model.engine", "wb") as f: f.write(serialized_engine)

这段代码里有两个容易被忽略但很重要的细节。一个是工作空间大小,也就是set_memory_pool_limit里的值,太小的空间可能导致某些融合策略无法使用,太大会浪费显存,一般从1GB开始调比较合适。另一个是FP16开关,它通常能带来1到2倍的加速提升,但它要求GPU本身对FP16运算有较强的支持,如果你用的GPU比较老,开了FP16反而可能会精度下降明显,需要实测对比后决定。

3.4 第四步:加载engine并完成推理

engine文件生成后,实际部署时不需要再加载ONNX或PyTorch模型了,直接把engine读进来反序列化即可。这里需要特别注意TensorRT 8.5以后的一个变化:推荐用deserialize_cuda_engine而不是老的deserialize_engine,接口更简洁,生命周期管理也更清晰。

推理的基本流程是:创建runtime -> 反序列化engine -> 创建execution context -> 在GPU上分配输入输出显存 -> 传入数据 -> 调用execute方法执行。如果只看代码结构,其实就那么几个步骤:

import numpy as np import tensorrt as trt import pycuda.driver as cuda import pycuda.autoinit logger = trt.Logger(trt.Logger.ERROR) with open("demo_model.engine", "rb") as f: engine = trt.Runtime(logger).deserialize_cuda_engine(f.read()) context = engine.create_execution_context() # 设置动态shape context.set_input_shape("input", (2, 3, 224, 224)) # 分配张量内存 h_input = cuda.mem_alloc(2 * 3 * 224 * 224 * 4) h_output = cuda.mem_alloc(2 * 4 * 224 * 224 * 4) # 把numpy数组传到GPU d_input = np.random.randn(2, 3, 224, 224).astype(np.float32) cuda.memcpy_htod(h_input, d_input) context.execute_v2(bindings=[int(h_input), int(h_output)]) # 或者使用 execute_async_v2(如果使用 stream) result = np.empty(2 * 4 * 224 * 224, dtype=np.float32) cuda.memcpy_dtoh(result, h_output)

这里有个小白容易踩的坑:推理时必须把输入数据放到GPU显存上,而不是直接把numpy数组传给execution context。我在初次写代码时,傻乎乎地把numpy数组传进去,结果程序直接崩溃,后来才意识到TensorRT只认显存指针。如果你平时用PyTorch比较多,会习惯.to("cuda")的写法,但在TensorRT接口中,你需要自己管理显存拷贝。

另一个坑:如果模型有多个输入或多个输出,bindings列表的顺序要和engine里张量的顺序一致,可以用engine[idx]查看张量名,避免张冠李戴。

4. 性能调优:别急,先验证一下收益再说

4.1 精度模式选择:FP32、FP16还是INT8

构建engine时最常见的优化选项就是降低精度。FP32是默认精度,最保险,但提速空间有限;FP16把中间计算精度减半,显存占用也几乎减半,大多数模型的精度损失很小;INT8则更进一步,用8位整数表示数据,提速最明显,但需要校准数据集来统计每层激活值的分布,否则精度可能崩坏。

对于“简单实用”的目标,我建议先从FP16入手。开启FP16只需要在builder config里加一个flag,代码我已经在上一节写出来了,操作成本几乎为零。开完之后一定要跑一遍推理结果,和PyTorch原模型的输出做对比,看误差是否在可接受范围内。以分类任务为例,Top-1准确率可能只会掉零点几个百分点,完全不影响使用。如果是检测任务,注意观察同类别的置信度有没有明显波动,有时候检测框数量会因为精度变化而出现细微差异。

INT8虽然诱人,但校准过程需要准备几百张有代表性的图片,跑一遍推理记录各层的激活值范围,然后生成校准表。这意味着你要额外维护一份校准脚本和数据集,对于快速交付的项目来说,成本不低,所以我大概率不会在“简单使用”的第一阶段就去碰INT8。先吃下FP16的红利,后续有需要再逐步尝试INT8。

4.2 动态shape的优化配置

我在前面提到,生产环境经常需要支持不同的batch-size,这时候就必须给TensorRT配置多个“优化画像(optimization profile)”。简单理解,TensorRT内部会根据你设置的三个shape(min、opt、max)来预先选择内核,同时分配足够的显存。

上面示例里我写的min=(1,...), opt=(2,...), max=(4,...)意味着TensorRT会按照2的batch去优化内核,同时保证在1到4的范围内都能运行。如果你实际推理时batch是8,而你max只设了4,运行会直接报错;同样,如果opt和真实常用batch差很远,比如常用4却设成2,性能可能不是最优。

我实际在项目中测过一个分类模型,opt batch设定为1时单次推理延迟约2.1毫秒;把opt batch改成4,并使用同样batch大小去推理时,单次延迟降到0.8毫秒左右。原因就是TensorRT针对4这个shape专门做了内核选择和融合优化。因此,优先根据线上真实的并发量和batch分布,把opt设置成最常见的情况,效果最明显。

4.3 正确测量推理延迟

很多人在测延时的时候会犯一个经典错误:直接套一个循环计时,但忘了“预热”GPU。GPU推理第一次调用时会经历驱动加载、context初始化、显存分配等开销,如果不先跑几十次再开始计时,测出来的数字会虚高。我在刚接触TensorRT时犯过这个错,当时兴致勃勃地测出8毫秒,还以为优化成功了,后来发现其实是PyTorch模型跑得更稳定,多测了几次才发现真实数据是3毫秒。

正确的延迟测量姿势应该是:先连续推理50到100次作为预热,然后跑至少200次取平均值或P99延迟。同时,执行推理后需要把GPUStream同步一下,否则计时可能只是“提交任务”的时间,而不是真正跑完计算的时间。这一点在异步执行时特别关键,异步接口返回的是提交成功,不代表计算已经结束。

5. 实际部署中常见的坑和排查思路

5.1 模型算子不支持或转换失败

这是最常遇到的问题。当ONNX解析器碰到TensorRT不支持的算子时,通常会直接报错,或者生成一个“网络不完整”的警告。我见过最典型的是某些目标检测模型里的grid_sampler操作,早期版本的TensorRT不支持,导致转换失败。

遇到这种情况,我的排查思路是分三步:第一步,看看能不能通过修改模型实现来绕开这个算子,比如把某个自定义op用组合的标准op重新实现;第二步,升级TensorRT版本,因为每个新版本都会增加一批算子支持;第三步,如果真的绕不开,就考虑把不支持的算子留在TensorRT之外的预处理或后处理阶段,让TensorRT只负责主干计算。这种做法虽然会增加开发量,但往往能救活一个模型。

注意:不要一看到报错就认为“TensorRT不行”,很多情况下是模型里的自定义算子太“野”了。把模型写得越接近标准算子库,转换成功率越高。

5.2 动态shape导致推理结果错乱

动态shape带来的另一类坑是:运行时报错“input shape does not match profile”,或者结果完全错乱。前者通常是你忘了在每次推理前调用context.set_input_shape;后者则可能是因为显存绑定缓冲区的尺寸没有随shape一起调整。

我调试过一个实例:模型的输入batch在1和2之间切换时,单batch的推理结果完全正常,切换到2以后输出全是0。查了很久才发现,是分配显存时用了固定的batch=1,而context运行时用的是batch=2,结果第二个batch的数据直接写到了显存的越界区域,可能被其他数据覆盖了。这个问题的解决方式很简单:确保绑定的显存大小覆盖最大shape,或者在每次shape变化时重新分配缓冲区。

5.3 显存OOM和内存碎片化

TensorRT会在构建engine时为你保留工作空间,同时还有激活值、权重、输入输出缓冲等,多项相加之后,如果GPU总显存不大,很容易出现OOM。我在一块8GB显存的卡上尝试同时跑两个不同engine的推理服务时,时不时就会报显存不足。

这种场景下的优化手段有几个方向:一是缩小set_memory_pool_limit工作空间上限,牺牲少量性能换内存;二是把输入输出缓冲区手动管理起来,复用同一块显存,而不是频繁申请释放;三是对多路服务做排队,避免同时触发多个大模型推理导致显存峰值叠加。另外,TensorRT本身有自己的显存复用机制,但前提是你在同一个runtime生命周期内复用context,尽量减少创建和销毁动作。

5.4 线程安全与多路并发

如果你像大多数人一样,把TensorRT推理封装成一个服务接口,那么线程并发就会成为一个绕不开的话题。一个重要知识点是:同一个engine可以创建多个execution context,但不要在多线程里同时使用同一个context。换句话说,每个线程最好持有自己的context实例,因为context内部维护了执行状态,并发调用会导致数据错乱。

实践中我会用一个对象池来管理context,线程取用时从池里借一个,用完归还,这样既能保证并发安全,又能避免重复创建context的额外开销。这里没有多复杂,但确实值得提前设计好,否则并发一上来,各种随机错误会让你排查到怀疑人生。

6. 做一次完整的最小闭环

6.1 从模型到engine的最小脚本

说了很多理论,这里我给你整理一个最小可跑的串联脚本,把导出ONNX、简化、构建engine、推理验证串在一起。假设你已经有一个结构简单的PyTorch模型,只需要把模型路径、输入形状填进去就行。这段脚本非常“简单实用”,适合作为项目模板来扩充。

# minimal_tensorrt_pipeline.py import torch import torch.nn as nn import onnxsim import onnx import tensorrt as trt import numpy as np class SimpleNet(nn.Module): def __init__(self): super().__init__() self.conv = nn.Sequential( nn.Conv2d(3, 16, 3, padding=1), nn.BatchNorm2d(16), nn.ReLU(), nn.AdaptiveAvgPool2d(1) ) self.fc = nn.Linear(16, 10) def forward(self, x): x = self.conv(x) x = x.view(x.size(0), -1) x = self.fc(x) return x # 1. 导出ONNX model = SimpleNet().eval() dummy = torch.randn(1, 3, 224, 224) onnx_file = "simple.onnx" torch.onnx.export(model, dummy, onnx_file, input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, opset_version=13) # 2. 简化ONNX onnxsim.simplify(onnx_file, overwrite=True) # 3. 构建engine logger = trt.Logger(trt.Logger.ERROR) builder = trt.Builder(logger) network = builder.create_network() parser = trt.OnnxParser(network, logger) with open(onnx_file, "rb") as f: parser.parse(f.read()) config = builder.create_builder_config() config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 1 << 30) config.set_flag(trt.BuilderFlag.FP16) # 动态batch profile profile = builder.create_optimization_profile() profile.set_shape("input", min=(1, 3, 224, 224), opt=(2, 3, 224, 224), max=(4, 3, 224, 224)) config.add_optimization_profile(profile) with open("simple.engine", "wb") as f: f.write(builder.build_serialized_network(network, config)) # 4. 推理验证 with open("simple.engine", "rb") as f: engine = trt.Runtime(logger).deserialize_cuda_engine(f.read()) context = engine.create_execution_context() context.set_input_shape("input", (2, 3, 224, 224)) # 分配gpu内存 import pycuda.driver as cuda import pycuda.autoinit d_input = cuda.mem_alloc(2 * 3 * 224 * 224 * 4) d_output = cuda.mem_alloc(2 * 10 * 4) input_data = torch.randn(2, 3, 224, 224).numpy().astype(np.float32) cuda.memcpy_htod(d_input, input_data) context.execute_v2(bindings=[int(d_input), int(d_output)]) output = np.empty((2, 10), dtype=np.float32) cuda.memcpy_dtoh(output, d_output) print("output shape:", output.shape)

这段脚本在大多数环境里可以直接扩展使用,把SimpleNet换成你自己的业务模型即可。它的设计目的不是炫技,而是让你有一条清晰的骨架,后续往里填业务细节就好。

6.2 工程化落地时还可以再做的事

走到这一步,你已经能完成“PyTorch模型 -> TensorRT engine -> 推理”的最小闭环。如果想把它真正上线,还需要考虑几件事:把engine文件版本化管理,避免模型升级后加载到旧engine;用模型哈希或者元信息记录构建参数,出现线上问题时可以快速复现;将engine的构建和推理分离,构建过程放到离线流水线,在线服务只加载现成engine。

在封装推理接口时,建议把输入预处理(如归一化、resize)输出后处理(如阈值过滤、NMS)全部放在同一段代码里,并且对输入输出统一使用批量容器。这样去做并发测试时,你会更容易发现性能瓶颈是在TensorRT执行阶段还是预处理阶段。

6.3 后续可以继续探索的方向

TensorRT提速的空间不止FP16一个开关。等你在“简单实用”阶段跑通之后,可以逐步探索更细的优化点:比如算子融合日志的分析、多profile的灵活切换、显存池手动复用、量化校准(INT8)的完整流程、以及和推理服务框架集成等。到那个阶段,你已经不是“会用TensorRT”的初级玩家,而是真正开始琢磨“怎么把GPU性能压榨到极致”了。

我个人在这些年部署不同模型时,一直保持的习惯是:先做最小闭环,再逐步加优化,永远留一份对照实验数据。不要一上来就同时开FP16、改动态shape、调核数,那样出了问题很难定位。每次只改动一个变量,用同一套测试数据去对比,你的优化路径会走得又稳又清晰。

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

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

立即咨询