3. 模型导出与转换

模型训练好只是第一步,真正要让它跑在推理服务里,还得过一道关——模型导出与转换。说白了,就是把PyTorch那套动态图,变成生产环境能高效执行的静态图。

我个人习惯把这一步叫做「模型工业化」。你想想看,训练时我们用PyTorch,灵活、好调试,但到了线上,我们需要的是速度、是稳定。所以就有了TorchScript、ONNX、TensorRT这些中间格式。

核心思路:训练框架(PyTorch)→ 中间表示(TorchScript/ONNX)→ 推理引擎(TensorRT)。每一步都有坑,但每一步也都有收益。

模型导出与转换流程 PyTorch 模型 .pth / .pt TorchScript trace / script ONNX .onnx TensorRT .engine / .plan 关键说明: • TorchScript 适合纯 PyTorch 部署场景,无需额外依赖 • ONNX 是通用中间格式,支持跨框架、跨硬件 • TensorRT 是 NVIDIA 专有优化,FP16/INT8 加速明显 • 实际项目中常走:PyTorch → ONNX → TensorRT 这条路径

TorchScript:PyTorch的原生序列化方案

TorchScript是PyTorch官方推出的模型序列化格式。它能把动态图「凝固」成静态图,脱离Python运行时也能跑。

我记得第一次用TorchScript是在一个文本分类项目里。模型在开发机上跑得好好的,一上生产环境就报错——因为生产环境没装PyTorch的完整版。后来改用TorchScript导出,问题就解决了。

TorchScript有两种导出方式:

方式 适用场景 优点 缺点
跟踪(Trace) 模型不含控制流(if/for依赖数据) 简单、快速、一次运行即可导出 无法处理动态分支
脚本(Script) 模型含控制流或动态结构 完整保留控制流逻辑 需要写类型注解,部分操作不支持

来看代码。先说说Trace方式:

import torch
import torchvision.models as models

# 加载预训练模型
model = models.resnet18(pretrained=True)
model.eval()

# 构造示例输入
dummy_input = torch.randn(1, 3, 224, 224)

# 使用 torch.jit.trace 导出
traced_model = torch.jit.trace(model, dummy_input)
traced_model.save("resnet18_traced.pt")

# 加载验证
loaded = torch.jit.load("resnet18_traced.pt")
output = loaded(dummy_input)
print(output.shape)  # torch.Size([1, 1000])

嗯,这里要注意。Trace方式有个大坑——它只记录实际执行过的路径。如果你的模型里有if x.shape[0] > 10这样的分支,Trace只会记录其中一条分支。我曾经因为这个原因,线上模型在特定输入下直接崩溃。

那Script方式怎么用?

import torch

class MyModel(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.linear = torch.nn.Linear(10, 5)
    
    def forward(self, x):
        # 包含控制流
        if x.sum() > 0:
            return self.linear(x)
        else:
            return torch.zeros(5)

model = MyModel()
model.eval()

# 使用 torch.jit.script 导出
scripted_model = torch.jit.script(model)
scripted_model.save("model_scripted.pt")

我的建议:能Trace就别Script。Trace更稳定,兼容性更好。如果模型必须用Script,记得给所有张量操作加上类型注解,否则容易报错。

ONNX:跨框架的通用中间格式

ONNX(Open Neural Network Exchange)是微软和Meta联合推出的开放格式。它的目标很明确——让模型在不同框架之间自由迁移。

我个人觉得,ONNX最大的价值在于「解耦」。你想想看,训练用PyTorch,推理用TensorRT,中间靠什么衔接?就是ONNX。它像一个万能转接头。

导出ONNX的代码其实很简单:

import torch
import torchvision.models as models

model = models.resnet50(pretrained=True)
model.eval()

dummy_input = torch.randn(1, 3, 224, 224)

# 导出ONNX
torch.onnx.export(
    model,                    # 模型
    dummy_input,              # 示例输入
    "resnet50.onnx",          # 输出路径
    export_params=True,       # 导出参数
    opset_version=11,         # ONNX算子集版本
    do_constant_folding=True, # 常量折叠优化
    input_names=["input"],    # 输入名称
    output_names=["output"],  # 输出名称
    dynamic_axes={
        "input": {0: "batch_size"},
        "output": {0: "batch_size"}
    }
)

print("ONNX导出完成")

这里有个关键参数——dynamic_axes。它允许输入输出的batch size是动态的。我在项目中遇到过,如果不设置这个,导出的ONNX只能处理固定batch size的输入,线上服务一旦遇到不同batch的请求就直接报错。

注意:ONNX导出时,opset_version不是越高越好。高版本算子虽然功能多,但某些推理引擎(比如TensorRT)可能不支持。我一般用opset 11或13,兼容性最好。

导出后怎么验证?用ONNX Runtime跑一下:

import onnx
import onnxruntime as ort
import numpy as np

# 检查模型结构
onnx_model = onnx.load("resnet50.onnx")
onnx.checker.check_model(onnx_model)
print("ONNX模型结构检查通过")

# 用ONNX Runtime推理
session = ort.InferenceSession("resnet50.onnx")
input_name = session.get_inputs()[0].name
output_name = session.get_outputs()[0].name

test_input = np.random.randn(1, 3, 224, 224).astype(np.float32)
result = session.run([output_name], {input_name: test_input})
print(f"输出形状: {result[0].shape}")

TensorRT:NVIDIA的推理加速引擎

TensorRT是NVIDIA推出的深度学习推理优化库。它能把ONNX模型编译成高度优化的引擎文件,支持FP16和INT8量化,推理速度能提升2-5倍。

我记得第一次用TensorRT是在一个实时视频分析项目里。原来用PyTorch直接推理,1080p的视频只能跑15fps。换成TensorRT的FP16模式后,直接飙到45fps。嗯,那个感觉,真爽。

TensorRT转换的基本流程:

  1. 准备ONNX模型——确保导出时算子兼容
  2. 创建Builder——配置精度、工作空间等参数
  3. 解析ONNX——用ONNX Parser读取模型
  4. 构建引擎——编译优化,生成.engine文件
  5. 序列化保存——保存引擎文件供后续加载

来看代码实现:

import tensorrt as trt
import numpy as np

# TensorRT日志记录器
TRT_LOGGER = trt.Logger(trt.Logger.WARNING)

def build_engine(onnx_file_path, engine_file_path, fp16_mode=False):
    """将ONNX模型转换为TensorRT引擎"""
    
    with trt.Builder(TRT_LOGGER) as builder, \
         builder.create_network(1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)) as network, \
         trt.OnnxParser(network, TRT_LOGGER) as parser:
        
        # 配置Builder
        config = builder.create_builder_config()
        config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 1 << 30)  # 1GB
        
        if fp16_mode and builder.platform_has_fast_fp16:
            config.set_flag(trt.BuilderFlag.FP16)
            print("启用FP16模式")
        
        # 解析ONNX
        with open(onnx_file_path, "rb") as f:
            if not parser.parse(f.read()):
                for error in range(parser.num_errors):
                    print(parser.get_error(error))
                raise RuntimeError("ONNX解析失败")
        
        print(f"网络输入: {network.num_inputs}")
        print(f"网络输出: {network.num_outputs}")
        
        # 构建引擎
        serialized_engine = builder.build_serialized_network(network, config)
        
        # 保存引擎文件
        with open(engine_file_path, "wb") as f:
            f.write(serialized_engine)
        
        print(f"引擎已保存至: {engine_file_path}")
        return serialized_engine

# 调用
build_engine("resnet50.onnx", "resnet50_fp16.engine", fp16_mode=True)

经验之谈:TensorRT构建引擎时,workspace大小很关键。设太小了,复杂模型可能构建失败;设太大了,显存不够用。我一般设1GB起步,不够再加。

引擎构建好后,加载推理:

import tensorrt as trt
import pycuda.driver as cuda
import pycuda.autoinit
import numpy as np

def load_engine(engine_file_path):
    """加载TensorRT引擎"""
    with open(engine_file_path, "rb") as f, \
         trt.Runtime(TRT_LOGGER) as runtime:
        return runtime.deserialize_cuda_engine(f.read())

def inference(engine, input_data):
    """执行推理"""
    with engine.create_execution_context() as context:
        # 分配GPU内存
        output_size = 1
        for binding in engine:
            if engine.binding_is_input(binding):
                input_idx = engine[binding]
                input_shape = context.get_binding_shape(input_idx)
                input_size = trt.volume(input_shape)
                # 分配输入输出缓冲区
                d_input = cuda.mem_alloc(input_size * np.dtype(np.float32).itemsize)
            else:
                output_idx = engine[binding]
                output_shape = context.get_binding_shape(output_idx)
                output_size = trt.volume(output_shape)
                d_output = cuda.mem_alloc(output_size * np.dtype(np.float32).itemsize)
        
        # 拷贝数据到GPU
        cuda.memcpy_htod(d_input, input_data.astype(np.float32).ravel())
        
        # 执行推理
        context.execute_v2([int(d_input), int(d_output)])
        
        # 拷贝结果回CPU
        output_data = np.empty(output_size, dtype=np.float32)
        cuda.memcpy_dtoh(output_data, d_output)
        
        return output_data

# 使用
engine = load_engine("resnet50_fp16.engine")
test_input = np.random.randn(1, 3, 224, 224).astype(np.float32)
result = inference(engine, test_input)
print(f"推理结果形状: {result.shape}")

三种格式的对比与选型建议

特性 TorchScript ONNX TensorRT
依赖 仅PyTorch ONNX Runtime / 各框架 NVIDIA GPU + CUDA
推理速度 中等 中等 最快(FP16/INT8)
跨平台 仅PyTorch生态 广泛 仅NVIDIA GPU
量化支持 有限 一般 原生支持
调试难度 低 中等 高

我的选型建议:

  • 纯PyTorch部署,不跨框架 → TorchScript,省事
  • 需要跨框架或跨硬件 → ONNX,通用性最强
  • 追求极致性能,且用NVIDIA GPU → TensorRT,值得折腾
  • 生产环境常见组合:PyTorch → ONNX → TensorRT,兼顾通用性和性能

最后说一句,模型导出这件事,看起来简单,但坑不少。我建议你每导出一个格式,都用对应的Runtime跑一遍验证。别等到上线了才发现模型跑不起来——那种感觉,嗯,我不想再体验第二次。