4. 卷积的矩阵化运算:im2col算法详解,GEMM在硬件中的实现
好,咱们进入第四章。这一章要聊的东西,可以说是卷积硬件加速的“地基”——im2col + GEMM。
你想想看,卷积操作本质上是个滑窗计算。在软件里,用多层循环嵌套就能搞定。但在硬件里,尤其是FPGA或者专用AI芯片上,这种不规则的数据访问模式,简直就是性能杀手。我早年做第一个卷积加速项目时,就吃过这个亏。当时天真地以为,直接把软件循环翻译成硬件逻辑就行。结果呢?逻辑资源爆了,时序还跑不到100MHz。后来才明白,把卷积“掰直”成矩阵乘法,才是硬件友好的做法。
4.1 为什么要把卷积变成矩阵乘法?
说白了,卷积的滑窗操作,数据复用率低,访存模式不规则。而矩阵乘法(GEMM),是硬件里最成熟、最容易被优化的运算。CPU有BLAS库,GPU有cuBLAS,FPGA上也有成熟的矩阵乘法IP核。
把卷积映射成GEMM,核心好处有三点:
- 数据局部性好:矩阵乘法的数据访问是连续的,容易做缓存和流水线。
- 并行度高:矩阵乘法天然适合拆分成多个独立的小矩阵块并行计算。
- 复用现有IP:不用自己从头搓一个卷积加速器,直接用现成的GEMM引擎就行。
核心思想:im2col(Image to Column)就是那个“掰直”的工具。它把输入特征图里每个卷积窗口内的数据,拉成一列。所有窗口拼起来,就构成一个巨大的矩阵。然后,卷积核的权重也被展平成另一个矩阵。两个矩阵一乘,结果就是输出特征图。
4.2 im2col算法详解
咱们用一个具体的例子来走一遍。假设输入特征图是 3x3,通道数 C=1,卷积核大小 K=2x2,步长 S=1,无填充。
第一步:确定输出尺寸和滑窗次数
输出尺寸公式:O = (I - K + 2P) / S + 1。这里 O = (3 - 2 + 0) / 1 + 1 = 2。所以输出是 2x2,一共需要滑窗 4 次。
第二步:对每个窗口执行im2col
每个 2x2 的窗口,有 4 个像素。把这4个像素按行优先顺序,拉成一列。4个窗口,就拼成 4行 x 4列 的矩阵?不对,这里要注意:im2col的结果矩阵,行数等于窗口内元素个数,列数等于滑窗次数。
所以,对于这个例子:
- 输入矩阵
A的维度:(K*K*C) x (O*O)=4 x 4 - 权重矩阵
B的维度:M x (K*K*C),其中M是输出通道数。假设M=1,则B是1 x 4 - 输出矩阵
C的维度:M x (O*O)=1 x 4,再reshape成2x2就是最终输出。
我个人习惯:在写RTL代码前,我会先用Python写一个im2col的参考模型。把输入、权重、输出都打印出来,跟手算的结果比对。这一步能帮你发现很多边界问题,比如步长不为1时,或者有空洞卷积时,窗口的索引怎么算。
第三步:数据排布细节
当输入有多个通道时,每个窗口内的元素,是按通道顺序排列的。比如 C=3,窗口大小 3x3,那么一个窗口就有 3*3*3=27 个元素。这27个元素,先取第一个通道的9个,再取第二个通道的9个,最后取第三个通道的9个。
这样做的好处是,在硬件里做矩阵乘法时,同一个窗口内不同通道的数据,在内存里是连续存放的。读取效率很高。
4.3 GEMM在硬件中的实现
im2col把问题转化成了矩阵乘法。接下来,咱们看看在FPGA上怎么高效地实现GEMM。
一个朴素的矩阵乘法,需要三重循环:
for i in range(M):
for j in range(N):
for k in range(K):
C[i][j] += A[i][k] * B[k][j]
在硬件里,这种写法是行不通的。我们需要分块(Tiling)和流水线(Pipelining)。
分块策略
把大矩阵切成小块,让每个小块能完全塞进FPGA的BRAM或者URAM里。比如,我们设定分块大小 Tm=16, Tn=16, Tk=16。那么每次只计算 16x16 的小矩阵乘法。
| 分块维度 | 含义 | 典型值(FPGA) |
|---|---|---|
| Tm | 输出通道分块 | 16, 32 |
| Tn | 输出空间位置分块 | 16, 32 |
| Tk | 输入通道/窗口元素分块 | 16, 32 |
脉动阵列(Systolic Array)
这是我在项目中用得最多的架构。想象一个 16x16 的PE(处理单元)阵列。每个PE里有一个乘加器。数据像波浪一样在阵列里流动:
- 权重数据从左边流入,逐列广播。
- 输入数据从上方流入,逐行广播。
- 部分和在PE内部累加,然后向下传递。
这样做的好处是,数据复用率极高。每个权重会被同一行的16个PE复用,每个输入会被同一列的16个PE复用。访存带宽需求大大降低。
我曾经踩过一个坑:在设计脉动阵列时,没有处理好边界情况。当矩阵维度不是分块大小的整数倍时,需要补零。但补零操作如果处理不当,会导致PE阵列空转,效率骤降。我的教训是:在控制逻辑里,一定要为每个分块单独生成“有效使能”信号,而不是简单地用计数器来驱动。
4.4 im2col + GEMM的完整流程
下面这张图,是我在项目里常用的数据流架构。它清晰地展示了im2col和GEMM如何协同工作。
整个流程可以概括为:
- 数据加载:从DDR读取输入特征图和权重,存入片上的Buffer。
- im2col重排:在写入Buffer的过程中,完成滑窗和重排。这一步可以用一个简单的地址生成器实现,不需要额外的存储开销。
- 矩阵分块:从Buffer中按分块大小读取数据,喂给GEMM阵列。
- 脉动计算:PE阵列执行乘加运算,部分和在阵列内部累加。
- 结果写回:将计算结果写回输出Buffer,最终写回DDR。
4.5 性能分析与优化方向
im2col + GEMM方案虽然高效,但也有代价。最大的代价就是内存膨胀。im2col会把输入数据复制多份,导致内存占用增加。比如,步长 S=1 时,im2col后的矩阵大小是原来的 K*K 倍。对于 3x3 卷积,就是9倍。
怎么优化?我常用的几个方法:
- Winograd算法:对于
3x3卷积,Winograd能进一步减少乘法次数。但它的数据流更复杂,适合对计算密度要求极高的场景。 - 内存折叠(Memory Folding):在im2col时,不显式地生成完整的矩阵,而是通过地址映射,让GEMM引擎直接读取原始特征图。这需要更复杂的地址生成逻辑,但能省下大量BRAM。
- 混合精度:在FPGA上,用INT8甚至INT4做矩阵乘法,能大幅提升吞吐量。我最近一个项目,从FP32切到INT8,性能提升了4倍,精度只掉了0.3%。
我的建议:如果你的FPGA芯片BRAM资源紧张,优先考虑内存折叠。如果逻辑资源紧张,优先考虑分块策略。如果两者都紧张...嗯,那就得在架构层面做取舍了,比如减少PE阵列的规模。
好了,这一章的内容就到这里。im2col + GEMM是卷积硬件加速的经典组合,理解透了,后面讲Winograd、稀疏加速、脉动阵列优化时,你就能更快上手。