卷积问题与 GPU 并行映射
卷积神经网络(CNN)的计算核心是卷积算子。无论是图像分类、目标检测还是语义分割,卷积运算都占据了整个模型 90% 以上的计算量 。在 ResNet-50 中,单张 224×224 图像的一次前向推理需要约 38 亿次乘加运算,其中卷积层贡献了绝大多数。如果不做任何优化,在单核 CPU 上执行这些运算需要数秒,而在现代 GPU 上则可以压缩到毫秒级。这种数量级的差异,正是源于卷积运算本身的结构特性与 GPU 并行架构的高度契合。
计算量的本质:从数学定义直观感受复杂度
先看最基本的 2D 卷积 定义。给定输入特征图 X∈RCin×H×WX \in \mathbb{R}^{C_{in} \times H \times W}X∈RCin×H×W,卷积核 K∈RCout×Cin×Kh×KwK \in \mathbb{R}^{C_{out} \times C_{in} \times K_h \times K_w}K∈RCout×Cin×Kh×Kw,输出特征图 Y∈RCout×Hout×WoutY \in \mathbb{R}^{C_{out} \times H_{out} \times W_{out}}Y∈RCout×Hout×Wout。输出特征图中每个像素的计算公式为:
Yco,ho,wo=∑ci=0Cin−1∑kh=0Kh−1∑kw=0Kw−1Xci,ho⋅s+kh, ph⋅Kco,ci,kh,kw+bco Y_{c_o, h_o, w_o} = \sum_{c_i=0}^{C_{in}-1} \sum_{k_h=0}^{K_h-1} \sum_{k_w=0}^{K_w-1} X_{c_i, h_o \cdot s + k_h, \ p_h} \cdot K_{c_o, c_i, k_h, k_w} + b_{c_o} Yco,ho,wo=ci=0∑Cin−1kh=0∑Kh−1kw=0∑Kw−1Xci,ho⋅s+kh, ph⋅Kco,ci,kh,kw+bco
其中 sss 为步长(stride),php_hph、pwp_wpw 为填充(padding)。单看这个公式,一次输出像素的计算量是 Cin×Kh×KwC_{in} \times K_h \times K_wCin×Kh×Kw 次乘加。但整个卷积层的总计算量是:
FLOPs=2×Cout×Cin×Kh×Kw×Hout×Wout \text{FLOPs} = 2 \times C_{out} \times C_{in} \times K_h \times K_w \times H_{out} \times W_{out} FLOPs=2×Cout×Cin×Kh×Kw×Hout×Wout
以经典的 AlexNet 第一个卷积层为例:Cin=3C_{in}=3Cin=3,Cout=96C_{out}=96Cout=96,K=11K=11K=11,输入为 224×224224 \times 224224×224,输出为 55×5555 \times 5555×55。这单层的计算量就是 2×96×3×11×11×55×55≈1.92 \times 96 \times 3 \times 11 \times 11 \times 55 \times 55 \approx 1.92×96×3×11×11×55×55≈1.9 亿次运算------而这只是整个网络中最浅的一层,后续层级的计算量还会成倍增长。
3D 卷积 则在此基础上进一步扩展------输入变为体积数据 X∈RCin×D×H×WX \in \mathbb{R}^{C_{in} \times D \times H \times W}X∈RCin×D×H×W,卷积核变为 K∈RCout×Cin×Kd×Kh×KwK \in \mathbb{R}^{C_{out} \times C_{in} \times K_d \times K_h \times K_w}K∈RCout×Cin×Kd×Kh×Kw。计算量公式中多出一个维度 DoutD_{out}Dout 和 KdK_dKd:
FLOPs3D=2×Cout×Cin×Kd×Kh×Kw×Dout×Hout×Wout \text{FLOPs}{3D} = 2 \times C{out} \times C_{in} \times K_d \times K_h \times K_w \times D_{out} \times H_{out} \times W_{out} FLOPs3D=2×Cout×Cin×Kd×Kh×Kw×Dout×Hout×Wout
医学影像(如 CT、MRI)中的 3D 卷积通常输入深度为 64~128 层,计算量比同分辨率的 2D 卷积多出一到两个数量级。例如,3D U-Net 的单次前向推理可达 数百 GFLOPs,远超 2D 视觉模型。
直观感受:一次 3D 卷积中,单个输出体素(voxel)需要累加 Cin×Kd×Kh×KwC_{in} \times K_d \times K_h \times K_wCin×Kd×Kh×Kw 次乘加。以 Cin=64C_{in}=64Cin=64、K=3K=3K=3 为例,就是 64×27=172864 \times 27 = 172864×27=1728 次乘加。而输出体素的总数是 Cout×Dout×Hout×WoutC_{out} \times D_{out} \times H_{out} \times W_{out}Cout×Dout×Hout×Wout,动辄百万级。两者相乘,计算自然爆炸。
数据复用:卷积优化的第一性原理
卷积计算量如此之大,但单个数据元素被重复使用的次数同样惊人。这构成了优化的第一性原理------通过合理的并行策略和存储层次设计,将重复使用的数据放在尽可能靠近计算单元的位置。
卷积中有两个层次的数据复用:
第一层:输入像素的复用。 每个输入像素会被多个输出像素用到。对于 Kh×KwK_h \times K_wKh×Kw 的卷积核,一个输入像素最多被 Kh×KwK_h \times K_wKh×Kw 个不同的输出像素使用(在无填充且步长为 1 时)。更准确地说,每个输入像素参与的计算次数等于它被卷积核窗口覆盖的次数,接近于 Kh×KwK_h \times K_wKh×Kw。以 3×3 卷积为例,这意味着每个输入像素被读取约 9 次。如果每次读取都从全局内存(GPU 的显存)获取,内存带宽将成为瓶颈------计算单元在等待数据,而非在计算。
第二层:卷积核权重的复用。 每个卷积核权重 Kco,ci,kh,kwK_{c_o,c_i,k_h,k_w}Kco,ci,kh,kw 会被所有输出空间位置(Hout×WoutH_{out} \times W_{out}Hout×Wout 个)共享。以 3×3 卷积、输出 112×112112 \times 112112×112 为例,一个权重值要被使用 12,544 次。卷积核本身的体积相对较小(例如 64 个输出通道、3×3 的卷积核仅 64×3×3×3=172864 \times 3 \times 3 \times 3 = 172864×3×3×3=1728 个浮点数),完全有希望放入 GPU 的共享内存 或常量内存中,实现接近寄存器级别的访问速度。
下图展示了这两个复用维度:
#mermaid-svg-Dp04NMURI6SlOHdJ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Dp04NMURI6SlOHdJ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Dp04NMURI6SlOHdJ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Dp04NMURI6SlOHdJ .error-icon{fill:#552222;}#mermaid-svg-Dp04NMURI6SlOHdJ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Dp04NMURI6SlOHdJ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Dp04NMURI6SlOHdJ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Dp04NMURI6SlOHdJ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Dp04NMURI6SlOHdJ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Dp04NMURI6SlOHdJ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Dp04NMURI6SlOHdJ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Dp04NMURI6SlOHdJ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Dp04NMURI6SlOHdJ .marker.cross{stroke:#333333;}#mermaid-svg-Dp04NMURI6SlOHdJ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Dp04NMURI6SlOHdJ p{margin:0;}#mermaid-svg-Dp04NMURI6SlOHdJ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Dp04NMURI6SlOHdJ .cluster-label text{fill:#333;}#mermaid-svg-Dp04NMURI6SlOHdJ .cluster-label span{color:#333;}#mermaid-svg-Dp04NMURI6SlOHdJ .cluster-label span p{background-color:transparent;}#mermaid-svg-Dp04NMURI6SlOHdJ .label text,#mermaid-svg-Dp04NMURI6SlOHdJ span{fill:#333;color:#333;}#mermaid-svg-Dp04NMURI6SlOHdJ .node rect,#mermaid-svg-Dp04NMURI6SlOHdJ .node circle,#mermaid-svg-Dp04NMURI6SlOHdJ .node ellipse,#mermaid-svg-Dp04NMURI6SlOHdJ .node polygon,#mermaid-svg-Dp04NMURI6SlOHdJ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Dp04NMURI6SlOHdJ .rough-node .label text,#mermaid-svg-Dp04NMURI6SlOHdJ .node .label text,#mermaid-svg-Dp04NMURI6SlOHdJ .image-shape .label,#mermaid-svg-Dp04NMURI6SlOHdJ .icon-shape .label{text-anchor:middle;}#mermaid-svg-Dp04NMURI6SlOHdJ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Dp04NMURI6SlOHdJ .rough-node .label,#mermaid-svg-Dp04NMURI6SlOHdJ .node .label,#mermaid-svg-Dp04NMURI6SlOHdJ .image-shape .label,#mermaid-svg-Dp04NMURI6SlOHdJ .icon-shape .label{text-align:center;}#mermaid-svg-Dp04NMURI6SlOHdJ .node.clickable{cursor:pointer;}#mermaid-svg-Dp04NMURI6SlOHdJ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Dp04NMURI6SlOHdJ .arrowheadPath{fill:#333333;}#mermaid-svg-Dp04NMURI6SlOHdJ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Dp04NMURI6SlOHdJ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Dp04NMURI6SlOHdJ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Dp04NMURI6SlOHdJ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Dp04NMURI6SlOHdJ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Dp04NMURI6SlOHdJ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Dp04NMURI6SlOHdJ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Dp04NMURI6SlOHdJ .cluster text{fill:#333;}#mermaid-svg-Dp04NMURI6SlOHdJ .cluster span{color:#333;}#mermaid-svg-Dp04NMURI6SlOHdJ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Dp04NMURI6SlOHdJ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Dp04NMURI6SlOHdJ rect.text{fill:none;stroke-width:0;}#mermaid-svg-Dp04NMURI6SlOHdJ .icon-shape,#mermaid-svg-Dp04NMURI6SlOHdJ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Dp04NMURI6SlOHdJ .icon-shape p,#mermaid-svg-Dp04NMURI6SlOHdJ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Dp04NMURI6SlOHdJ .icon-shape .label rect,#mermaid-svg-Dp04NMURI6SlOHdJ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Dp04NMURI6SlOHdJ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Dp04NMURI6SlOHdJ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Dp04NMURI6SlOHdJ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 每个像素被重复读取 K_h × K_w 次
每个权重被 H_out × W_out 个输出位置共享
输入特征图
计算单元
卷积核权重
输出特征图
这个简单的复用分析直接决定了卷积在 GPU 上的并行映射策略。
并行性的三维分解:输出像素、输出通道、批量
GPU 拥有数千个计算核心,要充分发挥算力,需要找到足够多的独立计算任务。卷积天然提供了三个并行维度:
输出像素并行(空间并行)。 每个输出像素 Yco,ho,woY_{c_o, h_o, w_o}Yco,ho,wo 的计算相互独立,不依赖其他输出像素的值。这意味着 Hout×WoutH_{out} \times W_{out}Hout×Wout 个空间位置可以完全并行。以 112×112112 \times 112112×112 的输出为例,这是 12,544 个完全独立的计算任务------远超单个 GPU 核心数,并行度绰绰有余。
输出通道并行。 每个输出通道 coc_oco 对应一组独立的卷积核权重。通道之间的计算互不干扰,可以随空间维度一起分解。对于 256 个输出通道的层,并行任务数再乘 256。
批量并行(batch 维度)。 实际训练和推理时,输入是批量的(batch size 通常为 32~256)。不同 batch 样本的卷积计算完全独立。
三个维度叠加后,一次卷积的并行任务总数为:
并行任务数=N×Cout×Hout×Wout \text{并行任务数} = N \times C_{out} \times H_{out} \times W_{out} 并行任务数=N×Cout×Hout×Wout
以 batch=64、Cout=256C_{out}=256Cout=256、输出 56×5656 \times 5656×56 为例:64×256×56×56=51.464 \times 256 \times 56 \times 56 = 51.464×256×56×56=51.4 百万个独立任务。现代数据中心 GPU(如 A100)拥有 6912 个 CUDA 核心,每个核心需要处理约 7400 个任务------负载均衡良好。
在 CUDA 编程模型中,经典的映射方式是:一个 thread 计算一个输出像素 ,线程块(block)的组织方式通常为 (Cout,Hout,Wout)(C_{out}, H_{out}, W_{out})(Cout,Hout,Wout) 的子块。一个 block 负责一小块输出区域(如 16×1616 \times 1616×16 像素 × 若干通道),block 内部的线程可以协作将输入数据加载到共享内存 ,供 block 内所有线程复用。而卷积核权重可以放入常量内存------因为这个 block 内所有线程使用相同的权重,常量内存的广播机制(同一时刻所有线程读取同一地址,仅需一次内存访问)能最大化权重读取效率。
这种映射方式有一个关键数字值得记住:计算访存比(arithmetic intensity) 。对于单个输出像素,计算量是 Cin×Kh×KwC_{in} \times K_h \times K_wCin×Kh×Kw,而需要的输入数据量(从全局内存角度看)约为 Cin×Kh×KwC_{in} \times K_h \times K_wCin×Kh×Kw------在无数据复用的情况下,计算访存比为 1 FLOP/byte,这是典型的访存密集型 (memory-bound)问题。但通过共享内存复用输入数据,计算访存比可以提升 Kh×KwK_h \times K_wKh×Kw(或更多)倍,转变为计算密集型(compute-bound)问题,从而真正发挥 GPU 的算力峰值。
本节从卷积的计算量出发,明确了其「计算密集」与「数据可复用」的双重特性,并基于此导出了 GPU 并行的三维分解:输出像素、输出通道与批量。这种并行分解直接决定了卷积算子的 thread 映射策略,也为下一节讨论 im2col 变换------将卷积转化为矩阵乘法以调用高度优化的 GEMM 库------奠定了基础。
Im2col算法实现
上一节的分析揭示了一个关键矛盾:卷积既拥有庞大的计算密度,又因数据复用不足而受制于访存带宽。若能将卷积运算转化为矩阵乘法 (GEMM),便可直接调用 cuBLAS 这类经过极致调优的通用矩阵乘库------这些库在 GPU 上能达到接近峰值算力的性能,而手工编写的卷积核通常只能发挥出峰值的 30%-50% 。实现这一转化的桥梁,就是 im2col(image to column)变换。
从滑窗到矩阵:im2col 的核心思想
卷积的每次乘加运算本质上是对输入上一个 局部窗口 与滤波器做内积。如果把每个窗口中的所有元素 展开成一列,将滤波器展开成一行,那么卷积就变成了标准的 GEMM。
考虑一个具体例子:输入为 4×44 \times 44×4 的单通道图像,滤波器大小为 3×33 \times 33×3,步长为 1。输出尺寸为 (4−3+1)2=4(4-3+1)^2 = 4(4−3+1)2=4 个像素。im2col 将输入图像变换为一个 9×49 \times 49×4 的矩阵:每一列对应一个滑窗位置的 9 个像素值,共计 4 列。同时将 3×33 \times 33×3 滤波器展开为 1×91 \times 91×9 的行向量。两者的矩阵乘积 1×91 \times 91×9 × 9×49 \times 49×4 恰好得到 1×41 \times 41×4 的向量,正是 4 个输出像素的卷积结果。
在 多通道 场景下,设输入有 CinC_{in}Cin 个通道,滤波器尺寸为 K×KK \times KK×K,输出位置数为 Hout×WoutH_{out} \times W_{out}Hout×Wout,滤波器个数为 CoutC_{out}Cout。im2col 将输入变换为维度为 (Cin⋅K⋅K)×(Hout⋅Wout)(C_{in} \cdot K \cdot K) \times (H_{out} \cdot W_{out})(Cin⋅K⋅K)×(Hout⋅Wout) 的矩阵,将全部滤波器展开为 Cout×(Cin⋅K⋅K)C_{out} \times (C_{in} \cdot K \cdot K)Cout×(Cin⋅K⋅K) 的矩阵。两者的乘积直接得出 Cout×(Hout⋅Wout)C_{out} \times (H_{out} \cdot W_{out})Cout×(Hout⋅Wout) 的输出------这正是 cuBLAS 中最擅长的 大规模稠密矩阵乘。
内存膨胀:im2col 的代价
im2col 的代价十分直观:输入中每个元素会被 复制到多个列 中。具体来说,一个输入像素在输出中被使用的次数等于覆盖它的滑窗数量------在步长为 1、滤波器尺寸为 KKK 时,这一数量为 K2K^2K2。
膨胀率=im2col矩阵大小原始输入大小=Cin⋅K2⋅Hout⋅WoutCin⋅Hin⋅Win≈K2膨胀率 = \frac{im2col矩阵大小}{原始输入大小} = \frac{C_{in} \cdot K^2 \cdot H_{out} \cdot W_{out}}{C_{in} \cdot H_{in} \cdot W_{in}} \approx K^2膨胀率=原始输入大小im2col矩阵大小=Cin⋅Hin⋅WinCin⋅K2⋅Hout⋅Wout≈K2
当 K=3K=3K=3 时,输入矩阵膨胀为原来的 9 倍 ;当 K=5K=5K=5 时膨胀为 25 倍 。以 ResNet-50 的中间层为例(输入 256×56×56256 \times 56 \times 56256×56×56,滤波器 3×33 \times 33×3),im2col 后的矩阵大小为 2304×3136≈7.2×1062304 \times 3136 \approx 7.2 \times 10^62304×3136≈7.2×106 个浮点数,约 29 MB ------而原始输入仅有 3.2 MB。一个 batch size 为 32 的 mini-batch 经过 im2col 后,内存占用将超过 900 MB,远超 L2 缓存容量。这使得 im2col 在深层次网络上经常成为显存瓶颈。
降低膨胀率:内存布局的权衡
在实际工程中,常见的缓解策略是:不进全局 im2col,而是在 GPU 上逐 block 执行局部变换 。cuDNN 中的隐式 GEMM(implicit GEMM)正是这一思路------不显式构造 im2col 矩阵,而是在共享内存中按需构建当前 block 所需的输入列块,执行矩阵乘后立即丢弃。这样,每次全局内存访问都直接服务于乘法运算 ,避免了中间矩阵的写入和读取,膨胀率从全局的 K2K^2K2 降为局部时共享内存的容量上限。
下表对比了三种方案的关键差异:
| 方案 | 内存膨胀率 | 全局内存访问次数 | 适用场景 |
|---|---|---|---|
| CPU 端 im2col + cuBLAS | K2K^2K2 | 输入读一遍、矩阵写一遍、矩乘读两遍 | 小 batch、浅层网络 |
| GPU 端 im2col + cuBLAS | K2K^2K2 | 输入读一遍、矩阵写一次、矩乘读两遍 | 显存充足的中型网络 |
| Implicit GEMM(cuDNN) | ≈1\approx 1≈1 | 输入读一遍且直接用于计算 | 深层网络、大 batch |
动手实践:一个完整的 im2col + cuBLAS 流程
以下代码展示了在 GPU 端执行 im2col 并调用 cuBLAS 完成卷积的完整流程。注释中标出了每个步骤的在整体 pipeline 中的角色。
python
import torch
import torch.nn.functional as F
# 配置:输入 (N=1, C=3, H=5, W=5),2个 3x3 滤波器,步长 1
N, C_in, H_in, W_in = 1, 3, 5, 5
C_out, K, stride = 2, 3, 1
H_out = W_out = (H_in - K) // stride + 1 # 3
# 随机初始化输入和滤波器(等效于展开的权重矩阵)
x = torch.randn(N, C_in, H_in, W_in, device='cuda')
w = torch.randn(C_out, C_in, K, K, device='cuda')
def im2col_gpu(x, K, stride):
"""GPU 端 im2col:将输入展开为 GEMM 所需的列矩阵"""
N, C, H, W = x.shape
H_out = (H - K) // stride + 1
W_out = (W - K) // stride + 1
# 使用 unfold 在 GPU 上高效提取所有滑窗
cols = F.unfold(x, kernel_size=K, stride=stride) # (N, C*K*K, L)
return cols.permute(0, 2, 1).reshape(N * H_out * W_out, -1) # (L, C*K*K)
# 第一步:im2col 变换
cols = im2col_gpu(x, K, stride) # (9, 27):9个输出位置 × 27个输入值
w_mat = w.reshape(C_out, -1) # (2, 27):滤波器展开为权重矩阵
# 第二步:调用 cuBLAS------torch.mm 在后端直接调度到 cuBLAS
out_mat = torch.mm(cols, w_mat.t()) # (9, 2):每行一个输出位置的C_out个通道值
# 第三步:还原为卷积输出的标准布局 (N, C_out, H_out, W_out)
out = out_mat.t().reshape(N, C_out, H_out, W_out)
# 验证:与 PyTorch 原生卷积对比
ref = F.conv2d(x, w, stride=stride)
print(f"最大误差: {(out - ref).abs().max().item():.2e}") # 期望输出 ~1e-6
这段代码实现了一个最小可用的 im2col 卷积流程:先用 F.unfold 在 GPU 上完成展开,再通过 torch.mm 调用 cuBLAS 完成矩阵乘,误差在浮点精度范围内(10−610^{-6}10−6 量级)与 PyTorch 原生 conv2d 一致。值得注意的是,torch.mm 选择了 cuBLAS 的通用 GEMM 内核------对于 9×279 \times 279×27 这样的小矩阵,调度开销本身可能超过计算时间;但在实际网络中,当 LLL 达到数万、Cin⋅K2C_{in} \cdot K^2Cin⋅K2 达到数千时,cuBLAS 的 tile-based 内核(如 128×128 的线程块划分)能够实现接近硬件峰值的数据复用率,这正是 im2col 路线的根本优势所在。
本节的代价与收益
回顾本节的两个核心点:im2col 的代价是 K2K^2K2 倍的内存膨胀 ,而收益是 将卷积无缝接入 cuBLAS 这一经过二十年优化的矩阵乘体系 。对于显存充足的场景,这仍是工程上最简洁的方案。下一节将探讨一种更极致的路径------不膨胀内存、直接在卷积的原始数据布局上进行核函数编写,将每个输出像素映射到一个 CUDA 线程,通过共享内存和常量内存手工榨取访存效率。
直接卷积:朴素 Kernel
im2col 通过空间换时间换来了 GEMM 的高效调用,但其代价同样醒目:内存膨胀 。从前文的公式可知,膨胀率只与滤波器尺寸相关------输入大小并不影响膨胀倍数 。以 5×5 滤波器为例,膨胀率恒为 K2=25K^2 = 25K2=25 倍:即使输入尺寸仅有 33×33,im2col 矩阵的大小也可能是原始输入的 25 倍。当输入是 224×224×64 的中间特征图时,仅这一层的 im2col 展开就需要约 1.6 GB 显存------这已经逼近甚至超出了许多消费级 GPU 的显存上限。更不用说,这个庞大的矩阵还需要额外的 cudaMemcpy 或 kernel 来做数据搬运,这部分开销在层数加深时会被急剧放大。
那么问题来了:有没有一种方式,既不牺牲正确性,又能把内存开销压到最小?
答案是有的。直接卷积(Direct Convolution)就是这条路径上的起点------它不做任何数据重排,输入是什么布局,计算就按什么布局读取。
本文讨论的直接卷积沿用了与第 1 节相同的输入输出布局约定。为便于对比例子,统一使用单通道 2D 卷积来描述,但所有结论均可直接推广到多通道情况。
最直观的并行策略:每个输出一个线程
直接卷积的并行划分方式朴素而自然:输出特征图中的每个像素,由一个线程负责计算。该线程遍历整个滤波器(以及所有输入通道),累加得到输出值。对应到 GPU 的线程组织上:
- 将输出特征图展平为一维数组,每个元素对应一个
threadIdx.x - 线程根据自身的全局索引,反推出输出像素的 (hout,wout)(h_{out}, w_{out})(hout,wout) 坐标
- 再从 (hout,wout)(h_{out}, w_{out})(hout,wout) 反推输入窗口的起始位置 (hin_start,win_start)(h_{in\start}, w{in\_start})(hin_start,win_start)
- 循环遍历滤波器每一个位置,从全局内存中读取对应的输入像素,乘上权重,累加
下面这段 CUDA 代码展示了最朴素的 2D 直接卷积 kernel 的完整实现:
cuda
// 单通道 2D 卷积:每个线程计算一个输出像素
// 输入: input (H_in x W_in), 滤波器: filter (K x K)
// 输出: output (H_out x W_out), 步长 stride = 1, 无 padding
__global__ void direct_conv2d_single_channel(
const float* input, // 尺寸: H_in * W_in
const float* filter, // 尺寸: K * K
float* output, // 尺寸: H_out * W_out
int H_in, int W_in,
int H_out, int W_out,
int K)
{
// 每个线程对应一个输出像素
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx >= H_out * W_out) return; // 边界保护
// 反推输出像素的行列坐标
int h_out = idx / W_out;
int w_out = idx % W_out;
float acc = 0.0f;
// 遍历滤波器
for (int kh = 0; kh < K; ++kh) {
for (int kw = 0; kw < K; ++kw) {
// 输入窗口起始位置 = 输出坐标 + 滤波器偏移(步长为1)
int h_in = h_out + kh;
int w_in = w_out + kw;
acc += input[h_in * W_in + w_in] * filter[kh * K + kw];
}
}
output[idx] = acc;
}
启动配置与调用方式如下:
cuda
int threads = 256;
int blocks = (H_out * W_out + threads - 1) / threads;
direct_conv2d_single_channel<<<blocks, threads>>>(
d_input, d_filter, d_output, H_in, W_in, H_out, W_out, K);
这段代码的逻辑直白到几乎不需要解释:一个输出像素 = 一个线程 = 一次滤波器遍历 。它的好处是极致简单------内核中唯一的循环是滤波器的 K2K^2K2 次遍历,不需要任何额外缓冲,也不需要 __syncthreads()。从工程角度看,这样的 kernel 几乎不可能写错,是第一版验证算法正确性的最佳选择。
但这种简单性,恰恰带来了两个绕不开的瓶颈。
瓶颈一:低算术强度下的全局访存放大
先算一笔账。对于每个输出像素,需要从全局内存读取 K2K^2K2 个输入值。由于相邻输出像素的输入窗口存在重叠------步长为 1 时相邻窗口重叠 (K−1)×K(K-1) \times K(K−1)×K 个元素------这些读取中有大量是重复的 。每个输入像素平均被多少个输出线程读取?答案是 K2K^2K2 次(边界除外)。也就是说,每个输入元素被从全局内存重复加载了 K2K^2K2 次。
以 K=3K=3K=3 为例:一个输入像素被加载 9 次。以 K=5K=5K=5 为例:被加载 25 次。而每次加载都是一次完整的全局内存事务------L1/L2 缓存可以吸收一部分重复访问,但缓存命中率受限于输入规模与并发线程数量,当特征图尺寸远超缓存容量时,命中率会急剧下降,DRAM 流量随之飙升。
与此同时,每个输出线程的计算量只有 K2K^2K2 次乘加。这意味着卷积的计算访存比(算术强度)为:
算术强度=K2 次乘加K2 次全局读取+1 次权重读取≈1 FLOP/Byte \text{算术强度} = \frac{K^2 \text{ 次乘加}}{K^2 \text{ 次全局读取} + 1 \text{ 次权重读取}} \approx 1 \text{ FLOP/Byte} 算术强度=K2 次全局读取+1 次权重读取K2 次乘加≈1 FLOP/Byte
这里约掉了常数系数。对比一下:现代 GPU(如 A100)的浮点峰值约为 19.5 TFLOPS,而 HBM 带宽约为 2 TB/s。要达到计算峰值,算术强度需要接近 19.5 TFLOPS / 2 TB/s ≈ 10 FLOP/Byte 。直接卷积的算术强度大约只有这个值的 1/10------这意味着 GPU 的计算单元绝大多数时间在等待数据从 DRAM 送达,而非在计算。
瓶颈二:权重访问的广播缺失
除了输入数据的重复加载,滤波器权重也存在类似问题。每个线程在遍历滤波器的过程中,需要读取 K2K^2K2 个权重值;而同一 block 内所有线程(通常是 256 个)访问的是完全相同的权重序列。
从硬件角度来看,这其实是一笔「友好的」访问------同一 warp 内 32 个线程同时读取同一地址时,GPU 会将其合并为一次广播事务,带宽开销是 1 次而非 32 次。问题在于缓存:权重总共只有 K2K^2K2 个浮点数 (例如 3×33\times33×3 时仅 36 字节),虽然完全可以常驻 L1 缓存,但每个线程仍要经过「全局内存地址 → 缓存查找」的路径,这笔延迟虽然在缓存命中时不算致命,但当同一个 block 内其他线程的输入访问引发大量缓存未命中时,权重访问的命中率也会受到连带影响。
更深层的问题在于:权重的复用方式是最简单的「同地址广播」,却恰恰是 GPU 最浪费的利用方式------因为没有利用到共享内存的广播能力。每个线程都在独立地从全局内存侧发起访问请求,而不是通过一次共享内存加载让整块线程共享。
直接卷积的真正定位
看到这里,读者可能会想:既然直接卷积这么低效,为什么还要讨论它?
原因有二。第一,它是正确性的基准 。im2col 和后面更复杂的优化实现,都需要跟朴素实现比对结果来验证正确性。第二,它揭示了卷积优化的核心矛盾 :卷积天然具有巨大的数据复用潜力(每个输入像素被复用 K2K^2K2 次),但朴素实现完全没有利用这种复用------每个线程各自为战,重复地从全局内存搬运数据。
这个矛盾恰恰是下一节优化的起点。呼应第 1 节的结论:直接卷积之所以在 GPU 上只能发挥峰值的 30%-50% (在很多情况下甚至更低),根本原因不在于计算密度不够,而在于访存路径上重复搬运了 K2K^2K2 倍的数据。要解决这个问题,思路已经清晰起来------把那些被反复使用的输入像素和权重,搬到离计算单元更近的地方:共享内存和常量内存。
共享内存 tile 与输入复用
朴素卷积核的致命伤在于访存:每个输出像素需要遍历 K2CK^2CK2C 个输入元素,而这些元素在相邻输出像素之间高度重叠。以 3×3 卷积为例,相邻两个输出像素共享了 3C3C3C 个输入元素------这意味着全局内存访问量是理论最小值的 K2K^2K2 倍 (上节已算得 9 倍)。共享内存(shared memory)正是为打破这一瓶颈而生的:它是 GPU 片上的一块高速存储,访问延迟比全局内存低 一个数量级(约 20-30 个周期 vs 400-800 个周期),且能被同一线程块内的所有线程共享。如果能将一块输入数据加载到共享内存中,让块内所有线程反复读取,就能把全局内存的访问次数从「每个输出像素一次」压缩到「每个 tile 一次」。
Tile 大小受限:容量、银行冲突与占用率的三角博弈
共享内存并非无限大------以 NVIDIA A100 为例,每个流多处理器(SM)的共享内存上限为 164 KB (可配置),而每个线程块最大可申请 48 KB (非动态分配时)。这个硬性上限直接决定了 tile 的几何尺寸。假设我们希望在一个 tile 中容纳 TH×TWT_H \times T_WTH×TW 个输出像素对应的输入区域,对于 CCC 通道输入和 K×KK \times KK×K 滤波器,需要的共享内存大小为:
SHARED_SIZE=(TH+K−1)×(TW+K−1)×C×4 bytes (float) \text{SHARED\_SIZE} = (T_H + K - 1) \times (T_W + K - 1) \times C \times 4 \text{ bytes (float)} SHARED_SIZE=(TH+K−1)×(TW+K−1)×C×4 bytes (float)
以一个典型配置为例:TH=TW=16T_H = T_W = 16TH=TW=16,C=64C = 64C=64,K=3K = 3K=3,则 tile 的输入尺寸为 (16+3−1)2×64=182×64=20736(16+3-1)^2 \times 64 = 18^2 \times 64 = 20736(16+3−1)2×64=182×64=20736 个 float,总计 81 KB 。这个数字已经逼近 48 KB 的线程块上限------也就是说,即使只容纳 16×16 个输出像素,单个 block 的共享内存申请也可能遭到拒绝。因此在实际实现中,要么缩小 tile 尺寸(如 8×8),要么减少每次加载的通道数(分通道处理),要么将滤波器权重存入共享内存而输入仍从全局内存读取(通过 L1/L2 缓存缓解)。
此外,tile 尺寸还受占用率 (occupancy)的制约。每个 SM 的共享内存总量固定,一个 block 占用的共享内存越多,SM 上能同时驻留的 block 就越少,直接导致 latency hiding 能力下降。例如 A100 上若每个 block 占用 48 KB 共享内存,则 SM 上最多驻留 3 个 block(164/48 ≈ 3),而若压缩到 16 KB,则可驻留 10 个 block。更小的 tile 意味着更高的占用率,但同时也意味着更多的 tile 边界开销------这两者之间需要针对具体硬件和卷积参数做平衡。业界常用的经验法则是:优先保证 tile 尺寸不小于滤波器尺寸的 4-5 倍,同时将共享内存占用控制在 SM 容量的 1/4 到 1/3 以内。
边界处理:halo 区域与银行冲突的规避
当 tile 尺寸确定后,下一个问题接踵而至:tile 边界上的像素怎么办? 下图展示了这一问题的几何结构。对于输出像素 (i,j)(i, j)(i,j),其计算需要输入像素 (i−K/2:i+K/2, j−K/2:j+K/2)(i-K/2 : i+K/2, \ j-K/2 : j+K/2)(i−K/2:i+K/2, j−K/2:j+K/2) 的区域。当 tile 内的线程需要读取超出 tile 边界的输入时,如果我们只将 tile 本身加载到共享内存,这部分数据将不在共享内存中------线程被迫回退到全局内存读取,这恰恰是我们要避免的。
#mermaid-svg-5cdBxbe2ejeGRC4r{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5cdBxbe2ejeGRC4r .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5cdBxbe2ejeGRC4r .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5cdBxbe2ejeGRC4r .error-icon{fill:#552222;}#mermaid-svg-5cdBxbe2ejeGRC4r .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5cdBxbe2ejeGRC4r .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5cdBxbe2ejeGRC4r .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5cdBxbe2ejeGRC4r .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5cdBxbe2ejeGRC4r .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5cdBxbe2ejeGRC4r .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5cdBxbe2ejeGRC4r .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5cdBxbe2ejeGRC4r .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5cdBxbe2ejeGRC4r .marker.cross{stroke:#333333;}#mermaid-svg-5cdBxbe2ejeGRC4r svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5cdBxbe2ejeGRC4r p{margin:0;}#mermaid-svg-5cdBxbe2ejeGRC4r .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-5cdBxbe2ejeGRC4r .cluster-label text{fill:#333;}#mermaid-svg-5cdBxbe2ejeGRC4r .cluster-label span{color:#333;}#mermaid-svg-5cdBxbe2ejeGRC4r .cluster-label span p{background-color:transparent;}#mermaid-svg-5cdBxbe2ejeGRC4r .label text,#mermaid-svg-5cdBxbe2ejeGRC4r span{fill:#333;color:#333;}#mermaid-svg-5cdBxbe2ejeGRC4r .node rect,#mermaid-svg-5cdBxbe2ejeGRC4r .node circle,#mermaid-svg-5cdBxbe2ejeGRC4r .node ellipse,#mermaid-svg-5cdBxbe2ejeGRC4r .node polygon,#mermaid-svg-5cdBxbe2ejeGRC4r .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-5cdBxbe2ejeGRC4r .rough-node .label text,#mermaid-svg-5cdBxbe2ejeGRC4r .node .label text,#mermaid-svg-5cdBxbe2ejeGRC4r .image-shape .label,#mermaid-svg-5cdBxbe2ejeGRC4r .icon-shape .label{text-anchor:middle;}#mermaid-svg-5cdBxbe2ejeGRC4r .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-5cdBxbe2ejeGRC4r .rough-node .label,#mermaid-svg-5cdBxbe2ejeGRC4r .node .label,#mermaid-svg-5cdBxbe2ejeGRC4r .image-shape .label,#mermaid-svg-5cdBxbe2ejeGRC4r .icon-shape .label{text-align:center;}#mermaid-svg-5cdBxbe2ejeGRC4r .node.clickable{cursor:pointer;}#mermaid-svg-5cdBxbe2ejeGRC4r .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-5cdBxbe2ejeGRC4r .arrowheadPath{fill:#333333;}#mermaid-svg-5cdBxbe2ejeGRC4r .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-5cdBxbe2ejeGRC4r .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-5cdBxbe2ejeGRC4r .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5cdBxbe2ejeGRC4r .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-5cdBxbe2ejeGRC4r .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5cdBxbe2ejeGRC4r .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-5cdBxbe2ejeGRC4r .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-5cdBxbe2ejeGRC4r .cluster text{fill:#333;}#mermaid-svg-5cdBxbe2ejeGRC4r .cluster span{color:#333;}#mermaid-svg-5cdBxbe2ejeGRC4r div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-5cdBxbe2ejeGRC4r .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-5cdBxbe2ejeGRC4r rect.text{fill:none;stroke-width:0;}#mermaid-svg-5cdBxbe2ejeGRC4r .icon-shape,#mermaid-svg-5cdBxbe2ejeGRC4r .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5cdBxbe2ejeGRC4r .icon-shape p,#mermaid-svg-5cdBxbe2ejeGRC4r .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-5cdBxbe2ejeGRC4r .icon-shape .label rect,#mermaid-svg-5cdBxbe2ejeGRC4r .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5cdBxbe2ejeGRC4r .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-5cdBxbe2ejeGRC4r .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-5cdBxbe2ejeGRC4r :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 共享内存Tile
输入特征图
一次批量加载
一次批量加载
全局内存中的输入
核心区域 T_H x T_W
halo 区域 K-1 像素宽
块内线程计算输出像素
解决方案是将 tile 连同其边界扩展(halo)一起加载 。具体做法是:共享内存中分配的 tile 尺寸不是 TH×TWT_H \times T_WTH×TW,而是 (TH+K−1)×(TW+K−1)(T_H + K - 1) \times (T_W + K - 1)(TH+K−1)×(TW+K−1)。加载时,线程协作将这一更大区域的数据从全局内存复制到共享内存,之后块内所有线程的计算都只需访问共享内存。这种「加载一次、多次复用 」的模式,将全局内存访问量从 TH⋅TW⋅K2T_H \cdot T_W \cdot K^2TH⋅TW⋅K2 降至 (TH+K−1)(TW+K−1)(T_H + K - 1)(T_W + K - 1)(TH+K−1)(TW+K−1),对于 16×16 tile 和 3×3 滤波器,削减比例约为 256/324≈21%256 / 324 \approx 21\%256/324≈21% 的访存冗余。然而,这种方案引入了两个新的挑战:
第一,边界越界处理。 输入特征图的边缘像素没有完整的邻域。加载 tile 时,超出图像边界的区域需要填充 0(zero padding)或复制边缘像素。这要求加载循环中增加边界判断,或者使用 cudaBoundary 之类的硬件指令来简化判断逻辑。但边界判断本身会引入分支发散(branch divergence),因此在实践中,常见的做法是将边界填充提前在全局内存中完成------即在 kernel 启动前,先将输入特征图按 padding 尺寸扩展好,这样 kernel 内部的 load 循环就不再需要分支判断,代价是额外的内存开销。
第二,共享内存的银行冲突(bank conflict)。 共享内存被划分为 32 个 bank,每个 bank 宽度 4 字节。当同一 warp 中多个线程同时访问同一 bank 的不同地址时,硬件会将这些访问串行化 ,导致性能灾难。假设我们将 tile 按行存储在共享内存中,行宽为 TW+K−1=18T_W + K - 1 = 18TW+K−1=18(以 16×16 tile、3×3 滤波器为例),则第 0 行第 0 列的元素地址为 0,第 1 行第 0 列的元素地址为 18。由于 18 mod 32 = 18,两行的起始地址落在不同的 bank 上,因此不会产生冲突。但若 tile 行宽恰好是 32 的倍数(例如 TW+K−1=32T_W + K - 1 = 32TW+K−1=32),则相邻两行的相同列会映射到同一 bank,导致 2-way bank conflict 。解决方法是 padding 一行 :将共享内存声明为 [T_H + K - 1][T_W + K](每行多分配 1 个 float),使行距变为 33,则 33 mod 32 = 1,相邻行错开 bank,冲突即可消除。这一技巧虽小,却能提升共享内存的有效带宽 30%-100%,是高性能卷积 kernel 中几乎必用的招数。
从朴素到可用的第一版优化 kernel
综合以上讨论,一个使用共享内存 tile 的卷积 kernel 的骨架如下:
cuda
// 边界填充已在 host 端完成:输入尺寸为 (H+2*pad) x (W+2*pad),
// 因此 kernel 内无需边界判断,所有 tile 加载均可直接寻址。
__global__ void conv_shared_tile(const float* input, const float* filter,
float* output,
int H, int W, int C, int K) {
// 假设 block 尺寸 = TILE_H x TILE_W,每个线程负责一个输出像素
const int TILE_H = 16, TILE_W = 16;
// 共享内存 tile:多分配 4 字节以消除 bank conflict
__shared__ float tile[16 + K - 1][16 + K + 1][C]; // [H_tile][W_tile][C]
int tx = threadIdx.x, ty = threadIdx.y;
int out_x = blockIdx.x * TILE_W + tx;
int out_y = blockIdx.y * TILE_H + ty;
// 1. 协作加载:每个线程负责将输入像素从全局内存复制到共享内存
for (int c = 0; c < C; ++c) {
int in_x = out_x - K/2;
int in_y = out_y - K/2;
// 输入已预先 padding,此处无需边界判断
tile[ty][tx][c] = input[(in_y) * W * C + (in_x) * C + c];
}
__syncthreads(); // 确保所有线程完成加载
// 2. 计算:每个线程遍历滤波器和通道,从共享内存读取数据
float acc = 0.0f;
for (int ky = 0; ky < K; ++ky) {
for (int kx = 0; kx < K; ++kx) {
for (int c = 0; c < C; ++c) {
acc += tile[ty + ky][tx + kx][c] *
filter[(ky * K + kx) * C + c];
}
}
}
output[out_y * W + out_x] = acc;
}
边界填充说明:在实际调用此 kernel 前,host 端会先将输入特征图按 padding 尺寸扩展(边缘补零),因此 kernel 内部的加载循环无需分支判断。若不预先 padding,则需在加载循环中增加边界检查,但会引入分支发散。
这段代码的关键在于:协作加载阶段每个线程只读一次全局内存,而计算阶段的所有数据全部来自共享内存 。对于 3×3×64 的滤波器,每个线程的计算循环执行 3×3×64=5763 \times 3 \times 64 = 5763×3×64=576 次乘加,但全局内存读取仅 C=64C = 64C=64 次(每个通道一次),访存指令减少为原来的 1/91/91/9。这恰好将算术强度从 10 FLOP/Byte 提升至约 90 FLOP/Byte,一举跨入计算密集型区间。
然而,这个 kernel 仍有两个明显的短板:滤波器被每个线程重复从全局内存读取(C×K2C \times K^2C×K2 次),且每个线程只计算一个输出像素,线程间没有数据复用。前者可以通过将滤波器也加载到共享内存 (若滤波器尺寸不超过共享内存容量)或常量内存 (若同一 warp 内所有线程访问同一滤波器系数,常量缓存将广播而非重复加载)来解决。后者则需要引入寄存器级的数据复用------即每个线程计算多个相邻输出像素,这是进一步优化中值得探索的方向。
至此,我们已经完成了一次完整的优化迭代:从朴素的「每个输出像素一个线程、直接遍历全局内存」升级为「分块加载到共享内存、块内复用」,全局内存访问量降至原来的约 1/(THTW)1/(T_H T_W)1/(THTW) 级别。但正如上文所述,共享内存的引入并非没有代价------tile 尺寸的选择本质上是在算力利用率、占用率和边界开销之间寻求最佳平衡点。理解了这一权衡,就掌握了 GPU 上绝大多数卷积优化技巧的底层逻辑。下一步,我们将把注意力转向这个 kernel 中还未被充分利用的资源------寄存器和常量内存,看看如何在不增加共享内存压力的前提下,进一步削减访存开销。
常量内存存取权重
共享内存 tile 策略解决了输入数据的复用问题,但卷积核的访存路径仍值得审视。每个输出像素在遍历滤波器时需要读取 K2CK^2CK2C 个权重值,而在一个线程块内,所有线程在同一时刻访问的是同一组权重 。这种「一读多享」的访问模式,恰恰指向了 GPU 片上另一个被低估的存储单元------常量内存(constant memory)。
为什么小卷积核适合常量内存
常量内存是 GPU 上一块 64 KB 的只读缓存(以 NVIDIA A100 为例;不同架构具体大小可能略有差异),其硬件设计的核心特征是广播机制 :当一个线程束(warp)内的 32 个线程同时访问同一个常量地址 时,硬件会将这次访问整合为一次内存事务,数据以广播方式同时送达所有线程。这与共享内存形成鲜明对比------共享内存即使所有线程读同一地址,也需要按 bank 冲突规则处理,而常量内存的广播路径是独立且高效的。
现代 CNN 的卷积核尺寸高度集中:ResNet 系列几乎全部使用 3×3 卷积,VGG 使用 3×3 堆叠,Inception 则混合 1×1、3×3 与 5×5。这些小型滤波器的权重总量极为有限:
| 卷积核尺寸 | 输入通道 CinC_{in}Cin | 输出通道 CoutC_{out}Cout | 权重总量(float32) |
|---|---|---|---|
| 1×1 | 256 | 256 | 256 KB |
| 3×3 | 64 | 64 | 144 KB |
| 3×3 | 256 | 256 | 2.25 MB |
| 5×5 | 32 | 32 | 100 KB |
单个 3×3 卷积层(64→64 通道)的权重仅 144 KB ,而 ResNet-50 中绝大多数层的单层权重都在 1 MB 以下 。64 KB 的常量内存虽然装不下单层全部权重,但我们可以将权重按输出通道分块 ,逐块加载。更重要的是,同一时刻活跃的线程束只会访问当前正在计算的输出通道对应的权重 ,这个活跃子集通常只有 K2CinK^2C_{in}K2Cin 个浮点数------对于 3×3、64 输入通道的卷积,仅为 2,304 个元素(9 KB),完全容纳得下。
正是这种「核小、活跃子集更小」的特性,使得常量内存成为存放卷积权重的理想位置:
线程束执行流程(以 3×3 卷积为例)
─────────────────────────────────────
Warp 内 32 个线程 → 计算 32 个相邻输出像素
每个线程遍历滤波器权重 w[0..8](9 个值)
→ warp 内所有线程同时访问 w[0] → 常量缓存广播 1 次事务
→ warp 内所有线程同时访问 w[1] → 常量缓存广播 1 次事务
...
→ 9 次广播事务替代 32×9 = 288 次独立访问
广播机制:一个线程束只需要一次事务
将权重放入常量内存的收益,来自 GPU 内存事务的粒度差异。全局内存(global memory)的一次事务通常为 32 字节或 128 字节,当 warp 内 32 个线程分别读取不同的全局地址时,硬件需要将这些访问合并成尽量少的事务;若地址不连续,则可能产生多次事务,访存效率骤降。
常量内存则截然不同。它的缓存层次专门为单地址多读取 的场景优化过。当一个 warp 内 32 个线程同时读取常量地址 c[0] 时:
- 硬件行为:常量缓存将这次访问识别为「uniform access」,直接广播给所有线程
- 事务计数:1 次(而非 32 次)
- 延迟:与一次普通缓存命中相当(约 20-30 个周期)
这一点在直接卷积的访存模式中几乎是量身定做的匹配。回顾朴素 kernel 的内层循环:
cuda
// 每个线程遍历整个滤波器
for (int kc = 0; kc < C_in; kc++)
for (int kh = 0; kh < K; kh++)
for (int kw = 0; kw < K; kw++) {
float w = weight[kc * K * K + kh * K + kw]; // 同一时刻,所有线程读同一地址
acc += input[...] * w;
}
在任意一个循环迭代中,warp 内所有 32 个线程读取的 weight 地址是完全相同 的。若 weight 位于全局内存,这 32 个访问会被硬件合并为一个事务(或根据缓存行对齐情况分成少量事务),每次迭代都需要一次全局内存延迟;若 weight 位于常量内存,则每次迭代只需一次常量缓存广播,且由于权重在卷积计算中被连续遍历,常量缓存能保持极高的命中率,后续迭代直接命中缓存。
这种差异在卷积核越大时越显著。以 5×5 卷积为例,每个输出像素需遍历 25 个权重,对应 25 次广播事务;而以全局内存方式访问时,每次迭代都面临数百周期的访存延迟。常量内存将权重访问的延迟从「全局内存级别」压缩到「片上缓存级别」,且不占用共享内存的宝贵容量。
权重加载与 Kernel 实现
将权重放入常量内存需要在 host 端完成一次显式的拷贝。由于常量内存是只读 的,且必须在 kernel 启动前完成数据写入,因此使用 cudaMemcpyToSymbol 将权重从 host 端复制到设备端的常量符号中:
cuda
// 设备端:声明常量内存符号
__constant__ float c_weight[MAX_WEIGHT_SIZE]; // MAX_WEIGHT_SIZE = 64KB / 4 = 16384
// Host 端:将训练好的权重拷贝到常量内存
cudaMemcpyToSymbol(c_weight, h_weight, weight_size_bytes, 0, cudaMemcpyHostToDevice);
在 kernel 内部,线程遍历滤波器时直接读取 c_weight,其余逻辑与朴素直接卷积完全一致。对于 3×3 卷积且输入通道为 64 的卷积层,权重总量为 3×3×64×64=36,8643 \times 3 \times 64 \times 64 = 36{,}8643×3×64×64=36,864 个 float,恰好 144 KB------超出 64 KB 常量内存容量。此时的处理策略是按输出通道分块:每次将 16 个输出通道 对应的权重(3×3×64×16=9,2163 \times 3 \times 64 \times 16 = 9{,}2163×3×64×16=9,216 个 float,即 36 KB)载入常量内存,计算这 16 个通道的全部输出后,再载入下一批。由于同一时刻 warp 内的线程只会访问当前批次权重,这种分块循环不会损失广播效率:
cuda
// 常量内存权重分块循环
// 关键:每个线程块负责计算同一批输出通道的像素,
// 确保 warp 内所有线程访问的是相同的权重地址,从而利用广播机制。
for (int co_block = 0; co_block < C_out; co_block += 16) {
cudaMemcpyToSymbol(c_weight, h_weight + co_block * K*K*C_in,
16 * K*K*C_in * sizeof(float), 0, cudaMemcpyHostToDevice);
// 启动 kernel 计算 co_block ~ co_block+15 的输出通道
// 网格配置:blockIdx.x 对应输出通道块,blockIdx.y/z 对应空间 tile
direct_conv_kernel<<<grid, block>>>(...);
}
广播机制的有效性 :在分块策略下,每个线程块被分配计算同一批输出通道的输出像素。这意味着 warp 内所有线程在遍历滤波器时,访问的权重地址完全相同------无论它们各自计算哪个空间位置的输出像素。因此,常量内存的广播机制在分块场景下依然完全有效。
与共享内存的互补分工
需要澄清的是,常量内存与共享内存的优化目标并不冲突,而是互补 的:共享内存负责输入数据 的 tile 复用,解决的是「每个输入元素被重复加载 K2K^2K2 次」的问题;常量内存负责权重数据的广播读取,解决的是「每个 warp 内 32 个线程重复发起相同地址访问」的问题。两者作用于不同的数据流,可以无缝叠加在同一 kernel 中------这也是大多数工业级卷积实现的标准做法。
常量内存的适用边界同样清晰:当卷积核尺寸增大(如 7×7 或更大)导致权重总量显著膨胀时,分块循环的频率会变得过高,此时将权重放入全局内存并依赖 L2 缓存可能更为实际。但对于现代 CNN 主流的 1×1 和 3×3 卷积,常量内存广播是提升权重访存效率的零成本优化 ------它不改变 kernel 的计算逻辑,不增加共享内存压力,只需在 host 端多一行 cudaMemcpyToSymbol。
至此,我们已经完成了从 im2col 的 GEMM 路线到直接卷积的访存优化的完整梳理:共享内存解决输入复用,常量内存解决权重广播。但还有一个隐蔽的性能陷阱贯穿了整条优化路径------bank conflict。当线程束内的线程访问共享内存中不同地址时,如果这些地址映射到同一个 bank,硬件会将一次访问串行化为多次事务。这个问题在共享内存 tile 一节中已初步涉及,但在多通道和边界处理的场景下会更加复杂,我们将在下一节中结合边界处理与多通道展开讨论。
边界处理与多通道
前几节的优化策略围绕着一个核心假设:输入特征图的每个位置都能与滤波器进行完整的内积运算。然而现实中的卷积几乎总是伴随两个"例外"------边界像素 无法凑齐完整的 K2K^2K2 邻域,而多通道的累加顺序直接影响访存效率。这两件事看似琐碎,却是从朴素的单通道卷积迈向可用实现的必经之路。本节首先讨论多通道循环的访存优化,然后引入边界掩码来消除分支发散,最后探讨 stride 与 dilation 对寻址的影响,以及这些技术在共享内存 tile 中的集成方式。
循环通道:从"一个内积"到"一组内积"
回顾 2D 卷积的定义,单个输出像素的计算是滤波器与输入局部窗口的逐元素乘累加。但真实场景中,输入特征图几乎都是多通道的------例如前文提到的 224×224×64 中间特征图,其通道数 CCC 达到 64。此时单个输出像素的计算不再是 K2K^2K2 次乘加,而是 K2CK^2 CK2C 次。
c
// 多通道卷积:单输出像素的完整计算
float acc = 0.0f;
for (int c = 0; c < C; c++) { // 外层循环:遍历所有输入通道
for (int ki = 0; ki < K; ki++) { // 滤波器行
for (int kj = 0; kj < K; kj++) { // 滤波器列
int in_y = out_y * stride + ki - pad;
int in_x = out_x * stride + kj - pad;
// 边界检查:越界视为 0(zero padding)
if (in_y >= 0 && in_y < H && in_x >= 0 && in_x < W) {
acc += input[c * H * W + in_y * W + in_x]
* weight[c * K * K + ki * K + kj];
}
}
}
}
output[out_y * W_out + out_x] = acc;
这段代码揭示了多通道卷积的两个关键结构:
第一,通道循环是天然并行的。 每个输出通道 MMM 的计算完全独立于其他输出通道,这提供了第二层并行维度------第 1 节讨论的输出像素级并行是"空间并行",而通道级并行是"特征并行"。在 CUDA 中,这种双重并行可以映射为线程块的三维组织:blockIdx.x 对应输出通道,blockIdx.y 对应 tile 行,blockIdx.z 对应 tile 列。每个线程块加载一个 tile 的输入数据(所有通道),然后计算对应的输出像素。这种映射方式将通道与空间两个并行维度同时展现在硬件面前,让 GPU 的调度器有更大的自由度来隐藏访存延迟。
第二,累加顺序影响数值精度与访存模式。 上述代码采用"先通道、后空间"的循环顺序,即对每个通道先累加完 K2K^2K2 个元素,再切换到下一个通道。这种顺序保证了全局内存访问的连续性问题 :当 ki 和 kj 变化时,input 地址的步长恰好是 W 和 1,即同一通道内的访问是连续的。如果调换循环顺序(先遍历空间位置、再遍历通道),则每次切换通道都会导致 c * H * W 的大跨度跳转,访存效率急剧下降。CUDA 编程指南中反复强调的**合并访问(coalesced access)**原则,在此处体现为:相邻线程应访问相邻内存地址。因此,通道循环应放在最外层,让线程束内的 32 个线程在空间维度上相邻,从而连续访问同一通道内的数据。
边界掩码:一种零分支的优雅处理
上一个代码片段中的 if 边界检查虽然直观,但在 GPU 上却是性能杀手。原因在于线程束发散:当一个 warp 中的 32 个线程处理 tile 边缘像素时,部分线程越界而部分不越界,硬件被迫分别执行两个分支路径,效率减半。更糟糕的是,这种发散在每个 tile 的四个边缘都会发生,累积开销在深层网络中不可忽视。
边界掩码(boundary mask)的思路是:将边界判断从运行时移到编译时------预先为滤波器每个位置计算一个布尔掩码,指示该位置是否在输入范围内。
c
// 预计算边界掩码(在 host 端完成)
// mask[c][ki][kj] = 1 表示位置 (out_y, out_x) 的邻域内该偏移有效
// 实际实现中,掩码只需按 (ki, kj) 计算,与通道 c 无关
__constant__ float mask[K * K]; // 常量内存存掩码
// kernel 内部:掩码乘法替代分支
for (int ki = 0; ki < K; ki++) {
for (int kj = 0; kj < K; kj++) {
int in_y = out_y * stride + ki - pad;
int in_x = out_x * stride + kj - pad;
bool valid = (in_y >= 0 && in_y < H && in_x >= 0 && in_x < W);
float in_val = valid ? input[c * H * W + in_y * W + in_x] : 0.0f;
acc += in_val * weight[c * K * K + ki * K + kj] * mask[ki * K + kj];
}
}
掩码的价值不仅在于消除分支------它还能与 zero padding 数学上等价。当 in_val 被置为 0 时,无论权重和掩码为何,乘积恒为 0。这意味可以在常量内存中保存掩码权重 w′=w×maskw' = w \times maskw′=w×mask,从而完全移除运行时判断:
acc+=input_valid×wc,ki,kj′acc += \text{input\valid} \times w'{c,k_i,k_j}acc+=input_valid×wc,ki,kj′
这种预计算方法将边界处理的开销从"每个输出像素的多次分支判断"压缩为"一次乘 0 运算"。在共享内存 tile 策略中,掩码还可以与 halo 加载结合------只加载有效区域的输入到 tile,无效区域预填 0,这样 tile 内的内积运算不再需要任何边界检查。具体来说,在协作加载阶段,当某个线程负责加载的坐标超出输入边界时,它直接向共享内存写入 0 而非从全局内存读取;计算阶段则完全无需区分边界内外。这种「边界填充内置于加载阶段」的做法,将边界处理的成本限制在每次 tile 加载的少数几个线程上,而不会影响计算阶段的高效流水。
步长与空洞:从"滑窗"到"跳窗"
padding 引入越界问题,而 stride 和 dilation (空洞卷积)则让"窗口"本身变得不再规则。stride s>1s > 1s>1 意味着输出像素的邻域在输入上间隔 sss 个像素选取;dilation d>1d > 1d>1 则让滤波器在每个维度上跳过 d−1d-1d−1 个输入像素。
c
// 支持 stride 和 dilation 的寻址公式
int in_y = out_y * stride + ki * dilation - pad;
int in_x = out_x * stride + kj * dilation - pad;
从并行计算的角度看,stride 和 dilation 并不引入新的并行维度------它们只改变寻址的算术运算 。一个值得注意的观察是,当 s>1s > 1s>1 时,输出特征图尺寸缩小为 ⌊(H+2p−d(K−1)−1)/s⌋+1\lfloor (H + 2p - d(K-1) - 1)/s \rfloor + 1⌊(H+2p−d(K−1)−1)/s⌋+1,这直接导致输出 tile 变小,线程块内的并行度下降。在极端情况下(如 s=2s = 2s=2 的步进卷积),输出像素数仅为输入的四分之一,此时不妨思考一个策略反转:是否可以使用 stride 卷积的等效操作来减少计算量 ?这正是前文埋下的伏笔------im2col 天然支持 stride 和 dilation,因为窗口的展开规则本身就包含了这些参数。
回顾本节开头的问题:多通道循环与边界处理,本质上是卷积实现的"最后一公里"。通道循环决定了访存模式能否命中 GPU 的合并访问特性;边界掩码则决定了 kernel 在面对现实输入时能否避免线程束发散。两者叠加,奠定了后续所有优化------无论是共享内存 tile 还是 im2col + cuBLAS------的正确性基础。