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如何协同工作。

im2col + GEMM 硬件加速数据流 im2col + GEMM 硬件加速数据流 输入特征图 (H, W, C) im2col 引擎 滑窗 + 重排 输入矩阵 Buffer (K*K*C) x N 权重 Buffer M x (K*K*C) GEMM 脉动阵列 Tm x Tn PE 阵列 输出 Buffer M x N 数据流方向:输入 → im2col重排 → 矩阵分块 → 脉动阵列计算 → 输出重组

整个流程可以概括为:

  1. 数据加载:从DDR读取输入特征图和权重,存入片上的Buffer。
  2. im2col重排:在写入Buffer的过程中,完成滑窗和重排。这一步可以用一个简单的地址生成器实现,不需要额外的存储开销。
  3. 矩阵分块:从Buffer中按分块大小读取数据,喂给GEMM阵列。
  4. 脉动计算:PE阵列执行乘加运算,部分和在阵列内部累加。
  5. 结果写回:将计算结果写回输出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、稀疏加速、脉动阵列优化时,你就能更快上手。