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到嵌入式:一条实际路径
我在项目中用过一条比较成熟的链路:
- PyTorch训练 → 导出ONNX(torch.onnx.export)
- ONNX模型优化 → 用onnx-simplifier去掉冗余节点,用onnx2ncnn或onnx2tflite转换
- 嵌入式推理库 → 加载转换后的模型文件,解析权重,在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:模型参数序列化与反序列化流程
下面这张图展示了从训练到嵌入式部署的完整链路,以及序列化/反序列化的位置。
总结一下
模型参数序列化,说白了就是解决“Python世界的张量”和“C世界的字节数组”之间的翻译问题。
ONNX是工业界的主流中间格式,但嵌入式场景下我们往往需要二次转换。自己写TLV格式虽然简陋,但胜在可控、轻量、没有依赖。
嗯,这里要注意:字节序、对齐、版本兼容,这三个问题只要有一个没处理好,模型就跑不起来。我当年在STM32上部署MobileNetV2,就因为权重顺序问题折腾了两天。后来老老实实写了一个单元测试,每次转换完先验证权重数值是否正确。
做嵌入式AI,耐心比技术更重要。
推荐实践:在C代码里实现一个model_validate()函数,加载完权重后,打印前几个数值和期望值对比。这个小习惯帮我省了无数debug时间。
公众号:蓝海资料掘金营,微信deep3321