3. 模型加载优化:从ONNX到TensorRT的序列化内存布局

模型加载,说白了就是把训练好的权重从硬盘搬到显存里。但这一步,很多人觉得不就是调个API吗?嗯,没那么简单。我见过不少项目,模型加载就占了上百兆内存,推理时又重复分配,白白浪费资源。

今天咱们就聊聊,从ONNX到TensorRT的序列化过程中,内存到底经历了什么。我个人习惯把这一步叫做“内存布局的二次重构”。

3.1 ONNX的序列化内存结构

ONNX文件本质上是一个Protobuf序列化的二进制文件。它的内存布局,我拆成三块来看:

区域 内容 内存占比
图结构 节点列表、边连接、算子属性 约5-10%
权重数据 卷积核、BN参数、偏置等 约85-90%
元信息 输入输出名称、数据类型、shape 约1-5%

你想想看,一个YOLOv8的ONNX文件大概40-60MB,其中90%都是权重。但这里有个坑——ONNX的权重是平铺存储的,没有任何内存对齐优化。

我曾经踩过的坑:有一次加载一个YOLOv5s的ONNX,发现内存占用比文件大小多了30%。查了半天,原来是Protobuf反序列化时,内部会额外分配临时缓冲区。如果你用Python的onnxruntime加载,它还会再拷贝一份到numpy数组里。一来一回,内存直接翻倍。

3.2 TensorRT的序列化内存布局

TensorRT的序列化引擎(.trt或.engine文件)跟ONNX完全是两码事。它做了三件大事:

  1. 权重重排:把权重按GPU最优访存模式重新排列
  2. 算子融合:把多个小算子合并成一个大kernel
  3. 内存池预分配:提前算好每层需要的显存大小

我画了一张图,帮你理解这个转换过程:

ONNX → TensorRT 序列化内存布局转换 ONNX 内存布局 图结构(节点+边) 权重数据(平铺存储) Conv权重 | BN参数 | Bias 元信息(name/dtype/shape) 序列化 + 优化 TensorRT 内存布局 优化后的图结构(融合后) 权重(重排+对齐) NHWC格式 | 16字节对齐 内存池元数据(预分配) 关键差异:ONNX是“描述型”存储,TensorRT是“执行型”存储,后者直接为GPU推理优化

3.3 序列化过程中的内存峰值

这里有个容易被忽视的问题——序列化过程中的内存峰值。我实测过,在把ONNX转成TensorRT引擎时,内存峰值可能是最终引擎文件的3-5倍。

实测数据(YOLOv8s,FP16):

  • ONNX文件大小:43MB
  • TensorRT引擎大小:28MB
  • 序列化过程中峰值内存:约180MB
  • 引擎加载后常驻内存:约35MB

为什么会这样?因为TensorRT在构建引擎时,需要同时持有ONNX的原始数据、中间优化表示、以及最终引擎数据。这三份数据同时存在内存里,峰值自然就上去了。

3.4 内存优化策略

针对序列化过程中的内存问题,我总结了几个实战策略:

策略 做法 效果
流式加载 不一次读入整个ONNX,而是分段解析 减少峰值内存30-50%
权重预量化 在ONNX阶段先做INT8/FP16量化 减少序列化时内存占用
内存池复用 复用临时缓冲区,避免频繁分配释放 减少碎片化,提升速度
延迟加载 只加载需要的子图,不加载整个模型 适合大模型场景

我的个人习惯:在嵌入式设备上(比如Jetson Orin),我会先用trtexec --saveEngine在PC上生成引擎文件,然后直接拷贝到设备上加载。这样设备端就不需要跑序列化过程,内存峰值能降80%。

3.5 代码实战:内存友好的引擎加载

下面是我在实际项目中用的加载方式,注意看内存管理部分:

// 内存友好的TensorRT引擎加载
class EngineLoader {
private:
    std::vector<char> engineData;
    nvinfer1::IRuntime* runtime;
    nvinfer1::ICudaEngine* engine;

public:
    bool loadEngine(const std::string& path) {
        // 1. 获取文件大小,避免一次性读入大文件
        std::ifstream file(path, std::ios::binary | std::ios::ate);
        if (!file.is_open()) return false;

        size_t fileSize = file.tellg();
        file.seekg(0, std::ios::beg);

        // 2. 预分配固定大小缓冲区,避免多次扩容
        engineData.resize(fileSize);
        
        // 3. 分块读取,每块4KB
        const size_t chunkSize = 4096;
        size_t offset = 0;
        while (offset < fileSize) {
            size_t toRead = std::min(chunkSize, fileSize - offset);
            file.read(engineData.data() + offset, toRead);
            offset += toRead;
        }
        file.close();

        // 4. 创建runtime并反序列化
        runtime = nvinfer1::createInferRuntime(gLogger);
        engine = runtime->deserializeCudaEngine(
            engineData.data(), engineData.size());

        // 5. 反序列化完成后立即释放原始数据
        engineData.clear();
        engineData.shrink_to_fit();

        return engine != nullptr;
    }
};

注意:上面的shrink_to_fit()很关键。我见过有人忘了这步,导致std::vector保留了已释放的内存空间,白白多占了几十MB。C++的vector不会自动缩容,你得手动告诉它。

3.6 序列化格式对比

最后,我把常见的序列化格式做个对比,方便你选型:

格式 加载速度 内存占用 适用场景
ONNX 慢(需解析Protobuf) 高(平铺存储) 模型交换、跨框架
TensorRT(FP32) 快(二进制加载) 中(有优化) GPU推理、高性能
TensorRT(FP16) 快 低(权重减半) 边缘设备、Jetson
TensorRT(INT8) 快 最低(1/4 FP32) 极致性能、精度可接受

嗯,到这里你应该明白了——模型加载不只是调个API那么简单。从ONNX到TensorRT,内存布局经历了从“描述型”到“执行型”的转变。理解这个过程,你才能写出内存友好的部署代码。