gpumode · 강의 아카이브
《GPU Mode》 L008 2024 · MAR · 02 High priority transcript · available

CUDA Performance Checklist

L004 의 정량 모델이 “왜 빠르고 왜 느린가” 의 진단이라면, L008 은 같은 진단 결과를 받았을 때 무엇을 해야 하는가의 actionable 체크리스트. coalesce / occupancy / divergence / coarsening / privatization / tiling 의 6개 항목 — 각 항목에 same kernel 의 before/after CUDA 코드와 NCU 측정. PMPP 책의 정수를 한 “체크리스트” 로 다시 정리한 형태. Mark Saroufim 이 자기 GitHub 의 7개 .cu 파일과 함께 실시간으로 NCU 결과를 깐다.

memory coalescing occupancy warp divergence thread coarsening privatization tiling PTX latency table PMPP 의 한 페이지 요약
M
Speaker
Mark Saroufim
Meta · PyTorch core · GPU Mode 운영진 · L001 의 후속편
강의 번호
L008
스피커
Mark Saroufim
학습 우선순위
High · 정독
다시 볼 때
7개 .cu 직접 빌드
§ 01강의가 풀려는 문제· Why this lecture exists

“NCU 가 hint 를 줬다 — 그래서 무엇을 할 것인가” 의 책

L004 와 L001 까지 따라온 학습자가 자기 커널의 NCU report 를 보면 “achieved occupancy 38%, long scoreboard stall 28%” 같은 숫자를 받는다. 이걸 어떻게 행동으로 옮길 것인가? L008 이 그 매뉴얼이다 — 각 hint 에 대응되는 표준 변환 (coalesce, divergence 제거, coarsening 등) 을 항목별로 정리. 각 항목은 짧은 “before/after .cu” 한 페이지로.

강의의 운영 방식.

  1. Mark 의 GitHub repo 에 6 ~ 7개의 .cu 파일이 있고, 각 파일이 한 항목.
  2. 모두 nvcc 로 직접 빌드해서 ncu 로 측정. cloud 환경에서 NCU 가 막혀 있으면 자기 desktop 또는 lambda labs.
  3. 각 항목마다 “naive 버전 vs 개선 버전” 의 NCU 결과 비교.
시리즈 안의 위치

이 강의는 “PMPP 의 4–5장에서 본 것을 다시 한 페이지로” 의 정리. L004 가 정량적 진단, L001 이 도구. 이 강의는 둘이 합쳐졌을 때의 행동 매뉴얼.

“NCU 가 ‘여기가 느리다’ 라고 답해주면 — 다음 질문은 ‘어떤 변환을 적용할까’. 이 강의가 그 매핑.”학습 노트 · L008 §01
§ 02PTX latency 표· 무엇이 가장 비싼가

모든 최적화가 같은 한 사실에서 출발한다 — DRAM 이 SRAM 보다 100x 느리다

강의 첫 부분에서 Mark 가 인용하는 자료 — “Demystifying GPU Microarchitecture through Microbenchmarking” 같은 PTX-level latency 측정 논문. 이 표가 “왜 어떤 변환이 효과적인가” 를 한 번에 답해 준다.

register read
~1 cycle
FMA
~4 cycle
int32 add
~4 cycle
shared memory load
~30 cycle
L1 hit
~30 cycle
L2 hit
~200 cycle
DRAM (HBM)
~400 cycle
PCIe
~수만 cycle

이 표의 의미를 한 줄로 — “DRAM 을 안 가는 것” 이 모든 최적화의 첫 표적. 이 한 줄이 강의의 모든 6개 항목의 공통 모티프.

warp divergence 만 “DRAM 과 무관” 한 항목 — 그건 SM 의 issue throughput 의 문제다.

§ 03[01] memory coalescing· 한 warp 가 한 transaction

인접한 thread 가 인접한 메모리를 읽으면 — 한 transaction 으로 모임

CUDA 의 메모리 시스템은 warp (32 thread) 단위로 transaction을 일으킨다. 한 warp 의 thread 들이 인접한 32 word 를 읽으면 — 하나의 32-byte transaction 으로 합쳐진다. 만약 thread 들이 흩어진 자리를 읽으면 — 32개의 별도 transaction. 이게 coalesced vs uncoalesced.

// coalesce.cu — Mark 의 demo (요약)
__global__ void copyDataNonCoalesced(
    float* in, float* out, int n)
{
  int i = blockIdx.x * blockDim.x + threadIdx.x;
  if (i < n)
    out[i] = in[(i * 2) % n];          // stride 2 — 흩어짐
}

__global__ void copyDataCoalesced(
    float* in, float* out, int n)
{
  int i = blockIdx.x * blockDim.x + threadIdx.x;
  if (i < n)
    out[i] = in[i];                       // 한 warp 가 인접 32 word
}

non-coalesced (stride 2) — 32 transactions

w0[0] [_] [4] [_] [8] [_]
w1[_] [_] [_] [_] [_] [_]
w2[2] [_] [6] [_] [10] [_]
w3… 매 thread 가 별도 transaction

coalesced — 1 transaction

w0[0] [1] [2] [3] [4] [5] [6] [7]
w1[8] [9] [10][11][12][13][14][15]
한 warp = 한 32-byte transaction
NCU 측정

Mark 의 demo 에서 — memory throughput 89%, L1 cache throughput 70% 같은 패턴이 non-coalesced 에서. coalesced 버전은 같은 metric 이 훨씬 낮은데, 시간은 더 빠르다. memory throughput 이 “높다” 가 무조건 좋은 건 아님 — uncoalesced 가 같은 데이터를 여러 transaction 으로 가져와 throughput 을 인위적으로 부풀린다.

실전 적용 — tensor 가 contiguous 인지, indexing 이 마지막 차원을 stride 1 로 가는지 항상 검증. tensor.is_contiguous() 가 PyTorch 에서의 첫 체크.

§ 04[02] occupancy· register/shared/launch shape 의 회계

SM 이 비어 있으면 — DRAM 기다리는 동안 누구도 일하지 않는다

L004 §06 에서 깐 정의. 여기서는 actionable 측면 — 무엇을 줄여 occupancy 를 올릴 것인가.

1 register/thread 줄이기32 또는 64 register 가 흔한 경계 큰 변수를 작은 dtype 으로 (int64 → int32). 함수 inline 줄이기. __launch_bounds__(N) 으로 nvcc 에 hint. +10-30%occupancy
2 shared memory/block 줄이기SM 당 ~100 KB 의 거래 자원 tile 을 작게. 또는 한 buffer 를 시간으로 재사용 (load 와 compute 가 다른 자리 share). +10-50%occupancy
3 block size 조정32의 배수, 보통 128/256/512 너무 작으면 (예: 32) max threads/SM 한도가 binding. 너무 크면 register 한도가 binding. ±5-20%
4 grid 키우기SM 당 충분한 wave grid 가 GPU 의 SM 수보다 적으면 일부 SM idle. 입력 padding 또는 work 분할 변경. +10-100%tail effect 제거

강의에서 Mark 가 짚는 미묘한 사실 — “higher occupancy 가 항상 좋은 건 아니다.” compute-bound 커널은 80% → 60% 떨어져도 거의 차이 없을 수 있다. memory-bound 커널에서만 occupancy 가 진짜 중요. roofline 위에서 자기 위치를 알면 어느 자리에 노력을 할지 정해진다.

CUDA Occupancy Calculator

강의 시점부터는 NCU 에 통합. ncu --set full 의 occupancy section 이 theoretical occupancyachieved occupancy 둘 다 보여준다. 차이가 크면 — 보통 tail effect 또는 divergence 가 원인.

§ 05[03] warp divergence· if-else 의 비용

warp 안에서 thread 들이 다른 path 를 타면 — 두 path 가 직렬

warp 의 32 thread 는 lockstep 으로 한 instruction 을 같이 실행. if (cond) A else B 형태에서 일부 thread 는 cond=true, 일부는 false 면 — 두 branch 를 순차 실행, 각자 자기 반쪽이 idle. 이게 warp divergence.

divergent
AABABABB AABABABB AABABABB AABABABB
A 실행
···· ···· ···· ····
B 실행
···· ···· ···· ····
두 path 가 직렬로 실행. 각 path 동안 다른 path 의 thread 들은 idle. 같은 일을 한 path 에 모으면 절반의 시간.
// divergence.cu — Mark 의 demo (요약)
// before: divergent
__global__ void processArrayWithDivergence(int* data, int N) {
  int idx = blockIdx.x * blockDim.x + threadIdx.x;
  if (idx < N) {
    if (data[idx] % 2 == 0) data[idx] = data[idx] * 2;     // path A
    else                          data[idx] = data[idx] + 1;     // path B
  }
}

// after: branchless via predicate
__global__ void processArrayWithoutDivergence(int* data, int N) {
  int idx = blockIdx.x * blockDim.x + threadIdx.x;
  if (idx < N) {
    int isEven = !(data[idx] % 2);
    data[idx] = isEven * (data[idx] * 2) + (!isEven) * (data[idx] + 1);
  }
}
predicate 변환의 한계

위 코드는 두 branch 가 가벼울 때만 효과적. branch 안의 일이 무거우면 — 두 branch 를 모두 매번 실행하는 게 더 비싸다. 그때는 data 자체를 sort 해서 같은 warp 안에 같은 path 의 thread 들만 모이게 (data layout 변환). 이건 L009 Reductions 에서 본격.

§ 06[04] thread coarsening· 한 thread 가 더 많은 일

“한 thread = 한 element” 가 항상 답이 아니다

PMPP 의 첫 패턴 (한 thread = 한 출력 element) 은 단순하지만, 같은 입력을 여러 thread 가 다시 읽는 비효율을 유발할 수 있다. 한 thread 가 N 개 element 를 책임지면, 같은 입력을 register 에 한 번 load 해서 N 번 사용. memory traffic 이 1/N.

// coarsening.cu — naive 한 thread = 한 element
__global__ void reduce_naive(float* in, float* out, int n) {
  // 한 thread 가 한 element 만 읽고 곧바로 sync/reduce ...
}

// coarsened — 한 thread 가 4 element 를 register 에 누적
__global__ void reduce_coarsened(float* in, float* out, int n) {
  int tid  = blockIdx.x * blockDim.x * 4 + threadIdx.x;
  float sum = 0;
  sum += in[tid + 0 * blockDim.x];
  sum += in[tid + 1 * blockDim.x];
  sum += in[tid + 2 * blockDim.x];
  sum += in[tid + 3 * blockDim.x];   // 4 element 를 한 thread 안에서
  // 그 다음 shared / sync / block-reduce ...
}

이 변환의 효과들.

  • coalescing 유지 — thread 들이 stride blockDim.x 로 떨어진 element 를 읽으니, 한 warp 가 한 transaction.
  • register reuse — sum 변수가 register 에 살아 있어 매번 HBM 안 감.
  • thread 수 감소 — block 수가 1/4. occupancy 가 떨어질 수 있음 — N 의 결정이 trade-off.
coarsening 과 occupancy

N 이 너무 크면 — block 수가 SM 수보다 작아져 일부 SM idle. 또 한 thread 의 register 사용이 늘어 occupancy 떨어짐. 보통 N = 4 또는 8 정도가 sweet spot.

§ 07[05] privatization· atomic 을 register 로

여러 thread 가 같은 자리에 atomicAdd 한다 — block 안에서 먼저 모은다

histogram 같은 패턴이 대표적. 여러 thread 가 같은 bin 에 atomicAdd. atomic 이 직렬화 → 느림. 해결책 — block 안에서 먼저 shared 의 local histogram 에 atomic, block 끝에 한 번씩만 global 에 atomic. atomic 의 contention 이 N → N/blockDim.

// privatization.cu — naive global atomic
__global__ void hist_naive(int* data, int* hist, int n, int nbins) {
  int i = blockIdx.x * blockDim.x + threadIdx.x;
  if (i < n) atomicAdd(&hist[data[i] % nbins], 1);  // global atomic — contention
}

// privatized — shared 안에서 먼저
__global__ void hist_private(int* data, int* hist, int n, int nbins) {
  extern __shared__ int local_hist[];          // per-block
  for (int j = threadIdx.x; j < nbins; j += blockDim.x)
    local_hist[j] = 0;
  __syncthreads();

  int i = blockIdx.x * blockDim.x + threadIdx.x;
  if (i < n) atomicAdd(&local_hist[data[i] % nbins], 1);  // shared atomic — fast
  __syncthreads();

  for (int j = threadIdx.x; j < nbins; j += blockDim.x)
    atomicAdd(&hist[j], local_hist[j]);   // global atomic — block 당 한 번
}

privatization 은 “한 작업 의 결과를 어디에 모으는가” 의 layered 결정. block 안에서 shared 로 모으고, 그 다음 grid 단위로 global 로 모으는 hierarchical reduction 이 같은 패턴. L009 에서 full 형태.

§ 08[06] tiling 의 다음 단· register tiling, double buffer

L005 의 shared tiling 위에 한 단 더

L005 의 tiled matmul 은 “한 thread = 한 출력 element”. 다음 단은 한 thread 가 register 에 작은 sub-tile (예: 4×4) 을 들고 있게. 같은 shared element 를 한 thread 가 여러 번 사용 → register reuse. 이게 register tiling.

그 다음 단이 double buffering — 다음 phase 의 load 와 현재 phase 의 compute 를 overlap. shared memory 를 두 buffer 로 나누고 ping-pong. Hopper 의 cp.async 또는 software pipelining 으로 구현.

A register tilingthread 당 작은 sub-tile 한 thread 가 4×4 또는 8×8 출력 책임. shared 의 한 element 가 여러 번 register 에서 사용. cuBLAS 의 비밀. ×2-4cuBLAS 격차 좁힘
B double bufferingload 와 compute overlap shared 두 buffer · 한쪽이 load, 다른 쪽 compute. 다음 phase 의 HBM 시간을 hide. +10-30%
C vectorized loadsfloat4, __ldg 한 instruction 으로 4 float load. memory bandwidth 의 효율 ↑. reinterpret_cast<float4*>. +10-50%
D Tensor CoreWMMA / MMA instruction 한 instruction 에 16×16 matmul. fp16/bf16/int8 의 dedicated unit. CUTLASS 의 본론. ×4-10peak FLOPs

이 4단의 추가가 L005 의 tiled matmul 을 cuBLAS 의 거의 1배까지 끌어올린다. 하지만 그게 production 에서의 답인 적은 거의 없다 — cuBLAS 가 이미 잘 한다. 직접 짜는 가치는 fused (예: matmul + bias + relu) 또는 unusual shape.

§ 09체크리스트의 운영 방법· 언제 어느 항목부터

매뉴얼을 위에서 아래로 따르지 마라 — NCU 의 hint 가 시작점

강의의 의도적 메시지. “이 6개 항목을 위에서 아래로 다 적용하지 마라.” 대신 — NCU 의 hint 를 보고 어느 항목이 자기 커널에 해당하는지부터.

NCU 첫 hint — “memory throughput 낮음”L1/L2 throughput 이 의외로 낮음 coalescing 를 의심. tensor 가 contiguous 인지, indexing 의 마지막 차원이 stride 1 인지. 또는 tiling 으로 reuse 잡기. [01] [06]
NCU 다음 hint — “achieved occupancy 낮음”theoretical 과 큰 차이 register pressure 의심. -Xptxas=-v 로 register 수 확인. 또는 grid 가 작아 tail effect. [02]
NCU — “warp execution efficiency 낮음”active 비율이 낮음 warp divergence. 같은 warp 안의 thread 들이 다른 path. predicate 또는 data 정렬. [03]
NCU — “long scoreboard stall”memory dependency 가 dominant occupancy 부족 + latency hiding 실패. coarsening 으로 register reuse, 또는 occupancy ↑. [02] [04]
NCU — “atomic 이 시간의 N%”contention privatization. block 안 shared 로 먼저 모음. [05]
아무 hint 도 actionable 안 보임peak 의 70%↑ 여기서 멈춘다. 직접 CUDA 의 다음 단 (Tensor Core, double buffer) 은 cuBLAS 와의 거리가 작은 자리에서만. stop
“6개 항목을 모두 적용해서 빠른 커널을 만드는 게 아니라, NCU 가 하나를 가리키면 그 하나를 한다. 다음 hint 가 새 항목이면 또 그 하나.”학습 노트 · L008 §09
§ 10기억할 메모와 코드· key takeaways · repo

다시 열었을 때 5분 안에 손으로 잡혀야 할 것

L008 의 6개 항목 + 운영 방법.

DRAM 의 위치
register 1 cycle, shared 30, L2 200, DRAM 400+. 모든 최적화의 첫 표적은 “DRAM 안 가기”.
[01] coalescing
한 warp 의 thread 들이 인접 32 word 를 읽으면 1 transaction. 흩어지면 32. PyTorch 에선 is_contiguous.
[02] occupancy
register/thread, shared/block, max threads 의 가장 빡빡한 자리. memory-bound 면 중요.
[03] divergence
warp 안 if-else 가 같은 warp 의 thread 들 사이에 갈라지면 두 path 직렬. predicate 또는 data 정렬.
[04] coarsening
한 thread 가 N element. register reuse + thread 수 감소. N=4-8 가 sweet spot.
[05] privatization
global atomic 을 shared atomic + block 끝에 global 한 번. histogram 의 표준.
[06] tiling 다음 단
register tiling · double buffer · vectorized load · Tensor Core. cuBLAS 와의 거리.
운영 원칙
위에서 아래로 다 적용하지 말라. NCU 의 hint 가 가리키는 한 항목만.
멈출 신호
peak 의 70%↑, actionable hint 없음. 더 짜낼 수 있어도 ROI 가 작음.
Slides Google Slides
참고 PMPP Ch.6 (Performance considerations) · Mei et al. Demystifying GPU Microarchitecture · Soumith Chintala “Speed bottlenecks”

손에 새기기 — 실습 시퀀스

  1. 7개 .cu 모두 빌드 · NCU 측정 — repo clone, nvcc 로 빌드, ncu 로 측정. 강의의 결과와 자기 GPU 의 결과를 비교.
  2. coalesce.cu 의 stride 변경 — stride 2 → 4, 8, 16. 시간 변화. memory throughput metric 의 변화.
  3. divergence.cu 의 predicate 검증 — 두 버전의 결과가 정확히 같은지, 시간이 정말 짧아지는지.
  4. occupancy.cu 의 block size sweep — 32, 64, 128, 256, 512, 1024. occupancy 와 시간의 비-단조 관계 확인.
  5. coarsening.cu 의 N sweep — N = 1, 2, 4, 8, 16. register 사용량 (-Xptxas=-v) 과 시간의 관계.
  6. privatization.cu 와 privatization2.cu 비교 — 두 변형의 차이가 무엇인가. global atomic 의 비중을 NCU profile 에서.
  7. 자기 PyTorch model 의 한 hot kernelncu --set full 로 hint 를 받고, 매핑되는 항목 하나 선택해서 적용.
  8. 한 페이지 운영 매뉴얼 — “자기 model 의 가장 느린 kernel 에 ① ~ ⑥ 의 어떤 항목이 해당하나” 의 일대일 매핑을 페이지로.
§ 11다른 강의로 이어지는 길· connections

이 체크리스트가 어디에서 다시 등장하는지

L008 의 6개 항목이 거의 모든 후속 강의의 reference frame.

§ 12열린 질문· open questions

다음에 다시 들었을 때 직접 검증해야 할 것들

강의 안에서 흐릿하게 지나간 자리들과, 자기 환경에서 직접 측정해야 손에 박힐 사실들.

← Lecture 007 Charles Hernandez — quantization 의 dtype 사다리 Lecture 009 → Mark Saroufim — Reductions, atomic, warp shuffle