2. CMSIS-NN 核心原理:神经网络量化基础与加速架构

各位同学,欢迎来到第二章。这一章我们不讲代码,先聊聊 CMSIS-NN 的“内功心法”。

很多初学者一上来就调库,结果模型跑起来要么精度崩了,要么速度还不如纯 C 循环。为什么?说白了,就是没搞懂底层在干什么。

我个人习惯,学任何库之前,先看它的设计哲学。CMSIS-NN 的核心就两件事:量化和加速。咱们一个一个拆开讲。

2.1 神经网络量化基础:为什么非要用 INT8?

先问个问题:MCU 上跑 FP32 模型,行不行?

行,但很蠢。你想想看,一个 Cortex-M4 内核,硬件浮点单元一次只能算一个 float,而一个 SIMD 指令一次能处理四个 int8。速度差四倍,功耗差更多。我在一个 STM32F4 项目上试过,FP32 的 MobileNet 推理一次要 3 秒,换成 INT8 直接降到 0.8 秒——这还没用 CMSIS-NN 优化。

所以,量化不是可选项,是必选项。

2.1.1 量化映射:从 FP32 到 INT8

量化的本质很简单:把连续的浮点数映射到离散的整数空间。公式长这样:

q = round(r / scale) + zero_point

其中 r 是原始浮点数,q 是量化后的 INT8,scale 是缩放因子,zero_point 是零点偏移。

反量化就是逆运算:

r = (q - zero_point) * scale

这里有个关键点:scale 是浮点数,zero_point 是整数。CMSIS-NN 在推理时会把 scale 转成定点数来算,避免浮点运算。具体怎么转的?后面讲卷积加速时会提到。

量化精度损失来源

  • 舍入误差:round() 操作会丢失低位信息
  • 截断误差:超出 INT8 范围(-128~127)的值被强制截断
  • 非均匀分布:如果权重分布太散,量化步长会变大,精度损失更明显

2.1.2 量化方式对比:Per-Tensor vs Per-Channel

量化方式 精度 计算量 适用场景
Per-Tensor 较低 低 权重分布均匀的小模型
Per-Channel 较高 中等 卷积层,尤其深度可分离卷积
Per-Layer 中等 低 全连接层为主的模型

我在一个关键词唤醒项目里踩过坑:用 Per-Tensor 量化,模型精度从 95% 掉到 82%。换成 Per-Channel 后,精度回到 93%。所以,能选 Per-Channel 就别省那点计算量。

避坑指南:我曾经在 STM32L4 上试过 Per-Channel 量化,结果 CMSIS-NN 的某些老版本不支持,跑出来结果全错。后来查文档才发现,CMSIS-NN 5.7.0 之后才完整支持 Per-Channel。所以,用之前先看版本号。

2.2 CMSIS-NN 库函数架构

CMSIS-NN 不是一个大而全的库,它是一组精心设计的 C 函数,专门针对 ARM Cortex-M 内核优化。它的架构分三层:

  1. 激活函数层:ReLU、Sigmoid、Tanh 的 INT8 实现
  2. 核心计算层:卷积、全连接、池化、Softmax
  3. 数据操作层:量化/反量化、数据重排、填充

我画了一张架构图,帮你理清关系:

CMSIS-NN 库函数架构 用户应用层 激活函数层 arm_relu_q7() | arm_sigmoid_q15() | arm_tanh_q15() 核心计算层 arm_convolve_s8() | arm_fully_connected_s8() arm_avgpool_s8() | arm_maxpool_s8() | arm_softmax_s8() arm_depthwise_conv_s8() | arm_grouped_conv_s8() 数据操作层 arm_requantize() | arm_offset_s8() | arm_nn_mat_mul_core_s8() 每层都依赖下层提供的 API,上层不直接操作硬件

看到没?用户只需要调用核心计算层的函数,数据操作层由库内部自动调用。这种分层设计的好处是:你不需要关心底层 SIMD 指令怎么用,库帮你封装好了。

2.2.1 核心函数速览

函数名 功能 输入格式 输出格式
arm_convolve_s8() 标准卷积 INT8 INT8
arm_depthwise_conv_s8() 深度可分离卷积 INT8 INT8
arm_fully_connected_s8() 全连接层 INT8 INT8
arm_softmax_s8() Softmax 分类 INT8 INT8
arm_avgpool_s8() 平均池化 INT8 INT8

小技巧:函数名末尾的 _s8 表示输入输出都是 INT8。老版本用 _q7 后缀,现在统一改成 _s8 了。如果你看到旧代码用 arm_convolve_q7(),那是 CMSIS-NN 5.x 之前的写法,功能一样但接口不同。

2.3 卷积与全连接层的加速原理

好了,理论讲完了,咱们来点硬核的。CMSIS-NN 到底怎么加速卷积和全连接?

核心就四个字:数据重排 + SIMD。

2.3.1 卷积加速:Im2Col + GEMM

标准卷积的计算模式是:滑动窗口,逐元素乘加。这种模式对缓存极不友好——每次滑动窗口,数据都要重新加载。

CMSIS-NN 的做法是:先把输入特征图做 Im2Col(Image to Column)重排,把卷积操作变成矩阵乘法。然后调用高度优化的矩阵乘函数 arm_nn_mat_mul_core_s8() 来算。

流程如下:

  1. Im2Col 重排:把每个卷积窗口拉成一列,所有列拼成一个大矩阵
  2. 权重重排:把卷积核也拉成行矩阵
  3. 矩阵乘:用 SIMD 指令并行计算 INT8 乘加
  4. 量化反量化:结果从 INT32 缩回 INT8

我画个流程图,你一看就懂:

CMSIS-NN 卷积加速流程(Im2Col + GEMM) 输入特征图 H x W x C Im2Col 重排 窗口 → 列向量 矩阵乘 GEMM SIMD 并行计算 输出特征图 H' x W' x K 权重重排 核 → 行矩阵 量化/反量化 INT32 → INT8 为什么 Im2Col 能加速? 1. 数据连续存放,缓存命中率大幅提升 2. 矩阵乘可以用 SIMD 指令一次算 4 个 INT8 乘加 3. 权重只需重排一次,后续推理直接复用

经验之谈:Im2Col 会占用额外内存。我在一个 96x96x32 的特征图上做 3x3 卷积,Im2Col 后的矩阵大小是 9x32 x 94x94,内存暴涨了 8 倍。所以,内存紧张时,可以考虑用 arm_convolve_1x1_s8() 替代标准卷积,它不需要 Im2Col。

2.3.2 全连接加速:矩阵乘 + 量化融合

全连接层本质上就是矩阵乘加偏置。CMSIS-NN 的 arm_fully_connected_s8() 做了三件事:

  1. 矩阵乘:调用 arm_nn_mat_mul_core_s8(),用 SIMD 并行计算
  2. 偏置加:偏置是 INT32 的,直接加到 INT32 中间结果上
  3. 量化反量化:把 INT32 结果缩回 INT8,同时做 ReLU 截断

这里有个细节:偏置的量化参数必须和输出特征图的量化参数一致。否则加偏置后结果会偏。我遇到过这个问题,排查了半天才发现是量化参数没对齐。

全连接层加速关键点

  • SIMD 指令:SMLAD(有符号乘加)一次算两个 INT16 乘加,SMLALD 一次算两个 INT32 乘加
  • 循环展开:CMSIS-NN 把内层循环展开成 4 路并行,减少循环开销
  • 预取指令:用 PLD 指令提前加载下一批数据,隐藏内存延迟

2.3.3 量化推理的完整数据流

最后,咱们串一下整个推理流程。假设输入是一张 INT8 图像:

输入图像 (INT8) 
  → arm_convolve_s8() 
    → Im2Col 重排 
    → 矩阵乘 (INT8 x INT8 → INT32) 
    → 加偏置 (INT32) 
    → 反量化 (INT32 → INT8) 
    → ReLU 截断 
  → arm_avgpool_s8() 
    → 池化窗口求和 
    → 平均 (INT16 除法) 
  → arm_fully_connected_s8() 
    → 矩阵乘 (INT8 x INT8 → INT32) 
    → 加偏置 (INT32) 
    → 反量化 (INT32 → INT8) 
  → arm_softmax_s8() 
    → 指数查找表 
    → 归一化 
  → 输出分类结果 (INT8)

每一步的中间结果都是 INT8,只有累加器用 INT32。这就是 CMSIS-NN 能在 MCU 上跑神经网络的核心秘密——全程定点运算,没有浮点。

注意:Softmax 的 INT8 实现用的是查找表,不是直接算指数。查找表大小是 256 个 INT16 值,占 512 字节。如果你自己实现 Softmax,千万别用 expf()——那会引入浮点库,代码体积直接翻倍。