Scan 基礎與 Kogge-Stone 演算法

重點總覽 (Overview)

Scan (prefix sum) 把「每一項都依賴前一項」的 sequential recurrence 轉成平行運算,是 radix sort、stream compaction、polynomial evaluation 等演算法的 primitive。本筆記涵蓋定義、循序基準、naïve 平行版本,以及 Kogge-Stone kernel 與其 work-efficiency 分析。

演算法 (Algorithm) 運算量 (Work) 步數 (Steps, P=N) Work-efficient? 備註
Sequential scan N − 1 加法, O(N) N ✅ (基準) 單一迴圈累加
Naïve parallel (每個輸出各做一棵 reduction) N(N−1)/2, O(N²) N (最長那條) 沒共享 partial sum,又慢又耗資源
Kogge-Stone N·log₂N − (N−1), O(N log N) log₂N ❌ (但遠優於 naïve) 共享 partial sum;步數少、resource 充足時最快
核心 tradeoff

Scan 是「平行演算法的 work complexity 可能高於 sequential」的典型例子。Kogge-Stone 用「多做工 (O(N log N))」換「少步數 (log₂N)」——只有在硬體 execution unit 夠多時才划算。


Scan 的定義:Inclusive vs Exclusive

給定 binary associative operator 與輸入 [x₀, x₁, …, x_{n-1}]

類型 輸出 第 0 個元素 直覺 (香腸範例)
Inclusive scan [x₀, (x₀⊕x₁), …, (x₀⊕…⊕x_{n-1})] x₀ 切點 (cut points):每段結束的位置
Exclusive scan [i, x₀, (x₀⊕x₁), …, (x₀⊕…⊕x_{n-2})] identity i 起點 (beginning points):每段開始的位置

範例 = 加法,輸入 [3 1 7 0 4 1 6 3]):

兩者可直接互轉

  • Inclusive → Exclusive:整體右移一格,第 0 位填 identity。
  • Exclusive → Inclusive:整體左移一格,末位填「舊末位 ⊕ 末項輸入」。
    因此書中只實作 inclusive;exclusive 只需改載入方式(見下)。


循序基準 (Sequential O(N) Baseline)

void sequential_scan(float *x, float *y, unsigned int N) {
    y[0] = x[0];
    for (unsigned int i = 1; i < N; ++i) {
        y[i] = y[i - 1] + x[i];   // 把前一個輸出累加一個新輸入
    }
}

Naïve 平行 Scan:為什麼不可行

直覺:讓每個 thread 對自己的輸出位置 y_i 獨立做一棵 reduction(累加 x₀…x_i)。

結論

Naïve 平行 scan 既不 work-efficient 又不快,還要更多 execution resource。Kogge-Stone 的關鍵突破就是跨 reduction tree 共享 partial sums


Kogge-Stone 平行 Scan (Kogge-Stone Algorithm)

源自 1970 年代高速加法器電路設計 (Kogge & Stone, 1973)。是 in-place 演算法,在陣列 XY 上反覆演進:

不變量 (invariant):經過 k 次 iteration 後,XY[i] 含有「位置 i 及其左側共 2ᵏ 個輸入元素」的和。

演進 (8-element 範例,stride 1→2→4)

初始:     x0   x1   x2   x3   x4   x5   x6   x7
          |    |    |    |    |    |    |    |
stride=1: |  +←|  +←|  +←|  +←|  +←|  +←|  +←|     XY[i] += XY[i-1]
          x0  x0:1 x1:2 x2:3 x3:4 x4:5 x5:6 x6:7
          |    |    |    |    |    |    |    |
stride=2: |    |  +←┘  +←┘  +←┘  +←┘  +←┘  +←┘     XY[i] += XY[i-2]
          x0  x0:1 x0:2 x0:3 x1:4 x2:5 x3:6 x4:7
          |    |    |    |    |    |    |    |
stride=4: |    |    |    |  +←──┘ +←──┘ +←──┘+←┘   XY[i] += XY[i-4]
          x0  x0:1 x0:2 x0:3 x0:4 x0:5 x0:6 x0:7   ← 最終 inclusive scan

(xa:b 表示 x_a + … + x_b+← 表示「加上左方 stride 距離的舊值」)

Kernel (Fig. 11.3)

__global__ void Kogge_Stone_scan_kernel(float *X, float *Y, unsigned int N) {
    __shared__ float XY[SECTION_SIZE];        // block size == SECTION_SIZE
    unsigned int i = blockIdx.x*blockDim.x + threadIdx.x;
    if (i < N) XY[threadIdx.x] = X[i];        // 合併載入 global → shared
    else       XY[threadIdx.x] = 0.0f;        // 越界補 identity

    for (unsigned int stride = 1; stride < blockDim.x; stride *= 2) {
        __syncthreads();                      // (1) 確保上一回合寫入完成
        float temp;                           // partial sum 暫存到 register
        if (threadIdx.x >= stride)
            temp = XY[threadIdx.x] + XY[threadIdx.x - stride];  // 先「讀」
        __syncthreads();                      // (2) 確保所有 thread 都讀完舊值
        if (threadIdx.x >= stride)
            XY[threadIdx.x] = temp;           // 才「寫」回
    }
    if (i < N) Y[i] = XY[threadIdx.x];
}
Write-after-read race condition(與直方圖的不同!)

每個 active thread 同時讀 XY[threadIdx.x]XY[threadIdx.x - stride]。若 thread i 太早把新值寫回 XY[i],thread i+stride 可能讀到新值而非舊值 → 結果錯誤(時序相依、run-to-run 不可重現)。

  • 這是 write-after-read (WAR) hazard,不能用 atomic 解決(直方圖那種是 read-modify-write)。
  • 解法:先把和寫入 register temp__syncthreads() → 再統一寫回 XY。需要第二個 barrier
Double-buffering(省掉第二個 barrier)

兩個 shared memory buffer,每回合「讀 buffer A、寫 buffer B」交替。讀寫位置不同 → 沒有 WAR race → 每回合只需 1 個 __syncthreads()(書中 exercise)。

對比 Chapter 10 Reduction:為何 reduction 不需要 temp?

Reduction (Ch.10) Kogge-Stone Scan
讀取位置 input[tid]input[tid+stride] XY[tid]XY[tid-stride]
被寫入位置是否被他人讀 (stride 距離位置不被任何 active thread 寫) (共享 partial sum 導致他人會讀)
需要第二個 barrier? (或 double-buffer)

Exclusive 版本

只改載入 4 行(取代 Fig. 11.3 的 04–08):XY[0] 載入 0(identity),其餘 XY[threadIdx.x] 載入 X[i-1](右移一格),iteration 邏輯完全不變。

Control divergence

小位置的 thread 比大位置早結束(if (threadIdx.x >= stride))。divergence 只發生在第一個 warp、且只在 stride < warp_size 的回合(stride 1,2,4,8,16)。large block 時影響很小。


速度與 Work Efficiency 分析 (Speed & Work Efficiency)

Work efficiency = 演算法實際做的工作量,距離「最小必要工作量」有多近。最小 = sequential 的 N − 1 次加法。

Kogge-Stone work 推導

每個 iteration 的 active thread 數 = N − stride(inactive = stride):

Work = Σ (N − stride),  stride ∈ {1, 2, 4, …, N/2}   ← 共 log₂N 項
     = N·log₂N − (1 + 2 + 4 + … + N/2)               ← 後段為等比級數 = N−1
     = N·log₂N − (N − 1)

O(N log₂N):比 naïve 的 O(N²) 好,但仍比 sequential 的 O(N) 多做工

實際 CUDA 上更糟

用了 N 個 thread;即使許多 thread 停止參與,它們仍佔住整個 warp 的 execution resource 直到 warp 完成。實際消耗接近 N·log₂N(連那 −(N−1) 都省不掉)。

量化範例

指標 公式 N=512 N=1024
Sequential 步數 N 512 1024
比 sequential 多做幾倍工 ≈ log₂N ≈ 8× ≈ 10×
步數 (P=N,資源無限) log₂N 9 10
理想步數縮減 N / log₂N ≈ 56.9× ≈ 102×
步數 (P=32 execution units) (N·log₂N)/P (1024·10)/32 = 320
實際 speedup (P=32) N / steps 1024/320 = 3.2×
兩個代價 + 一個強項

  • 代價 1:硬體利用率低;若 P 太小,平行版甚至比 sequential 還
  • 代價 2:多做的工 = 多耗能源,不適合 mobile / power-constrained。
  • 強項:resource 充足時速度極快、control divergence 極少、可用 warp-level shuffle 指令加速。適合處理 512 / 1024 的中等 section,是現代高速 scan 的重要組件。


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

情境 / 關鍵字 答案 / 技巧
Inclusive vs exclusive 差別 inclusive 含本身(切點);exclusive 排除本身、首位填 identity(起點)。右移/左移一格互轉
Exclusive scan 第 0 個元素是什麼 operator 的 identity value(加法 → 0
Sequential scan 的 work / 複雜度 N − 1 次加法,O(N)(work-efficiency 基準)
Naïve 平行 scan 為何爛 每輸出獨立 reduction → O(N²)y_{n-1} 仍要 N 步、無 speedup
Kogge-Stone 不變量 k 個 iteration 後 XY[i] = 位置 i 及左側 2ᵏ 個元素的和
Kogge-Stone work / 步數 work = N·log₂N − (N−1) → O(N log N);步數 = log₂N
為何要 temp + 第二個 __syncthreads() 解決 write-after-read race;先全讀進 register,barrier 後再寫回
Kogge-Stone 的 race 與直方圖的差別 此處 WAR(不可用 atomic);直方圖是 read-modify-write(可用 atomic)
避免第二個 barrier 的方法 double-buffering(兩個 buffer,讀寫交替)
Kogge-Stone control divergence 範圍 第一個 warp、stride < warp_size(1,2,4,8,16)
何時選 Kogge-Stone execution resource 充足、section 中等(512/1024)、要最少步數時
512 元素比 sequential 多做幾倍工 約 8 倍(log₂512 = 9)