卷积运算的硬件映射:从算法到电路

大家好,我是做芯片加速的老工程师了。今天咱们聊聊卷积运算怎么映射到硬件上。说实话,这问题我入行头三年都没完全搞明白,直到亲手调过几个加速器才真正吃透。

卷积在深度学习里太常见了。但软件里跑得飞快的卷积,到了硬件上就各种水土不服。为什么?因为卷积本质上是数据复用和计算模式的问题。你想想看,一个3x3的卷积核滑过特征图,每个像素要被用好几次,怎么安排数据流才能让计算单元不闲着?这就是硬件映射要解决的核心。

im2col + GEMM:最经典的映射方法

这个方法说白了就是「把卷积变成矩阵乘法」。我第一次看到这个思路时,觉得这招太聪明了——矩阵乘法在硬件上有多成熟啊,GEMM(通用矩阵乘法)的优化都做了几十年了。

具体怎么做?

假设输入是 HxWxC 的特征图,卷积核是 KxKxC,输出通道是 M。im2col 会把每个卷积窗口拉成一个列向量,所有窗口拼起来形成一个矩阵。这个矩阵的维度是 (K*K*C) x (H_out*W_out)。

然后呢?把卷积核也拉成矩阵,维度是 M x (K*K*C)。两个矩阵一乘,就得到输出。

// 伪代码示意
// 输入特征图: [H][W][C]
// 卷积核: [M][C][K][K]
// im2col 展开
for (int h = 0; h < H_out; h++) {
    for (int w = 0; w < W_out; w++) {
        for (int c = 0; c < C; c++) {
            for (int kh = 0; kh < K; kh++) {
                for (int kw = 0; kw < K; kw++) {
                    col_matrix[c*K*K + kh*K + kw][h*W_out + w] = 
                        input[h*stride + kh][w*stride + kw][c];
                }
            }
        }
    }
}
// GEMM 计算
output[M][H_out*W_out] = weight[M][K*K*C] * col_matrix[K*K*C][H_out*W_out];

优点很明显:

  • 复用成熟的矩阵乘法加速器,比如 systolic array
  • 数据排布规整,访存模式简单
  • 容易做并行化,M、C、H_out*W_out三个维度都能拆

缺点也致命:

  • 内存爆炸!im2col 展开后数据量膨胀 K*K 倍。我做过一个 3x3 卷积,输入 224x224x64,展开后光存这个矩阵就要几百 MB
  • 数据搬移开销大。展开操作本身就要大量访存
  • 对 1x1 卷积还行,大核卷积就扛不住了
我的经验:im2col + GEMM 适合做 1x1 卷积或者 batch size 很大的场景。我在一个视频处理芯片上用过,因为 batch 够大,GEMM 的效率能跑到 90% 以上。但如果是实时推理,单张图处理,这方法就有点浪费了。

直接卷积的硬件实现

直接卷积,就是老老实实按卷积定义来算。每个输出像素,累加 K*K*C 次乘加。听起来简单,但硬件实现起来门道很多。

核心思路:设计一个乘加树,每个时钟周期处理一个卷积窗口的部分计算。我习惯把乘加树组织成 M 行 x (K*K*C) 列 的阵列,每行对应一个输出通道。

数据流设计是关键。我见过三种主流方案:

  1. 权重固定:权重存在寄存器里不动,输入数据流进来。适合权重小、输入大的场景
  2. 输入固定:输入数据不动,权重轮流加载。适合输入小、权重多的场景
  3. 输出固定:部分和存在本地,数据流式处理。这是我最常用的方案

避坑指南:我曾经在一个项目里用了「输入固定」的方案,结果发现卷积核数量一多,权重加载带宽根本跟不上。后来改成「输出固定」,把部分和存在片上 SRAM 里,带宽问题才解决。所以设计前一定要算清楚:你的瓶颈在计算还是访存?

直接卷积的好处是内存占用小,不需要展开。但控制逻辑复杂,尤其是处理 padding、stride、dilation 这些参数时,地址生成器要写不少代码。

Winograd 快速卷积算法

Winograd 是个数学技巧。它用更少的乘法次数完成卷积。对于 3x3 卷积,标准方法需要 9 次乘法,Winograd F(2,3) 只需要 4 次。省了一半多!

原理简述:把输入和卷积核都变换到另一个空间,在那个空间做 element-wise 乘法,再变换回来。变换矩阵是固定的,可以提前算好。

// Winograd F(2,3) 变换矩阵
// 输入变换: A^T * ((G * w * G^T) ⊙ (B^T * d * B)) * A
// 其中:
// B^T = [1  0 -1  0;  0  1  1  0;  0 -1  1  0;  0  1  0 -1]
// G   = [1    0    0;  0.5  0.5  0.5;  0.5 -0.5  0.5;  0    0    1]
// A^T = [1  1  1  0;  0  1 -1 -1]

硬件实现要点:

  • 变换矩阵的系数是常数,可以用硬连线实现
  • 变换过程本身也有计算开销,要权衡
  • 适合小卷积核(3x3、5x5),大核收益不明显
注意:Winograd 对数值精度敏感。我在一个 8-bit 量化加速器上试过,变换后的数值范围会变大,导致精度损失。如果做低精度推理,建议先仿真验证一下。

三种方法的对比

方法 计算效率 内存开销 控制复杂度 适用场景
im2col + GEMM 高(矩阵乘法优化成熟) 高(数据膨胀 K² 倍) 低(GEMM 核简单) 大 batch、1x1 卷积
直接卷积 中等(受限于数据复用) 低(无额外存储) 高(地址生成复杂) 实时推理、任意卷积
Winograd 最高(乘法次数少) 中等(变换矩阵) 中等(变换逻辑) 小卷积核、高精度

我个人建议:不要死磕一种方法。实际芯片里往往是混合使用。比如我做过的一个加速器,1x1 卷积走 im2col+GEMM,3x3 卷积走 Winograd,其他走直接卷积。调度器根据卷积参数自动选择最优路径。

一个小技巧:设计时把三种方法的计算单元统一成乘加阵列,只是数据通路和控制逻辑不同。这样硬件复用率高,面积也省。我上一个项目就是这么干的,面积比分开设计省了 30%。

知识体系总览

下面这张图是我自己画的,把三种映射方法的核心逻辑串起来了。你看一眼就能明白它们之间的关系。

卷积运算硬件映射方法对比 输入特征图 im2col 展开 GEMM 矩阵乘法 乘加树阵列 数据流控制 输入/权重变换 Element-wise 乘加 输出特征图 内存膨胀 K² 倍 控制逻辑复杂 乘法次数减少 三种方法各有优劣,实际芯片常混合使用

嗯,今天就先聊到这儿。这三种方法各有各的脾气,选哪个取决于你的芯片目标——要吞吐量还是要面积?要灵活还是要效率?没有银弹,只有权衡。

可可精品资料库