3. 模型序列化与反序列化:从ONNX到TensorRT引擎
好,咱们今天聊聊模型序列化与反序列化。说白了,就是把ONNX模型变成TensorRT能直接跑的.engine文件,以及反过来从.engine文件恢复模型的过程。这个环节,我当年刚接触TensorRT时踩过不少坑,今天一并分享给你。
3.1 为什么需要序列化?
你想想看,每次部署模型都要重新解析ONNX、构建引擎,那得多慢?尤其在生产环境,模型加载时间直接影响服务启动速度。我有个项目,ONNX模型解析加引擎构建要花40秒,用户等得直骂娘。后来改成预先生成.engine文件,启动时直接反序列化,3秒搞定。
序列化的核心价值就两点:
- 加速加载:省去ONNX解析和引擎构建的时间
- 跨平台部署:在开发机上构建引擎,部署到目标设备
3.2 ONNX到TensorRT引擎的完整流程
咱们一步步来。先看最核心的代码,我习惯用onnx-tensorrt parser来解析ONNX模型。
// C++示例:从ONNX构建TensorRT引擎
#include <NvOnnxParser.h>
#include <NvInfer.h>
// 1. 创建builder和network
nvinfer1::IBuilder* builder = nvinfer1::createInferBuilder(logger);
const auto explicitBatch = 1U << static_cast<uint32_t>(NetworkDefinitionCreationFlag::kEXPLICIT_BATCH);
nvinfer1::INetworkDefinition* network = builder->createNetworkV2(explicitBatch);
// 2. 创建ONNX parser
nvonnxparser::IParser* parser = nvonnxparser::createParser(*network, logger);
// 3. 解析ONNX文件
if (!parser->parseFromFile("model.onnx",
static_cast<int>(nvinfer1::ILogger::Severity::kWARNING))) {
// 解析失败,打印错误
for (int i = 0; i < parser->getNbErrors(); ++i) {
std::cerr << parser->getError(i)->desc() << std::endl;
}
}
// 4. 配置builder
nvinfer1::IBuilderConfig* config = builder->createBuilderConfig();
config->setMemoryPoolLimit(nvinfer1::MemoryPoolType::kWORKSPACE, 1 << 30); // 1GB
// 5. 构建引擎
nvinfer1::IHostMemory* serializedModel = builder->buildSerializedNetwork(*network, *config);
// 6. 保存到文件
std::ofstream file("model.engine", std::ios::binary);
file.write(reinterpret_cast<const char*>(serializedModel->data()), serializedModel->size());
file.close();
// 清理资源
delete serializedModel;
delete config;
delete parser;
delete network;
delete builder;
关键点:buildSerializedNetwork直接返回序列化后的引擎数据,省去了先build再serialize的步骤。TensorRT 8.0以后推荐这种方式。
3.3 onnx-tensorrt parser的使用细节
这个parser说白了就是个翻译官,把ONNX的算子翻译成TensorRT能理解的格式。但翻译过程中经常出幺蛾子。
我遇到过最典型的问题:
- 算子不支持:ONNX里有些算子TensorRT不认识,比如某些自定义算子。这时候需要手动实现plugin
- 动态shape处理:ONNX里如果输入是动态的,parser解析时得指定优化profile
- 精度问题:默认FP32,想用FP16/INT8得额外配置
来看一个带动态shape和FP16的完整示例:
// 动态shape + FP16配置
nvinfer1::IOptimizationProfile* profile = builder->createOptimizationProfile();
profile->setDimensions("input", nvinfer1::OptProfileSelector::kMIN,
nvinfer1::Dims4{1, 3, 224, 224});
profile->setDimensions("input", nvinfer1::OptProfileSelector::kOPT,
nvinfer1::Dims4{4, 3, 224, 224});
profile->setDimensions("input", nvinfer1::OptProfileSelector::kMAX,
nvinfer1::Dims4{8, 3, 224, 224});
config->addOptimizationProfile(profile);
// 开启FP16
if (builder->platformHasFastFp16()) {
config->setFlag(nvinfer1::BuilderFlag::kFP16);
std::cout << "FP16 enabled" << std::endl;
}
我的习惯:构建引擎时先不加任何优化,跑通后再逐步加FP16、INT8。这样出了问题好定位。
3.4 序列化与反序列化API详解
序列化就是把引擎变成二进制数据,反序列化就是变回来。TensorRT提供了两套API:
| 操作 | API | 说明 |
|---|---|---|
| 序列化 | builder->buildSerializedNetwork() |
直接返回序列化数据,推荐 |
| 序列化 | engine->serialize() |
先build再serialize,两步走 |
| 反序列化 | runtime->deserializeCudaEngine() |
从二进制数据恢复引擎 |
反序列化的代码更简洁:
// 从.engine文件反序列化
nvinfer1::IRuntime* runtime = nvinfer1::createInferRuntime(logger);
// 读取文件到内存
std::ifstream file("model.engine", std::ios::binary);
file.seekg(0, std::ios::end);
size_t size = file.tellg();
file.seekg(0, std::ios::beg);
std::vector<char> buffer(size);
file.read(buffer.data(), size);
file.close();
// 反序列化
nvinfer1::ICudaEngine* engine = runtime->deserializeCudaEngine(
buffer.data(), size, nullptr);
// 创建执行上下文
nvinfer1::IExecutionContext* context = engine->createExecutionContext();
// 用完记得释放
delete context;
delete engine;
delete runtime;
曾经踩过的坑:反序列化时如果TensorRT版本不一致,会直接崩溃。我建议在部署脚本里加个版本检查:
// 检查TensorRT版本
int major = runtime->getVersion() / 1000;
int minor = (runtime->getVersion() % 1000) / 100;
std::cout << "TensorRT version: " << major << "." << minor << std::endl;
3.5 Python端的实现
Python端其实更常用,尤其是做快速原型验证的时候。我一般先用Python调通,再转C++部署。
# Python示例:ONNX转TensorRT引擎
import tensorrt as trt
# 创建logger和builder
logger = trt.Logger(trt.Logger.WARNING)
builder = trt.Builder(logger)
# 创建network,显式batch
network = builder.create_network(
1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH))
# 创建parser
parser = trt.OnnxParser(network, logger)
# 解析ONNX
with open("model.onnx", "rb") as f:
if not parser.parse(f.read()):
for error in range(parser.num_errors):
print(parser.get_error(error))
raise RuntimeError("Failed to parse ONNX")
# 配置builder
config = builder.create_builder_config()
config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 1 << 30) # 1GB
# 构建序列化引擎
serialized_engine = builder.build_serialized_network(network, config)
# 保存到文件
with open("model.engine", "wb") as f:
f.write(serialized_engine)
print("Engine saved to model.engine")
小技巧:Python端可以用trt.OnnxParser的parse方法直接传bytes,省去文件读写。我经常在内存里完成ONNX到engine的转换。
3.6 实战中的避坑指南
嗯,这里要重点说说我踩过的坑,希望能帮你省点时间:
- 内存泄漏:C++里每个
create出来的对象都要delete。我写过一个脚本,跑了100次后内存爆了,排查半天发现是parser没释放 - 动态shape的profile数量:一个engine最多支持多少个优化profile?我记得文档说最多8个,但实际测试发现超过4个就有点慢了
- 序列化文件大小:FP32的engine文件可能很大,我有个ResNet-152的模型,engine文件有200多MB。部署时注意磁盘空间
- 跨设备兼容性:不同GPU架构的engine不通用。比如T4(Turing架构)和A100(Ampere架构)的engine不能互换
最后说一句,序列化反序列化这个环节,说白了就是「一次构建,多次运行」。但构建过程本身很耗时,所以建议在CI/CD流程里预先生成好engine文件,部署时直接反序列化。这样既快又稳。
下一章咱们聊聊如何用TensorRT做推理,包括context的创建、输入输出的绑定,以及异步推理的实现。到时候见!