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: [同上]
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() 内部处理流程。你看完就知道数据是怎么流动的了。
流程其实不复杂:
- Im2Col:把输入张量按卷积窗口展开成一个大矩阵
- GEMM:权重矩阵和展开后的输入矩阵做乘法
- 累加:加上偏置,结果还是int32
- 量化:用每个通道的缩放因子和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 优化、权重重排、多线程加速,你才能接得住。