Thread Coarsening 與 Register Tiling

重點總覽 (Overview)

針對 3D stencil,shared memory tiling 受限於 block size ≤ 1024shared memory 容量 ∝ T³,使得 tile 邊長 T 只能取到 8,reuse 與 coalescing 都偏差。本節用 thread coarsening(z 方向) 解除這個限制,再用 register tiling 把只給單一 thread 使用的 z-鄰居搬進 register,紓解 shared memory 壓力。

版本 (Kernel) Block size On-chip 用量 可用 T OP/B (七點 stencil)
Basic sweep (Fig 8.6) T³ threads 無 tiling 0.46
Shared-mem tiling (Fig 8.8) T³ threads shared 元素 實際上限 T=8 1.37 (T=8)
Coarsening z (Fig 8.10) T² threads shared 3T² 元素 T=32 2.68 (T=32)
Coarsening + Register tiling (Fig 8.12) T² threads shared + 3 regs/thread T=32 2.68(同上,但更快)
理論上限 (T→∞) 3.25
Important

Thread coarsening 提高 OP/B(讓 T 變大、降低 halo 比例);register tiling 不改變 OP/B 也不改變 global memory 流量,它只是把 on-chip 的 reuse 從 shared memory 移到更快的 register,並省下 2/3 的 shared memory。


Z 方向 Thread Coarsening (Thread Coarsening in the Z Direction)

動機:3D seven-point stencil 的 tiled kernel 上限 3.25 OP/B,但硬體把 block 限制在 1024 threads,且 shared memory ∝ T³,逼得 T=8(512 threads)。此時:

核心想法:把每個 thread 的工作從「算一個 grid point」加重為「算 z 方向一整條 column」。Block 只覆蓋 input tile 的一個 x-y plane(T² threads),沿 z 逐層 iterate,每次算出一個 output plane。

peeled-away 視角 (T=6 → input plane 6×6, output plane 4×4)

  z (into page)
   │   input tile = T³ = 6³ = 216 grid points
   │   output tile = (T-2)³ = 4³ = 64 grid points
   ▼
        x ──►
   ┌───────────────┐  ← block = 1 個 input x-y plane = 6×6 threads
 y │ . . . . . . . │     其中內層 4×4 thread 才真正算 output
 │ │ . ┌───────┐ . │
 ▼ │ . │ o o o │ . │     o = active output thread (4×4)
   │ . │ o o o │ . │     . = halo-loading thread (周邊一圈)
   │ . └───────┘ . │
   └───────────────┘
   block 沿 z 反覆 iterate:每個 iteration 算一層 output plane

每個 iteration 只需要 3 個 input plane 同時在片上:inPrev_s(z-1)、inCurr_s(z)、inNext_s(z+1)。算完一層後把它們**輪替(rotate)**再往 z 前進一格:

iteration i              iteration i+1
inPrev_s  ← plane z-1     inPrev_s  ← (舊 inCurr_s) plane z
inCurr_s  ← plane z       inCurr_s  ← (舊 inNext_s) plane z+1
inNext_s  ← plane z+1     inNext_s  ← 新載入       plane z+2
   │ shift ──────────────────►│
// Fig 8.10: thread coarsening in z, 3D seven-point stencil
// IN_TILE_DIM = T, OUT_TILE_DIM = T-2
__global__ void stencil_kernel(float* in, float* out, unsigned int N) {
    int iStart = blockIdx.z * OUT_TILE_DIM;                 // z 起點(output)
    int j = blockIdx.y * OUT_TILE_DIM + threadIdx.y - 1;    // y(-1 = 含 halo)
    int k = blockIdx.x * OUT_TILE_DIM + threadIdx.x - 1;    // x(-1 = 含 halo)

    __shared__ float inPrev_s[IN_TILE_DIM][IN_TILE_DIM];
    __shared__ float inCurr_s[IN_TILE_DIM][IN_TILE_DIM];
    __shared__ float inNext_s[IN_TILE_DIM][IN_TILE_DIM];

    // 預載前兩層 (z-1, z)
    if (iStart-1 >= 0 && iStart-1 < N && j >= 0 && j < N && k >= 0 && k < N)
        inPrev_s[threadIdx.y][threadIdx.x] = in[(iStart-1)*N*N + j*N + k];
    if (iStart   >= 0 && iStart   < N && j >= 0 && j < N && k >= 0 && k < N)
        inCurr_s[threadIdx.y][threadIdx.x] = in[ iStart   *N*N + j*N + k];

    for (int i = iStart; i < iStart + OUT_TILE_DIM; ++i) {
        // 載入第三層 (z+1)
        if (i+1 >= 0 && i+1 < N && j >= 0 && j < N && k >= 0 && k < N)
            inNext_s[threadIdx.y][threadIdx.x] = in[(i+1)*N*N + j*N + k];
        __syncthreads();
        // 邊界 + halo thread 關閉
        if (i >= 1 && i < N-1 && j >= 1 && j < N-1 && k >= 1 && k < N-1) {
            if (threadIdx.x >= 1 && threadIdx.x < IN_TILE_DIM-1 &&
                threadIdx.y >= 1 && threadIdx.y < IN_TILE_DIM-1) {
                out[i*N*N + j*N + k] =
                    c0*inCurr_s[threadIdx.y][threadIdx.x]
                  + c1*inCurr_s[threadIdx.y][threadIdx.x-1]
                  + c2*inCurr_s[threadIdx.y][threadIdx.x+1]
                  + c3*inCurr_s[threadIdx.y-1][threadIdx.x]
                  + c4*inCurr_s[threadIdx.y+1][threadIdx.x]
                  + c5*inPrev_s[threadIdx.y][threadIdx.x]   // z-1
                  + c6*inNext_s[threadIdx.y][threadIdx.x];  // z+1
            }
        }
        __syncthreads();
        // rotate:往 z 前進一格
        inPrev_s[threadIdx.y][threadIdx.x] = inCurr_s[threadIdx.y][threadIdx.x];
        inCurr_s[threadIdx.y][threadIdx.x] = inNext_s[threadIdx.y][threadIdx.x];
    }
}

收益(兩件事)

Tip

Coarsening 的本質(見 06-Performance-Considerations/03-Thread-Coarsening):把平行單位「部分序列化」進單一 thread,換取較少的重複代價——這裡換掉的是「每個 block 重複載入 halo」的低 reuse。


Register Tiling(把 z-鄰居放進 Register)

關鍵觀察:在 Fig 8.3 的所有 stencil(只取 x/y/z 軸鄰居、不含角落)中:

於是把 inPrev_sinNext_s 改成 register 變數 inPrevinNext,僅保留 inCurr_s

            Fig 8.10 (coarsening)        Fig 8.12 (+ register tiling)
 plane z-1  inPrev_s  [shared]    ──►    inPrev   [register]
 plane z    inCurr_s  [shared]    ──►    inCurr_s [shared]  (+ inCurr 一份 register)
 plane z+1  inNext_s  [shared]    ──►    inNext   [register]
 shared 用量   3·T²                        T²   (=1/3)
// Fig 8.12: thread coarsening + register tiling
__global__ void stencil_kernel(float* in, float* out, unsigned int N) {
    int iStart = blockIdx.z * OUT_TILE_DIM;
    int j = blockIdx.y * OUT_TILE_DIM + threadIdx.y - 1;
    int k = blockIdx.x * OUT_TILE_DIM + threadIdx.x - 1;

    float inPrev;                                   // register(z-1)
    __shared__ float inCurr_s[IN_TILE_DIM][IN_TILE_DIM];  // 只剩這一層 shared
    float inCurr;                                   // register(z)
    float inNext;                                   // register(z+1)

    if (iStart-1 >= 0 && iStart-1 < N && j >= 0 && j < N && k >= 0 && k < N)
        inPrev = in[(iStart-1)*N*N + j*N + k];
    if (iStart   >= 0 && iStart   < N && j >= 0 && j < N && k >= 0 && k < N) {
        inCurr = in[iStart*N*N + j*N + k];
        inCurr_s[threadIdx.y][threadIdx.x] = inCurr;  // 同時保留一份在 shared 供共享
    }

    for (int i = iStart; i < iStart + OUT_TILE_DIM; ++i) {
        if (i+1 >= 0 && i+1 < N && j >= 0 && j < N && k >= 0 && k < N)
            inNext = in[(i+1)*N*N + j*N + k];
        __syncthreads();
        if (i >= 1 && i < N-1 && j >= 1 && j < N-1 && k >= 1 && k < N-1) {
            if (threadIdx.x >= 1 && threadIdx.x < IN_TILE_DIM-1 &&
                threadIdx.y >= 1 && threadIdx.y < IN_TILE_DIM-1) {
                out[i*N*N + j*N + k] =
                    c0*inCurr                              // 自己(register)
                  + c1*inCurr_s[threadIdx.y][threadIdx.x-1]  // x-y 鄰居(shared)
                  + c2*inCurr_s[threadIdx.y][threadIdx.x+1]
                  + c3*inCurr_s[threadIdx.y-1][threadIdx.x]
                  + c4*inCurr_s[threadIdx.y+1][threadIdx.x]
                  + c5*inPrev                              // z-1(register)
                  + c6*inNext;                             // z+1(register)
            }
        }
        __syncthreads();
        inPrev = inCurr;                                   // register rotate
        inCurr = inNext;
        inCurr_s[threadIdx.y][threadIdx.x] = inNext;       // 更新 shared 那一層
    }
}

兩大好處 vs Fig 8.10

  1. 大量 shared memory 讀寫改成 register 存取(latency 更低、bandwidth 更高)→ 更快。
  2. 每個 block 只用 1/3 的 shared memory(從 3T²)。

代價:每個 thread 多用 3 個 register → 32×32 block 多 3072 registers/block。Stencil order 越高、z 方向鄰居越多,register 用量越大。

Warning

Register 是稀缺資源,過度使用會壓低 occupancy(見 04-Compute-Architecture-And-Scheduling/03-Resource-Partitioning-and-Occupancy)。若 register 變成瓶頸,可把部分 plane 改回 shared memory——這是典型的 shared memory ↔ register 取捨

Important

Register tiling 不是新技巧:matrix multiplication / convolution kernel 早就把每個 thread 算出的 output 值存在 register,整個 block 的 output tile 就是「集體存在 block 各 thread register 裡的 tile」。這裡只是首次拿 register 來存 input tile 的一部分——同一塊 tile 在運算過程中時而在 register、時而在 shared memory。


OP/B 與資源公式 (Quantitative Ratios)

公式 數值範例
Tiled OP/B(七點 stencil) 13·(T-2)³ / (4·T³) = (13/4)·(1 - 2/T)³ T=8→1.37;T=32→2.68
OP/B 上限 (T→∞) 13/4 3.25 OP/B
Shared mem(Fig 8.10 coarsening) 3·T² 元素 T=32 → 3·32²·4B = 12KB
Shared mem(Fig 8.12 register tiling) 元素 = 1/3 T=32 → 4KB
額外 register(七點) 3 regs/thread 32×32 block → 3072 regs/block
Halo 佔比(order-1 3D, 邊長 T) 1 - ((T-2)/T)³ T=8 → 58%
Tip

七點 stencil 一次做 13 FLOP(7 乘 + 6 加)、讀 7 個 4-byte input;basic kernel 的 0.46 OP/B 即 13/(7·4)。Coarsening 讓 T 變大、halo 比例下降,是把 OP/B 推向 3.25 的手段;register tiling 只是讓既有 reuse 跑得更快。


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

情境 / 關鍵字 答案 / 技巧
「為何 3D stencil 不能用大 tile?」 block ≤ 1024 且 shared memory ∝ ,逼 T=8,halo 佔 58%,OP/B 只 1.37
Thread coarsening 用在哪個方向、解決什麼 z 方向;block 降為 threads,可取 T=32,提高 reuse 與 coalescing
Coarsening 後 shared memory 需求 3T² 元素(只存 3 層);T=32 → 12KB
為何 z 鄰居可放 register inPrev/inNext 只被單一 thread 用,不需跨 thread 共享
為何 inCurr 必須留在 shared memory 它的 x-y 鄰居被多個 thread 共享
Register tiling 對 OP/B / global 流量的影響 皆不變;只把 reuse 從 shared memory 移到 register,省 2/3 shared mem
Register tiling 的代價 多 3 regs/thread(七點);高階 stencil 更多 → 可能壓 occupancy
Shared mem vs register 取捨 register 不夠時把部分 plane 改回 shared memory
計算 T=32 七點 stencil OP/B (13/4)(1-2/32)³ = 3.25·0.824 ≈ 2.68 OP/B
練習題 2(32×32 block、coarsen=16) input tile 32·32·18=18432;output 30·30·16=14400;OP/B 13·14400/(4·18432)≈2.54;shared 無 reg-tiling 3·32²·4B=12KB,有 reg-tiling 32²·4B=4KB
為何只能 register-tile「軸向」stencil 五/七/十三點等不含角落,z 鄰居才會被單一 thread 獨用