副标题: 一个卷积算子,四种实现路线。本文不再停留在概念层面,而是用可运行的代码逐一拆解:直接卷积怎么算、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 省得更多,但数值误差也更大)
为什么不能无限省:
- 只对特定 kernel 大小有效------1×1 卷积没有冗余可省,3×3/5×5 效果最好
- 数值精度损失------变换矩阵里的分数(0.5)和加减抵消会让 FP16 误差显著(比 GEMM 大约 100 倍)
- 变换开销------B^T 和 A^T 的矩阵乘在小 batch 下可能吃掉省下的乘法收益
- 大输入不划算------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 开始裁剪。
注意其他工程细节:
- rfft2 而不是 fft2------输入是实数,频域共轭对称,只算一半省一半计算
- padding 到 2 的幂------FFT 对 2 的幂尺寸最优
- 权重 FFT 可离线------权重在推理时不变,FFT 结果可以预计算缓存,运行时只算输入的 FFT
- 不支持 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 + 激活 + 量化)
→ 输出
为什么华为选显式而非隐式:
- 达芬奇的 Cube Unit 只能做 GEMM,不能做"有条件的 GEMM"------无法实现 Implicit GEMM 的在线索引计算
- Vector Unit 本来就是向量处理器,做数据重排(im2col)比 Tensor Core 做地址计算更高效
- 显式 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。
五、总结
核心发现
-
四种算法是"不连续访存"这一个问题的四种解法------直接卷积硬扛、im2col 重排成矩阵、Implicit GEMM 在线算地址、Winograd 换数学形式、FFT 换域。没有绝对最优,只有"针对什么硬件、什么 shape 最优"。
-
Implicit GEMM 是当前大算力芯片的主流 ------把卷积变成 GEMM(能用 Tensor Core),又不产生 im2col 的显存开销。核心就一行:
在 GEMM 的 K 循环里现场算输入地址。 -
Winograd 的 44% 乘法是"数学上的省",不是"时间上的省" ------它省的是乘法次数,代价是变换开销、FP16 数值不稳定性、权重变换内存膨胀。实测结论:FP16 + Tensor Core 下 Implicit GEMM 反而更快 (乘法便宜,变换访存开销更贵);只有 FP32、无 Tensor Core(乘法真贵)时才值得。且训练场景权重每 step 变换无法离线复用,进一步削弱其优势 。对"只有 matmul 引擎、M/N≥32"的自研芯片,Winograd 核心点乘(element-wise + 4×4 tile)根本没有硬件能执行,Implicit GEMM 是唯一正解。
-
FFT 只在核足够大时划算------O(N log N) vs O(N·K²),7×7 以上才值得。且不支持 stride>1、需要 pad 到 2 的幂避免循环卷积混叠。
-
华为走了一条独特的路(显式 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 |
附录:进一步阅读
- Lavin & Gray (2016): Fast Algorithms for Convolutional Neural Networks --- Winograd 原始论文
- Chetlur et al. (2014): cuDNN: Efficient Primitives for Deep Learning
- NVIDIA cuDNN: Developer Guide
- AMD MIOpen: GitHub
- Intel oneDNN: Documentation
- 华为昇腾 CANN: 算子开发指南
- 隐式 GEMM 详解: cuDNN Internals
"省乘法 ≠ 快"的实测来源
- Lc0 开发者实测:RTX + FP16 + Tensor Core 下,cuDNN 的 Winograd 在所有测试网络规模下都比 implicit_gemm 慢------直接证伪"Winograd 更快"
- Liu et al., ICCP '21: Optimizing Winograd-Based Convolution with Tensor Cores:深度优化(点乘重组成 64 个 batched GEMM)后 Winograd + Tensor Core 可反超 implicit GEMM 2.41×------但这是高度定制实现,不是默认路径
- Winograd 数值不稳定真实案例:FP16 F(4,3) 因变换矩阵灾难性抵消静默丢失 2.1 mAP,一个多月未发现
- 知乎讨论:implicit gemm 为什么取代 im2col+gemm:算法选择的工程权衡