4、卷积层加速(上):arm_convolve_s8()函数详解、输入输出张量布局、权重与偏置的格式要求

卷积层,说白了就是整个神经网络里最吃算力的那个环节。我刚开始在Cortex-M上做推理时,发现一个3x3的卷积就能把CPU跑满,心里那个慌啊。后来摸透了CMSIS-NN的套路,才明白——加速的关键,在于数据怎么摆,而不是算法怎么算。

这一节,咱们就死磕 arm_convolve_s8() 这个核心函数。我会把输入输出张量的布局、权重偏置的格式要求,掰开了揉碎了讲清楚。

4.1 arm_convolve_s8() 函数签名与参数

先看函数原型。你打开CMSIS-NN的头文件,找到这个函数:

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 临时缓冲区,由你分配,函数内部用它做中间计算
配置 conv_params, quant_params 卷积参数(步长、填充、激活范围)和量化参数(缩放因子、零点)
数据 input_data, filter_data, bias_data, output_data 四块内存,分别对应输入、权重、偏置、输出

嗯,这里要注意:bias_data 的类型是 int32_t *,不是 int8_t *。为什么?因为偏置的精度必须和累加器对齐。我在项目里见过有人把偏置也量化成int8,结果精度直接崩了。

4.2 输入输出张量布局:NHWC 是唯一选择

CMSIS-NN 只支持一种数据布局——NHWC。也就是:

  • N:Batch,通常为1
  • H:高度
  • W:宽度
  • C:通道数

为什么不用NCHW?你想想看,Cortex-M的缓存就那么一丁点大。NHWC布局下,每个像素的所有通道在内存里是连续存放的。做卷积时,你一次性加载一个像素的所有通道,缓存命中率能高不少。

举个例子,一张 32x32 的RGB图像,NHWC布局在内存里长这样:

// 像素(0,0): R G B
// 像素(0,1): R G B
// ...
// 像素(31,31): R G B
// 内存顺序: [R0, G0, B0, R1, G1, B1, ..., R1023, G1023, B1023]

对应的 input_dims 设置:

cmsis_nn_dims input_dims;
input_dims.n = 1;    // batch
input_dims.h = 32;   // 高度
input_dims.w = 32;   // 宽度
input_dims.c = 3;    // 通道
我的习惯:在分配输入缓冲区时,直接按 H * W * C 算大小,别去管N。因为推理时batch几乎永远是1,多一个维度反而容易搞混。

4.3 权重格式:OHWI 与通道对齐

权重的布局是 OHWI:

  • O:输出通道数
  • H:卷积核高度
  • W:卷积核宽度
  • I:输入通道数

注意,这里的 I 必须和输入张量的 C 相等。否则函数会直接报错。

举个例子,一个 3x3 卷积,输入3通道,输出16通道:

cmsis_nn_dims filter_dims;
filter_dims.n = 16;   // 输出通道
filter_dims.h = 3;    // 核高
filter_dims.w = 3;    // 核宽
filter_dims.c = 3;    // 输入通道

// 权重数组大小: 16 * 3 * 3 * 3 = 432 个 int8_t

权重数据在内存里的排列顺序是:

// 输出通道0: [核(0,0)的3个输入通道, 核(0,1)的3个输入通道, ..., 核(2,2)的3个输入通道]
// 输出通道1: [同上]
// ...
// 输出通道15: [同上]
我曾经踩过的坑:用TensorFlow训练模型时,默认的权重格式是 KHWC(核高、核宽、输入通道、输出通道)。直接把这个权重dump出来喂给CMSIS-NN,结果推理结果全是错的。必须做一次维度转置,变成 OHWI 才行。

4.4 偏置格式:每个输出通道一个int32

偏置就简单多了。每个输出通道对应一个int32的偏置值。所以 bias_dims 的设置:

cmsis_nn_dims bias_dims;
bias_dims.n = 1;
bias_dims.h = 1;
bias_dims.w = 1;
bias_dims.c = 16;  // 输出通道数

// 偏置数组大小: 16 个 int32_t

偏置的量化参数和权重是绑定的。每个输出通道有自己的缩放因子和零点。这些信息放在 quant_params 里:

cmsis_nn_per_channel_quant_params quant_params;
quant_params.multiplier = (int32_t *)multiplier_array;  // 每个输出通道一个
quant_params.shift = (int32_t *)shift_array;            // 每个输出通道一个

为什么偏置要用int32?因为卷积的累加结果就是int32。如果偏置也用int8,每次都要做一次类型转换,反而更慢。CMSIS-NN的设计思路是:能用int32算的,绝不降精度。

4.5 核心流程:一张图看懂

下面这张图,是我自己总结的 arm_convolve_s8() 内部处理流程。你看完就知道数据是怎么流动的了。

arm_convolve_s8() 内部处理流程 arm_convolve_s8() 内部处理流程 输入张量 (NHWC) 权重张量 (OHWI) 偏置 (int32) 量化参数 Im2Col 展开 矩阵乘法 (GEMM) 累加 (int32) 量化 (int8) 输出张量 (NHWC) 关键说明: 1. Im2Col 将输入展开为矩阵,权重本身已经是矩阵格式 2. GEMM 使用 CMSIS-DSP 的矩阵乘法,支持 4x4 分块加速

流程其实不复杂:

  1. Im2Col:把输入张量按卷积窗口展开成一个大矩阵
  2. GEMM:权重矩阵和展开后的输入矩阵做乘法
  3. 累加:加上偏置,结果还是int32
  4. 量化:用每个通道的缩放因子和shift,把int32截断回int8

性能关键点:Im2Col 这一步会引入内存开销。输入越大,展开后的矩阵越大。我建议输入特征图尺寸超过 64x64 时,考虑用 arm_convolve_wrapper_s8(),它会自动选择更优的实现路径。

4.6 实战:组装一个完整的卷积调用

说了这么多,咱们来写一段完整的代码。假设输入是 32x32x3,卷积核 3x3x3,输出 16 通道,步长1,填充1:

// 1. 定义维度
cmsis_nn_dims input_dims  = {1, 32, 32, 3};
cmsis_nn_dims filter_dims = {16, 3, 3, 3};
cmsis_nn_dims bias_dims   = {1, 1, 1, 16};
cmsis_nn_dims output_dims = {1, 32, 32, 16};  // 填充1,步长1,尺寸不变

// 2. 配置卷积参数
cmsis_nn_conv_params conv_params;
conv_params.padding.h = 1;
conv_params.padding.w = 1;
conv_params.stride.h = 1;
conv_params.stride.w = 1;
conv_params.dilation.h = 1;
conv_params.dilation.w = 1;
conv_params.activation.min = -128;  // ReLU 范围
conv_params.activation.max = 127;

// 3. 配置量化参数(每个输出通道一组)
int32_t multiplier[16] = { /* 从训练模型导出 */ };
int32_t shift[16]      = { /* 从训练模型导出 */ };
cmsis_nn_per_channel_quant_params quant_params;
quant_params.multiplier = multiplier;
quant_params.shift = shift;

// 4. 分配临时缓冲区
cmsis_nn_context ctx;
ctx.size = arm_convolve_s8_get_buffer_size(&input_dims, &filter_dims);
ctx.buf = malloc(ctx.size);

// 5. 执行卷积
arm_convolve_s8(
    &ctx,
    &conv_params,
    &quant_params,
    &input_dims,  input_data,
    &filter_dims, filter_data,
    &bias_dims,   bias_data,
    &output_dims, output_data
);

// 6. 清理
free(ctx.buf);
注意:arm_convolve_s8_get_buffer_size() 这个函数一定要调用。它返回的缓冲区大小取决于输入尺寸和卷积核大小。我见过有人直接写死一个固定值,结果在输入尺寸变化时,缓冲区不够用,程序直接hardfault。

4.7 避坑指南

最后,分享几个我实际踩过的坑:

  • 权重对齐:CMSIS-NN要求权重数组的起始地址必须4字节对齐。如果不对齐,DMA传输会出问题。我建议用 __attribute__((aligned(4))) 来声明权重数组。
  • 量化参数顺序:multiplier 和 shift 数组的顺序必须和输出通道一一对应。如果你在训练时用了 tf.quantization.quantize_and_dequantize,导出的参数顺序可能是反的,需要手动调整。
  • 激活范围:如果网络用了ReLU6,记得把 activation.max 设成 6 的量化值,而不是 127。否则精度会下降。

嗯,卷积层加速的上半部分就到这。数据布局和函数接口是基础,基础打牢了,后面讲到的 im2col 优化、权重重排、多线程加速,你才能接得住。