3. CMSIS-NN 核心数据结构:从类型到内存布局

做嵌入式AI部署,说白了就是跟数据结构和内存布局打交道。CMSIS-NN 这套库,我用了快五年了,每次移植新模型都得跟这些结构体碰面。今天咱们就把它的核心数据结构彻底捋清楚。

3.1 基础数据类型:q7_t、q15_t、q31_t

CMSIS-NN 没有用标准的 float 或 double,而是用了三种定长整型。为什么?因为 Cortex-M 内核没有 FPU 或者只有单精度 FPU,用整型做量化计算才能跑得快。

类型 位宽 取值范围 典型用途
q7_t 8 位 -128 ~ 127 激活值、权重(8位量化)
q15_t 16 位 -32768 ~ 32767 中间累加结果、部分权重
q31_t 32 位 -2^31 ~ 2^31-1 偏置、累加器

我个人习惯把 q7_t 当作「主力选手」,因为 8 位量化在精度和速度之间平衡最好。q15_t 我一般用在需要更高精度的中间层,比如某些检测模型的头部。q31_t 嘛,几乎只用来存偏置——你想想看,偏置是累加后的结果,位宽不够会溢出。

小技巧: 在 CMSIS-NN 里,q7_t 数组通常按 8 字节对齐。我踩过坑——没对齐时,某些内核函数会硬 fault。建议用 __attribute__((aligned(8))) 声明。

3.2 卷积层结构体:cmsis_nn_conv_params

卷积是神经网络的主力,CMSIS-NN 用结构体把参数打包好。来看定义:

typedef struct {
    int32_t input_offset;    // 输入量化零点
    int32_t output_offset;   // 输出量化零点
    int32_t multiplier;      // 量化乘数(Q格式)
    int32_t shift;           // 量化移位量
    int32_t activation_min;  // 激活函数最小值(如ReLU的0)
    int32_t activation_max;  // 激活函数最大值
} cmsis_nn_conv_params;

这里有个关键点:multiplier 和 shift 是成对出现的。我曾经在项目里只改了 multiplier 忘了调 shift,结果输出全乱套。嗯,调试了两天才发现。

卷积核的权重内存布局是这样的:

// 权重排列:[输出通道][输入通道][核高][核宽]
// 实际存储时,CMSIS-NN 会按 HWC 顺序重排
q7_t weights[out_ch][in_ch][kernel_h][kernel_w];

为什么重排?因为 Cortex-M 的 SIMD 指令(如 SMLAD)喜欢连续的内存访问。把同一输出通道的权重连续放,能减少 cache miss。

3.3 池化层结构体:cmsis_nn_pool_params

池化层相对简单,但参数也不少:

typedef struct {
    int32_t input_offset;
    int32_t output_offset;
    int32_t multiplier;
    int32_t shift;
    int32_t activation_min;
    int32_t activation_max;
    int32_t stride_width;
    int32_t stride_height;
    int32_t pool_width;
    int32_t pool_height;
} cmsis_nn_pool_params;

注意看,池化层也有 multiplier 和 shift。为什么?因为平均池化做完除法后,结果还是量化值,需要重新缩放。最大池化则不需要——直接取最大值就行。

避坑: 平均池化时,如果输入是 q7_t,累加结果会溢出。CMSIS-NN 内部会用 q31_t 做累加,但前提是你得把 input_offset 设对。我曾经设成 0 而不是真正的零点,结果池化输出偏了半个刻度。

3.4 全连接层结构体:cmsis_nn_fc_params

全连接层其实就是矩阵乘法,但 CMSIS-NN 把它单独封装了:

typedef struct {
    int32_t input_offset;
    int32_t output_offset;
    int32_t multiplier;
    int32_t shift;
    int32_t activation_min;
    int32_t activation_max;
    int32_t bias_offset;  // 偏置的量化零点
} cmsis_nn_fc_params;

全连接层的权重布局和卷积不同:

// 权重:[输出神经元][输入神经元]
// 按行主序存储,方便矩阵乘法
q7_t weights[num_output][num_input];

我建议你把全连接层的权重单独拿出来做一次「转置检查」。因为有些训练框架(比如 TensorFlow)存的是列主序,而 CMSIS-NN 要行主序。不转置的话,推理结果就是错的。

3.5 权重与偏置的内存布局

这是最容易出问题的地方。CMSIS-NN 对内存布局有严格要求:

  1. 权重: 必须按 [输出通道][核高][核宽][输入通道] 排列(即 NHWC 的变体)。
  2. 偏置: 每个输出通道一个 q31_t 值,连续存放。
  3. 对齐: 权重数组首地址必须 8 字节对齐,偏置数组 4 字节对齐即可。

来看一个实际的内存布局示例:

// 假设:输入通道=3,输出通道=2,核=3x3
// 权重内存(共 2*3*3*3 = 54 字节):
// [out0][h0][w0][in0] [out0][h0][w0][in1] [out0][h0][w0][in2]
// [out0][h0][w1][in0] ...
// ...
// [out1][h0][w0][in0] ...

// 偏置内存(共 2*4 = 8 字节):
// [bias0] [bias1]
核心原则: 权重布局必须与卷积核的滑动顺序一致。CMSIS-NN 的 im2col + 矩阵乘法实现,要求权重按「输出通道优先」排列。如果你从 PyTorch 导出,记得做 permute。

3.6 整体数据结构关系图

下面这张图展示了 CMSIS-NN 各数据结构之间的关系:

CMSIS-NN 核心数据结构关系图 CMSIS-NN 核心数据结构关系 神经网络模型 卷积层 池化层 全连接层 cmsis_nn_conv_params input_offset, multiplier, shift... cmsis_nn_pool_params stride, pool_size, offset... cmsis_nn_fc_params bias_offset, multiplier... 权重 + 偏置 q7_t weights[out][h][w][in] q31_t bias[out] 输入/输出 q7_t input[batch][h][w][c] q7_t output[...] 临时缓冲区 q15_t im2col_buf[] q31_t acc_buf[] 所有数据必须按 8 字节对齐,否则 CMSIS-NN 内核会异常 建议使用 __attribute__((aligned(8))) 声明数组

3.7 实战中的内存布局检查

我在项目中总结了一套检查内存布局的方法:

  1. 打印首地址: 用 printf("%p", weights) 确认对齐。
  2. 打印前几个值: 对比训练时的权重,看顺序对不对。
  3. 检查偏置符号: 偏置是 q31_t,如果符号位不对,输出会全偏。
我曾经... 在一个语音唤醒项目里,因为权重布局是 NHWC 而不是 CMSIS-NN 要求的 OHWI,导致推理结果完全不对。查了三天,最后用十六进制 dump 对比才发现。从那以后,我每次转换模型都会写一个布局验证脚本。

好了,CMSIS-NN 的核心数据结构就这些。记住:类型选对、布局排对、对齐做对,你的模型部署就成功了一半。