本文面向有一定编程基础的开发者,系统介绍卷积运算的数学原理以及如何用 CUDA 编写高效的 GPU 并行卷积核函数。
一、卷积运算是什么?
卷积的本质是用一个小窗口(过滤器)在输入数据上滑动,对每个局部区域做加权求和。
以图像处理为例,对一个 的输入矩阵,使用
的过滤器进行卷积,计算过程如下

过滤器在输入上逐步滑动,每滑动一步就输出一个值,最终得到一张新的"特征图"(feature map)。
二、CUDA 并行实现 2D 卷积
2.1 为什么用 GPU?
卷积中每个输出像素的计算相互独立,天然适合 GPU 的大规模并行架构。GPU 可以让数千个线程同时工作,每个线程负责一个输出像素的全部计算。
2.2 线程映射策略
CUDA 采用二维线程网格对应二维输出图像:
线程网格(Grid)
┌─────────────────────────────────────┐
│ Block(0,0) Block(1,0) Block(2,0) │
│ Block(0,1) Block(1,1) Block(2,1) │ ←── 每个 Block 内有 N×N 个线程
│ Block(0,2) Block(1,2) Block(2,2) │
└─────────────────────────────────────┘
↕ 一一对应 ↕
输出图像像素矩阵
每个线程的输出像素坐标由以下公式计算:
int outCol = blockIdx.x * blockDim.x + threadIdx.x; // 列坐标
int outRow = blockIdx.y * blockDim.y + threadIdx.y; // 行坐标
2.3 核函数完整实现
/**
* 2D 卷积 CUDA 核函数
* @param N 输入图像(行主序展开的一维数组)
* @param F 卷积核((2r+1)×(2r+1),行主序展开)
* @param P 输出图像
* @param r 卷积核半径(核大小 = 2r+1)
* @param width 图像宽度
* @param height 图像高度
*/
__global__ void convolution_2D_kernel(
float *N,
float *F,
float *P,
int r,
int width,
int height
) {
// 1. 计算当前线程负责的输出像素坐标
int outCol = blockIdx.x * blockDim.x + threadIdx.x;
int outRow = blockIdx.y * blockDim.y + threadIdx.y;
// 超出图像范围的线程直接退出
if (outCol >= width || outRow >= height) return;
// 2. 初始化累加器
float pValue = 0.0f;
// 3. 遍历卷积核的每个元素(大小为 (2r+1) × (2r+1))
int kernelSize = 2 * r + 1;
for (int fRow = 0; fRow < kernelSize; fRow++) {
for (int fCol = 0; fCol < kernelSize; fCol++) {
// 计算对应的输入像素坐标(以输出像素为中心向外扩展)
int inRow = outRow - r + fRow;
int inCol = outCol - r + fCol;
// 4. 边界检查:超出图像范围的位置视为 0(zero-padding 语义)
if (inRow >= 0 && inRow < height && inCol >= 0 && inCol < width) {
pValue += F[fRow * kernelSize + fCol] // 卷积核元素(正确的线性索引)
* N[inRow * width + inCol]; // 输入像素
}
}
}
// 5. 写入输出图像
P[outRow * width + outCol] = pValue;
}
注意:卷积核以
float *传入,必须用F[fRow * kernelSize + fCol]进行线性寻址,不能写F[fRow][fCol](后者在float *上无法编译)。
2.4 Host 端调用
void launch_convolution(
float *h_N, float *h_F, float *h_P,
int r, int width, int height
) {
int kernelSize = 2 * r + 1;
size_t imgBytes = width * height * sizeof(float);
size_t kernelBytes = kernelSize * kernelSize * sizeof(float);
// 分配 GPU 内存
float *d_N, *d_F, *d_P;
cudaMalloc(&d_N, imgBytes);
cudaMalloc(&d_F, kernelBytes);
cudaMalloc(&d_P, imgBytes);
// 拷贝数据到 GPU
cudaMemcpy(d_N, h_N, imgBytes, cudaMemcpyHostToDevice);
cudaMemcpy(d_F, h_F, kernelBytes, cudaMemcpyHostToDevice);
// 配置线程块与网格(推荐 16×16,共 256 线程/Block)
dim3 blockDim(16, 16);
dim3 gridDim(
(width + blockDim.x - 1) / blockDim.x,
(height + blockDim.y - 1) / blockDim.y
);
// 启动核函数
convolution_2D_kernel<<<gridDim, blockDim>>>(d_N, d_F, d_P, r, width, height);
// 拷回结果
cudaMemcpy(h_P, d_P, imgBytes, cudaMemcpyDeviceToHost);
cudaFree(d_N);
cudaFree(d_F);
cudaFree(d_P);
}
三、控制发散
3.1 什么是控制发散?
GPU 以 Warp(32 个线程)为单位调度执行。同一 Warp 内的线程必须执行相同的指令分支,否则不满足条件的线程会被挂起等待,称为控制发散(Control Divergence)。
在卷积中,边缘像素的输入窗口超出图像范围,触发 if (inRow >= 0 && ...) 分支的跳过,而中心线程正常执行,导致同一 Warp 内分支不一致:
同一 Warp 内(32 个线程)
┌──────────────────────────────────────┐
│ 线程 0-3:边缘像素,部分跳过 if 分支 │ ← 闲置等待
│ 线程 4-31:中心像素,正常执行全部计算 │ ← 正常工作
└──────────────────────────────────────┘
↓ 实际效果
Warp 效率下降(部分线程空转)
3.2 实际影响有多大?
控制发散只出现在图像边缘,其严重程度与以下因素成反比:
| 因素 | 影响 |
|---|---|
| 图像尺寸越大 | 边缘占比越低,影响越小 |
| 卷积核越小 | 受影响的边缘范围越窄 |
以常见场景为例: 图像 +
卷积核,边缘像素约占总量的 0.3%,控制发散的影响可忽略不计。
结论:对于大图像 + 小卷积核(深度学习典型场景),无需针对控制发散专门优化。只有在处理小图像或极大卷积核时,才值得考虑针对性方案。
四、进一步优化方向
本文介绍的是基础实现。实际生产环境中,还有以下优化手段:
4.1 共享内存 Tiling
全局内存(Global Memory)延迟高达数百个时钟周期。相邻线程会重复读取输入图像的相同区域,可以将输入 Tile 预加载到共享内存(Shared Memory,延迟约为全局内存的 1/30),大幅减少全局内存访问次数。
全局内存访问模式(基础版) 共享内存 Tiling(优化版)
每个线程独立读取 (2r+1)² 每个 Block 协作加载一块 Tile
个全局内存位置 到共享内存,再从共享内存读取
→ 大量重复读取 → 重复读取命中缓存,带宽压力大降
4.2 常量内存缓存卷积核
卷积核 在整个计算过程中只读不写,且所有线程访问完全相同的数据,非常适合存入常量内存(Constant Memory):
__constant__ float d_F[MAX_KERNEL_SIZE]; // 存入常量内存
常量内存有专用缓存,广播读取效率极高。
4.3 线程粗化(Thread Coarsening)
让单个线程负责多个输出像素,减少线程调度开销和 Warp 同步消耗,在某些架构上可提升吞吐量。
参考延伸阅读:NVIDIA CUDA Programming Guide、Deep Learning by Ian Goodfellow(第 9 章:卷积网络)