Tiled 與 Circular Buffer Merge Kernel (Memory Coalescing)

重點總覽 (Overview)

本筆記延續 12-Merge/02-Co-Rank-Function-and-Basic-Merge-Kernel,解決 basic merge kernel 的記憶體存取弱點:透過 shared memory tiling 取得 coalesced load,再用 circular buffer 消除 tiled 版本的頻寬浪費。

Kernel Co-rank 在哪執行 Load 是否 coalesced Shared memory 利用率 程式複雜度
Basic (12.5) 每 thread 在 global memory binary search 否 (相鄰 thread 讀 A[0],A[2],B[0]) 無 shared memory
Tiled (12.6) block 級 1 次在 global;thread 級在 shared memory (threadIdx.x 連續載入) 50%(每輪 load 2x,只用 x)
Circular Buffer (12.7) 同 tiled,但用 circular index ~100%(只 refill 被消耗的部分)
核心權衡

Merge 是 memory-bandwidth bound,運算與 register 資源通常閒置。因此用「更多 register + 更多 address 計算」換「節省 memory bandwidth」是划算的交易,即使 circular buffer 增加的 register 使用可能略降 occupancy。


Basic Kernel 的兩個 Coalescing 弱點 (Motivation)

basic merge kernel 每個 thread 獨立做 sequential merge,導致:

warp 中相鄰三個 thread (t0,t1,t2) 在 merge_sequential 第一輪:
  讀取 →  A[0]      A[2]      B[0]      ← 位址不連續,未 coalesced
  寫入 →  C[0]      C[3]      C[6]      ← 位址不連續,未 coalesced
  1. Sequential merge 階段:相鄰 thread 的 input/output 子陣列彼此相鄰,但每個 thread 內部是順序掃描,同一時刻的存取位址分散 → 未 coalesced。
  2. Co-rank 階段:binary search 本身存取模式不規則,且直接打 global memory → 幾乎不可能 coalesced。
Ch.6 的三種改善 coalescing 策略

(1) 重排 thread→data 映射;(2) 重排資料本身;(3) 以 coalesced 方式把資料搬進 shared memory,再在 shared memory 做不規則存取。Merge 採用策略 (3),同時順帶捕捉 co-rank 與 sequential merge 之間少量的資料重用。


Block-Level Co-Rank 與協同載入 (Block-Level Co-Rank & Coalesced Loading)

關鍵觀察:一個 block 內所有 thread 共同使用 A、B 的較大「block 級子陣列」生成一個較大的 C 子陣列。因此只需整個 block 呼叫一次 co-rank 取得 block 級邊界,全 block 即可協同 coalesced 載入。

        block 0          block 1          block 2
C: |■■■■■■■■|--------|■■■■■■■■|--------|■■■■■■■■|   ← C 平均切成 gridDim.x 段
        ↑ co_rank(C_curr) / co_rank(C_next) 決定 A、B 的對應切點
A: |■■■■■■■■■■■■|----|■■■|--------------|■■■■■■|   ← 各 block 的 A 子陣列大小隨資料而異
B: |■■|--------------|■■■■■■■■■■■■|------|■■■■|     ← 但 A_len + B_len = C_len 恆成立

Part 1:只用 1 個 thread 算 block 級 co-rank,存入 shared memory 給全 block 看(減少 global 存取):

__global__ void merge_tiled_kernel(int* A, int m, int* B, int n,
                                   int* C, int tile_size) {
    extern __shared__ int shareAB[];
    int* A_S = &shareAB[0];           // 前半: tile_size 個 A 元素
    int* B_S = &shareAB[tile_size];   // 後半: tile_size 個 B 元素
    int C_curr = blockIdx.x * ceil((m+n)/(float)gridDim.x);
    int C_next = min((blockIdx.x+1) * (int)ceil((m+n)/(float)gridDim.x), m+n);
    if (threadIdx.x == 0) {                       // 只 1 個 thread 做 co-rank
        A_S[0] = co_rank(C_curr, A, m, B, n);     // 借 shared memory 廣播給全 block
        A_S[1] = co_rank(C_next, A, m, B, n);
    }
    __syncthreads();                              // 等 co-rank 結果可見
    int A_curr = A_S[0], A_next = A_S[1];
    int B_curr = C_curr - A_curr, B_next = C_next - A_next;
    __syncthreads();
    // ... 接 Part 2
}
tile_size 的意義與成本

tile_sizeA 與 B 各自容納的元素數。tile_size = 1024 表示 A_S 放 1024 個、B_S 放 1024 個,共 (1024+1024)×4 = 8192 bytes shared memory。Shared memory 通常裝不下整個 block 子陣列,故必須迭代載入


迭代式 Tile 載入與 Consumed 追蹤 (Iterative Loading with Consumed Tracking)

每個 block 需 total_iteration = ceil(C_length / tile_size) 輪。每輪協同載入 x 個 A + x 個 B(x = tile_size),用 threadIdx.x 選元素使存取 coalesced。

Part 2(載入 tile)

    int counter = 0;
    int C_length = C_next - C_curr;
    int A_length = A_next - A_curr;
    int B_length = B_next - B_curr;
    int total_iteration = ceil(C_length / (float)tile_size);
    int C_completed = 0, A_consumed = 0, B_consumed = 0;
    while (counter < total_iteration) {
        // blockDim.x 個 thread 每輪載 blockDim.x 個 → 共跑 tile_size/blockDim.x 次
        for (int i = 0; i < tile_size; i += blockDim.x)
            if (i + threadIdx.x < A_length - A_consumed)         // 防止越界 (最後一輪)
                A_S[i+threadIdx.x] = A[A_curr + A_consumed + i + threadIdx.x];
        for (int i = 0; i < tile_size; i += blockDim.x)
            if (i + threadIdx.x < B_length - B_consumed)
                B_S[i+threadIdx.x] = B[B_curr + B_consumed + i + threadIdx.x];
        __syncthreads();
        // ... 接 Part 3

Part 3(thread 級 merge + 更新 consumed)

        int c_curr = threadIdx.x * (tile_size/blockDim.x);
        int c_next = (threadIdx.x+1) * (tile_size/blockDim.x);
        c_curr = min(c_curr, C_length - C_completed);     // 夾住最後一輪
        c_next = min(c_next, C_length - C_completed);
        int a_curr = co_rank(c_curr, A_S, min(tile_size, A_length-A_consumed),
                                     B_S, min(tile_size, B_length-B_consumed));
        int b_curr = c_curr - a_curr;
        int a_next = co_rank(c_next, A_S, min(tile_size, A_length-A_consumed),
                                     B_S, min(tile_size, B_length-B_consumed));
        int b_next = c_next - a_next;
        merge_sequential(A_S+a_curr, a_next-a_curr, B_S+b_curr, b_next-b_curr,
                         C + C_curr + C_completed + c_curr);   // co-rank 全在 shared memory
        counter++;
        C_completed += tile_size;
        A_consumed += co_rank(tile_size, A_S, tile_size, B_S, tile_size);
        B_consumed = C_completed - A_consumed;   // 由 C_completed 與 A_consumed 導出
        __syncthreads();
    }
}

A_consumed 指標如何推進(block 0 的第 1 輪):

A 子陣列:  [□□□|■■■■■■■■■■■■......]
            ↑   ↑
   iter0 消耗  A_curr + A_consumed  ← iter1 從這裡開始載入
   (白色=已消耗, 長度=A_consumed)
下一輪 tile 起點 = A[A_curr + A_consumed] , B[B_curr + B_consumed]
最後一輪的 consumed 值會錯,但無害

最後一輪可能沒有完整一個 tile,故 A_consumed / B_consumed / C_completed 在迴圈結束後不正確。因為迴圈已不再迭代,這些錯值不會被使用。但若出迴圈後仍需用到,必須改用 A_length / B_length / C_length(屆時所有指定元素都已消耗完)。


Tiled Kernel 的 50% 頻寬浪費 (The Half-Bandwidth Deficiency)

為何載 2x 只能保證生成 x

最壞情況下,當前 x 個 output 可能全部來自單一輸入(全 A 或全 B)。為保證每個 thread 都拿得到所需元素,必須 A、B 各載 x 個(共 2x)。但實際只會消耗 x 個(A_consumed_iter + B_consumed_iter = tile_size)。

每輪載入:  A_S [x 個] + B_S [x 個]  = 2x 載入
每輪消耗:  只用掉合計 x 個
          → 剩下的 x 個被「丟棄並在下一輪重新載入」
利用率 =  x / 2x = 50%  ← 浪費一半 memory bandwidth
tiled kernel 公式/數值 (例: m=33000, n=31000, 16 blocks, 128 threads, tile=1024)
每 block 輸出 C_length 64000/16 = 4000
迭代次數 ceil(C_length/tile_size) ceil(4000/1024) = 4
每輪 load 迴圈次數 tile_size/blockDim.x 1024/128 = 8
每輪載入 2 × tile_size 2048
每輪消耗 tile_size 1024 → 半數浪費

Circular Buffer Merge Kernel (Circular Buffer Scheme)

核心改動:不再每輪從 A_S[0] 重載整個 tile,而是保留上一輪未消耗的元素,只 refill 被消耗掉的部分。新增 A_S_start / B_S_start(tile 在 buffer 中的動態起點)與 A_S_consumed / B_S_consumed(本輪消耗量、即下輪需補的量)。

(A) iter0 載入: A_S_start=0    [■■■■■■■■]              整段新載入
(B) iter0 結束: A_S_start 前移 [□□□|■■■■■]              白=已消耗, 灰=保留
(C) iter1 補載:               [▓▓▓|■■■■■]              只補 A_S_consumed 個 (wrap around)
(D) iter1 結束: A_S_start wrap [■■|□□□|▓▓]              消耗段繞回開頭, start 用 % 環繞
       index 環繞:  idx % tile_size

Part 1 與 tiled 完全相同。Part 2 的差異在迴圈出口與 wrap-around index:

    int A_S_start = 0, B_S_start = 0;
    int A_S_consumed = tile_size, B_S_consumed = tile_size; // 第一輪需填滿
    while (counter < total_iteration) {
        for (int i = 0; i < A_S_consumed; i += blockDim.x)        // 只補需要的量
            if (i+threadIdx.x < A_length-A_consumed && i+threadIdx.x < A_S_consumed)
                A_S[(A_S_start + (tile_size - A_S_consumed) + i + threadIdx.x) % tile_size]
                    = A[A_curr + A_consumed + i + threadIdx.x];   // % 環繞回開頭
        // ... B_S 同理 (留作習題)

Part 3 末尾的更新(環繞 start、更新 consumed):

        counter++;
        A_S_consumed = co_rank_circular(min(tile_size, C_length-C_completed),
                          A_S, min(tile_size, A_length-A_consumed),
                          B_S, min(tile_size, B_length-B_consumed),
                          A_S_start, B_S_start, tile_size);
        B_S_consumed = min(tile_size, C_length-C_completed) - A_S_consumed;
        A_consumed += A_S_consumed;
        C_completed += min(tile_size, C_length-C_completed);
        B_consumed  = C_completed - A_consumed;
        A_S_start = (A_S_start + A_S_consumed) % tile_size;   // 環繞推進起點
        B_S_start = (B_S_start + B_S_consumed) % tile_size;
        __syncthreads();
    }

Simplified Model 與 Circular 函式 (Simplified Co-Rank Model)

直接把 circular buffer 的真實索引交給 user code 會很複雜(例如 a_next 可能因環繞而小於 a_curr,長度要分兩種情況算 a_next-a_curra_next-a_curr+tile_size)。

簡化模型:把環繞隱藏進 library 函式

對 user code 呈現「tile 是從 A_S_start 起、連續最多 tile_size 個元素」的幻覺,使 a_next ≥ a_currb_next ≥ b_curr 永遠成立。環繞的真實映射只在 co_rank_circular / merge_sequential_circular 存取元素的那一刻才展開。Part 3 的 thread 級程式碼幾乎不變——只是改呼叫 _circular 版本,傳入額外 3 參數 A_S_start, B_S_start, tile_size

(A) 真實 circular buffer:  b_next 環繞 → 在實體上 b_next < b_curr
    B_S: [ ...b_next | (空) | B_S_start...b_curr... ]
(B) 簡化模型 (user 看到的): 一段連續 tile_size,b_next ≥ b_curr
    B_S: [ B_S_start ............ b_curr .. b_next .. ]

co_rank_circular:邏輯與原 co-rank 完全相同,唯一差別是 i, i-1, j, j-1 不再直接當索引,而是當 offset 加到 start 上、再判斷是否需環繞:

__device__ int co_rank_circular(int k, int* A, int m, int* B, int n,
                                int A_S_start, int B_S_start, int tile_size) {
    int i = (k < m) ? k : m;
    int j = k - i;
    int i_low = max(0, k-n), j_low = max(0, k-m);
    bool active = true;
    while (active) {
        int i_cir     = (A_S_start + i     >= tile_size) ? A_S_start+i-tile_size     : A_S_start+i;
        int i_m_1_cir = (A_S_start + i - 1 >= tile_size) ? A_S_start+i-1-tile_size   : A_S_start+i-1;
        int j_cir     = (B_S_start + j     >= tile_size) ? B_S_start+j-tile_size     : B_S_start+j;
        int j_m_1_cir = (B_S_start + j - 1 >= tile_size) ? B_S_start+j-1-tile_size   : B_S_start+j-1;
        if (i > 0 && j < n && A[i_m_1_cir] > B[j_cir]) {        // i 太大
            int delta = (i - i_low + 1) >> 1;
            j_low = j; j += delta; i -= delta;
        } else if (j > 0 && i < m && B[j_m_1_cir] >= A[i_cir]) {// j 太大
            int delta = (j - j_low + 1) >> 1;
            i_low = i; i += delta; j -= delta;
        } else active = false;
    }
    return i;
}
不能用 i_cir - 1 取代 i - 1

必須先在「邏輯 offset」上算出 i-1形成最終索引並檢查是否環繞(i_m_1_cir)。直接對已環繞的 i_cir 做減 1 會在邊界跨越時取到錯誤位置。

merge_sequential_circular 同理:邏輯與 Fig.12.2 的順序 merge 一致,只在四處存取 A/B 時把 ij 轉成 i_cirj_cir 並判斷環繞。


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

情境 / 關鍵字 答案 / 技巧
basic merge kernel 為何 coalescing 差? 每 thread 順序掃描,同時刻讀 A[0],A[2],B[0]、寫 C[0],C[3],C[6],位址分散;co-rank binary search 也不規則
tiled kernel 用哪種 coalescing 策略? Ch.6 策略 (3):coalesced 搬進 shared memory,不規則存取改在 shared memory 做
為何 block 級 co-rank 只用 1 個 thread? 減少 global memory 存取數量;結果存 A_S[0]/A_S[1] 經 __syncthreads() 廣播全 block
為何載 2x 元素只保證生成 x? 最壞情況 x 個 output 可能全來自單一輸入,故 A、B 各須備 x 個
tiled kernel 浪費多少 bandwidth? 50%:每輪載 2×tile_size、只消耗 tile_size,剩下重載
circular buffer 解決什麼?怎麼做? 全頻寬利用:保留上輪未消耗元素,只 refill A_S_consumed/B_S_consumed 個,index 用 % tile_size 環繞
circular buffer 的代價? 程式複雜度大增、每 thread 多用 register 追蹤 start/剩餘量,可能降 occupancy(但 merge 是 bandwidth bound,划算)
為何引入「simplified model」? 讓 user code 看到連續 tile(a_next≥a_curr),環繞只藏在 co_rank_circular/merge_sequential_circular
迭代次數公式 total_iteration = ceil(C_length / tile_size)
載入 for-loop 跑幾次 tile_size / blockDim.x(例 1024/128 = 8)
最後一輪 A_consumed 為何不可信? 不足一個完整 tile;出迴圈後須改用 A_length/B_length/C_length
習題: 1,030,400 與 608,000、每 thread 8 元素、block=1024 basic: 全部 thread 在 global 做 binary search;tiled: 只 block 級少數在 global、其餘在 shared memory