Conv2D 算子实现深入——im2col、Implicit GEMM、Winograd、FFT 代码级拆解

副标题: 一个卷积算子,四种实现路线。本文不再停留在概念层面,而是用可运行的代码逐一拆解:直接卷积怎么算、im2col 怎么展开成矩阵、Implicit GEMM 怎么在 kernel 内部在线计算访存地址、Winograd F(2,3) 的三组变换矩阵从哪来、FFT 怎么利用卷积定理做一次频域点乘。训练 vs 推理的差异分析见 博客 #34


一、Conv2D 的计算本质与核心挑战

Conv2D 的核心操作:一个滑动窗口在输入特征图上移动,每个位置做一次 kernel_h × kernel_w × C_in 维度的点积。

复制代码
输入:   (N, C_in, H, W)         N=batch, C=通道, H/W=空间尺寸
卷积核: (C_out, C_in, K_h, K_w)
输出:   (N, C_out, H_out, W_out)
        H_out = (H + 2·pad - K_h) / stride + 1

每个输出位置 = Σ(C_in × K_h × K_w) 次乘加
总 FLOPs = N × C_out × H_out × W_out × C_in × K_h × K_w

这个看似简单的操作,面临的工程挑战是:输入数据的访问模式高度不连续------滑动窗口跨 row、跨 channel,直接实现在 GPU/NPU 上效率极低。

为什么四种算法会存在? 因为"不连续访存"这个核心矛盾,有四种不同的解法:

  • 直接卷积:硬扛不连续访存,实现最简单但最慢
  • im2col + GEMM:把不连续访存重排成连续矩阵,交给 GEMM 加速
  • Implicit GEMM:不重排数据,而是在 GEMM 内部在线算地址------im2col 的"虚拟版"
  • Winograd:换一种数学形式,用更少的乘法算出同样的结果
  • FFT:换一个域(频域),把卷积变成点乘

下面逐一用代码拆解。


二、四种算法的代码级实现

2.1 直接卷积(Direct)------最朴素的三重循环

思路:按卷积定义老老实实算,7 层循环(N, C_out, H_out, W_out, C_in, K_h, K_w)。

python 复制代码
import numpy as np

def conv2d_direct(x, w, b=None, stride=1, padding=0):
    """
    直接卷积:最朴素的多重循环实现
    x: (N, C_in, H, W)
    w: (C_out, C_in, K_h, K_w)
    返回: (N, C_out, H_out, W_out)
    """
    N, C_in, H, W = x.shape
    C_out, _, K_h, K_w = w.shape

    # padding:在 H/W 外围补一圈 0
    x_pad = np.pad(x, ((0,0),(0,0),(padding,padding),(padding,padding)))
    H_out = (H + 2*padding - K_h) // stride + 1
    W_out = (W + 2*padding - K_w) // stride + 1

    out = np.zeros((N, C_out, H_out, W_out))

    for n in range(N):            # 每个 batch
        for cout in range(C_out): # 每个输出通道
            for h in range(H_out):      # 输出行
                for w_ in range(W_out): # 输出列
                    acc = 0.0
                    for c in range(C_in):      # 输入通道
                        for kh in range(K_h):  # 卷积核行
                            for kw in range(K_w):  # 卷积核列
                                acc += (x_pad[n, c, h*stride+kh, w_*stride+kw]
                                        * w[cout, c, kh, kw])
                    out[n, cout, h, w_] = acc + (b[cout] if b is not None else 0.0)
    return out

为什么慢 :最内层循环里,x_pad[n, c, h*stride+kh, w_*stride+kw] 的地址跨 kh 时跳了 W_pad 个元素、跨 c 时跳了 H_pad×W_pad 个元素------访存完全不连续 ,GPU 的 cache 和 SIMD 都发挥不出来。实测效率只有理论峰值的 ~0.5%

什么时候用 :几乎不用。唯一例外是 1×1 卷积------此时 K_h=K_w=1,内层循环退化为 sum_c x[n,c,h,w]*w[cout,c,0,0],本质就是一个 GEMM(后面 2.2 会看到)。

2.2 Im2Col + GEMM------把卷积变成矩阵乘

核心思想 :把每个滑动窗口展平成一行,拼成一个大矩阵 cols。这样卷积的"每个输出位置 = 窗口内点积"就变成了矩阵乘------而 GEMM 是每个芯片优化得最好的算子。

复制代码
输入特征图 (C_in, H, W),K_h=K_w=3:

  [a b c d
   e f g h      每个 3×3 窗口 → 展平成一行 [a,b,c, e,f,g, i,j,k]
   i j k l  →   滑动窗口遍历 → cols 矩阵 (H_out×W_out, C_in×9)
   m n o p]

卷积核展平:
  w (C_out, C_in, 3, 3) → w_mat (C_out, C_in×9)

然后一次 GEMM:
  out = cols @ w_mat.T     # (H_out×W_out, C_out)
python 复制代码
def im2col(x, w, stride=1, padding=0):
    """
    im2col: 把滑动窗口展开成矩阵
    x: (N, C_in, H, W)
    返回: cols (N × H_out × W_out, C_in × K_h × K_w)
    """
    N, C_in, H, W = x.shape
    K_h, K_w = w.shape[2], w.shape[3]

    x_pad = np.pad(x, ((0,0),(0,0),(padding,padding),(padding,padding)))
    H_out = (H + 2*padding - K_h) // stride + 1
    W_out = (W + 2*padding - K_w) // stride + 1

    cols = np.zeros((N * H_out * W_out, C_in * K_h * K_w))

    idx = 0
    for n in range(N):
        for h in range(H_out):
            for w_ in range(W_out):
                # 提取当前滑动窗口 (C_in, K_h, K_w),展平成一维
                window = x_pad[n, :, h*stride:h*stride+K_h, w_*stride:w_*stride+K_w]
                cols[idx] = window.flatten()
                idx += 1
    return cols


def conv2d_im2col(x, w, b=None, stride=1, padding=0):
    N, C_in, H, W = x.shape
    C_out = w.shape[0]

    cols = im2col(x, w, stride, padding)          # (N·H_out·W_out, C_in·K_h·K_w)
    w_mat = w.reshape(C_out, -1)                  # (C_out, C_in·K_h·K_w)

    out = cols @ w_mat.T                          # 一次 GEMM
    if b is not None:
        out += b

    H_out = (H + 2*padding - w.shape[2]) // stride + 1
    W_out = (W + 2*padding - w.shape[3]) // stride + 1
    # (N·H_out·W_out, C_out) → (N, H_out, W_out, C_out) → (N, C_out, H_out, W_out)
    return out.reshape(N, H_out, W_out, C_out).transpose(0, 3, 1, 2)

优点 :访存连续了,可以调用高度优化的 GEMM(cuBLAS / Tensor Core)。缺点cols 矩阵比原输入大 K_h × K_w 倍(3×3 是 9 倍,7×7 是 49 倍),显存开销和搬移时间是额外成本。

2.3 Implicit GEMM(隐式 GEMM)------不展开,在线算地址

核心思想im2col + GEMM 的两步是"先展开成 cols,再做 GEMM"。Implicit GEMM 把这两步合并------不真正创建 cols 矩阵,而是在 GEMM kernel 的 K 循环内部,实时算出当前元素对应输入特征图的哪个位置

复制代码
GEMM 的语义:  C[m, n] = Σ_k A[m, k] × B[k, n]

把卷积映射到 GEMM:
  m → 输出空间位置 (n, h, w_out)          # M 维 = N × H_out × W_out
  n → 输出通道 cout                        # N 维 = C_out
  k → (c_in, kh, kw)                      # K 维 = C_in × K_h × K_w

关键的 trick:
  A[m, k] 在 im2col 里 = x_pad[n, c, h·stride+kh, w_out·stride+kw]
  → 不存 A!每次需要 A[m,k] 时,现场用 (m, k) 反解出 (n, c, h, w_out, kh, kw) 去读内存

用 CUDA kernel 伪代码展示这个"在线索引":

cuda 复制代码
// Implicit GEMM 卷积 kernel(每个 thread 算一个输出元素)
__global__ void implicit_gemm_conv(
    const float* __restrict__ x_pad,  // (N, C_in, H_pad, W_pad) 已 padding 的输入
    const float* __restrict__ w,      // (C_out, C_in, K_h, K_w)
    float* __restrict__ out,          // (N, C_out, H_out, W_out)
    int N, int C_in, int H_pad, int W_pad,
    int C_out, int K_h, int K_w,
    int H_out, int W_out, int stride)
{
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    int total = N * C_out * H_out * W_out;
    if (idx >= total) return;

    // 把线性 idx 拆解成 (n, cout, h, w_out)
    int w_out = idx % W_out;
    int h     = (idx / W_out) % H_out;
    int cout  = (idx / (W_out * H_out)) % C_out;
    int n     = idx / (W_out * H_out * C_out);

    float acc = 0.0f;
    // K 维 = C_in × K_h × K_w,逐项在线计算访存地址
    for (int k = 0; k < C_in * K_h * K_w; k++) {
        // 把 k 拆解成 (c, kh, kw)
        int kw = k % K_w;
        int kh = (k / K_w) % K_h;
        int c  = k / (K_w * K_h);

        // ★ 核心:实时计算输入地址(这就是"implicit"的含义------不展开 im2col)
        int in_h = h * stride + kh;
        int in_w = w_out * stride + kw;

        float x_val = x_pad[((n * C_in + c) * H_pad + in_h) * W_pad + in_w];
        float w_val = w[((cout * C_in + c) * K_h + kh) * K_w + kw];
        acc += x_val * w_val;
    }
    out[idx] = acc;
}

关键区别一句话

复制代码
im2col + GEMM:  先把 x 重排成 cols 存进显存,再跑 GEMM
Implicit GEMM:  不重排,GEMM 的循环里每次读 x 时现场算地址
                → 省掉 cols 的 K_h×K_w 倍显存开销
                → 代价是每次访存多一次地址计算(但可被 GEMM 的并行性分摊)

为什么它是主流:GPU 的 Tensor Core 只吃 GEMM;Implicit GEMM 既把卷积变成了 GEMM(能用 Tensor Core),又没有 im2col 的显存开销。NVIDIA cuDNN、AMD MIOpen 的主力实现都是它。唯一不用它的是华为昇腾------因为达芬奇架构的 Cube Unit 不支持在矩阵乘内部做这种条件地址计算,只能退回显式 im2col。

2.4 Winograd------用数学变换减少乘法

2.4.1 原理:为什么能省乘法?

Winograd 最小滤波算法的核心:对于输出 m 点、卷积核 r 点的 1D 卷积,只需要 m + r - 1 次乘法 ,而不是 m × r 次。

复制代码
朴素 1D 卷积(输出 2 点,核 3 点):
  y0 = w0·x0 + w1·x1 + w2·x2      ← 3 次乘法
  y1 = w0·x1 + w1·x2 + w2·x3      ← 3 次乘法
  合计 6 次乘法

Winograd F(2,3):
  m0 = w0·(x0 - x2)                          ← 1 次乘法
  m1 = (w0+w1+w2)/2 · (x1 + x2)              ← 1 次乘法
  m2 = (w0-w1+w2)/2 · (x2 - x1)              ← 1 次乘法
  m3 = w2·(x1 - x3)                          ← 1 次乘法
  y0 = m0 + m1 + m2                          ← 加法(不花乘法)
  y1 = m1 - m2 - m3                          ← 加法
  合计 4 次乘法!

用 4 次乘法代替 6 次------省了 33%。 2D 卷积(3×3 核)是 1D 的外积组合,乘法从 9 次降到 4 次(省 55%)。代价是多了很多加法------但对 GPU/NPU 来说,加法的代价远小于乘法(乘法在 Tensor Core 上是稀缺资源)。

2.4.2 变换矩阵从哪来?

Winograd 的优雅之处在于,上面的计算可以统一写成三次矩阵变换

复制代码
输入变换:   X = B^T · d · B         # d 是 4×4 输入 tile
权重变换:   W = G · g · G^T         # g 是 3×3 卷积核(可离线算一次)
点乘:       M = W ⊙ X               # 逐元素乘(4×4=16 次乘法)
逆变换:     Y = A^T · M · A         # 输出 2×2 tile

三组变换矩阵(F(2,3),Lavin & Gray 2016):

复制代码
B^T (输入变换, 4×4):      G (权重变换, 4×3):       A^T (输出变换, 2×4):
[ 1  0 -1  0]             [1     0     0   ]        [ 1  1  1  0]
[ 0  1  1  0]             [0.5   0.5   0.5 ]        [ 0  1 -1 -1]
[ 0 -1  1  0]             [0.5  -0.5   0.5 ]
[ 0  1  0 -1]             [0     0     1   ]

验证 (1D):X = B^T·d 得到 [x0-x2, x1+x2, x2-x1, x1-x3]W = G·g 得到 [w0, (w0+w1+w2)/2, (w0-w1+w2)/2, w2],点乘后 A^T·M 还原出 y0, y1------和朴素卷积完全一致(见下面代码的验证部分)。

2.4.3 完整实现代码
python 复制代码
import numpy as np

# ===== F(2,3) 三组变换矩阵(固定常量)=====
BT = np.array([[1, 0, -1, 0],
               [0, 1,  1, 0],
               [0, -1, 1, 0],
               [0, 1,  0, -1]], dtype=np.float32)   # (4,4) 输入变换
G  = np.array([[1,    0,    0   ],
               [0.5,  0.5,  0.5 ],
               [0.5, -0.5,  0.5 ],
               [0,    0,    1   ]], dtype=np.float32) # (4,3) 权重变换
AT = np.array([[1,  1,  1,  0],
               [0,  1, -1, -1]], dtype=np.float32)  # (2,4) 输出变换


def demo_winograd_1d():
    """1D 演示:Winograd F(2,3) 与朴素卷积结果一致,且只花 4 次乘法"""
    d = np.array([1., 2., 3., 4.])   # 输入 4 点
    g = np.array([2., 3., 1.])       # 卷积核 3 点

    # 朴素 1D 卷积: 6 次乘法
    y_naive = np.array([
        g[0]*d[0] + g[1]*d[1] + g[2]*d[2],   # y0 = w0x0 + w1x1 + w2x2
        g[0]*d[1] + g[1]*d[2] + g[2]*d[3],   # y1 = w0x1 + w1x2 + w2x3
    ])

    # Winograd: 4 次乘法
    X = BT @ d            # (4,) 输入变换(加法为主)
    W = G @ g             # (4,) 权重变换(可离线)
    M = X * W             # (4,) 只有这一步是 4 次乘法!
    y_wino = AT @ M       # (2,) 逆变换(加法为主)

    print("朴素:   ", y_naive)
    print("Winograd:", y_wino)
    assert np.allclose(y_naive, y_wino, atol=1e-5)
    return y_wino


def conv2d_winograd_f23(x, w, b=None):
    """
    2D Winograd F(2,3):3×3 卷积核,stride=1
    x: (N, C_in, H, W)   要求 H, W 是 2 的倍数(tile 步长 2),已处理好 padding
    w: (C_out, C_in, 3, 3)
    返回: (N, C_out, H-2, W-2)
    """
    N, C_in, H, W = x.shape
    C_out = w.shape[0]

    # 权重变换: G·g·G^T ------ 离线只算一次,之后所有 batch / tile 复用!
    W_t = G @ w @ G.T            # (C_out, C_in, 4, 4)

    H_out, W_out = H - 2, W - 2
    tiles_h, tiles_w = H_out // 2, W_out // 2
    out = np.zeros((N, C_out, H_out, W_out))

    for n in range(N):
        for c_in in range(C_in):
            for t_h in range(tiles_h):
                for t_w in range(tiles_w):
                    h0, w0 = t_h * 2, t_w * 2
                    # 1. 提取 4×4 输入 tile(相邻 tile 步长 2,重叠 2 像素)
                    tile = x[n, c_in, h0:h0+4, w0:w0+4]     # (4,4)
                    # 2. 输入变换: B^T · d · B
                    X_t = BT @ tile @ BT.T                   # (4,4)
                    for c_out in range(C_out):
                        # 3. 变换域点乘: 16 次乘法算 4 个输出像素
                        M = W_t[c_out, c_in] * X_t           # (4,4) 逐元素乘
                        # 4. 逆变换: A^T · M · A → 2×2 输出 tile
                        Y = AT @ M @ AT.T                    # (2,2)
                        out[n, c_out, h0:h0+2, w0:w0+2] += Y
    if b is not None:
        out += b.reshape(1, -1, 1, 1)
    return out

乘法次数对比(关键数据):

复制代码
朴素 3×3 卷积(每输出像素):   C_in × 3 × 3 = 9 次乘法
Winograd F(2,3)(每输出像素): 4×4 tile / 4 像素 = 4 次乘法
→ 省了 55%(对 3×3 卷积)

朴素 5×5 卷积:    25 次乘法
Winograd F(4,3):  16 次乘法(更大的 tile 省得更多,但数值误差也更大)

为什么不能无限省

  1. 只对特定 kernel 大小有效------1×1 卷积没有冗余可省,3×3/5×5 效果最好
  2. 数值精度损失------变换矩阵里的分数(0.5)和加减抵消会让 FP16 误差显著(比 GEMM 大约 100 倍)
  3. 变换开销------B^T 和 A^T 的矩阵乘在小 batch 下可能吃掉省下的乘法收益
  4. 大输入不划算------H/W 很大时 Implicit GEMM 反而更快

⚠️ 最重要的一点:省乘法 ≠ 快

上面所有"乘法从 9 降到 4"的分析,都只是数学 FLOPs 的减少 ,不等于硬件执行时间的减少。Winograd 有三个致命软肋,让它经常在实战中输给 Implicit GEMM:

软肋 1:核心计算是 element-wise,用不上 matmul/Tensor Core

Winograd 变换域的 M = W ⊙ X逐元素乘(Hadamard product),不是 GEMM。它的算术强度只有 1/12 FLOP/Byte------极低。这意味着:

  • 在 GPU 上,点乘跑在 CUDA core 上而不是 Tensor Core 上
  • 在只有 matmul 引擎的自研芯片上,这步计算根本没有硬件能高效执行
  • 要硬用 matmul 得把点乘重组成 batched GEMM(F(6,3) 需要 64 个独立小矩阵乘),数据重排 + 同步开销巨大

软肋 2:训练场景权重变换无法离线复用

G·w·G^T 权重变换在推理时可以离线算一次(权重固定);但训练时权重每 step 都更新,变换每 step 都要重新算。叠加:

  • 变换后权重内存膨胀 1.78×(F(2,3))/ 4×(F(4,3)),训练还要存梯度
  • 反向传播的 BackpropInput / BackpropFilter 也是卷积,权重变换要重复做
  • FP16 下变换矩阵的条件数差导致数值误差指数增长(真实案例:某团队 FP16 F(4,3) 静默丢失 2.1 mAP 一个多月没发现)

软肋 3:Tensor Core 时代乘法变便宜了,省乘法的价值缩水

Lc0 开发者在 RTX + FP16 下实测:cuDNN 的 Winograd 在所有测试网络规模下都比 implicit_gemm 慢。原因就是搜索到的这句原话:

"算法运算量的减少不足以抵消各阶段(输入变换、滤波器变换、批量矩阵乘、输出逆变换)中间数据读写的额外时间,而且 Tensor Core 上数学运算相对便宜。"

只有当乘法真的贵时(FP32、不用 Tensor Core),Winograd 才快约 2 倍。 这就是为什么 cuDNN 在 FP32 下倾向 Winograd、在 FP16 Tensor Core 下倾向 Implicit GEMM。

2.5 FFT 卷积------频域一次点乘

2.5.1 原理:卷积定理

时域卷积 = 频域点乘。这是 FFT 卷积的数学基础:

复制代码
卷积定理:  F(x * w) = F(x) × F(w)
因此:      x * w = IFFT( FFT(x) × FFT(w) )

把卷积变成三步:
  1. 输入和核都 FFT 到频域
  2. 频域逐元素相乘(不再是滑动窗口,而是"同位置乘")
  3. 结果 IFFT 回时域

为什么能省 :滑动窗口卷积的复杂度是 O(N·K²)(N 是像素数,K 是核大小);FFT 的复杂度是 O(N log N)。核越大,FFT 的优势越明显------7×7 核时 FFT 比滑动窗口快得多,3×3 核时 FFT 的开销(变换本身的成本)反而超过省下的计算。

2.5.2 关键边界:为什么要 padding?

直接 FFT 得到的是循环卷积 (circular convolution)------边缘的像素会"绕回"和另一头相乘,产生混叠(aliasing)。要得到线性卷积 (普通卷积),必须把输入和核都 padding 到 H + K_h - 1 的尺寸。

复制代码
线性卷积:   y[n] = Σ_k x[k]·w[n-k]     # 标准的卷积
循环卷积:   y[n] = Σ_k x[k]·w[(n-k) mod N]  # FFT 默认算的是这个
                                        # 边缘像素绕回 → 结果错误!

解决: 把 x 和 w 都 padding 到 N ≥ H + K_h - 1
      → 循环卷积的"绕回"部分落在 padding 的 0 上 → 结果 == 线性卷积
2.5.3 完整实现代码
python 复制代码
import numpy as np

def next_pow2(n):
    p = 1
    while p < n:
        p *= 2
    return p

def conv2d_fft(x, w, b=None, padding=0):
    """
    FFT 卷积:利用卷积定理 F(x*w) = F(x)·F(w)
    x: (N, C_in, H, W)
    w: (C_out, C_in, K_h, K_w)
    padding: 空间 padding(和 PyTorch 的 padding 参数一致)
    返回: (N, C_out, H+2p-K_h+1, W+2p-K_w+1)
    """
    N, C_in, H, W = x.shape
    C_out = w.shape[0]
    K_h, K_w = w.shape[2], w.shape[3]

    # 先做空间 padding(pad 到 H+2p),再走 FFT
    x = np.pad(x, ((0,0),(0,0),(padding,padding),(padding,padding)))
    H, W = H + 2*padding, W + 2*padding

    # FFT 尺寸 = 线性卷积输出尺寸,取 2 的幂(FFT 对 2 的幂最友好)
    fft_h = next_pow2(H + K_h - 1)
    fft_w = next_pow2(W + K_w - 1)

    # ★ 关键点 1:FFT 定理算的是"真卷积"(核翻转),而 CNN 的 conv2d 是"互相关"(核不翻转)
    #   → 先把核翻转 w[...,::-1,::-1],FFT 结果才对齐互相关语义
    w_flip = w[..., ::-1, ::-1]

    # padding 到 FFT 尺寸 → 循环卷积退化为线性卷积(避免边缘混叠)
    x_pad = np.pad(x, ((0,0),(0,0),(0, fft_h-H),(0, fft_w-W)))
    w_pad = np.pad(w_flip, ((0,0),(0,0),(0, fft_h-K_h),(0, fft_w-K_w)))

    out = np.zeros((N, C_out, fft_h, fft_w))

    for n in range(N):
        for c_in in range(C_in):
            # rfft2 = 实信号 FFT,只算一半频域(实对称),省一半计算
            X_f = np.fft.rfft2(x_pad[n, c_in])          # (fft_h, fft_w//2+1)
            for c_out in range(C_out):
                # 权重 FFT 可以离线缓存(每个 c_out,c_in 只算一次,所有 batch 复用)
                W_f = np.fft.rfft2(w_pad[c_out, c_in])  # (fft_h, fft_w//2+1)
                # ★ 核心:频域点乘(不再是滑动窗口,而是同位置相乘)
                out[n, c_out] += np.fft.irfft2(X_f * W_f, s=(fft_h, fft_w))

    if b is not None:
        out += b.reshape(1, -1, 1, 1)

    # ★ 关键点 2:线性卷积的输出起点在 (K_h-1, K_w-1),裁剪时跳过这些位置
    return out[:, :, K_h-1:K_h-1+H-K_h+1, K_w-1:K_w-1+W-K_w+1]


def conv2d_fft_v2(x, w):
    """优化版:一次性批量 FFT,避免 Python 双重循环"""
    N, C_in, H, W = x.shape
    C_out = w.shape[0]
    K_h, K_w = w.shape[2], w.shape[3]
    fft_h, fft_w = next_pow2(H+K_h-1), next_pow2(W+K_w-1)

    w_flip = w[..., ::-1, ::-1]          # 同样的核翻转
    x_pad = np.pad(x, ((0,0),(0,0),(0,fft_h-H),(0,fft_w-W)))
    w_pad = np.pad(w_flip, ((0,0),(0,0),(0,fft_h-K_h),(0,fft_w-K_w)))

    # 整批 FFT: (N, C_in, fft_h, fft_w//2+1) 和 (C_out, C_in, ...)
    X_f = np.fft.rfft2(x_pad, axes=(-2, -1))
    W_f = np.fft.rfft2(w_pad, axes=(-2, -1))

    # 频域点乘: einsum 一次性完成 (N, C_in) × (C_out, C_in) → (N, C_out)
    Y_f = np.einsum('nihw,oihw->nohw', X_f, W_f)
    out = np.fft.irfft2(Y_f, s=(fft_h, fft_w))

    return out[:, :, K_h-1:K_h-1+H-K_h+1, K_w-1:K_w-1+W-K_w+1]

两个最容易踩的坑(代码里已标注 ★)

坑 1:核翻转 ------FFT 卷积定理 F(x*w)=F(x)·F(w) 算的是数学卷积(核翻转),而 CNN 的 conv2d 实际是互相关 (核不翻转)。直接对 w 做 FFT 得到的结果和 PyTorch 完全对不上。解法:先翻转核 w[...,::-1,::-1]

坑 2:裁剪偏移 ------线性卷积的输出从 (K_h-1, K_w-1) 位置才开始对应互相关输出。如果裁剪成 [:H-K+1],结果会整体偏移。解法:从 K_h-1 开始裁剪。

注意其他工程细节

  1. rfft2 而不是 fft2------输入是实数,频域共轭对称,只算一半省一半计算
  2. padding 到 2 的幂------FFT 对 2 的幂尺寸最优
  3. 权重 FFT 可离线------权重在推理时不变,FFT 结果可以预计算缓存,运行时只算输入的 FFT
  4. 不支持 stride > 1------FFT 天然是"逐像素对位",stride 卷积需要额外抽取,通常不如直接用 Implicit GEMM

2.6 四种算法对比总结

算法 乘法次数(每输出像素) 额外显存 适用核大小 数值精度 芯片依赖
直接卷积 C_in·K² 0 任意 精确
im2col+GEMM C_in·K² K² 倍输入 任意 精确 需 GEMM
Implicit GEMM C_in·K² 0 任意 精确 需 GEMM + 在线索引
Winograd F(2,3) C_in·4(≈44%) 变换中间量 3×3、5×5 FP32 好,FP16 差 需 element-wise 单元
FFT O(log N) 摊派 频域缓存 ≥7×7 精确(双精度更好) 需 FFT 单元

⚠️ 注意上表"乘法次数"只是数学 FLOPs,不代表执行更快------见 2.4 末尾的"省乘法 ≠ 快"分析。Winograd 的实际胜出条件很苛刻。

选型一句话(修正版)

  • 1×1 → 直接就是 GEMM(什么都不用做)
  • 有 Tensor Core/matmul 引擎的芯片,3×3/5×5 → Implicit GEMM(FP16 实测更快)
  • FP32、无 Tensor Core 的芯片 (乘法贵)→ 3×3 可以考虑 Winograd
  • 3×3、小 batch / 任意 stride → Implicit GEMM
  • 7×7+ 大核 → FFT
  • 没有矩阵乘单元但有向量 SIMD → 直接卷积(慢)或 Winograd(快一点)
  • 只有 matmul 引擎、M/N ≥ 32 的自研芯片Implicit GEMM 是唯一正解,Winograd 没有立足之地(点乘是 element-wise,你的硬件跑不了)

三、各芯片厂商的工程落地

训练 vs 推理的差异分析见 博客 #34。这里只讲四种算法在各厂商硬件上的工程实现差异。

3.1 NVIDIA cuDNN------Implicit GEMM + 自动调优

硬件基础:Tensor Core(专门吃 GEMM 的矩阵乘单元)+ CUDA 编程模型。

实现策略 :手写多种 kernel 变体,用 Find API 自动调优------对每个卷积配置 benchmark 所有候选算法,选最快的缓存结果:

复制代码
候选算法:
  ├─ Implicit GEMM(IMPLICIT_GEMM)        ← 主力,Tensor Core + 在线索引
  ├─ Implicit Precomp GEMM(预计算索引版)   ← 大 batch 下摊薄索引计算
  ├─ Winograd(F(2,3) / F(4,3))            ← 3×3 大 batch 时最快
  ├─ FFT / FFT Tiled                        ← 仅核 ≥ 5 时考虑
  └─ Direct(回退)

当前 cuDNN 的倾向(精度是关键变量):

  • FP16 + Tensor Core (训练主流)→ 3×3 也是 Implicit GEMM。实测 cuDNN 的 Winograd 在所有规模下都比 implicit_gemm 慢(变换的访存开销 > 省下的乘法)
  • FP32 (不用 Tensor Core,乘法贵)→ 3×3 大 batch → Winograd
  • 1×1、5×5 → Implicit Precomp GEMM
  • 小 batch 推理 → Implicit GEMM(不预计算)

3.2 AMD MIOpen------Winograd 纯汇编 + Solver 架构

硬件基础:Matrix Core(XDL ops)+ 64-lane wavefront(NVIDIA 是 32-lane)。

两大独特之处

1. Winograd 是纯汇编手写的

MIOpen 的 Winograd kernel(ConvBinWinogradRxSf2x3g1)没有 C++ 版本,全部手写汇编。因为 Winograd 的指令序列非常固定(变换→点乘→逆变换),手写汇编能精确控制寄存器分配,且 CDNA 的 64-lane wavefront 让 shuffle 一次交换 64 个元素。

2. Solver 冷启动问题

MIOpen 第一次跑模型要 benchmark 几百个卷积 shape 来选最优 solver。训练场景没问题(一次几小时),推理场景首次加载要等几秒到几十秒------这是推理部署的痛点。

3.3 Intel oneDNN------JIT 编译生成

硬件基础:CPU 用 AVX-512 / AMX,GPU 用 DPAS 脉动指令。

实现策略 :不手写多种 kernel,而是写一个 JIT 代码生成器------运行时根据卷积参数(shape、dtype、stride)即时生成刚好匹配的机器码,并把 Conv + Bias + ReLU 融合进一个 kernel。

复制代码
NVIDIA: 手写 20 个 kernel 变体 → runtime 选最快
Intel:  运行时生成 1 个刚好适配当前参数的 kernel

CPU 上卷积优化和 GPU 完全不同:CPU 的瓶颈是 cache 局部性而非显存带宽,用 AVX-512/AMX 向量化,INT8 推理用 VNNI 指令。

3.4 华为昇腾 CANN------显式 Im2Col + Cube GEMM

硬件基础:达芬奇架构------Cube Unit(矩阵乘)+ Vector Unit(向量处理)+ 三层存储(GM→L1→UB)。

实现策略显式 im2col(不是隐式)------用 Vector Unit 做 im2col 展开,再喂给 Cube Unit 做 GEMM:

复制代码
输入 (N, C_in, H, W)
  → Vector Unit 做 im2col 展开(数据重排)
  → L0A/L0B → Cube Unit 做矩阵乘(INT8/FP16/FP32)
  → Vector Unit 做后处理(Bias + 激活 + 量化)
  → 输出

为什么华为选显式而非隐式

  1. 达芬奇的 Cube Unit 只能做 GEMM,不能做"有条件的 GEMM"------无法实现 Implicit GEMM 的在线索引计算
  2. Vector Unit 本来就是向量处理器,做数据重排(im2col)比 Tensor Core 做地址计算更高效
  3. 显式 im2col 虽多一次数据搬移,但 Cube Unit 可以持续打满

代价:显式 im2col 需要 K_h×K_w 倍中间显存。3×3 是 9 倍没问题,7×7 是 49 倍可能 OOM------华为用 Tiling(分块)缓解,但 tile 太小又让 Cube 利用率下降。

3.5 各家路线对比

维度 NVIDIA cuDNN AMD MIOpen Intel oneDNN 华为昇腾 CANN
核心算法 Implicit GEMM Implicit GEMM + Winograd(asm) JIT 生成 显式 Im2Col + Cube GEMM
加速单元 Tensor Core Matrix Core (XDL) AMX/DPAS Cube Unit
im2col 方式 隐式(无缓存) 隐式(主流) 隐式 显式(有缓存)
Winograd 实现 C++ 手写 纯汇编,无 C++ JIT 生成 支持有限
自动调优 Find API(benchmark) Solver(benchmark + 缓存) JIT 即时生成 固定算法 + Tiling 参数
开源 ❌(文档透明) ❌(有社区版)

四、对自研芯片的算法选择建议

4.1 算法路线选择的底层逻辑

从四家厂商的做法可以提炼出一个规律------你的硬件有什么,决定你走哪条路

硬件能力 推荐的卷积实现路线 代表
有通用矩阵乘单元(Tensor Core / Matrix Core / Cube),且支持小 batch Implicit GEMM 优先,不支持隐式才退而显式 im2col NVIDIA、AMD、华为
只有 matmul 引擎,且 M/N ≥ 32 Implicit GEMM 是唯一正解。Winograd 核心点乘是 element-wise 且 tile 只有 4×4,你的 matmul 引擎根本跑不了 自研大矩阵乘芯片
有 SIMD 向量单元但没有独立矩阵乘 Winograd + 向量化(乘法贵,省乘法划算) CPU(Intel、ARM)
有较大片上 SRAM / L1 Winograd 的小 tile 可以全在片上完成 低成本芯片
带宽极度受限 Winograd(减少乘法 = 减少访存) 端侧芯片

⚠️ Winograd 的前提是有高效执行 element-wise 点乘的单元 。如果你的芯片只有 matmul 引擎(没有高效向量单元),Winograd 的核心计算 M = W ⊙ X 就没有硬件能跑------此时它连"能用"都谈不上,更别说比 Implicit GEMM 快。

4.2 一个容易被忽略的问题:自动调优

推理部署有个训练场景没有的要求------模型加载后要 1 秒内出结果。训练可以接受 Find API 的 benchmark 开销(几十秒 vs 几小时训练),推理不能。

复制代码
方案 A: 离线调优 + 缓存
   训练/首次部署时做一次 benchmark,最优配置写入 json
   推理时直接加载缓存,不 benchmark
   缺点: 训练和推理的 shape 可能不同

方案 B: 固定策略(推荐)
   不使用自动调优,所有卷积走同一个算法(如 Implicit GEMM)
   缺点: 某些 shape 下不是最优,但延迟可预测

方案 C: 规则引擎
   根据 kernel_size、stride、batch 等特征查表决定算法
   比 Find 快,比固定策略灵活,缺点: 规则需要人为维护

对推理场景,固定策略或规则引擎更实用。 只有大 batch 训练场景才值得用 Find API。


五、总结

核心发现

  1. 四种算法是"不连续访存"这一个问题的四种解法------直接卷积硬扛、im2col 重排成矩阵、Implicit GEMM 在线算地址、Winograd 换数学形式、FFT 换域。没有绝对最优,只有"针对什么硬件、什么 shape 最优"。

  2. Implicit GEMM 是当前大算力芯片的主流 ------把卷积变成 GEMM(能用 Tensor Core),又不产生 im2col 的显存开销。核心就一行:在 GEMM 的 K 循环里现场算输入地址

  3. Winograd 的 44% 乘法是"数学上的省",不是"时间上的省" ------它省的是乘法次数,代价是变换开销、FP16 数值不稳定性、权重变换内存膨胀。实测结论:FP16 + Tensor Core 下 Implicit GEMM 反而更快 (乘法便宜,变换访存开销更贵);只有 FP32、无 Tensor Core(乘法真贵)时才值得。且训练场景权重每 step 变换无法离线复用,进一步削弱其优势 。对"只有 matmul 引擎、M/N≥32"的自研芯片,Winograd 核心点乘(element-wise + 4×4 tile)根本没有硬件能执行,Implicit GEMM 是唯一正解

  4. FFT 只在核足够大时划算------O(N log N) vs O(N·K²),7×7 以上才值得。且不支持 stride>1、需要 pad 到 2 的幂避免循环卷积混叠。

  5. 华为走了一条独特的路(显式 im2col) ------因为达芬奇架构的 Cube Unit 不能做 Implicit GEMM 的在线索引计算。这提醒自研芯片团队:算法的选择权有一部分掌握在硬件架构手里,如果你的矩阵乘单元不能做条件访存,就只能退回显式 im2col 并承受它的显存代价。

一张快查表

你的芯片有什么? Conv2D 怎么做?
矩阵乘单元(Tensor Core 类型,FP16) Implicit GEMM(实测比 Winograd 快)
矩阵乘单元 + FP32(乘法贵) 3×3 可考虑 Winograd
只有 matmul 引擎,M/N ≥ 32 Implicit GEMM(唯一正解,Winograd 核心是 element-wise 跑不了)
矩阵乘单元 + 大带宽 显式 Im2Col + GEMM
向量 SIMD 单元(无矩阵乘) Winograd 或直接滑窗
小 SRAM + 小带宽 Winograd(变换在片上完成)
FFT 硬件单元 FFT 卷积(大核 7×7+)
CPU 无硬件加速 JIT 生成 AVX2/AVX-512 kernel

附录:进一步阅读

"省乘法 ≠ 快"的实测来源

相关推荐
Briwisdom7 天前
MoE 推理优化实战——从“瓶颈罗列“到“性能调优“
gemm·vllm·moe·decode·prefill
你是我的解忧王子21 天前
10进制和16进制 超声波 FFT 变换分析筛选系统误差-ADC采样筛选工具
数据分析·adc·fft·系统误差
aichitang20241 个月前
数论变换(NTT)
c++·算法·fft·ntt
chase20252 个月前
谐波降噪(1) - 基于步进脉冲相位调制
fft·3d打印·谐波降噪·位置脉冲调制·开环步进
惆怅客1232 个月前
UVa12327/LA5705 Xavier is Learning to Count
fft·快速傅里叶变换·容斥原理·icpc·uva·生成函数的乘法
ACQTEC研索仪器2 个月前
DIC 技术测量结构固有振型的优势:以研索仪器VIC-3D系统为例的实测解析
fft·vic-3d·dic技术·dic测量·非接触全场测量·结构动力学·模态振型
ACQTEC研索仪器2 个月前
数字图像相关(DIC)技术应用于手机振动ODS分析
fft·vic-3d·dic技术·dic测量·工作变形ods
DreamLife☼2 个月前
OpenBCI-特征提取技术:频域分析与时频分析
gui·脑机接口·fft·时域·频域·cyton·openbic
一条大祥脚2 个月前
【Tilelang入门】Tilelang Puzzles 05
gemm·tilelang·tilelang-puzzle