任意長度輸入與 Single-Pass Scan

重點總覽 (Overview)

前兩篇 (11-Prefix-Sum-Scan/01-Scan-Foundations-and-Kogge-Stone11-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
Important

關鍵觀察:全域 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

三-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 跳過
}
Warning

三-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

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)
Tip

雖然 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

本章總結 (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) 串行子段 + 並行整合,硬體利用率最佳

考試/面試重點 (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 不是