任意長度輸入與 Single-Pass Scan
重點總覽 (Overview)
前兩篇 (11-Prefix-Sum-Scan/01-Scan-Foundations-and-Kogge-Stone、11-Prefix-Sum-Scan/02-Work-Efficient-Scan-Brent-Kung-and-Coarsening) 的 kernel 都只能對「一個 block 能裝下的 section」做 scan。本篇處理百萬∼數十億元素的任意長度輸入,並把 segment 結果整合成全域結果。
| 主題 | 核心想法 | 優點 | 代價 |
|---|---|---|---|
| Hierarchical (Segmented) Scan | 三層:局部 scan → 對各 block 總和做 scan → 加回 | 任意長度、實作簡單、概念清晰 | 中間結果 (S 陣列) 須來回 global memory,延遲無法被計算掩蓋 |
| 三-Kernel 實作 | 三個 kernel 串接,kernel 邊界即隱式 global barrier | 正確且穩健 | 額外的 global store/load 不能與運算重疊 |
| Single-Pass (Domino) Scan | 單一 kernel,partial sum 在相鄰 block 間以骨牌方式單向傳遞 | 省去額外 global 往返,traffic 多落在 L2、可與 phase 1/3 重疊 | 需 adjacent synchronization + __threadfence + 防 deadlock |
| Dynamic Block Index | 用 atomic counter 動態指派 bid,不綁 blockIdx.x |
保證 block 線性排程、避免 deadlock | 多一個 global counter 與 atomic |
關鍵觀察:全域 scan 步驟 (Fig. 11.9 的中段) 不需要 grid-wide 同步,可用骨牌式逐塊傳遞完成。這正是 single-pass scan 能擺脫三-kernel 額外往返的根本原因。
階層式分段 Scan (Hierarchical / Segmented Scan)
對大型資料集:先把輸入切成數個 scan block,每個剛好塞進一個 SM 的 shared memory,由單一 block 處理。執行三個邏輯步驟。
三步驟流程
輸入 X (16 元素, 切成 4 個 scan block, 每塊 4 元素)
[2 1 3 1 | 0 4 1 2 | 0 3 1 2 | 2 6 1 2]
Step 1 各 block 局部 inclusive scan (彼此獨立, 不含跨塊貢獻)
blk0: 2 3 6 7 blk1: 0 4 5 7 blk2: 0 3 4 6 blk3: 2 8 9 11
^最後一格 = 該塊總和
Step 2 蒐集各塊「最後一格」成 S 陣列, 再對 S 做一次 scan
S = [ 7 , 7 , 6 , 11 ] (各塊總和)
S' = [ 7 , 14, 20, 31 ] (scan 後 = 全域策略點的最終結果)
| | | |
X[3] X[7] X[11] X[15] 的最終 scan 值
Step 3 把 S'[blockIdx-1] 加回該塊每個元素 (blk0 不需加)
blk0: 2 3 6 7
blk1: +7 -> 7 11 12 14
blk2: +14 -> 14 17 18 20
blk3: +20 -> 22 28 29 31
- S 陣列維度 =
N / SECTION_SIZE。S[i] 是「從 X[0] 到 scan block i 結尾」的累積和,即原問題在策略性位置 (各塊末端) 的最終 scan 值。 - 這與第二篇 thread-coarsening 的三-phase scan (11-Prefix-Sum-Scan/02-Work-Efficient-Scan-Brent-Kung-and-Coarsening) 邏輯相同,差別在 Step 2 跨越多個 thread block,故各塊末端值必須寫進 global memory 才能跨塊可見。
- 原理等同硬體加法器的 carry look-ahead(進位先行):先算各段進位,再廣播回去。
三-Kernel 實作
| Kernel | 角色 | 重點 |
|---|---|---|
| K1 局部 scan | 對每個 scan block 做 Kogge-Stone / Brent-Kung / coarsened scan | 多傳一個參數 S (維度 N/SECTION_SIZE);末尾由 block 最後一個 thread 寫出 block 總和 |
| K2 對 S 做 scan | 單一 block 對整個 S 做 scan | 直接重用任一 scan kernel,輸入/輸出皆 S,不產生額外 partial sum |
| K3 加回 | 把 S[blockIdx-1] 加進對應 scan block | 以 SECTION_SIZE 個 thread/block 啟動,每 thread 加一個元素 |
// K1 結尾:由 block 內最後一個 thread 把該塊總和寫進 S
__syncthreads();
if (threadIdx.x == blockDim.x - 1) {
S[blockIdx.x] = XY[SECTION_SIZE - 1]; // = 本 scan block 的所有元素總和
}
// K2:對 S 做 scan(單一 block 即可,因元素數 = block 數,通常很小)
// 直接呼叫 Kogge_Stone_scan_kernel / Brent_Kung_scan_kernel(S, S, N/SECTION_SIZE)
// K3:把前面所有 scan block 的總和加回本塊
unsigned int i = blockIdx.x * blockDim.x + threadIdx.x;
if (blockIdx.x > 0) {
Y[i] += S[blockIdx.x - 1]; // blk0 不需加,blockIdx==0 跳過
}
三-kernel 的致命缺點:K1 把 scan block 寫進 global memory,K3 又把 S 讀回。這些額外的 store/load 延遲無法與後續 kernel 的運算重疊(kernel 之間是隱式 global barrier),對大型輸入會顯著拖慢整體速度。Single-pass scan 就是為了消除這段往返。
Single-Pass (Domino-style) Scan
又稱 stream-based scan(注意:與 Chapter 20 的 CUDA streams 無關)。用單一 kernel 完成三個步驟,partial sum 在相鄰 block 之間透過 global memory 單向傳遞,像骨牌一樣。
骨牌傳遞 (Producer-Consumer Chain)
時間 →
phase 1 (全部 block 並行做局部 scan)
blk0 blk1 blk2 blk3
| | | |
phase 2 (傳遞 partial sum, 序列化)
blk0 --7--> blk1 --14--> blk2 --20--> blk3
(blk_i 等左鄰 blk_{i-1} 的和, 加上自身 local_sum, 再傳給右鄰 blk_{i+1})
| | | |
phase 3 (收到前綴和後即可並行加回, 與其他塊重疊)
blk0 blk1 blk2 blk3
- block i 流程:① 先對自己的 scan block 做局部 scan(與所有 block 並行)→ ② 等待左鄰 block i-1 傳來累積和 → ③ 收到後加上自己的 local sum,把新的累積和傳給右鄰 block i+1 → ④ 再把收到的值加到自己所有 partial scan 值上,產生最終輸出。
- phase 1 全並行;phase 2 傳遞被序列化;但只要和值傳得夠快,phase 3 又能在各 block 間取得大量並行。
Adjacent (Block) Synchronization
骨牌要能運作,需要 adjacent synchronization —— 一種讓相鄰 block 同步並交換資料的客製化同步,以 atomic 操作實作。只由每個 block 的 leader thread (例如 threadIdx.x == 0) 執行,其餘 thread 在最後的 __syncthreads() 等候。
__shared__ float previous_sum;
if (threadIdx.x == 0) {
// consumer 端:反覆檢查 flag,直到 producer (block bid-1) 設好
while (atomicAdd(&flags[bid], 0) == 0) { } // busy-wait 直到旗標被設
previous_sum = scan_value[bid]; // 載入前驅傳來的 partial sum
scan_value[bid + 1] = previous_sum + local_sum; // 累積後傳給後繼 (producer 端)
__threadfence(); // 確保 scan_value 先寫進 global memory
atomicAdd(&flags[bid + 1], 1); // 再設後繼的旗標 -> 解除其等待
}
__syncthreads(); // 其餘 thread 在此等 leader 完成
| 元件 | 作用 |
|---|---|
flags[] (global) |
producer 設旗標、consumer 輪詢旗標,構成握手 |
scan_value[] (global) |
傳遞的 partial sum 本體 |
__threadfence() |
記憶體屏障:保證 scan_value[bid+1] 先到達 global memory,才設旗標 |
atomicAdd |
對 flags / counter 做原子讀-改-寫 (見 09-Parallel-Histogram/01-Atomic-Operations-and-Basic-Histogram) |
雖然 flags 與 scan_value 看似都打到 global memory,但近代 GPU 的這些存取多半落在 L2 cache,而且能與其他 block 的 phase 1 / phase 3 運算重疊。三-kernel 版本的 S 陣列存取在獨立 kernel 裡,完全無法重疊 —— 這正是 single-pass 的效能來源。
__threadfence() 不可省
若先設旗標再寫 scan_value,consumer 可能讀到尚未寫入的舊值。必須 fence 確保資料可見性先於旗標,否則結果錯誤。這是 weak memory model 下的典型陷阱。
Dynamic Block Index Assignment(防 Deadlock)
GPU 不保證依 blockIdx 線性排程 block。若 scheduler 先排了 block i ∼ i+N 並占滿所有 SM,而它們全都在等 block i-1 的和,但 block i-1 還排不進去 —— 就死鎖 (deadlock)。
解法:dynamic block index assignment —— 讓 bid 不再綁 blockIdx.x,改由原子計數器決定執行順序。
__shared__ unsigned int bid_s;
if (threadIdx.x == 0) {
bid_s = atomicAdd(blockCounter, 1); // 原子遞增全域計數器,取得動態索引
}
__syncthreads();
unsigned int bid = bid_s; // 全 block 共用此動態 bid
- leader thread 原子遞增
blockCounter,先執行 atomic 的 block 才拿到較小的 bid。 - 保證:若某 block 拿到 bid = i,則 bid = i-1 的 block 必定已被排程(因為它已執行過 atomic)→ 等待對象一定存在 → 不會死鎖,所有 scan block 邏輯上線性排程。
本章總結 (Chapter Summary)
| 演算法 | 加法次數 | 複雜度 | 定位 |
|---|---|---|---|
| Sequential scan | N − 1 | O(N) | 工作效率基準 |
| Kogge-Stone | N·log₂N − (N−1) | O(N·log₂N) | 快但不 work-efficient;適合資源充足下的小 section |
| Brent-Kung | 2N − 3 | O(N) | work-efficient (data-scalable);步數較多 |
| Thread-coarsened | (N−T)+(T·log₂T)+(N−T) | O(N) | 串行子段 + 並行整合,硬體利用率最佳 |
- Scan 把「以數學遞迴描述的看似序列」運算轉成並行,削減許多應用的序列瓶頸(資源/工作分配、polynomial、recurrence、radix sort、stream compaction、tree 操作…)。
- Hierarchical scan 把上述 section 級 kernel 擴展到任意長度;但三-kernel 版本有無法重疊的冗餘 global memory 往返。
- Domino-style single-pass scan 以單 kernel + adjacent synchronization 消除往返、提升 global memory 存取效率,但需謹慎處理 atomic 旗標、
__threadfence、__syncthreads,並用 dynamic block index 防死鎖。 - 進一步可用 warp-level shuffle 再優化。實務上一般人更可能直接用 Thrust 等 GPU scan library,而非自己手刻。
考試/面試重點 (Exam / Test Patterns)
| 情境 / 關鍵字 | 答案 / 技巧 |
|---|---|
| 「scan 處理百萬元素」 | Hierarchical / segmented scan;單 block kernel 只能處理一個 section |
| S 陣列大小 | N / SECTION_SIZE;S[i] = X[0]…scan block i 結尾的累積和 |
| 為何需要 Step 2 跨 block 寫 global | block 末端值要跨 thread block 可見,shared memory 不夠 |
| 三-kernel 缺點 | S 的 store/load 在獨立 kernel,延遲無法與運算重疊 |
| Single-pass 為何不需 grid-wide sync | 全域 scan 步驟可骨牌式逐塊單向傳遞 |
| 「stream-based scan」 | = domino-style scan,與 CUDA streams 無關 |
| Adjacent synchronization 實作 | global flags[] 旗標 + scan_value[] + atomic + __threadfence + __syncthreads;只由 leader thread 做 |
__threadfence() 目的 |
確保 scan_value 先寫進 global memory,才設旗標(防讀到舊值) |
| Domino 為何會 deadlock | GPU 不保證 block 依 blockIdx 線性排程;占滿 SM 的後段 block 全等前驅 |
| 防 deadlock 方法 | Dynamic block index:atomicAdd(blockCounter,1) 取得 bid,保證 i-1 已排程 |
| Producer / Consumer 哪邊設旗標 | Producer (block i-1) 設;Consumer (block i) 輪詢 |
| 為何 single-pass 較快但 traffic 不貴 | flags/scan_value 多落 L2 cache,且與其他 block 的 phase 1/3 重疊 |
| data-scalable 演算法 | 操作數隨輸入線性成長 → Brent-Kung (2N−3) 是,Kogge-Stone 不是 |
Related Notes
- 11-Prefix-Sum-Scan/01-Scan-Foundations-and-Kogge-Stone
- 11-Prefix-Sum-Scan/02-Work-Efficient-Scan-Brent-Kung-and-Coarsening
- 09-Parallel-Histogram/01-Atomic-Operations-and-Basic-Histogram
- 10-Reduction/03-Scaling-Reduction-Hierarchical-and-Coarsening
- 04-Compute-Architecture-And-Scheduling/01-GPU-Architecture-and-Block-Scheduling
- 13-Sorting/01-Sorting-Foundations-and-Parallel-Radix-Sort