4. 模型序列化与反序列化:.plan文件格式、序列化流程、反序列化流程、模型加密与安全考量
好,咱们今天聊聊模型序列化。说白了,就是把训练好的模型存成文件,下次要用的时候直接加载,不用重新构建和优化。在TensorRT里,这个文件就是.plan文件。
我刚开始接触TensorRT时,觉得序列化不就是个保存加载嘛,有啥好讲的?直到有一次,我在客户现场部署模型,发现同样的.plan文件,在不同显卡上跑出来的结果不一样……嗯,从那以后,我对序列化这件事就再也不敢马虎了。
4.1 .plan文件格式:不只是二进制
.plan文件,全称是TensorRT Plan File。它不是一个简单的权重存储文件,而是一个完整的执行计划。
它里面包含了什么?我拆开给你看:
- 网络结构:每一层的定义、连接关系、输入输出张量信息
- 权重参数:经过量化、融合后的最终权重值
- 优化后的内核选择:针对特定GPU架构选择的CUDA kernel
- 内存分配策略:TensorRT计算出的最优内存复用方案
- 硬件绑定信息:生成该plan时所用的CUDA计算能力版本、显存大小等
你想想看,这其实就是一个可执行的推理蓝图。它不依赖原始模型框架,也不依赖TensorRT的构建器,只要运行时库就能跑。
核心要点:.plan文件是硬件绑定的。在A100上生成的plan,不一定能在T4上跑。反过来,T4上生成的plan,在A100上可能浪费了性能。
4.2 序列化流程:从网络到文件
序列化,就是把构建好的ICudaEngine对象写入文件。流程其实不复杂,但有几个坑。
标准做法是这样的:
// 1. 创建构建器
IBuilder* builder = createInferBuilder(gLogger);
// 2. 创建网络定义
INetworkDefinition* network = builder->createNetwork();
// 3. 解析模型(比如用ONNX解析器)
// ... 省略解析代码 ...
// 4. 创建构建配置
IBuilderConfig* config = builder->createBuilderConfig();
config->setMemoryPoolLimit(MemoryPoolType::kWORKSPACE, 1 << 30); // 1GB
// 5. 构建引擎
ICudaEngine* engine = builder->buildSerializedNetwork(*network, *config);
// 6. 序列化到文件
IHostMemory* serializedModel = engine->serialize();
std::ofstream file("model.plan", std::ios::binary);
file.write(reinterpret_cast<const char*>(serializedModel->data()),
serializedModel->size());
file.close();
我个人习惯在序列化前,先检查一下引擎是否真的构建成功了。曾经有一次,我忘了检查buildSerializedNetwork的返回值,结果写了个空文件到磁盘,部署时排查了半天……
我的经验:序列化时,建议在文件名中加入GPU型号和TensorRT版本号。比如resnet50_a100_trt861.plan。这样以后看到文件就知道它是在什么环境下生成的,避免混淆。
4.3 反序列化流程:从文件到推理
反序列化,就是把.plan文件加载回来,创建可执行的引擎。这个过程比序列化快得多,因为它跳过了所有优化步骤。
// 1. 读取文件
std::ifstream file("model.plan", 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();
// 2. 创建运行时
IRuntime* runtime = createInferRuntime(gLogger);
// 3. 反序列化
ICudaEngine* engine = runtime->deserializeCudaEngine(buffer.data(), size);
// 4. 创建执行上下文
IExecutionContext* context = engine->createExecutionContext();
这里有个关键点:反序列化时,GPU必须和生成plan时的GPU兼容。什么叫兼容?就是计算能力版本要匹配。
举个例子:你在RTX 3090(计算能力8.6)上生成的plan,可以在RTX 3080(计算能力8.6)上跑,但不能在GTX 1080(计算能力6.1)上跑。为什么?因为plan里保存的CUDA kernel是针对8.6架构编译的,6.1的硬件不支持这些指令。
避坑指南:我曾经在A100上生成plan,然后部署到V100上,结果反序列化直接报错。后来我养成了习惯:在目标部署环境的GPU上生成plan,或者至少保证计算能力版本一致。
4.4 模型加密与安全考量
说到安全,很多同学会问:.plan文件里包含了模型权重,别人拿到文件是不是就能窃取我的模型?
答案是:能,但没那么容易。
.plan文件是二进制格式,不是标准的模型格式(比如ONNX、TensorFlow SavedModel)。直接解析它需要深入理解TensorRT的内部结构。但话说回来,只要有人愿意花时间逆向,总能提取出权重信息。
那怎么办?我常用的几种方案:
| 方案 | 实现方式 | 安全等级 | 性能影响 |
|---|---|---|---|
| 文件加密 | 对.plan文件进行AES加密,运行时解密到内存 | 中等 | 无(仅加载时解密) |
| 自定义序列化 | 自己实现序列化/反序列化,加入校验逻辑 | 较高 | 无 |
| 硬件绑定 | 将plan与特定GPU的UUID绑定 | 高 | 无 |
| 模型混淆 | 在序列化前对权重进行混淆变换 | 较高 | 轻微(反混淆开销) |
我个人最推荐的是文件加密 + 硬件绑定的组合方案。加密防止文件被直接读取,硬件绑定防止plan被复制到其他机器上使用。
实现硬件绑定的思路:
// 获取当前GPU的UUID
cudaDeviceProp prop;
cudaGetDeviceProperties(&prop, 0);
std::string deviceUUID(prop.uuid.bytes, 16);
// 将UUID作为元数据写入plan文件头部
// 反序列化时,先读取UUID,与当前GPU的UUID比对
// 如果不匹配,拒绝加载
小技巧:如果你只是做内部部署,不涉及客户分发,其实不用太担心安全问题。TensorRT的plan文件本身就不是标准格式,普通用户拿到也没法直接用。真正需要加密的场景,是你要把模型分发到不受控的环境中。
4.5 序列化与反序列化的最佳实践
总结一下我这些年积累的经验:
- 版本对齐:生成plan的TensorRT版本和加载plan的TensorRT版本必须一致。小版本不同也可能出问题。
- 硬件兼容性检查:反序列化前,检查当前GPU的计算能力是否与plan匹配。
- 文件完整性校验:在plan文件末尾添加CRC32或MD5校验码,防止文件损坏。
- 内存管理:反序列化后的engine对象会占用显存,记得在不用时释放。
- 多线程安全:同一个engine可以被多个context共享,但每个context只能被一个线程使用。
嗯,序列化这块其实不难,但细节决定成败。我见过太多人因为版本不匹配、硬件不兼容这种低级问题浪费一整天。希望今天的分享能帮你少踩几个坑。