利用CUDA并行加速卷积运算(基础版本)

本文面向有一定编程基础的开发者,系统介绍卷积运算的数学原理以及如何用 CUDA 编写高效的 GPU 并行卷积核函数。


一、卷积运算是什么?

卷积的本质是用一个小窗口(过滤器)在输入数据上滑动,对每个局部区域做加权求和

以图像处理为例,对一个 5 \times 5 的输入矩阵,使用 3 \times 3 的过滤器进行卷积,计算过程如下

过滤器在输入上逐步滑动,每滑动一步就输出一个值,最终得到一张新的"特征图"(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 实际影响有多大?

控制发散只出现在图像边缘,其严重程度与以下因素成反比:

因素 影响
图像尺寸越大 边缘占比越低,影响越小
卷积核越小 受影响的边缘范围越窄

以常见场景为例:1920 \times 1080 图像 + 3 \times 3 卷积核,边缘像素约占总量的 0.3%,控制发散的影响可忽略不计。

结论:对于大图像 + 小卷积核(深度学习典型场景),无需针对控制发散专门优化。只有在处理小图像或极大卷积核时,才值得考虑针对性方案。


四、进一步优化方向

本文介绍的是基础实现。实际生产环境中,还有以下优化手段:

4.1 共享内存 Tiling

全局内存(Global Memory)延迟高达数百个时钟周期。相邻线程会重复读取输入图像的相同区域,可以将输入 Tile 预加载到共享内存(Shared Memory,延迟约为全局内存的 1/30),大幅减少全局内存访问次数。

全局内存访问模式(基础版)    共享内存 Tiling(优化版)
每个线程独立读取 (2r+1)²    每个 Block 协作加载一块 Tile
个全局内存位置               到共享内存,再从共享内存读取
→ 大量重复读取               → 重复读取命中缓存,带宽压力大降

4.2 常量内存缓存卷积核

卷积核 F 在整个计算过程中只读不写,且所有线程访问完全相同的数据,非常适合存入常量内存(Constant Memory)

__constant__ float d_F[MAX_KERNEL_SIZE];   // 存入常量内存

常量内存有专用缓存,广播读取效率极高。

4.3 线程粗化(Thread Coarsening)

让单个线程负责多个输出像素,减少线程调度开销和 Warp 同步消耗,在某些架构上可提升吞吐量。


参考延伸阅读:NVIDIA CUDA Programming Guide、Deep Learning by Ian Goodfellow(第 9 章:卷积网络)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容