《GPU Mode》L020Scan Algorithm — Part 1High prioritytranscript · slides · available
Scan Algorithm
Sequential 코드에서 가장 빼기 어려운 패턴 — out[i] = out[i-1] + in[i] 의 loop-carried dependency. 그 obvious 한 sequential 을 parallel 로 어떻게 바꾸는가. Beirut 미국대학교의 Izzat El Haj 가 단일 thread block 안에서 Hillis-Steele · Brent-Kung 두 알고리즘을 깐 part 1 의 학습 노트.
Izzat 의 강의는 PMPP 교과서 7장 의 raw 영상 버전으로 보면 거의 정확하다. 다른 chapter 와 달리 scan 은 알고리즘이 직관과 정반대다 — sequential 로 짜면 5줄, parallel 로 잘 짜려면 한 chapter 가 든다.
강의가 깐 큰 질문 두 개.
왜 scan 이 중요한가 — radix sort, stream compaction, segmented reduction, dynamic memory allocation, sparse matrix 처리 등 거의 모든 GPU 데이터 알고리즘의 빌딩블록. L019 의 CSV 파서가 그 사용 사례 중 하나.
obvious 한 sequential 을 어떻게 parallel 로 짜는가 — 두 가지 핵심 알고리즘 (Hillis-Steele, Brent-Kung) 이 답을 준다. 같은 결과, 다른 trade-off.
강의의 인지적 frame
Izzat 은 part 1 에서 “single thread block 안에서만” 의 scan 을 다룬다. multi-block scan, decoupled lookback 같은 production 알고리즘은 다음 강의 L021 · 그리고 더 깊게 L024 의 영역. 이 part 1 은 알고리즘의 골격을 깐다.
“scan 은 GPU 컴퓨팅의 기본기다 — sort, compaction, reduce, parsing, sparse — 거의 모든 곳에 들어 있다.”Izzat El Haj (요약)
§ 02scan 의 정의· inclusive · exclusive
같은 데이터, 두 가지 출력 — 한 칸의 차이
scan = prefix sum 의 일반화. binary 결합 연산 ⊕ 와 입력 [x0, x1, ..., x_{n-1}] 가 주어지면, 출력은 위치 i 의 누적값. 두 변형이 있다.
FIG · inclusive vs exclusive scan (⊕ = +)같은 입력의 두 출력
입력31704163
inclusive34111115162225
exclusive0341111151622
inclusive 의 i 번째 = x0 ⊕ … ⊕ x_i (자기 포함). exclusive 의 i 번째 = x0 ⊕ … ⊕ x_{i-1} (자기 제외, 첫 칸은 identity 0). 둘은 한 칸 shift 차이. radix sort 같은 알고리즘은 exclusive 가 자연스럽고, running average 같은 작업은 inclusive 가 자연스럽다.
일반적으로 ⊕ 는 associative (결합법칙) 면 충분하다 — commutative 일 필요는 없다. min, max, or, and, 행렬 곱 모두 가능. associative 하지 않은 op (예: subtraction) 는 scan 으로 표현할 수 없다.
§ 03sequential 의 loop-carried dep· 왜 어려운가
5줄짜리 sequential 코드 — 그게 곧 문제다
// sequential — 단순하다 못해 자명한 코드void scan_seq(const float* in, float* out, int n) {
out[0] = in[0];
for (int i = 1; i < n; i++) {
out[i] = out[i-1] + in[i]; // ← 의존
}
}
loop iteration i 가 i-1 의 결과를 본다. iteration 들이 직렬로 흐를 수밖에 없다. 이게 GPU 가 못 푸는 패턴 — thread 들이 서로 다른 iteration 을 잡으려면 그 사이에 결과를 주고받아야 한다.
n 개 element 에 대해:
work = n-1 개 add 연산
step = n-1 개 (직렬)
parallelism = 0 (순차)
parallel 알고리즘의 목표 — 같은 work 를 유지하면서 step 을 log(n) 까지 내리는 것. 또는 work 를 약간 늘리는 대신 step 을 더 내리는 것.
왜 scan 이 “직관과 반대” 인가
Brent–Kung 같은 work-efficient scan 은 sequential 코드와 정확히 같은 work (n-1 op) 를 하면서 step 만 2log(n) 으로 줄인다. “같은 일을, 다른 순서로” 라는 GPU 알고리즘 디자인의 표준 패턴.
§ 04Hillis-Steele scan· 단순한 doubling
“멀리 있는 element 를 한 칸씩 더 멀리 본다” — log(n) step
가장 단순한 parallel scan. step k 에서 thread i 는 자기 값에 2^k 떨어진 element 를 더한다. log(n) step 후 모든 thread 가 자기 자리의 prefix sum 을 들고 있다.
3 step 만에 모두 자기 prefix sum. 일반화하면 step = log₂(n). 그런데 work 는 n·log(n) — 매 step 마다 거의 모든 thread 가 add 한다. sequential 의 n 보다 log(n) 배 많은 일.
언제 Hillis-Steele 가 이기는가
n 이 작을 때(예: 32, warp 크기). 코드가 매우 단순하고 control divergence 가 거의 없어서 instruction 효율이 높다. __shfl_up_sync 같은 warp shuffle 로 구현하면 shared memory 도 안 쓰고 warp 안에서 끝난다 — § 21 에서 다시 등장.
§ 05Brent-Kung (Blelloch) scan· work-efficient tree
tree 위로 reduce → tree 아래로 distribute — work O(n)
두 phase 알고리즘. up-sweep (reduce) — binary tree 를 위로 올라가면서 partial sum 을 모은다. down-sweep — 트리를 다시 내려오면서 누적값을 분배한다.
FIG · Brent-Kung up-sweep (n=8)partial sum 누적
log(n) step. 각 step 의 work 가 절반씩 줄어 — 총 work = n−1 (sequential 과 동일). 그 후 down-sweep 이 또 log(n) step 으로 누적값을 트리 아래로 뿌린다. 총 work O(n), 총 step 2log(n).
// Brent-Kung — 약식 (up-sweep)for (stride = 1; stride < BLOCK; stride *= 2) {
int idx = (tid + 1) * stride * 2 - 1;
if (idx < BLOCK) buf[idx] += buf[idx - stride];
__syncthreads();
}
// down-sweep (exclusive 의 경우 root 를 0 으로 set 후)for (stride = BLOCK/2; stride > 0; stride /= 2) {
int idx = (tid + 1) * stride * 2 - 1;
if (idx + stride < BLOCK) {
float t = buf[idx];
buf[idx] += buf[idx - stride];
buf[idx - stride] = t;
}
__syncthreads();
}
§ 06work vs step efficiency· 두 비용 축
같은 결과, 두 가지 가격표 — 알고리즘을 비교하는 정식 frame
PMPP 가 가르치는 정식 frame. work efficiency = 알고리즘이 sequential 과 같은 op 수를 쓰는가. step efficiency = critical path 의 길이가 얼마나 짧은가. 두 축은 trade-off — Hillis-Steele 은 step 이 짧지만 work 가 많다.
Hillis-Steele
total workO(n log n)
step (depth)log₂(n)
work-efficient?no
step-efficient?yes
코드 복잡도simple
언제 좋은가n 작을 때
Brent-Kung (Blelloch)
total workO(n)
step (depth)2·log₂(n)
work-efficient?yes
step-efficient?2× 의 step
코드 복잡도more
언제 좋은가n 클 때
실전의 진실
둘 다 “순수” 형태로는 거의 안 쓰인다. 대부분의 production 코드는 warp-level Hillis-Steele + block-level Brent-Kung + grid-level decoupled lookback 의 hierarchical 합성. 이 합성이 L021 의 main 주제. 그리고 L024 가 “speed of light” 까지 짜내는 영역.
“sequential 과 같은 work 를 유지하면서 step 만 줄이는 게 좋은 GPU 알고리즘이다 — Brent-Kung 이 그 정신을 가장 깔끔하게 보여준다.”Izzat El Haj (요약)
§ 07double buffering· in-place 의 함정
같은 buffer 에 read 와 write 가 섞이면 race
강의에서 Izzat 이 직접 보여준 작은 “함정”. naive 한 Hillis-Steele 구현은 buf[tid] = buf[tid] + buf[tid - stride] — 같은 buffer 에 읽고 쓴다. 어떤 thread 가 자기 자리를 update 한 뒤, 다른 thread 가 그 update 된 값을 읽으면 결과가 망가진다.
해결은 double buffering — read 와 write 를 다른 buffer 로 분리, 매 step 마다 swap. buf[2][BLOCK] 으로 두 개를 두고 read, write index 를 toggle.
trade-off
shared memory 사용량이 2배. 작은 BLOCK 에서는 문제 없고, 큰 BLOCK (1024+) 에서는 occupancy 에 영향. Brent-Kung 은 in-place 가 안전하다 — read 와 write 의 자리가 step 별로 disjoint 하기 때문.
같은 warp 안 thread 도 “SIMD 처럼 동시에 도는 것” 이 더 이상 보장되지 않는다
강의의 미묘한 디테일. Volta 이전 GPU 에서는 같은 warp 의 32 thread 가 lockstep 으로 도는 게 자연스러운 가정이었다 — warp-internal 동기화가 implicit. Volta 부터 independent thread scheduling 이 들어가서 — warp 안 thread 들이 더 이상 lockstep 으로 도는 게 보장되지 않는다.
Volta+ 에서 안전하게 짜는 법
warp 안에서 다른 thread 가 쓴 결과를 읽으려면 — (1) __syncwarp() 로 명시적 barrier, (2) __shfl_*_sync 같은 sync 가 명시된 collective 만 사용. compiler 도 memory 명령 reorder 를 더 적극적으로 한다 — volatile 또는 __threadfence_block() 가 필요한 경우 늘어남.
그리고 강의의 또 한 자리 — 같은 warp 안 thread 들 사이에서도 ordering 보장이 약해서 “thread A 가 buf[5] 에 쓰고 thread B 가 그 직후 buf[5] 를 읽는다” 같은 패턴은 __syncthreads() 또는 __syncwarp() 없이는 안전하지 않다. scan 처럼 stride 마다 의존성이 바뀌는 알고리즘에서 매 step 후 반드시 sync.