3、嵌入式神经网络基础:卷积、池化、全连接等算子原理及在MCU上的实现挑战
在MCU上部署神经网络,本质上是将浮点域的数学运算映射到资源受限的嵌入式环境。与服务器端GPU或桌面CPU不同,MCU通常只有几十到几百KB的SRAM,主频在几十到几百MHz之间,且缺乏专用的硬件加速器。因此,理解卷积、池化、全连接等基础算子的数学本质与数据流特征,是进行输入对齐与buffer布局优化的前提。本章将深入剖析这些算子的计算模式,并重点讨论它们在Cortex-M内核上实现时面临的内存带宽、计算效率与数据复用三大核心挑战。
上图展示了三类核心算子在MCU环境下的基本特征与主要矛盾。卷积层是计算密集型算子,其计算量远高于其他层;池化层虽然计算简单,但数据访问模式对缓存友好性要求高;全连接层则是参数量密集的算子,对Flash存储和SRAM带宽构成压力。理解这些差异是后续进行buffer布局优化的基础。
3.1 卷积算子:计算密集与数据复用
核心原理
卷积运算在神经网络中扮演特征提取器的角色。对于输入特征图 \( X \in \mathbb{R}^{C_{in} \times H \times W} \) 和卷积核 \( W \in \mathbb{R}^{C_{out} \times C_{in} \times K \times K} \),输出特征图 \( Y \in \mathbb{R}^{C_{out} \times H_{out} \times W_{out}} \) 的每个元素计算如下:
for (co = 0; co < C_out; co++)
for (ho = 0; ho < H_out; ho++)
for (wo = 0; wo < W_out; wo++) {
int32_t acc = 0;
for (ci = 0; ci < C_in; ci++)
for (kh = 0; kh < K; kh++)
for (kw = 0; kw < K; kw++)
acc += X[ci][ho*stride+kh][wo*stride+kw] * W[co][ci][kh][kw];
Y[co][ho][wo] = acc + bias[co];
}
这段伪代码揭示了卷积的本质:六重循环内的乘累加(MAC)操作。在MCU上,每个MAC操作需要两次内存读取(输入像素和权重)和一次累加写回。以CIFAR-10分类网络的第一层为例(3×32×32输入,32个3×3卷积核),单次推理就需要约 32×3×3×3×30×30 ≈ 777,600 次MAC。若MCU主频为100MHz,每个周期执行一次MAC,仅此一层就需要约7.8ms,而实际由于内存访问延迟,耗时往往是理论值的3-5倍。
MCU实现挑战
在Cortex-M内核上实现卷积,主要面临以下三个维度的挑战:
- 内存带宽瓶颈:SRAM带宽通常为32位/周期(Cortex-M4/M7)或64位/周期(Cortex-M33/M55)。每次MAC需要读取输入(8位或16位)和权重(8位),若数据未命中寄存器,则需从SRAM加载。当卷积核在输入特征图上滑动时,相邻窗口有大量重叠区域,若不加优化,重复读取会造成严重的带宽浪费。
- 数据对齐要求:CMSIS-NN要求输入特征图的行起始地址按4字节对齐(对于8位数据)或8字节对齐(对于16位数据)。这是因为其核心函数使用LDRD/STRD等双字加载指令,未对齐的地址会导致HardFault异常或性能骤降。
- 寄存器资源有限:Cortex-M4仅有16个通用寄存器(R0-R12),而一次高效的卷积内循环需要同时缓存多个输入像素、多个权重和累加器。寄存器溢出(spill)会迫使数据频繁出入栈,严重拖慢执行速度。
案例拆解:3×3卷积的im2col替代方案
传统PC端常用im2col将卷积转为矩阵乘法,但该方法会显式展开输入数据,导致内存膨胀9倍(3×3核),在MCU上不可行。CMSIS-NN采用直接卷积+滑动窗口优化策略:
- 行缓冲(Row Buffer):仅缓存当前计算所需的K行输入数据(K为卷积核高度),而非整个特征图。对于3×3卷积,只需3行×W×C_in字节的SRAM,大幅降低内存占用。
- 权重重排:将权重按 [C_out][K][K][C_in] 排列,使得内层循环连续访问C_in维度的权重,配合SIMD(Single Instruction Multiple Data)指令一次处理多个通道。
- 输出驻留:在寄存器中累加多个输出通道的部分和,减少对SRAM的写回次数。
以STM32F746(Cortex-M7,216MHz)为例,运行8位量化MobileNet第一层(3×224×224输入,32个3×3核),使用CMSIS-NN的 arm_convolve_s8 函数,单层推理时间约 12.3ms,而朴素实现需要 45ms。性能提升主要来源于行缓冲减少了约70%的输入重复读取,以及SIMD指令将MAC吞吐率提升了3.2倍。
3.2 池化算子:简单计算中的对齐陷阱
核心原理
池化层的主要作用是降采样和特征压缩。最大池化(Max Pooling)取窗口内最大值,平均池化(Average Pooling)取窗口内均值。以最常见的2×2、stride=2最大池化为例:
for (ho = 0; ho < H_out; ho++)
for (wo = 0; wo < W_out; wo++) {
int8_t max_val = INT8_MIN;
for (kh = 0; kh < 2; kh++)
for (kw = 0; kw < 2; kw++) {
int8_t val = X[ho*2+kh][wo*2+kw];
if (val > max_val) max_val = val;
}
Y[ho][wo] = max_val;
}
池化层没有可学习的权重,计算量也远小于卷积层,但其对数据布局的敏感性常被忽视。在CMSIS-NN中,池化函数要求输入和输出buffer满足特定的对齐条件,否则会触发断言或产生错误结果。
MCU实现挑战
- 边界处理:当输入尺寸为奇数时,池化窗口可能超出边界。CMSIS-NN默认采用补零(zero-padding)策略,但补零位置必须与训练框架(如TensorFlow Lite)保持一致,否则会导致精度下降。
- 数据对齐:池化操作按行读取输入,若行起始地址未对齐,则无法使用LDRD指令一次读取8字节。对于2×2池化,每次需要读取2行数据,若行间地址不连续(存在padding),则需两次独立加载,增加指令周期。
- SIMD利用:Cortex-M33/M55支持DSP扩展指令,可一次比较4个8位数据。但前提是数据在内存中连续排列且地址对齐。若输入特征图的宽度不是4的倍数,则每行末尾会有剩余像素无法SIMD化。
实战场景:池化层与卷积层的联合优化
在实际网络中,池化层通常紧跟在卷积层之后。CMSIS-NN提供 arm_max_pool_s8 函数,其内部实现了一个关键优化:将池化窗口的读取与卷积输出的写入合并。具体做法是:
- 在卷积层计算输出时,不立即写回SRAM,而是暂存在寄存器中。
- 当同一池化窗口内的所有卷积输出都计算完毕后,直接在寄存器中执行最大值比较。
- 仅将池化结果写回SRAM,减少一次完整的特征图读写。
这种融合策略可将池化层的额外内存访问开销从100%降至约20%。以ImageNet类网络中的block2为例(112×112×64输入,池化后56×56×64),融合后节省了约 112×112×64 = 802,816 字节的SRAM写带宽。
src_offset 和 dst_offset 参数。这两个偏移量用于处理输入/输出通道数不为4的倍数的情况。若网络设计时确保通道数为4的倍数(如32、64、128),可避免额外的偏移计算,提升约5-8%的性能。
3.3 全连接算子:内存墙与Flash带宽
核心原理
全连接层将前层特征图展平为一维向量,与权重矩阵相乘并加上偏置。其数学形式为 \( y = Wx + b \),其中 \( W \in \mathbb{R}^{N_{out} \times N_{in}} \)。在MCU上,全连接层通常位于网络末端,用于分类或回归。
全连接层的计算模式是典型的矩阵-向量乘。与卷积层不同,每个输入元素仅被使用一次(对应一个输出神经元),因此不存在数据复用。这意味着内存访问次数等于MAC次数,内存带宽成为绝对瓶颈。
MCU实现挑战
| 挑战维度 | 具体表现 | 影响程度 |
|---|---|---|
| Flash读取带宽 | 权重存储在Flash中,每次MAC需从Flash读取权重。Flash读取速度远低于SRAM(通常为24MHz vs 216MHz) | 高 |
| SRAM容量 | 输入向量和输出向量需驻留SRAM。若N_in=1024,N_out=256,则权重矩阵需256KB Flash,输入输出各需1KB和256B SRAM | 中 |
| 数据对齐 | CMSIS-NN要求权重矩阵按行存储,且每行起始地址4字节对齐。若N_in不是4的倍数,需填充至4的倍数 | 低 |
案例拆解:全连接层的Flash带宽优化
假设一个全连接层:N_in=512,N_out=128,权重为8位量化。权重总大小为 512×128 = 65,536 字节。若MCU的Flash读取带宽为 4字节/周期(Cortex-M7从Flash执行代码时的典型值),则读取全部权重需要 65,536/4 = 16,384 个周期。而计算本身仅需 512×128 = 65,536 次MAC,若每次MAC需2个周期(乘加+累加),则计算需131,072周期。理论上计算时间远大于读取时间,Flash带宽似乎不是瓶颈。
然而,实际中Flash通常通过指令总线读取,与数据总线共享带宽。当卷积层也在运行时,指令预取和数据读取会争抢Flash带宽。CMSIS-NN的 arm_fully_connected_s8 函数采用以下策略缓解:
- 权重矩阵分块:将权重按 4×16 的小块组织,每次从Flash加载一个小块到SRAM暂存,然后执行16次MAC。这样Flash读取与计算可以流水线重叠。
- 输出通道分组:每次计算4个输出通道的部分和,利用SIMD指令同时处理4个输入元素,减少循环开销。
- Flash缓存利用:利用Cortex-M7的指令缓存(I-Cache)或数据缓存(D-Cache)缓存频繁访问的权重行。若权重矩阵较大无法完全缓存,则按行循环,确保每行权重只从Flash读取一次。
在STM32H743(Cortex-M7,480MHz,带32KB I-Cache和32KB D-Cache)上,上述优化使全连接层吞吐率达到 1.8 GMAC/s,而朴素实现仅为 0.6 GMAC/s。性能提升主要来自Flash读取与计算的流水线重叠,以及D-Cache对权重行的缓存命中。
arm_fully_connected_s8 的 weight_scaled 参数,将权重预取到SRAM中,牺牲部分SRAM容量换取确定性执行。
3.4 算子间数据流与Buffer布局的协同
核心原理
在真实网络中,卷积、池化、全连接并非孤立运行,而是构成一条数据流水线。每层的输出是下一层的输入,因此层间buffer布局的连续性直接影响整体性能。CMSIS-NN采用"NHWC"数据格式(通道在最后),这种布局对SIMD友好,但要求每层输出的通道数必须是4的倍数(对于8位数据)或2的倍数(对于16位数据)。
判断方法:何时需要填充通道
在设计网络结构时,应遵循以下规则:
- 卷积层输出通道数建议为4的倍数(如32、64、128、256)。
- 若最后一层卷积输出通道数不是4的倍数(例如10分类的10通道),则需在后续全连接层前手动填充至12或16通道,填充值设为0。
- 池化层不改变通道数,因此不会引入对齐问题。
- 全连接层的输入维度(N_in)建议为4的倍数,否则CMSIS-NN会在内部填充,但会额外消耗SRAM。
实战场景:MobileNet v1在MCU上的buffer规划
以在STM32F746上部署MobileNet v1(输入224×224×3,8位量化)为例,其关键层的buffer需求如下:
| 层类型 | 输出尺寸 | 通道数 | 输出buffer大小(KB) | 对齐要求 |
|---|---|---|---|---|
| Conv1 | 112×112 | 32 | 112×112×32 = 401.4 | 4字节对齐 |
| DW Conv2 | 112×112 | 32 | 401.4 | 4字节对齐 |
| PW Conv3 | 112×112 | 64 | 802.8 | 4字节对齐 |
| ... | ... | ... | ... | ... |
| FC Last | 1×1 | 1000 | 1 | 4字节对齐 |
从表中可见,Conv3的输出buffer高达802.8KB,远超STM32F746的320KB SRAM。因此必须采用分块推理(tiling)策略:将特征图按行分块,每块大小限制在SRAM预算内。CMSIS-NN的 arm_convolve_s8 支持 input_offset 和 output_offset 参数,允许在推理过程中动态切换输入行范围,从而实现分块。