21、序列化与机器学习:模型参数的序列化、ONNX格式简介

说实话,嵌入式工程师和机器学习工程师,以前是两个世界的人。

我做嵌入式十几年,以前觉得AI就是跑在服务器上的东西。直到这几年,边缘计算火了,模型要跑到MCU上、跑到ARM Cortex-M上,我才发现——模型参数的序列化,是连接这两个世界的桥梁

今天我们就聊聊这个桥怎么搭。

为什么模型参数需要序列化?

你想想看,一个训练好的神经网络,本质上是什么?

是一堆浮点数——权重、偏置、BatchNorm的均值和方差。这些数在Python的PyTorch或TensorFlow里,是以张量(Tensor)的形式存在的。但你的C语言程序不认识张量,它只认识字节流。

所以,我们需要把模型参数从内存中的结构体/张量,转换成可以存储或传输的字节序列。这就是序列化。

反过来,C程序从文件或Flash里读出一串字节,再还原成权重数组,这就是反序列化。

核心要点:模型参数序列化 = 把浮点数组 + 元数据(形状、类型、名称)打包成字节流。

我踩过的坑:直接memcpy的后果

我记得刚接触模型部署时,有个同事图省事,直接把float数组用fwrite写进文件。读的时候用fread读回来。看起来没问题对吧?

结果模型在PC上训练,在ARM板子上推理,准确率直接掉到30%。

为什么?字节序(Endian)不一样。PC是x86小端,有些ARM芯片是大端。float的字节顺序反了,数值就全乱了。

还有一次,模型结构升级了,加了一个卷积层。旧的文件里没有这组权重,程序直接崩溃——版本兼容性没处理好

警告:不要直接用memcpy或fwrite序列化结构体。结构体对齐、字节序、版本变化,任何一个都能让你debug到怀疑人生。

自己动手:一个轻量级模型参数序列化方案

在嵌入式场景下,我们不需要ONNX那么重的格式。很多时候,一个简单的自定义格式就够了。

我个人习惯用TLV(Type-Length-Value)结构。每个参数块包含:

  • Type:数据类型标识(比如0x01表示float32,0x02表示int8)
  • Length:数据总字节数
  • Value:实际的权重数据

这样设计的好处是:解析时遇到不认识Type可以跳过,遇到版本升级也能向后兼容。

// 模型参数序列化头文件示例
#pragma pack(1)  // 取消对齐,保证跨平台

typedef struct {
    uint32_t magic;        // 魔数,用于校验文件完整性
    uint32_t version;      // 版本号
    uint32_t num_layers;   // 层数
} ModelHeader;

typedef struct {
    uint8_t  type;         // 数据类型:0x01=float32, 0x02=int8
    uint8_t  dims;         // 维度数(支持1~4维)
    uint32_t shape[4];     // 各维度尺寸
    uint32_t data_len;     // 数据长度(字节)
    // 后面紧跟实际数据
} LayerParamBlock;

#pragma pack()  // 恢复默认对齐

序列化时,按顺序写入Header,然后逐层写入ParamBlock。反序列化时,先读Header校验版本,再循环读Block。

小技巧:写入数据时统一转为小端字节序。ARM Cortex-M0+没有浮点单元,但字节序转换用移位操作就能搞定,开销很小。

ONNX格式简介:为什么它很重要?

ONNX(Open Neural Network Exchange)是微软和Facebook牵头搞的开放格式。说白了,它想成为AI模型的“通用语言”。

你在PyTorch里训练,导出ONNX;在TensorRT里推理,导入ONNX。中间不需要重写模型。

ONNX的核心结构是计算图(Computational Graph)。它用Protobuf序列化,包含:

  • GraphProto:整个计算图,包含所有节点(Node)、初始值(Initializer)、输入输出(Input/Output)
  • NodeProto:每个算子(Conv、Relu、Gemm等)
  • TensorProto:权重参数,支持float32、float16、int8等多种类型

ONNX的序列化机制就是Protobuf。Protobuf会把结构体变成紧凑的二进制流,自带字段编号和类型信息,天然支持向前向后兼容。

关键点:ONNX不是给嵌入式MCU直接用的。它太大了,解析库动辄几MB。但它是模型转换的中间格式。你从PyTorch导出ONNX,再用工具(如ONNX Runtime、Tengine、NCNN)转成嵌入式专用格式。

从ONNX到嵌入式:一条实际路径

我在项目中用过一条比较成熟的链路:

  1. PyTorch训练 → 导出ONNX(torch.onnx.export)
  2. ONNX模型优化 → 用onnx-simplifier去掉冗余节点,用onnx2ncnn或onnx2tflite转换
  3. 嵌入式推理库 → 加载转换后的模型文件,解析权重,在MCU上推理

其中第2步,转换工具会帮我们把ONNX的Protobuf格式,转成嵌入式友好的扁平二进制格式。比如NCNN的.bin文件,就是直接把权重按顺序排列,配合一个.param文件描述网络结构。

我曾经踩过一个坑:ONNX导出的权重顺序和NCNN期望的不一样。Conv算子的权重在ONNX里是[out_channels, in_channels, kh, kw],但NCNN期望的是[kh, kw, in_channels, out_channels]。不调整的话,推理结果完全错误。

注意:不同推理框架对权重内存布局的要求不同。转换时一定要确认数据排列顺序,必要时写一个重排函数。

SVG:模型参数序列化与反序列化流程

下面这张图展示了从训练到嵌入式部署的完整链路,以及序列化/反序列化的位置。

模型参数序列化与反序列化流程 PyTorch/TF训练 张量(Tensor) 导出 ONNX模型 Protobuf序列化 转换 嵌入式模型文件 扁平二进制 / TLV MCU 序列化(训练端 → 文件) 1. 读取张量数据 + 形状/类型 2. 写入Header(魔数+版本) 3. 逐层写入TLV Block 反序列化(文件 → MCU内存) 1. 读取Header,校验版本 2. 循环读取Block,解析Type 3. 字节序转换 + 填充权重数组 文件存储/传输

总结一下

模型参数序列化,说白了就是解决“Python世界的张量”“C世界的字节数组”之间的翻译问题。

ONNX是工业界的主流中间格式,但嵌入式场景下我们往往需要二次转换。自己写TLV格式虽然简陋,但胜在可控、轻量、没有依赖。

嗯,这里要注意:字节序、对齐、版本兼容,这三个问题只要有一个没处理好,模型就跑不起来。我当年在STM32上部署MobileNetV2,就因为权重顺序问题折腾了两天。后来老老实实写了一个单元测试,每次转换完先验证权重数值是否正确。

做嵌入式AI,耐心比技术更重要。

推荐实践:在C代码里实现一个model_validate()函数,加载完权重后,打印前几个数值和期望值对比。这个小习惯帮我省了无数debug时间。


公众号:蓝海资料掘金营,微信deep3321