Kernel Functions 與 Threading

重點總覽 (Overview)

本節說明「如何撰寫 kernel 本體」:用限定詞標記函式、理解 grid/block/thread 階層、用內建變數算出每個 thread 的全域索引,再以 if(i<n) 防止越界。啟動語法 <<<>>> 與編譯流程屬於 02-Heterogeneous-Data-Parallel-Computing/04-Calling-Kernels-Compilation-and-Summary

元素 (Element) 角色 (Role) 關鍵點 (Key point)
__global__ 宣告 kernel function 在 device 執行、由 host 呼叫,呼叫後啟動一個 grid
__device__ 宣告 device function device 執行、僅能被 kernel/device function 呼叫,不啟動新 thread
__host__ 宣告 host function 預設值;無任何限定詞的函式都是 host function
blockDim 內建變數 (struct, x/y/z) 每個 block 的 thread 數 (本例 blockDim.x = 256)
blockIdx 內建變數 block 在 grid 內的座標 (= 電話「區碼」)
threadIdx 內建變數 thread 在 block 內的座標 (= 電話「本地號碼」)
global index i blockIdx.x*blockDim.x + threadIdx.x thread → data 的唯一映射
if(i < n) 邊界檢查 關閉多出來的 thread,支援任意長度 n
Important

一個 kernel 的所有 thread 執行完全相同的程式碼,靠內建變數區分自己要處理哪一份資料。這就是 SPMD (Single-Program Multiple-Data) 風格。


Kernel Function 與 SPMD 模型 (Kernel Functions and the SPMD Model)

SPMD ≠ SIMD

  • SPMD:所有處理單元跑「同一支程式」處理不同資料,但不要求同一時刻執行同一條指令
  • SIMD (Single-Instruction Multiple-Data):所有處理單元在任一瞬間都執行同一條指令
  • CUDA 程式設計是 SPMD;硬體在 warp 層級才呈現 SIMD 特性 (見 04-Compute-Architecture-And-Scheduling/02-Warps-SIMD-and-Control-Divergence)。

向量加法 kernel (書中 Fig. 2.10) —— 與序列版 (Fig. 2.4) 相比,for-loop 不見了

// Compute vector sum C = A + B
// Each thread performs one pair-wise addition
__global__
void vecAddKernel(float* A, float* B, float* C, int n) {
    int i = threadIdx.x + blockDim.x * blockIdx.x;
    if (i < n) {
        C[i] = A[i] + B[i];
    }
}
迴圈去哪了? (Loop Parallelism)

原本序列迴圈的「每一次迭代」被「grid 中的每一個 thread」取代了。整個 grid 等價於那個 loop,這稱為 loop parallelism。kernel 內無 _h/_d 命名慣例,因為 kernel 不會碰到 host memory,沒有混淆風險。

Automatic variable 是 thread-private

kernel 內的區域變數 (如 i) 對每個 thread 各有一份。若啟動 10,000 個 thread,就有 10,000 份 i,彼此不可見。其儲存細節見 05-Memory-Architecture-And-Data-Locality/01-Memory-Access-Efficiency-and-CUDA-Memory-Types


三種函式限定詞 (Function Declaration Qualifiers)

Qualifier 執行於 (Executed on) 可被呼叫 (Callable from) 啟動新 grid?
__host__ (預設) Host Host function No
__global__ Device Host (支援 dynamic parallelism 時亦可由 Device) Yes — 啟動一個 grid
__device__ Device Kernel 或另一 device function No (由呼叫它的 thread 執行)
__host__ __device__ 同時使用

兩者並用會請編譯器產生兩份 object code:一份給 host、一份給 device。常見於可同時在 CPU/GPU 重用的函式庫函式 —— 不必把同一份程式碼手抄兩遍 (這正是書末 Exercise 10「實習生抱怨要寫兩次」的標準解答)。

__device__               ─ device 執行,device 呼叫,不開新 thread
__global__  void K(...)   ─ device 執行,host 呼叫,launch 一個 grid
__host__                 ─ host 執行,host 呼叫 (default)

Thread 階層:Grid → Block → Thread (Thread Hierarchy)

呼叫 kernel 時,runtime 啟動一個組織成兩層階層的 grid:

            ┌───────────────────────── Grid (一次 kernel 呼叫的全部 thread) ─────────────────────────┐
            │                                                                                        │
        Block 0                         Block 1                         Block 2          ...    Block (gridDim-1)
   ┌──────────────────┐           ┌──────────────────┐           ┌──────────────────┐
   │ t0 t1 ... t255   │           │ t0 t1 ... t255   │           │ t0 t1 ... t255   │   每個 block 同樣大小
   └──────────────────┘           └──────────────────┘           └──────────────────┘   (本例 blockDim.x = 256)
   blockIdx.x = 0                  blockIdx.x = 1                  blockIdx.x = 2
電話系統類比 (Hierarchical Organization)

把每條電話線看成一個 thread:blockIdx = 區碼,threadIdx = 本地號碼。兩者合起來給全國每條線一個唯一號碼,同時保留「同區撥號只需本地號碼」的 locality


內建變數與全域索引 (Built-in Variables and the Global Index)

CUDA 提供一組唯讀內建變數,由 runtime 預先初始化,讓 thread 找到自己:

變數 (Variable) 型別 意義 (Meaning)
blockDim struct (x, y, z) 每個 block 的 thread 數;維度反映資料維度 (1D 只用 .x)
blockIdx struct (x, y, z) block 在 grid 內的座標 (同 block 內所有 thread 相同)
threadIdx struct (x, y, z) thread 在 block 內的座標 (block 內第 0 個為 0,第 1 個為 1...)

每個 thread 用以下公式算出整個 grid 內唯一的全域索引:

i = blockIdx.x * blockDim.x + threadIdx.x

blockDim.x = 256 為例,前三個 block 連續覆蓋 i = 0 .. 767:

 blockDim.x = 256

 Block 0 (blockIdx.x=0)      Block 1 (blockIdx.x=1)      Block 2 (blockIdx.x=2)
 threadIdx.x: 0 ... 255      threadIdx.x: 0 ... 255      threadIdx.x: 0 ... 255
        │                           │                           │
   i = 0*256+tIdx              i = 1*256+tIdx              i = 2*256+tIdx
        ▼                           ▼                           ▼
   i =   0 ... 255             i = 256 ... 511             i = 512 ... 767
   ───────────────────────────────────────────────────────────────────────►  連續覆蓋 0..767
建議:每個維度的 thread 數為 32 的倍數

硬體以 warp (32 threads) 為排程單位,因此 block 各維度的 thread 數最好是 32 的倍數,以避免浪費 (細節見 04-Compute-Architecture-And-Scheduling/02-Warps-SIMD-and-Control-Divergence)。


邊界檢查 if(i < n) (Boundary Check)

並非所有向量長度都能被 block size 整除,因此 grid 通常會多啟動一些 thread:

 n = 100,   block size = 32,   launch 4 blocks = 128 threads

  thread i:  0 ............................... 99 | 100 ............ 127
             └──── i < n  → 執行 C[i]=A[i]+B[i] ──┘ └─ i >= n → 跳過 ─┘
                          (前 100 個)                    (後 28 個)
為何一定要 if(i < n)?

因為 grid 的 thread 總數 = ceil(n / blockDim.x) * blockDim.x ≥ n,幾乎總是大於 n。少了邊界檢查,尾端 thread 會越界讀寫 device global memory,導致錯誤結果或 runtime error。


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

情境 / 關鍵字 (Scenario / Keyword) 答案 / 技巧 (Answer / Technique)
每個 thread 算 1 個元素,求 i i = blockIdx.x * blockDim.x + threadIdx.x (Ex.1 → C)
每個 thread 算 2 個相鄰 元素,求第一個 i i = (blockIdx.x*blockDim.x + threadIdx.x) * 2 (Ex.2 → C)
每 thread 算 2 元素、分兩個 section(各掃一遍) i = blockIdx.x * blockDim.x * 2 + threadIdx.x (Ex.3 → D)
為什麼需要 if(i < n)? grid threads = ceil(n/blk)*blk ≥ n;關閉多餘 thread 防越界,支援任意 n
__global__ vs __device__ vs __host__ global:device 執行/host 呼叫/啟動 grid;device:device 執行/device 呼叫;host:預設
函式無任何限定詞 預設為 host function
抱怨「同函式要寫兩次 (host+device)」 __host__ __device__ 同時標記,編譯器自動產生兩版 (Ex.10)
每個 block 最多幾個 thread? 1024 (current systems)
block 每維 thread 數的建議值 32 的倍數 (warp 大小)
kernel 內 i 等 automatic variable 的可見範圍 thread-private,每 thread 一份,互不可見
「序列迴圈去哪了?」 grid of threads 取代 → loop parallelism
SPMD vs SIMD SPMD 同程式不同資料、不必同指令;SIMD 任一瞬間同指令
N=200000, block=128, (N+127)/128 blocks blocks=1563,grid threads=1563*128=200064;算 i 的 thread=200064;進入 if body 的 thread=200000 (Ex.9)