3、CMSIS-NN库初探:软件包结构与核心函数

好,咱们进入第三章。这一章我打算带大家看看CMSIS-NN这个库到底长什么样。说实话,我第一次接触CMSIS-NN的时候,也被它那套命名规则搞得有点晕。但用顺手之后,你会发现它其实非常规整,像个精密的工具箱。

3.1 CMSIS-NN软件包结构

CMSIS-NN是ARM官方推出的神经网络推理库,专门针对Cortex-M和Cortex-A系列处理器做了深度优化。它的核心思想很简单:用查表法和汇编指令,把卷积、全连接这些操作榨干CPU的每一滴性能

整个软件包的结构大致分三层:

  • NN函数层:提供卷积、池化、全连接、激活函数等高层API。我们平时调用的arm_convolve_s8就在这层。
  • 基础数学层:提供矩阵乘法、向量点积等底层运算。这些函数通常用汇编手写,性能极高。
  • 数据结构层:定义张量、量化参数等数据结构。比如cmsis_nn_contextcmsis_nn_dims这些结构体。

我个人习惯把CMSIS-NN想象成一个「乐高积木盒」。你不需要关心每块积木是怎么注塑成型的,只需要知道怎么拼起来就行。但如果你想做深度定制,那还是得打开盒子看看内部结构。

核心文件一览

  • arm_nnfunctions.h — 所有NN函数的头文件,你写代码时主要包含它
  • arm_nnsupportfunctions.h — 辅助函数,比如数据重排、量化转换
  • arm_nn_types.h — 数据结构定义,比如cmsis_nn_context
  • arm_nn_tables.h — 查表法用的激活函数表,比如tanh、sigmoid的近似值

3.2 核心函数:arm_convolve_s8

这个函数是CMSIS-NN的「门面担当」。几乎所有CNN模型在Cortex-M上跑,都得靠它做卷积运算。它的原型长这样:

arm_status arm_convolve_s8(
    const cmsis_nn_context *ctx,
    const cmsis_nn_conv_params *conv_params,
    const cmsis_nn_per_channel_quant_params *quant_params,
    const cmsis_nn_dims *input_dims,
    const int8_t *input_data,
    const cmsis_nn_dims *filter_dims,
    const int8_t *filter_data,
    const cmsis_nn_dims *bias_dims,
    const int32_t *bias_data,
    const cmsis_nn_dims *output_dims,
    int8_t *output_data
);

参数看着多,其实分四类:

  • 上下文ctx,存放临时缓冲区,避免重复malloc
  • 卷积参数conv_params,包含stride、padding、dilation等
  • 量化参数quant_params,每个输出通道的scale和offset
  • 数据描述input_dimsfilter_dims等,描述张量的形状

嗯,这里要注意:输入和输出都是int8类型。CMSIS-NN默认走的是对称量化,权重和激活值都映射到[-128, 127]区间。我在项目中遇到过有人直接把float模型扔进去,结果跑出来全是噪声——说白了,你得先做量化校准。

我的经验:如果你用TFLite Micro导出的模型,量化参数通常已经算好了。但如果你自己写量化工具,记得检查scale和offset的精度。我曾经因为scale精度不够,导致模型精度掉了2个点,排查了整整一天。

3.3 核心函数:arm_fully_connected_s8

全连接层在CNN里通常出现在最后几层,做分类或回归。CMSIS-NN的全连接函数签名如下:

arm_status arm_fully_connected_s8(
    const cmsis_nn_context *ctx,
    const cmsis_nn_fc_params *fc_params,
    const cmsis_nn_per_tensor_quant_params *quant_params,
    const cmsis_nn_dims *input_dims,
    const int8_t *input_data,
    const cmsis_nn_dims *filter_dims,
    const int8_t *filter_data,
    const cmsis_nn_dims *bias_dims,
    const int32_t *bias_data,
    const cmsis_nn_dims *output_dims,
    int8_t *output_data
);

你会发现它和卷积函数的参数结构几乎一样。区别在于:

  • fc_params 里没有stride、padding这些卷积特有的参数
  • quant_params 是per-tensor的,不是per-channel的(全连接层通常所有输出通道共享一个scale)

说白了,全连接就是「把输入拉成一维向量,然后做矩阵乘法」。CMSIS-NN内部会调用arm_nn_mat_mul_core_1x_s8这类底层函数,用SIMD指令一次处理多个数据。

避坑指南:我曾经在STM32H743上跑一个全连接层,发现推理时间比预期慢了3倍。后来一查,是输入数据没有做内存对齐。CMSIS-NN要求数据地址至少4字节对齐,否则会触发异常处理路径,性能直接崩掉。所以,分配缓冲区时记得用__ALIGNED(4)memalign

3.4 运行官方示例

光说不练假把式。咱们来跑一下CMSIS-NN自带的示例。官方仓库在GitHub上,搜索CMSIS_5就能找到。示例代码在CMSIS/NN/Examples目录下。

我建议你从arm_nn_example这个工程开始。它实现了一个简单的2层网络:卷积+全连接,用来识别MNIST手写数字。编译步骤很简单:

  1. 下载CMSIS_5源码,或者用ARM的cmsis-pack管理器安装
  2. 打开Examples/ARM/arm_nn_example下的MDK或IAR工程
  3. 选择你的目标芯片(比如STM32F746G-DISCO)
  4. 编译、下载、运行

运行后,串口会输出识别结果。如果一切正常,你会看到类似这样的日志:

Test 1: Expected 7, Got 7
Test 2: Expected 2, Got 2
Test 3: Expected 1, Got 1
...
Accuracy: 98.5%

你想想看,一个只有几千行C代码的库,在几十MHz的MCU上跑出98%以上的准确率,是不是挺震撼的?

调试小技巧:如果输出全是乱码,先检查串口波特率是否匹配(通常是115200)。如果识别率很低,检查输入图像的预处理——MNIST是28x28的灰度图,像素值要归一化到[-128, 127]区间。我刚开始跑的时候忘了做归一化,结果准确率只有20%,还以为是库有bug。

3.5 性能数据参考

下面这张表是我在STM32F746(Cortex-M7,216MHz)上实测的数据,供大家参考:

操作 输入尺寸 输出尺寸 耗时(ms)
3x3卷积(8位) 32x32x16 30x30x32 2.1
全连接(8位) 512 10 0.08
ReLU激活 32x32x32 32x32x32 0.02

可以看到,卷积是主要耗时点,全连接和激活几乎可以忽略。所以后面几章,咱们会重点讲卷积的优化技巧。

3.6 小结

这一章咱们做了三件事:

  • 拆解了CMSIS-NN的软件包结构,知道它分NN函数层、数学层、数据结构层
  • 分析了arm_convolve_s8arm_fully_connected_s8这两个核心函数,理解了它们的参数含义
  • 跑通了官方示例,验证了库的正确性

下一章,咱们会深入arm_convolve_s8的内部实现,看看它到底用了哪些「黑魔法」把卷积跑得这么快。到时候我会带大家手撕汇编代码,嗯,做好准备。

课后练习:试着修改官方示例,把卷积层的输出通道数从32改成64,看看推理时间翻了多少倍。然后思考一下:为什么不是线性增长?