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% 以上。

注意:直接卷积在 Cortex-M 上效率极低。原因有三:
1. 循环嵌套深,分支预测失败率高
2. 数据访问不连续,Cache 命中率低
3. 边界检查带来额外开销

那直接卷积是不是一无是处?也不是。当卷积核很小(比如 1x1),或者输入输出通道数很少时,直接卷积反而有优势——省去了数据重排的开销。我建议只在调试阶段或者通道数 < 8 的场景下使用。

3.2 im2col+GEMM——把卷积变成矩阵乘法

这个方法的核心思想很简单:把卷积操作转换成矩阵乘法。为什么这么做?因为矩阵乘法有现成的优化库(比如 CMSIS-NN 里的矩阵乘),而且数据访问更规整。

具体分两步走:

  1. im2col:把输入特征图中每个卷积窗口拉成一列,所有列拼成一个大矩阵
  2. GEMM:用优化后的矩阵乘法计算

举个例子,输入是 4x4,卷积核 3x3,stride=1,pad=0。im2col 之后,输入矩阵大小是 9x4(9 个窗口,每个窗口 9 个元素),权重矩阵大小是 1x9(1 个输出通道,9 个权重)。

方法 内存开销 计算效率 适用场景
直接卷积 低(无额外内存) 低(循环开销大) 小模型、调试阶段
im2col+GEMM 高(数据重排) 高(矩阵乘优化) 中等规模卷积
Winograd 中(变换矩阵) 极高(乘法次数少) 小卷积核(3x3)
关键点:im2col 的内存开销是硬伤。输入矩阵大小 = (输出特征图面积) × (卷积核大小)。对于 3x3 卷积,输入矩阵是原来的 9 倍。在 Cortex-M 上,这往往意味着内存不够用。

我曾经在一个项目里用 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 的流程分三步:

  1. 输入变换:用变换矩阵 G 把卷积核变换到 Winograd 域
  2. 逐元素乘法:在 Winograd 域做点乘
  3. 输出逆变换:用逆变换矩阵 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 在 Cortex-M7 上实测能提速 2-3 倍。但要注意,变换矩阵本身也有开销。我建议只在卷积核为 3x3、stride=1 的场景下使用,这是 Winograd 的甜点区。

为什么 Winograd 能省乘法?说白了就是利用了多项式卷积的冗余。你想想看,3x3 卷积核在 4x4 的输入块上滑动,其实有很多重复计算。Winograd 把这些重复计算合并了。

3.4 三种方法对比与选型建议

下面这张图展示了三种方法的计算流程对比:

三种卷积实现方式对比 直接卷积 五层循环嵌套 数据访问不连续 效率最低 im2col + GEMM 数据重排 + 矩阵乘 内存开销大 中等效率 Winograd 变换 + 点乘 + 逆变换 乘法次数最少 效率最高 选型建议 • 内存充足(> 256KB):优先用 im2col+GEMM,CMSIS-NN 有现成实现 • 内存紧张(< 128KB):用 Winograd F(2,3),省内存又省算力 • 调试阶段或通道数极少:直接卷积,简单可控 • 1x1 卷积:直接卷积或 im2col,Winograd 无优势
避坑指南:我曾经在 STM32L4 上试过 Winograd,结果发现变换矩阵的定点化精度不够,导致分类准确率掉了 2%。后来改用 16 位定点数,并且做了饱和处理,才把精度拉回来。记住——Winograd 对数值精度更敏感,一定要做精度验证。

最后说一句:没有银弹。三种方法各有适用场景,关键是根据你的硬件资源(内存、算力、指令集)和模型结构(卷积核大小、步长、通道数)来选。CMSIS-NN 里三种方法都有实现,但默认用的是 im2col+GEMM。如果你内存够用,直接用默认的就好;如果不够,再考虑 Winograd。