4、CMSIS-NN库入门:CMSIS-NN架构、核心函数介绍、如何在项目中集成

说到在Cortex-M上跑神经网络,我第一个想到的就是CMSIS-NN。说实话,刚接触TinyML那会儿,我试过自己手写卷积、池化,结果性能惨不忍睹。后来才发现,ARM官方早就把这块优化好了——CMSIS-NN,就是专门给Cortex-M系列芯片准备的神经网络推理库。

你想想看,Cortex-M芯片就那么点Flash和RAM,还要跑神经网络,没有CMSIS-NN这种高度优化的库,基本是跑不动的。我个人的习惯是,只要项目里用到Cortex-M做推理,第一件事就是把CMSIS-NN集成进来。

CMSIS-NN架构概览

CMSIS-NN的架构其实不复杂。它主要分三层:

  • 底层函数层:提供基础的数学运算,比如矩阵乘法、激活函数、池化操作。这些函数直接用汇编或者内联指令写的,充分利用了ARM的SIMD指令集。
  • 中层函数层:封装了神经网络常用的层,比如卷积层、全连接层、池化层。你调用一个arm_convolve_s8(),底层会自动调用多个优化过的子函数。
  • 高层接口层:这部分其实不是CMSIS-NN的核心,但很多框架(比如TensorFlow Lite Micro)会基于它做封装。说白了,就是帮你把多个中层函数串起来,形成一个完整的推理流程。

我记得第一次看CMSIS-NN源码时,发现它大量使用了权重量化对称量化技术。为什么?因为Cortex-M没有浮点运算单元(FPU)的型号太多了,用int8做运算比float快好几倍。嗯,这里要注意,CMSIS-NN默认就是int8量化模型,你如果拿float模型硬往上怼,性能会很难看。

核心函数介绍

我挑几个最常用的函数,给你讲讲它们到底是干什么的。

1. 卷积函数:arm_convolve_s8()

这是最核心的函数,没有之一。它实现了int8量化卷积。参数包括输入数据、权重、偏置、输出数据,还有卷积核大小、步长、填充方式等。

// 一个典型的调用示例
arm_cmsis_nn_status status;
arm_convolve_s8(&conv_params,
                &quant_params,
                input_data,
                input_dims,
                weights_data,
                weights_dims,
                bias_data,
                bias_dims,
                output_data,
                output_dims);

我在项目中遇到过一个问题:输入数据的维度顺序搞反了。CMSIS-NN默认输入是CHW格式(通道、高度、宽度),而有些模型导出的是HWC格式。如果你不转换,推理结果就是错的。这个坑我踩过,后来写了个预处理函数专门做维度重排。

2. 全连接函数:arm_fully_connected_s8()

全连接层在分类网络的最后几层很常见。它的实现其实就是一个矩阵乘法加偏置。CMSIS-NN对这个函数做了极致优化,利用MVE指令(如果芯片支持的话)一次处理多个数据。

arm_fully_connected_s8(&fc_params,
                       &quant_params,
                       input_data,
                       input_dims,
                       weights_data,
                       weights_dims,
                       bias_data,
                       bias_dims,
                       output_data,
                       output_dims);

你可能会问:为什么全连接层也要单独优化?因为很多轻量级网络(比如MobileNet V1)最后几层就是全连接,如果这里慢了,整个推理时间就上去了。

3. 池化函数:arm_max_pool_s8() 和 arm_avg_pool_s8()

池化层用来降采样。CMSIS-NN提供了最大池化和平均池化两种。实现上,它用了滑动窗口的方式,但内部做了循环展开和指令优化。

arm_max_pool_s8(&pool_params,
                input_data,
                input_dims,
                output_data,
                output_dims);

我个人建议,能用最大池化就别用平均池化。为什么?因为最大池化只需要比较大小,而平均池化要做除法,虽然CMSIS-NN做了优化,但平均池化还是稍微慢一点。

4. 激活函数:arm_relu6_s8() 和 arm_tanh_s8()

激活函数在推理中也很关键。CMSIS-NN支持ReLU、ReLU6、Tanh、Sigmoid等。这些函数都是查表法实现的,速度很快。

小提示: 如果你用ReLU6,记得输入数据的量化参数要匹配。我曾经因为量化参数没对齐,导致ReLU6输出全是0,排查了半天才发现是scale和offset的问题。

如何在项目中集成CMSIS-NN

集成CMSIS-NN其实不复杂,但有几个关键步骤。我按顺序给你捋一遍。

第一步:获取CMSIS-NN源码

CMSIS-NN是ARM官方的开源库,你可以在GitHub上找到。我建议直接克隆整个CMSIS_5仓库,因为CMSIS-NN是CMSIS_5的一部分。

git clone https://github.com/ARM-software/CMSIS_5.git

你只需要把CMSIS/NN目录下的源文件复制到你的项目中。注意,还要包含CMSIS/CoreCMSIS/DSP的部分头文件,因为CMSIS-NN依赖它们。

第二步:配置编译器

CMSIS-NN对编译器有要求。我推荐用ARM Compiler 6或者GCC for ARM。如果你用IAR,也可以,但要注意优化等级要开到最高(比如-O3或-Ofast)。

另外,记得开启ARM的SIMD指令支持。对于Cortex-M4/M7,要定义__ARM_FEATURE_DSP;对于Cortex-M33/M55,要定义__ARM_FEATURE_MVE

警告: 如果你用的是Cortex-M0/M0+,CMSIS-NN的部分函数可能无法使用,因为这些芯片没有DSP指令集。我建议你检查一下芯片手册,确认是否支持。

第三步:链接内存

CMSIS-NN的权重和偏置通常很大,我建议把它们放到Flash里,而不是RAM。因为Cortex-M的RAM通常只有几十KB到几百KB,放不下动辄几百KB的模型权重。

在链接脚本里,你可以这样定义:

/* 将权重数据放到Flash区域 */
.weights : {
    *(.weights)
} > FLASH

/* 将输入输出缓冲区放到RAM区域 */
.buffer : {
    *(.buffer)
} > RAM

然后在代码里用__attribute__((section(".weights")))来指定权重数组的存放位置。

第四步:编写推理代码

集成完成后,你就可以写推理代码了。我一般会封装一个model_inference()函数,里面依次调用卷积、池化、全连接等层。

void model_inference(int8_t *input, int8_t *output) {
    // 第一层:卷积
    arm_convolve_s8(...);
    // 第二层:池化
    arm_max_pool_s8(...);
    // 第三层:全连接
    arm_fully_connected_s8(...);
    // 输出结果
    memcpy(output, output_buffer, output_size);
}

嗯,这里要注意,每个层的输入输出缓冲区要提前分配好,而且大小要对齐。CMSIS-NN要求缓冲区地址是4字节对齐的,否则会触发硬件异常。

避坑指南

最后,我分享几个实战中遇到的坑,你遇到了可以少走弯路。

  • 量化参数不匹配:CMSIS-NN的每个层都有自己的量化参数(scale和offset)。如果你从TensorFlow导出的模型量化参数和CMSIS-NN的不一致,推理结果会完全错误。我建议用arm_nn_requantize()函数手动做一次重量化。
  • 内存溢出:Cortex-M的RAM很宝贵。我曾经在一个项目中,因为临时缓冲区分配太大,导致堆栈溢出。后来我改用静态分配,并且仔细计算了每个层的最大内存需求。
  • 性能瓶颈:如果你发现推理速度慢,可以用CMSIS-NN自带的性能测试函数arm_nn_mat_mult_s8()来定位瓶颈。我遇到过卷积层太慢的情况,后来把卷积核从3x3改成1x1,速度提升了3倍。

好了,CMSIS-NN入门就讲到这里。下一章我会带你实际搭建一个完整的推理项目,从模型转换到板端运行,一步步走通。到时候你会觉得,原来在Cortex-M上跑神经网络也没那么难嘛。