Radix Sort 效能優化:Memory Coalescing、Radix 大小與 Thread Coarsening

重點總覽 (Overview)

本筆記接續 13-Sorting/01-Sorting-Foundations-and-Parallel-Radix-Sort 的 one-thread-per-key 基礎核心,處理它的最大瓶頸:寫回 global memory 時無法 coalesce。三個優化彼此相關,目標都是讓「連續的 thread 寫到連續的記憶體位址」並降低 global scan 開銷。

優化 (節次) 核心做法 主要收益 主要代價
Shared Memory Local Sort (13.4) 每個 block 先在 shared memory 做 block-level local sort,再把 local buckets 整段 coalesced 寫回 global 大幅改善 global 寫入 coalescing;scan 只需 block 內 local scan 需多一次 global exclusive scan 算各 block local bucket 的起始位置
Choice of Radix Value (13.5) 用 r-bit radix(一次處理 r bits → 2^r 個 buckets) iteration 數降為 ⌈N_bits / r⌉,grid launch / global scan / global 存取次數減少 bucket 變多、每桶 key 變少 → coalescing 變差;global scan 的 table 變大 → 開銷 上升
Thread Coarsening (13.6) 每個 thread 負責 多個 key → block 變少、每 block key 變多 local buckets 變大 → coalescing 更好;block 數變少 → scan table 變小、開銷下降 平行度下降(以序列化時不浪費為前提才划算)
Important

三者的共同主軸是**「把不可 coalesce 的 scatter 搬進 shared memory,在 block 內排好後再以連續寫回」**,並透過減少 block 數 / iteration 數來壓低 global exclusive scan 的成本。


為何 Global 寫入無法 Coalesce (The Coalescing Problem)

基礎核心(sibling note 01)讓每個 thread 算出 key 的 destination index 後直接 scatter 寫回 global output。問題在於:相鄰 thread 的 key 不一定落在同一個 bucket。

Block 0 input:   k0    k1    k2    k3
bit (LSB):        0     1     0     1
                  |     |     |     |
            +-----+     |     +--+  |
            | +---------+        |  +-----+
            v v                  v        v
Global out: | ... 0-bucket ... | ... 1-bucket ... |
                ^k0 ^k2            ^k1 ^k3
  t0->posA   t1->posX   t2->posA+1   t3->posX+1
  (一個 warp 內位址跳來跳去 => 多個 memory request => 未 coalesced)

Shared Memory 區域排序優化 (Optimizing for Memory Coalescing) — 13.4

做法:不再做 global sort;改為每個 block 在 shared memory 用同樣演算法做一次 block-level local sort(只需 block 內 local exclusive scan),把 0/1 桶在 shared memory 排成連續段,再整段 coalesced 寫回 global。

Block b shared memory (local sort 後,桶內連續):
   [ k k k | k k ]      <- 左段=local 0-bucket, 右段=local 1-bucket
     ^^^^^   ^^^
   寫 0-bucket: t0,t1,t2 -> 連續 global 位址 (coalesced)
   寫 1-bucket: t3,t4    -> 連續 global 位址 (coalesced)

核心挑戰:每個 block 要知道自己的 local bucket 在 global output 中的起始位置,而這取決於其他 block 的 local bucket 大小:

解法:對「各 block 的 local bucket 大小表」做一次 global exclusive scan。表以 row-major 排列(先所有 block 的 0-bucket size,再所有 block 的 1-bucket size):

              B0   B1   B2   B3
 row0 (#0):   s00  s01  s02  s03
 row1 (#1):   s10  s11  s12  s13
 linearized:  [s00 s01 s02 s03 | s10 s11 s12 s13]
                       |  exclusive scan
                       v
 result    :  各 block 各 local bucket 的「global 起始位移」

掃描結果即為各 local bucket 的 global 起始位移。寫回時每個 thread 依自己的 threadIdx 落在 local 0 或 1 桶,決定寫哪個 global 段。

Tip

對比 sibling note 01 的基礎核心:scan 從 grid-wide 降為 block 內 local scan(快很多),代價只是多算一張小小的 bucket-size 表的 global scan。

基礎(未優化)核心回顧,優化版以此為「block 內」子程序(Fig. 13.4):

// 基礎 global 版 (sibling 01);優化版把它變成 block 內 local sort
__global__ void radix_sort_iter(unsigned int* in, unsigned int* out,
                                unsigned int* bits, unsigned int N,
                                unsigned int iter) {
    unsigned int i = blockIdx.x * blockDim.x + threadIdx.x;   // 03
    unsigned int key, bit;
    if (i < N) {                                              // 04 邊界檢查
        key = in[i];                                          // 06
        bit = (key >> iter) & 1;                              // 07 取出本回合的 bit
        bits[i] = bit;                                        // 08
    }
    exclusiveScan(bits, N);  // 10: 在 block 外 (全 thread 須活著做 barrier)
    if (i < N) {
        unsigned int onesBefore = bits[i];                    // 12 #ones before
        unsigned int onesTotal  = bits[N];                    // 13 #ones total
        unsigned int dst = (bit == 0)
            ? (i - onesBefore)                                // 14 destination of 0
            : (N - onesTotal + onesBefore);                   // 15 destination of 1
        out[dst] = key;                                       // 16 (未優化:scatter)
    }
}

優化版概念(本章把實作留作習題):

// 概念示意:block 在 shared memory 做 local sort,再 coalesced 寫回
__global__ void radix_sort_coalesced(unsigned int* in, unsigned int* out,
                                     unsigned int* blockBucketOffsets, /* 已 scan */
                                     unsigned int N, unsigned int iter) {
    __shared__ unsigned int s_keys[BLOCK_SIZE];
    // 1) 載入 + 取 bit + block 內 local exclusive scan -> 得 local destination
    // 2) 在 shared memory 內把 key 排成 [0-bucket | 1-bucket] 連續段
    // 3) 每 thread 依 threadIdx 落在 local 0 或 1 桶,從 blockBucketOffsets
    //    取得該桶 global 起始位移,連續 (coalesced) 寫回 out[]
}
Warning

exclusiveScan 必須放在邊界檢查 之外:scan 內部可能要 barrier (__syncthreads),須保證所有 thread 皆活著。grid-wide 同步可用 single-pass scan 技巧,或拆成「scan 前核心 / scan / scan 後核心」三次 grid launch


Radix 大小的選擇 (Choice of Radix Value) — 13.5

r-bit radix:一次 iteration 處理 r 個 bits,分到 2^r 個 buckets。

2-bit radix:一次分成 4 個 buckets
  bits: 00   01   10   11
        [..] [..] [..] [..]
local sort = 1-bit(LSB) 後再 1-bit(次低位),兩次 local scan

取捨表:小 radix vs 大 radix

項目 小 radix(如 1-bit) 大 radix(r-bit)
iteration 數 ⌈N_bits/r⌉
grid launches / global 存取 / global scan 次數
每 block local buckets 數 (2^r)
每桶 key 數、每段寫入量 多(段大) 少(段小)
memory coalescing 機會 變差(段小、桶多)
global scan table 大小 (∝ 2^r × #blocks) → 開銷上升
Important

radix 不能無限放大:雖然減少 iteration,但每桶 key 變少 → coalescing 惡化,且 global scan table 變大 → scan 開銷上升。需在「iteration 數」與「coalescing + global scan 開銷」之間取平衡。


Thread Coarsening 改善 Coalescing (Thread Coarsening) — 13.6

把 radix sort 切到很多 block 的代價是寫回 coalescing 差:block 越多 → 每 block key 越少 → local buckets 越小 → coalescing 機會越少。

Warning

若這些 block 真能並行執行,付這代價或許值得;但若硬體把它們序列化,這代價就是白付的。Thread coarsening 正是為後者準備。

做法:每個 thread 負責多個 key(而非一個)→ block 數減少、每 block key 增多。

無 coarsening:  t0  t1  t2  t3        (每 thread 1 key,local bucket 小)
有 coarsening:  t0  t0  t1  t1 ...    (每 thread 多 key,local bucket 大)
                local buckets 變大 => 相鄰 thread 更可能寫到相鄰位址 (coalesced)

兩個收益:

  1. 改善 coalescing:local buckets 變大,連續 thread 更可能寫到連續位址(對比 Fig. 13.8 vs 13.10)。
  2. 降低 global scan 開銷:scan table 大小 ∝ bucket 數 × block 數;coarsening 減少 block 數 → table 變小 → exclusive scan 開銷下降。
Tip

Coarsening 同時打到本章兩個痛點(coalescing 差 + global scan 開銷),與 06-Performance-Considerations/03-Thread-Coarsening10-Reduction/03-Scaling-Reduction-Hierarchical-and-Coarsening 中「以序列化必然性換取效率」的原則一致。


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

情境 / 關鍵字 答案 / 技巧
「為何基礎 radix sort 寫回未 coalesced?」 相鄰 thread 的 key 落在不同 bucket(0/1 交錯)→ warp 內位址不連續 → 多次 memory request
「如何改善寫回 coalescing?」 用 Ch.6 第 3 法:block 在 shared memory 做 local sort 排成連續桶,再整段 coalesced 寫回 global
「local sort 用 local scan,那 global 還需要什麼?」 需對「各 block local bucket 大小表(row-major)」做一次 global exclusive scan,得各 local bucket 的 global 起始位移
「local 0-bucket / 1-bucket 全域位置怎麼算?」 0-bucket 在所有前面 block 的 0-bucket 之後;1-bucket 在全部 block 的 0-bucket + 前面 block 的 1-bucket 之後
「r-bit radix 需幾次 iteration / 幾個 bucket / 幾次 local scan?」 iteration = ⌈N_bits/r⌉;2^r 個 bucket;每回合 r 次 1-bit local scan
「為何 radix 不能取很大?」 桶多段小 → coalescing 變差;scan table(2^r × #blocks)變大 → global scan 開銷上升
「thread coarsening 為何同時改善兩件事?」 block 變少 → local buckets 變大(coalescing↑)、scan table 變小(scan 開銷↓)
「coarsening 何時才划算?」 當 block 本會被硬體序列化時;若能真正並行則代價未必該省
「destination of 0 / of 1 公式?」 of 0 = key_index − #ones_before;of 1 = input_size − #ones_total + #ones_before(細節見 sibling 01)