3. 卷积算子深度解析:直接卷积、im2col+GEMM 与 Winograd
卷积是 CNN 的绝对核心。在 Cortex-M 上跑卷积,说白了就是跟算力和内存较劲。我最早做 STM32F4 上的手势识别时,一个 3x3 卷积层就能把 CPU 吃满,当时就在想——这玩意儿到底怎么优化?
今天咱们把三种主流实现方式掰开揉碎讲清楚:直接卷积、im2col+GEMM、Winograd。每种方法都有它的脾气,选对了事半功倍。
3.1 直接卷积实现——最朴素,也最容易被忽视
直接卷积就是按定义算。输入特征图、卷积核、滑动窗口、乘加累加。代码写起来最直白,但坑也最多。
// 直接卷积核心循环(伪代码)
for (int oh = 0; oh < H_out; oh++) {
for (int ow = 0; ow < W_out; ow++) {
for (int ic = 0; ic < C_in; ic++) {
for (int kh = 0; kh < K; kh++) {
for (int kw = 0; kw < K; kw++) {
int ih = oh * stride + kh - pad;
int iw = ow * stride + kw - pad;
if (ih >= 0 && ih < H_in && iw >= 0 && iw < W_in) {
p_out[oh * W_out + ow] +=
p_weight[kh * K + kw] *
p_in[ih * W_in + iw];
}
}
}
}
}
}
这段代码在 PC 上跑没问题,放到 Cortex-M 上就麻烦了。五层循环嵌套,每次都要做边界检查,数据访问完全不连续。我在一个项目里试过,直接卷积占掉了整个推理时间的 70% 以上。
1. 循环嵌套深,分支预测失败率高
2. 数据访问不连续,Cache 命中率低
3. 边界检查带来额外开销
那直接卷积是不是一无是处?也不是。当卷积核很小(比如 1x1),或者输入输出通道数很少时,直接卷积反而有优势——省去了数据重排的开销。我建议只在调试阶段或者通道数 < 8 的场景下使用。
3.2 im2col+GEMM——把卷积变成矩阵乘法
这个方法的核心思想很简单:把卷积操作转换成矩阵乘法。为什么这么做?因为矩阵乘法有现成的优化库(比如 CMSIS-NN 里的矩阵乘),而且数据访问更规整。
具体分两步走:
- im2col:把输入特征图中每个卷积窗口拉成一列,所有列拼成一个大矩阵
- GEMM:用优化后的矩阵乘法计算
举个例子,输入是 4x4,卷积核 3x3,stride=1,pad=0。im2col 之后,输入矩阵大小是 9x4(9 个窗口,每个窗口 9 个元素),权重矩阵大小是 1x9(1 个输出通道,9 个权重)。
| 方法 | 内存开销 | 计算效率 | 适用场景 |
|---|---|---|---|
| 直接卷积 | 低(无额外内存) | 低(循环开销大) | 小模型、调试阶段 |
| im2col+GEMM | 高(数据重排) | 高(矩阵乘优化) | 中等规模卷积 |
| Winograd | 中(变换矩阵) | 极高(乘法次数少) | 小卷积核(3x3) |
我曾经在一个项目里用 im2col 做 5x5 卷积,结果内存直接爆了。后来改成逐通道 im2col,一次只处理一个输出通道,才把内存降下来。这个技巧叫「分块 im2col」,CMSIS-NN 里就是这么干的。
3.3 Winograd 快速卷积原理——少算乘法,多算加法
Winograd 的核心思想是:用更多的加法来换取更少的乘法。在 Cortex-M 上,乘法比加法贵得多(乘法指令周期长),所以这个交换很划算。
以 F(2,3) 为例(输出 2x2,卷积核 3x3):
- 直接卷积需要 4 × 9 = 36 次乘法
- Winograd 只需要 4 × 4 = 16 次乘法
- 节省了 55% 的乘法
Winograd 的流程分三步:
- 输入变换:用变换矩阵 G 把卷积核变换到 Winograd 域
- 逐元素乘法:在 Winograd 域做点乘
- 输出逆变换:用逆变换矩阵 A 把结果变回来
// Winograd F(2,3) 核心变换(伪代码)
// 输入变换:U = G * w * G^T
// 输出变换:Y = A^T * (M ⊙ U) * A
// 变换矩阵 G (4x3)
const int16_t G[4][3] = {
{1, 0, 0},
{1/2, 1/2, 1/2},
{1/2, -1/2, 1/2},
{0, 0, 1}
};
// 逆变换矩阵 A (2x4)
const int16_t A[2][4] = {
{1, 1, 1, 0},
{0, 1, -1, -1}
};
为什么 Winograd 能省乘法?说白了就是利用了多项式卷积的冗余。你想想看,3x3 卷积核在 4x4 的输入块上滑动,其实有很多重复计算。Winograd 把这些重复计算合并了。
3.4 三种方法对比与选型建议
下面这张图展示了三种方法的计算流程对比:
最后说一句:没有银弹。三种方法各有适用场景,关键是根据你的硬件资源(内存、算力、指令集)和模型结构(卷积核大小、步长、通道数)来选。CMSIS-NN 里三种方法都有实现,但默认用的是 im2col+GEMM。如果你内存够用,直接用默认的就好;如果不够,再考虑 Winograd。