GPU Convolutional Layer:CUDA Kernel 與 GEMM 化

重點總覽 (Overview)

主題 直接 CUDA Kernel (§16.3) GEMM 化 / im2col (§16.4)
核心想法 每個 thread 算一個 output pixel,平行展開 N·M·H_out·W_out 把 conv 攤平成一次大型矩陣乘法,交給 cuBLAS GEMM
平行度來源 4 層「easy」迴圈 n, m, h, w 矩陣乘法本身的大規模平行
Thread organization 2D block TILE_WIDTH×TILE_WIDTH;3D grid (M, T, N) 1D block;unroll_KernelC·H_out·W_out threads
主要瓶頸 global memory bandwidth-bound unroll 寫+讀放大記憶體流量,降低 arithmetic intensity
關鍵指標 parallel iterations = N·M·H_out·W_out expansion ratio → K·K
優化方向 constant memory + shared-memory tiling(同 Ch.7) 用 1 個 buffer 逐 sample 重用;cuDNN 改為 on-chip lazy materialization
Important

兩種做法殊途同歸:CNN 訓練/推論的 conv layer 既 compute-intensive 又高度平行,計算型態本質上「像矩陣乘法」。§16.3 直接寫 kernel,§16.4 索性真的轉成 GEMM 借力高度優化的函式庫。


卷積層的可平行性 (Parallelism in the Convolutional Layer)

回顧 16-Deep-Learning/02-Convolutional-Neural-Networks-Layers 的 minibatch forward 程式碼 (Fig. 16.12) 共有 7 層迴圈:

for n          // minibatch sample      ┐
  for m        // output feature map    │  4 層 "easy" parallel
    for h      // output row            │  iterations = N·M·H_out·W_out
      for w    // output col            ┘
        for c      // input channel     ┐  3 層需 atomic 累加
          for p    // filter row        │  (read-modify-write 同一個 Y)
            for q  // filter col        ┘  → 預設保持序列
迴圈層 是否平行 原因
n, m, h, w easy parallel 各 iteration 寫不同 Y[n,m,h,w],互不干擾
c, p, q 需 atomic 多 iteration 累加進同一個 Y element(race condition)
Warning

MH_outW_out 都很小(例如網路末端 feature map 已縮到 1×1),easy parallelism 不足時,才考慮把 c/p/q 也平行化並付出 atomic 成本。


直接 CUDA 推論 Kernel (Direct CUDA Inference Kernel)

Thread → data 映射設計

grid (M, T, N),block (TILE_WIDTH, TILE_WIDTH, 1)

blockIdx.x = m  ──► 哪一張 output feature map
blockIdx.z = n  ──► 哪一個 minibatch sample
blockIdx.y      ──► row-major 線性化的 tile 編號

  一張 output feature map (W_grid=2, H_grid=2):
   ┌─────────┬─────────┐
   │ tile0,0 │ tile0,1 │   blockIdx.y = 0,1,2,3
   │ (by=0)  │ (by=1)  │   (row-major)
   ├─────────┼─────────┤
   │ tile1,0 │ tile1,1 │   tile 內由 threadIdx.x/y
   │ (by=2)  │ (by=3)  │   定位 16×16 pixel
   └─────────┴─────────┘

還原 tile 座標:
  vertical_tile   = blockIdx.y / W_grid
  horizontal_tile = blockIdx.y % W_grid

Host code (Fig. 16.13)

#define TILE_WIDTH 16
W_grid = W_out / TILE_WIDTH;     // horizontal tiles per output map
H_grid = H_out / TILE_WIDTH;     // vertical   tiles per output map
int T  = H_grid * W_grid;        // tiles per map, linearized into grid.y
dim3 blockDim(TILE_WIDTH, TILE_WIDTH, 1);
dim3 gridDim(M, T, N);
ConvLayerForward_Kernel<<<gridDim, blockDim>>>(/* C, W_grid, K, X, W, Y */);

Kernel (Fig. 16.15)

__global__ void
ConvLayerForward_Kernel(int C, int W_grid, int K,
                        float* X, float* W, float* Y) {
    int m = blockIdx.x;                                        // output feature map
    int h = (blockIdx.y / W_grid) * TILE_WIDTH + threadIdx.y;  // output row
    int w = (blockIdx.y % W_grid) * TILE_WIDTH + threadIdx.x;  // output col
    int n = blockIdx.z;                                        // minibatch sample
    float acc = 0.0f;
    for (int c = 0; c < C; c++)        // sum over all input channels
        for (int p = 0; p < K; p++)    // KxK filter
            for (int q = 0; q < K; q++)
                acc += X[n, c, h + p, w + q] * W[m, c, p, q];  // row-major linearize!
    Y[n, m, h, w] = acc;
}
Warning

程式中 X[n,c,h+p,w+q]W[m,c,p,q]Y[n,m,h,w]多維索引偽碼,實作時須依 row-major 手動線性化(見 03-Multidimensional-Grids-And-Data/02-Mapping-Threads-to-Multidimensional-Data)。同理 W_out/TILE_WIDTH 假設可整除,否則需 boundary check。

效能定位

Important

此 kernel 平行度高但 global memory bandwidth-bound——和 基本 convolution kernel 完全相同的問題:每次 MAC 都從 global memory 重新讀 X 與 W。

解法(書中留作習題,原理見 Ch.7):

  • Constant memory 快取 filter bank W(小且全 block 共用)。
  • Shared-memory tiling 重用 input patch(相鄰 output pixel 的 patch 大量重疊)。

GEMM 化:im2col Unrolling (Formulating Convolution as GEMM)

中心思想(Chellapilla et al., 2006):展開並複製 input pixel,讓計算某一 output pixel 所需的所有輸入剛好排成 unrolled 矩陣的一整欄 (column),於是整個 forward 變成一次大矩陣乘法,丟給 cuBLAS GEMM。

三個矩陣的形狀

矩陣 維度 是否複製資料 說明
Filter matrix W M × (C·K·K) 無複製 每列 = 一張 output map 所需全部權重,僅是 filter bank 的 row-major 串接
Unrolled input X_unroll (C·K·K) × (H_out·W_out) 有複製 每欄 = 算一個 output pixel 所需的全部輸入
Output Y M × (H_out·W_out) 每列 = 一張完整 output feature map(已 row-major,可直接餵下一層)
範例 (Fig. 16.16): C=3, 每張 3×3;M=2,每張 output 2×2;filter 2×2

  filter matrix        ×     X_unroll           =    Y
  (2 × 12)                  (12 × 8...實為 4)        (2 × 4)
 ┌────────────┐         ┌──────────────┐        ┌──────────┐
 │ map0 weights│        │col=各 output │        │ out map0 │
 │ map1 weights│        │  pixel 的    │   →    │ out map1 │
 └────────────┘         │  輸入 patch  │        └──────────┘
                        └──────────────┘
 一欄展開示意 (output (0,0)):
   X 三張 map 的 patch 串接 → (1,2,1,1, 0,2,0,3, 1,2,0,1)ᵀ
   filter 串接             → (1,1,2,2, 1,1,1,1, 0,1,1,0)
   內積 = 14 = Y[0,0,0]

Expansion ratio(膨脹比)

由於 convolution 的 patch 彼此重疊,input pixel 被重複多次:

3×3 input map 中:
  4 個角 pixel  → 各用 1 次  (4·1)
  4 個邊 pixel  → 各用 2 次  (2·4)
  1 個中心pixel → 用 4 次    (4·1)
  展開後 = 4 + 8 + 4 = 16 個,原本 9 個 → ratio = 16/9 ≈ 1.8

Unroll CUDA kernel (Fig. 16.18)

設計:每個 thread 從一張 input map 蒐集 K·K 個元素,填入 unrolled 矩陣某欄的一段;總 thread 數 = C · H_out · W_out,用 1D block。

__global__ void
unroll_Kernel(int C, int H, int W, int K, float* X, float* X_unroll) {
    int t = blockIdx.x * blockDim.x + threadIdx.x;
    int H_out = H - K + 1, W_out = W - K + 1;
    int W_unroll = H_out * W_out;            // width of unrolled matrix
    if (t < C * W_unroll) {
        int c       = t / W_unroll;          // which input channel
        int w_unroll= t % W_unroll;          // column = linearized output pixel
        int h_out   = w_unroll / W_out;
        int w_out   = w_unroll % W_out;
        int w_base  = c * K * K;             // section start row for channel c
        for (int p = 0; p < K; p++)
            for (int q = 0; q < K; q++) {
                int h_unroll = w_base + p*K + q;          // row in unrolled matrix
                X_unroll[h_unroll, w_unroll] = X[c, h_out + p, w_out + q];
            }
    }
}
Loop interchange 換來 coalescing

相較序列版 (Fig. 16.17),CUDA 版把原本最內層的 h/w 迴圈外提成 thread 平行維度(loop transformation)。結果:相鄰 thread(相鄰 w_unroll)寫入 X_unroll 同一列的相鄰位置 → coalesced write(見 06-Performance-Considerations/01-Memory-Coalescing)。

執行流程與記憶體取捨

優點 (Pros) 缺點 (Cons)
借力高度優化的 cuBLAS GEMM 複製輸入最多 K·K 倍,記憶體可能爆量
GEMM 在 GPU 上 FLOP/byte 高,矩陣越大越有效率 X_unroll寫一次又讀一次(外加讀 X),記憶體流量遠多於直接法 → 降低 arithmetic intensity
各層矩陣維度多為乘積 (C·H_out·W_out 等),通常都很大 → 效能穩定高 逐 sample materialize 限制平行度,小矩陣難餵滿 GPU
Important

早期層 C 小但 H_out·W_out 大;末端層 C 大但 H_out·W_out 小——乘積 C·H_out·W_out所有層都偏大,故矩陣尺寸一致地大,GEMM 效能因而穩定。

缺點的最終解:cuDNN 不把 X_unroll 落到 off-chip global memory,而是 lazy 地在 on-chip memory 邊算 GEMM tile 邊生成展開資料,省下膨脹後的頻寬。


考試/面試重點 (Exam / Test Patterns)

情境 / 關鍵字 答案 / 技巧
conv layer 有幾層 easy parallelism? 4 層n, m, h, w;總平行 iteration = N·M·H_out·W_out
為何 c/p/q 不平行化? 多 iteration 累加進同一個 Y element → 需 atomic(read-modify-write race),預設保持序列
直接 kernel 的 grid 維度安排? (M, T, N):X=feature map、Z=sample、Y=H_grid·W_grid 線性化 tile 索引
blockIdx.y 還原 tile 位置? vert = blockIdx.y/W_gridhoriz = blockIdx.y%W_grid,再 ×TILE_WIDTH + threadIdx
直接 kernel 的效能瓶頸? global memory bandwidth-bound;解法 constant memory 放 filter + shared-memory tiling
im2col 的展開比是多少? (C·K·K·H_out·W_out)/(C·H_in·W_in),feature map 大時 → K·K;範例 16/9
X_unroll 矩陣維度? (C·K·K) × (H_out·W_out);高度=每 output 所需輸入數,寬度=output pixel 數
filter matrix 維度與是否複製? M × (C·K·K)無複製,僅 row-major 串接 filter banks
GEMM 法的兩大缺點? (1) 複製放大記憶體 (2) X_unroll 寫+讀降低 computational intensity
unroll kernel 為何能 coalesce? loop interchange 讓相鄰 thread 寫 unrolled 矩陣同列相鄰位置
cuDNN 如何避開 GEMM 法的缺點? lazy materialization:在 on-chip memory 邊算 tile 邊生成 X_unroll,不落 off-chip