利用CUDA并行加速卷積運(yùn)算(基礎(chǔ)版本)

本文面向有一定編程基礎(chǔ)的開發(fā)者,系統(tǒng)介紹卷積運(yùn)算的數(shù)學(xué)原理以及如何用 CUDA 編寫高效的 GPU 并行卷積核函數(shù)。


一、卷積運(yùn)算是什么?

卷積的本質(zhì)是用一個小窗口(過濾器)在輸入數(shù)據(jù)上滑動,對每個局部區(qū)域做加權(quán)求和。

以圖像處理為例,對一個 5 \times 5 的輸入矩陣,使用 3 \times 3 的過濾器進(jìn)行卷積,計算過程如下

過濾器在輸入上逐步滑動,每滑動一步就輸出一個值,最終得到一張新的"特征圖"(feature map)。

二、CUDA 并行實現(xiàn) 2D 卷積

2.1 為什么用 GPU?

卷積中每個輸出像素的計算相互獨(dú)立,天然適合 GPU 的大規(guī)模并行架構(gòu)。GPU 可以讓數(shù)千個線程同時工作,每個線程負(fù)責(zé)一個輸出像素的全部計算。

2.2 線程映射策略

CUDA 采用二維線程網(wǎng)格對應(yīng)二維輸出圖像:

線程網(wǎng)格(Grid)
┌─────────────────────────────────────┐
│  Block(0,0)  Block(1,0)  Block(2,0) │
│  Block(0,1)  Block(1,1)  Block(2,1) │  ←── 每個 Block 內(nèi)有 N×N 個線程
│  Block(0,2)  Block(1,2)  Block(2,2) │
└─────────────────────────────────────┘
         ?  一一對應(yīng)  ?
       輸出圖像像素矩陣

每個線程的輸出像素坐標(biāo)由以下公式計算:

int outCol = blockIdx.x * blockDim.x + threadIdx.x;   // 列坐標(biāo)
int outRow = blockIdx.y * blockDim.y + threadIdx.y;   // 行坐標(biāo)

2.3 核函數(shù)完整實現(xiàn)

/**
 * 2D 卷積 CUDA 核函數(shù)
 * @param N      輸入圖像(行主序展開的一維數(shù)組)
 * @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. 計算當(dāng)前線程負(fù)責(zé)的輸出像素坐標(biāo)
    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++) {
            // 計算對應(yīng)的輸入像素坐標(biāo)(以輸出像素為中心向外擴(kuò)展)
            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] 進(jìn)行線性尋址,不能寫 F[fRow][fCol](后者在 float * 上無法編譯)。

2.4 Host 端調(diào)用

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 內(nèi)存
    float *d_N, *d_F, *d_P;
    cudaMalloc(&d_N, imgBytes);
    cudaMalloc(&d_F, kernelBytes);
    cudaMalloc(&d_P, imgBytes);

    // 拷貝數(shù)據(jù)到 GPU
    cudaMemcpy(d_N, h_N, imgBytes,    cudaMemcpyHostToDevice);
    cudaMemcpy(d_F, h_F, kernelBytes, cudaMemcpyHostToDevice);

    // 配置線程塊與網(wǎng)格(推薦 16×16,共 256 線程/Block)
    dim3 blockDim(16, 16);
    dim3 gridDim(
        (width  + blockDim.x - 1) / blockDim.x,
        (height + blockDim.y - 1) / blockDim.y
    );

    // 啟動核函數(shù)
    convolution_2D_kernel<<<gridDim, blockDim>>>(d_N, d_F, d_P, r, width, height);

    // 拷回結(jié)果
    cudaMemcpy(h_P, d_P, imgBytes, cudaMemcpyDeviceToHost);

    cudaFree(d_N);
    cudaFree(d_F);
    cudaFree(d_P);
}

三、控制發(fā)散

3.1 什么是控制發(fā)散?

GPU 以 Warp(32 個線程)為單位調(diào)度執(zhí)行。同一 Warp 內(nèi)的線程必須執(zhí)行相同的指令分支,否則不滿足條件的線程會被掛起等待,稱為控制發(fā)散(Control Divergence)。

在卷積中,邊緣像素的輸入窗口超出圖像范圍,觸發(fā) if (inRow >= 0 && ...) 分支的跳過,而中心線程正常執(zhí)行,導(dǎo)致同一 Warp 內(nèi)分支不一致:

同一 Warp 內(nèi)(32 個線程)
┌──────────────────────────────────────┐
│ 線程 0-3:邊緣像素,部分跳過 if 分支  │  ← 閑置等待
│ 線程 4-31:中心像素,正常執(zhí)行全部計算 │  ← 正常工作
└──────────────────────────────────────┘
            ↓ 實際效果
   Warp 效率下降(部分線程空轉(zhuǎn))

3.2 實際影響有多大?

控制發(fā)散只出現(xiàn)在圖像邊緣,其嚴(yán)重程度與以下因素成反比:

因素 影響
圖像尺寸越大 邊緣占比越低,影響越小
卷積核越小 受影響的邊緣范圍越窄

以常見場景為例:1920 \times 1080 圖像 + 3 \times 3 卷積核,邊緣像素約占總量的 0.3%,控制發(fā)散的影響可忽略不計。

結(jié)論:對于大圖像 + 小卷積核(深度學(xué)習(xí)典型場景),無需針對控制發(fā)散專門優(yōu)化。只有在處理小圖像或極大卷積核時,才值得考慮針對性方案。


四、進(jìn)一步優(yōu)化方向

本文介紹的是基礎(chǔ)實現(xiàn)。實際生產(chǎn)環(huán)境中,還有以下優(yōu)化手段:

4.1 共享內(nèi)存 Tiling

全局內(nèi)存(Global Memory)延遲高達(dá)數(shù)百個時鐘周期。相鄰線程會重復(fù)讀取輸入圖像的相同區(qū)域,可以將輸入 Tile 預(yù)加載到共享內(nèi)存(Shared Memory,延遲約為全局內(nèi)存的 1/30),大幅減少全局內(nèi)存訪問次數(shù)。

全局內(nèi)存訪問模式(基礎(chǔ)版)    共享內(nèi)存 Tiling(優(yōu)化版)
每個線程獨(dú)立讀取 (2r+1)2    每個 Block 協(xié)作加載一塊 Tile
個全局內(nèi)存位置               到共享內(nèi)存,再從共享內(nèi)存讀取
→ 大量重復(fù)讀取               → 重復(fù)讀取命中緩存,帶寬壓力大降

4.2 常量內(nèi)存緩存卷積核

卷積核 F 在整個計算過程中只讀不寫,且所有線程訪問完全相同的數(shù)據(jù),非常適合存入常量內(nèi)存(Constant Memory)

__constant__ float d_F[MAX_KERNEL_SIZE];   // 存入常量內(nèi)存

常量內(nèi)存有專用緩存,廣播讀取效率極高。

4.3 線程粗化(Thread Coarsening)

讓單個線程負(fù)責(zé)多個輸出像素,減少線程調(diào)度開銷和 Warp 同步消耗,在某些架構(gòu)上可提升吞吐量。


參考延伸閱讀:NVIDIA CUDA Programming Guide、Deep Learning by Ian Goodfellow(第 9 章:卷積網(wǎng)絡(luò))

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容