TL;DR
- CUDA는 NVIDIA의 GPU 프로그래밍 플랫폼. 2007년 출시 이후 AI/HPC의 사실상 표준. ChatGPT, Stable Diffusion 같은 모든 주요 AI 워크로드가 CUDA 위에서.
- SIMT (Single Instruction, Multiple Threads): 프로그래머는 스칼라 코드를 쓰고, 하드웨어가 32 threads = 1 warp로 묶어 병렬 실행. SIMD의 효율 + 스칼라 프로그래밍의 생산성.
- 스레드 계층: Grid → Block → Warp → Thread. 각 block은 같은 SM(Streaming Multiprocessor)에서 실행. Warp 단위 스케줄링.
- 메모리 계층: Register(가장 빠름) → Shared Memory(L1 크기) → L2 Cache → Global Memory(느리지만 큼). 각 레벨이 수십~수백 배 차이.
- Memory Coalescing: Warp의 32 스레드가 연속 주소를 접근하면 1 transaction. 랜덤 접근은 32 transactions. 32배 대역폭 차이 → CUDA 최적화의 1순위.
- Warp Divergence: Warp 내 스레드가 다른 경로를 타면 순차 실행 → 병렬성 손실.
- Tensor Core (Volta, 2017): 행렬 곱을 하나의 명령어로. FP16 기준 125 TFLOPS. AI 가속의 열쇠.
- 현대적 흐름: Triton, cutlass, FlashAttention 같은 고수준 추상화가 핸드-튜닝 CUDA를 대체.
- 대안: ROCm/HIP (AMD), SYCL/oneAPI (Intel), Metal (Apple). 생태계 격차로 CUDA가 여전히 지배.
1. GPU가 왜 AI를 지배하는가
1.1 CPU vs GPU
| 항목 | CPU | GPU |
|---|---|---|
| 코어 수 | 8-128 | 수천-수만 |
| 코어당 성능 | 매우 빠름 | 상대적으로 느림 |
| 캐시 | 큰 L1/L2/L3 | 작은 공유 캐시 |
| 병렬성 | ILP + MIMD | SIMT (massive) |
| 용도 | 범용, 분기 많은 로직 | 수학, 데이터 병렬 |
CPU: "적은 수의 영리한 코어가 복잡한 일을". GPU: "수많은 단순 코어가 같은 일을 병렬로".
1.2 AI 워크로드의 특성
Transformer 추론 한 번:
MatMul × 수천 번
Element-wise 연산 (GELU, LayerNorm)
Attention (QK^T, softmax, @V)
모두 대규모 행렬 연산. 수십억 개의 MAC (multiply-accumulate) 연산. CPU로는 초당 100-1000 GFLOPS, GPU는 100,000+ TFLOPS (H100 기준).
1000배 성능 차이. "AI 시대의 CPU는 GPU"가 맞는 말.
1.3 NVIDIA의 독점
왜 NVIDIA만? 세 가지 요인:
- CUDA 생태계: 2007년부터 쌓인 라이브러리, 문서, 커뮤니티.
- 하드웨어 투자: Tensor Core, HBM, NVLink, NVSwitch.
- Jensen Huang의 장기 베팅: 2012년 AlexNet 이후 AI 올인.
AMD (MI300), Intel (Gaudi), Google (TPU), Apple (M-series), 중국 (Huawei Ascend) 모두 경쟁 중이지만 2025년 기준 CUDA 호환성과 성숙도가 여전히 NVIDIA 독점.
1.4 이 글의 초점
"CUDA를 모르고 AI 시스템을 이해할 수 없다". 이 글은 프로그래밍 모델부터 하드웨어까지 해부한다.
2. GPU 하드웨어 아키텍처
2.1 Streaming Multiprocessor (SM)
GPU의 기본 단위는 SM (Streaming Multiprocessor). 각 SM은 독립된 실행 유닛.
H100: 132 SM
A100: 108 SM
RTX 4090: 128 SM
각 SM 내부:
┌─────────────────────────────────┐
│ SM │
│ ┌─────────┐ ┌─────────┐ │
│ │ Warp │ │ Warp │ × 4 │
│ │ Sched │ │ Sched │ │
│ └─────────┘ └─────────┘ │
│ │
│ CUDA Cores (INT32, FP32, FP64) │
│ Tensor Cores │
│ Special Function Units │
│ Load/Store Units │
│ │
│ Register File (수만 개) │
│ Shared Memory / L1 (수백 KB) │
└─────────────────────────────────┘
- CUDA Cores: 일반 ALU. FP32 연산.
- Tensor Cores: 행렬 곱 전용.
- Register File: 매우 큼 (수만 개). 스레드가 많아서.
- Shared Memory: L1 캐시와 같은 공간. 사용자가 직접 관리.
2.2 Warp
Warp = 32 threads가 lockstep으로 실행되는 그룹. NVIDIA GPU의 근본 단위.
SM은 여러 warp를 동시에 실행:
- A100: SM당 최대 64 warps (= 2048 threads) 활성.
- H100: 더 많음.
Warp scheduler가 매 cycle 실행 가능한 warp를 선택 → 지연 시간 숨김.
2.3 SIMT — 프로그래밍 모델
SIMT (Single Instruction, Multiple Threads):
- 프로그래머는 하나의 스레드처럼 코드를 쓴다.
- 하드웨어가 32 threads를 묶어 같은 명령어를 다른 데이터에 실행.
SIMD와의 차이:
| SIMD (e.g., AVX) | SIMT (CUDA) |
|---|---|
| 명시적 vector | 스칼라 코드 |
| 8×float 한 번에 | 32 threads가 32×float |
| 분기 어려움 | 분기 가능 (divergence 대가) |
| 개발 어려움 | 자연스러움 |
SIMT는 SIMD의 효율 + 스칼라 프로그래밍의 생산성. CUDA의 킬러 피처.
2.4 GPU 전체 구조
Host (CPU + DRAM)
│ PCIe / NVLink
GPU
├── L2 Cache (수십 MB, 전체 공유)
├── Global Memory (HBM, 수십-수백 GB)
└── SM × N
├── L1 / Shared Memory
├── Register File
└── Cores
Global Memory (= HBM, High Bandwidth Memory):
- GPU 전체에서 공유.
- 크다 (40-192 GB).
- 느리다 (수백 cycle 지연).
- 하지만 HBM 대역폭은 엄청나다 (3 TB/s on H100).
L2 Cache: 모든 SM 공유. 수십 MB.
L1 / Shared Memory: SM 안에. 매우 빠름. Shared Memory는 프로그래머가 직접 관리.
Register: 가장 빠름. SM 내부. 스레드별로 자기 공간.
2.5 계층별 접근 시간
대략적 지연 시간 (A100 기준):
Register: 1 cycle
Shared Memory: ~20 cycles
L1 cache: ~30 cycles
L2 cache: ~200 cycles
HBM: ~500 cycles
PCIe로 host DRAM: ~10,000+ cycles.
데이터 locality가 성능의 전부. 가능한 한 낮은 레벨에 데이터를 유지.
3. CUDA 프로그래밍 모델
3.1 Hello World
#include <stdio.h>
__global__ void hello_kernel() {
int tid = threadIdx.x + blockIdx.x * blockDim.x;
printf("Hello from thread %d\n", tid);
}
int main() {
hello_kernel<<<2, 32>>>(); // 2 blocks × 32 threads = 64 threads
cudaDeviceSynchronize();
return 0;
}
__global__: GPU에서 실행되는 함수 (kernel).<<<grid, block>>>: 실행 구성 — grid 크기 × block 크기.threadIdx.x: block 내 thread 인덱스.blockIdx.x: grid 내 block 인덱스.blockDim.x: block 크기.
3.2 스레드 계층
Grid
├── Block 0
│ ├── Thread 0
│ ├── Thread 1
│ └── ...
├── Block 1
│ └── ...
└── ...
Grid: 여러 block. 커널 실행당 하나. Block: 여러 thread. 같은 SM에서 실행, shared memory 공유. Thread: 기본 단위.
이차원/삼차원도 가능:
dim3 grid(16, 16);
dim3 block(32, 32);
kernel<<<grid, block>>>();
// 총 16×16×32×32 = 262,144 threads
threadIdx.y, blockIdx.z 등으로 접근.
3.3 전역 인덱스 계산
__global__ void add_kernel(float *a, float *b, float *c, int n) {
int idx = threadIdx.x + blockIdx.x * blockDim.x;
if (idx < n) {
c[idx] = a[idx] + b[idx];
}
}
// 호출
int n = 1000000;
int blockSize = 256;
int gridSize = (n + blockSize - 1) / blockSize; // ceil(n / blockSize)
add_kernel<<<gridSize, blockSize>>>(d_a, d_b, d_c, n);
- 각 thread가 배열의 한 원소 처리.
n이blockSize의 배수가 아니면 마지막 block에 일부 thread가 범위 초과 →if체크.
3.4 메모리 관리
// Host (CPU) 메모리
float *h_a = (float*)malloc(n * sizeof(float));
// Device (GPU) 메모리
float *d_a;
cudaMalloc(&d_a, n * sizeof(float));
// Host → Device 복사
cudaMemcpy(d_a, h_a, n * sizeof(float), cudaMemcpyHostToDevice);
// Kernel 실행 (d_a 사용)
kernel<<<grid, block>>>(d_a);
// Device → Host 복사
cudaMemcpy(h_a, d_a, n * sizeof(float), cudaMemcpyDeviceToHost);
// 해제
cudaFree(d_a);
free(h_a);
명시적으로 host/device 메모리 관리. PCIe 복사가 비쌈 → 최소화.
3.5 Unified Memory
CUDA 6+에서 managed memory:
float *data;
cudaMallocManaged(&data, n * sizeof(float));
// CPU에서 접근
for (int i = 0; i < n; i++) data[i] = i;
// GPU에서 접근
kernel<<<grid, block>>>(data);
cudaDeviceSynchronize();
런타임이 페이지 폴트를 감지해 자동으로 host↔device 이동. 개발 편의성 ↑, 수동 관리보다 약간 느림.
4. 메모리 계층 상세
4.1 Global Memory
- 가장 큼 (40-192 GB).
- GPU 전체 공유.
- 느림 (수백 cycle).
- 모든 kernel에서 접근 가능.
__global__ void kernel(float *data) {
data[threadIdx.x] = threadIdx.x * 2.0; // global memory 쓰기
}
4.2 Shared Memory
- SM 내부 (L1과 동일 물리).
- 매우 빠름 (수십 cycle).
- Block 내 thread들이 공유.
- 크기 제한 (~48-228 KB per SM).
__global__ void kernel() {
__shared__ float shared_data[256];
shared_data[threadIdx.x] = threadIdx.x;
__syncthreads(); // block 내 모든 thread 동기화
// 모든 thread가 shared_data 읽을 수 있음
float sum = 0;
for (int i = 0; i < 256; i++) {
sum += shared_data[i];
}
}
Use case: 같은 데이터를 여러 thread가 반복 접근 → global에서 shared로 가져와서 빠르게.
4.3 Register
- 가장 빠름 (1 cycle).
- Thread 로컬.
- 컴파일러가 자동 할당.
__global__ void kernel() {
float x = 1.0; // register
float y = 2.0; // register
float z = x + y;
}
각 SM의 register file이 크다 (수만 개). 그래도 한 thread가 너무 많이 쓰면 register spill → global memory로 넘어감 → 느림.
4.4 Constant Memory
__constant__ float coefficients[256];
__global__ void kernel(float *input) {
float x = input[threadIdx.x];
float y = 0;
for (int i = 0; i < 256; i++) {
y += coefficients[i] * pow(x, i);
}
}
// Host에서
cudaMemcpyToSymbol(coefficients, host_data, 256 * sizeof(float));
- 64 KB 크기.
- Read-only.
- 빠른 broadcast (warp의 모든 thread가 같은 값 읽을 때).
4.5 Texture Memory
원래 그래픽 텍스처용이지만 compute에서도 사용:
- 하드웨어 캐싱.
- 2D 공간 지역성 최적화.
- 보간 하드웨어.
이미지 처리에 유용. 현대에서는 __ldg() (Load Global Read-only cache)로 부분 대체.
5. Memory Coalescing — 최우선 최적화
5.1 원리
Warp (32 threads)가 메모리에 접근할 때:
Coalesced: 32 threads가 연속된 128 바이트 접근.
Thread 0 → address 0-3
Thread 1 → address 4-7
...
Thread 31 → address 124-127
→ 1 memory transaction (128 bytes).
Uncoalesced: 32 threads가 흩어진 주소 접근.
Thread 0 → address 0
Thread 1 → address 1024
Thread 2 → address 2048
...
→ 32 memory transactions, 각 128 bytes 중 4 bytes만 사용.
대역폭 차이 32배. 실제 GPU 성능의 성패가 여기서 결정된다.
5.2 예제
BAD:
__global__ void transpose_bad(float *in, float *out, int N) {
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
out[x * N + y] = in[y * N + x];
// ↑ 스트라이드 N
}
Warp의 thread들이 out[0], out[N], out[2N], ...에 접근 → stride 접근 → uncoalesced.
GOOD (shared memory 중간 단계):
__global__ void transpose_good(float *in, float *out, int N) {
__shared__ float tile[32][33]; // +1 for bank conflict 방지
int x = blockIdx.x * 32 + threadIdx.x;
int y = blockIdx.y * 32 + threadIdx.y;
// Coalesced read from input
tile[threadIdx.y][threadIdx.x] = in[y * N + x];
__syncthreads();
x = blockIdx.y * 32 + threadIdx.x;
y = blockIdx.x * 32 + threadIdx.y;
// Coalesced write to output
out[y * N + x] = tile[threadIdx.x][threadIdx.y];
}
Read와 write 모두 coalesced. Shared memory에서 "transpose" 수행.
결과: 20-30배 성능 차이.
5.3 패턴 암기
"Thread i가 array[i]를 접근하라". 이것이 coalescing의 기본.
행렬을 2D 접근할 때:
- Row-major: thread
tid가A[blockIdx.y * N + tid]→ coalesced. - Column-major:
A[tid * N + blockIdx.x]→ uncoalesced.
6. Warp Divergence
6.1 문제
Warp 내 32 threads가 다른 경로를 타면?
__global__ void divergent_kernel(int *data) {
if (threadIdx.x < 16) {
data[threadIdx.x] = compute_a();
} else {
data[threadIdx.x] = compute_b();
}
}
Hardware는 양쪽 경로 모두 순차 실행한다:
- Threads 0-15:
compute_a()실행, 16-31은 idle. - Threads 16-31:
compute_b()실행, 0-15는 idle.
→ 병렬성 절반. Warp divergence의 대가.
6.2 완화 패턴
패턴 1: Warp 단위로 분기
if (blockIdx.x % 2 == 0) { /* path A */ }
else { /* path B */ }
같은 block의 모든 thread가 같은 경로 → warp 전체가 같은 경로 → divergence 없음.
패턴 2: 분기 대신 산술
// Bad
if (x > 0) y = x; else y = -x;
// Good (no branch)
y = fabs(x);
패턴 3: 양쪽 계산 후 선택
float a = compute_a();
float b = compute_b();
float result = (threadIdx.x < 16) ? a : b;
두 결과 모두 계산하지만 분기 없음.
6.3 Independent Thread Scheduling (Volta+)
Volta 아키텍처 (2017)부터 warp 내 thread들이 독립적 스케줄. 더 유연한 divergence 처리 가능.
하지만 성능 관점에선 여전히 divergence 피하는 것이 좋음.
7. Shared Memory Bank Conflict
7.1 Shared Memory 구조
Shared memory는 32개 bank로 나뉘어 있다. 각 bank는 32-bit word 단위.
- Bank 0: words 0, 32, 64, ...
- Bank 1: words 1, 33, 65, ...
- ...
- Bank 31: words 31, 63, 95, ...
Warp의 32 threads가 서로 다른 bank에 접근 → 1 cycle. 같은 bank에 동시 접근 → bank conflict → 순차 실행.
7.2 예제
__shared__ float data[32][32];
// Row access (bank conflict 없음)
float x = data[threadIdx.x][0]; // thread i → bank i
// Column access (bank conflict!)
float y = data[0][threadIdx.x]; // 모든 thread → bank 0 → 32-way conflict
Column access는 모든 thread가 같은 bank.
해결: 패딩
__shared__ float data[32][33]; // 33 대신 32
Row 크기를 33으로 → column access가 각자 다른 bank에 도착.
7.3 Broadcast
같은 word를 여러 thread가 읽는 것은 OK (broadcast). Hardware가 한 번 읽고 모두에게 분배.
Bank conflict는 같은 bank의 다른 word일 때만 문제.
8. Occupancy
8.1 개념
Occupancy = "SM의 최대 warp 수 대비 현재 활성 warp 수".
A100 SM: 최대 64 warps 동시. 내 커널이 32 warps만 쓴다면 occupancy = 50%.
8.2 왜 높은 occupancy가 좋은가
GPU는 warp switching으로 지연 시간 숨김. 한 warp가 메모리 대기 중이면 다른 warp 실행.
Warps가 많을수록 더 많은 지연 시간을 숨길 수 있다.
8.3 Occupancy 제한 요인
- Register per thread: 너무 많이 쓰면 register file 부족.
- Shared memory per block: 너무 많이 쓰면 block 수 제한.
- Threads per block: 블록 크기가 작으면 warp 적음.
__global__ __launch_bounds__(256, 4)
void kernel() {
// ...
}
__launch_bounds__로 컴파일러에 힌트. "최대 256 thread/block, 4 blocks/SM 보장해줘".
8.4 Occupancy가 전부 아님
Latency hiding이 이미 충분하면 occupancy 증가가 도움 안 됨. 100% occupancy 추구가 항상 옳은 것 아님. Tensor core 사용 코드는 낮은 occupancy에서도 최고 성능.
NVIDIA Nsight Compute로 측정.
9. Tensor Core — AI 가속의 열쇠
9.1 등장 (Volta, 2017)
기존 GPU는 FP32 MAC이 기본 연산. AI 워크로드는 대부분 **행렬 곱 (MatMul)**인데, GEMM을 FP32로 돌리면 이론 TFLOPS의 50% 정도만.
NVIDIA의 답: Tensor Core — 행렬 곱 전용 유닛.
9.2 구조
V100 Tensor Core: 4×4 × 4×4 행렬 곱을 한 cycle에.
D = A × B + C
- A: 4×4 FP16
- B: 4×4 FP16
- C: 4×4 FP32
- D: 4×4 FP32
16 MAC per cycle (vs CUDA core의 1 MAC).
결과: V100 FP16 = 125 TFLOPS (vs FP32의 15 TFLOPS).
9.3 진화
- V100 (Volta, 2017): FP16 → FP32 matmul.
- A100 (Ampere, 2020): TF32, BF16, INT8, 2:4 sparsity. 312 TFLOPS (BF16).
- H100 (Hopper, 2022): FP8, Transformer Engine. 2000 TFLOPS (FP8).
- B100/B200 (Blackwell, 2024): FP4, 20,000+ TFLOPS.
세대마다 새 정밀도 지원. Tensor core가 가속기의 진짜 주역.
9.4 사용 방법
직접 사용: wmma API
#include <mma.h>
using namespace nvcuda;
__global__ void matmul_wmma(half *a, half *b, float *c) {
wmma::fragment<wmma::matrix_a, 16, 16, 16, half, wmma::row_major> a_frag;
wmma::fragment<wmma::matrix_b, 16, 16, 16, half, wmma::col_major> b_frag;
wmma::fragment<wmma::accumulator, 16, 16, 16, float> c_frag;
wmma::fill_fragment(c_frag, 0.0f);
wmma::load_matrix_sync(a_frag, a, 16);
wmma::load_matrix_sync(b_frag, b, 16);
wmma::mma_sync(c_frag, a_frag, b_frag, c_frag);
wmma::store_matrix_sync(c, c_frag, 16, wmma::mem_row_major);
}
복잡하다. 직접 쓰는 사람 드물다.
일반적 접근:
- cuBLAS:
cublasGemmEx()이 Tensor Core 자동 사용. - cuDNN: Convolution/RNN 등 자동.
- PyTorch / TensorFlow: 프레임워크가 내부적으로 cuBLAS/cuDNN 호출.
- Triton / cutlass: 고수준 라이브러리.
대부분 AI 개발자는 Tensor Core 사용을 전혀 모른 채 효과를 누린다.
9.5 Mixed Precision
Tensor Core는 낮은 정밀도 입력 + 높은 정밀도 누산을 쓴다.
A, B: FP16 (2 bytes)
C, D: FP32 (4 bytes)
이점:
- 메모리: 절반.
- 대역폭: 절반.
- 속도: Tensor Core 사용.
- 정확도: 누산이 FP32라 수치 안전.
PyTorch의 torch.cuda.amp.autocast()가 이를 자동화. "AMP (Automatic Mixed Precision)".
10. Streams와 비동기 실행
10.1 Stream
CUDA stream은 순서 있는 연산의 큐. 다른 stream은 병렬 가능.
cudaStream_t stream1, stream2;
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);
kernel1<<<grid, block, 0, stream1>>>(...);
kernel2<<<grid, block, 0, stream2>>>(...);
// kernel1과 kernel2 병렬 가능
10.2 겹침 최적화
일반적 워크플로:
Host → GPU (copy) → Kernel → GPU → Host (copy)
[wait] [wait] [wait]
Stream으로 겹치기:
Stream 1: copy1 kernel1 copy_back1
Stream 2: copy2 kernel2 copy_back2
Stream 3: copy3 kernel3 copy_back3
각 단계가 병렬. 시간 절약.
10.3 Pinned Memory
비동기 복사는 pinned host memory 필요:
float *h_data;
cudaMallocHost(&h_data, n * sizeof(float)); // pinned
// ...
cudaMemcpyAsync(d_data, h_data, size, cudaMemcpyHostToDevice, stream);
malloc으로 할당된 메모리는 pageable이라 복사 중 OS가 이동 가능 → 비동기 복사 불가.
10.4 이벤트
cudaEvent_t event;
cudaEventCreate(&event);
kernel1<<<grid, block, 0, stream1>>>(...);
cudaEventRecord(event, stream1);
cudaStreamWaitEvent(stream2, event); // stream2는 event 대기
kernel2<<<grid, block, 0, stream2>>>(...);
Stream 간 동기화.
11. CUDA Graph — Launch Overhead 제거
11.1 문제
매 커널 호출마다 launch overhead 존재 (수십 μs). 작은 커널이 많으면 이 오버헤드가 지배.
예: Transformer 추론 = 수백 개의 작은 커널 호출 → overhead만 수 ms.
11.2 Graph
CUDA Graph는 "커널 호출 시퀀스를 미리 기록"해서 한 번에 submit.
cudaGraph_t graph;
cudaGraphExec_t graphExec;
// 기록 시작
cudaStreamBeginCapture(stream, cudaStreamCaptureModeGlobal);
kernel1<<<..., stream>>>(...);
kernel2<<<..., stream>>>(...);
kernel3<<<..., stream>>>(...);
cudaStreamEndCapture(stream, &graph);
// Graph 인스턴스화
cudaGraphInstantiate(&graphExec, graph, NULL, NULL, 0);
// 여러 번 실행 (빠름!)
for (int i = 0; i < N; i++) {
cudaGraphLaunch(graphExec, stream);
}
Launch overhead가 하나의 graph launch로 감축. 수백 μs → 수 μs.
11.3 실전
PyTorch 2.0+의 CUDAGraphs가 자동. Transformer 추론 단일 토큰 생성에서 2-3배 속도 향상.
12. Multi-GPU와 NCCL
12.1 확장성 문제
LLM 훈련은 모델이 단일 GPU 메모리를 초과. 수백~수천 GPU 병렬 필요.
패러다임:
- Data Parallel: 같은 모델을 여러 GPU에 복제, 다른 데이터 학습 → gradient 합산.
- Model Parallel: 모델을 여러 GPU에 분할.
- Pipeline Parallel: 모델의 layer를 GPU별로 나눔.
모두 GPU 간 통신이 필요.
12.2 NVLink와 NVSwitch
NVIDIA의 고속 GPU 간 연결:
- NVLink: GPU 쌍 사이 직접 연결. H100: 900 GB/s.
- NVSwitch: GPU 간 fully-connected switch. DGX H100이 이걸 포함.
PCIe는 ~64 GB/s. NVLink는 훨씬 빠르다.
12.3 NCCL
NCCL (NVIDIA Collective Communications Library). MPI 유사 API, GPU 간 통신:
- AllReduce: 모든 GPU의 값을 합산, 결과를 모두 공유 → gradient 동기화.
- AllGather: 각 GPU의 데이터를 모든 GPU에 복사.
- Broadcast: 한 GPU의 데이터를 모든 GPU에.
- Reduce: 한 GPU로 합산.
- AllToAll: 모든 GPU 간 교차.
NCCL은 NVLink, PCIe, InfiniBand, Ethernet을 자동으로 선택. PyTorch의 torch.distributed 기본 backend.
12.4 P2P
두 GPU가 NVLink로 직접 연결되어 있으면 peer-to-peer memcpy 가능. Host 거치지 않음.
cudaDeviceEnablePeerAccess(dev1, 0);
cudaMemcpyPeer(dst, dev1, src, dev0, size);
12.5 RDMA
다른 노드의 GPU와 직접 통신 (InfiniBand + GPUDirect RDMA). CPU 거치지 않음.
대규모 훈련의 핵심. 이 세션의 RDMA 포스트 참고.
13. 고수준 추상화
직접 CUDA 쓰는 개발자는 적다. 대부분 라이브러리 사용.
13.1 cuBLAS / cuDNN / cuSPARSE
NVIDIA의 수학 라이브러리:
- cuBLAS: 행렬 연산 (GEMM, GEMV 등). BLAS 호환.
- cuDNN: 딥러닝 primitive (convolution, pooling, RNN).
- cuSPARSE: sparse 연산.
- cuSOLVER: linear algebra solver.
PyTorch/TF 내부에서 호출. "표준 GPU 라이브러리".
13.2 Thrust
C++ STL 유사 GPU 라이브러리:
#include <thrust/device_vector.h>
#include <thrust/transform.h>
thrust::device_vector<float> a(n), b(n), c(n);
thrust::transform(a.begin(), a.end(), b.begin(), c.begin(), thrust::plus<float>());
STL과 유사한 API로 GPU 프로그래밍. 낮은 러닝 커브.
13.3 CUB
Low-level 빌딩 블록. Reduction, scan, sort 같은 operation의 최적화된 구현.
13.4 cutlass
GEMM 빌딩 블록 라이브러리. 커스텀 GEMM 커널 작성 시 사용. FlashAttention도 cutlass 기반.
13.5 Triton
OpenAI의 Python-like 언어:
import triton
import triton.language as tl
@triton.jit
def add_kernel(x_ptr, y_ptr, output_ptr, n_elements, BLOCK_SIZE: tl.constexpr):
pid = tl.program_id(axis=0)
block_start = pid * BLOCK_SIZE
offsets = block_start + tl.arange(0, BLOCK_SIZE)
mask = offsets < n_elements
x = tl.load(x_ptr + offsets, mask=mask)
y = tl.load(y_ptr + offsets, mask=mask)
output = x + y
tl.store(output_ptr + offsets, output, mask=mask)
CUDA보다 훨씬 단순. 내부적으로 MLIR로 컴파일 → NVIDIA 최적화 커널 생성. 퍼포먼스가 핸드-튜닝 CUDA에 필적.
2024+ Triton이 AI 연구자의 주류 선택.
13.6 FlashAttention
Tri Dao의 attention 최적화 구현. Tensor Core + Shared Memory + fused kernel로 attention 연산을 메모리 경쟁 없이 수행. GPT/LLaMA 훈련에 필수.
CUDA로 작성 + cutlass 활용. Triton 버전도 존재.
14. 프로파일링과 최적화
14.1 Nsight Systems
전체 파이프라인 프로파일. Timeline 뷰. 커널 실행, 메모리 복사, CPU/GPU 활동.
nsys profile --stats=true ./my_app
"전체적으로 어디가 병목"인지 파악.
14.2 Nsight Compute
단일 커널 심층 분석:
- Occupancy.
- Memory throughput.
- Warp efficiency.
- Instruction mix.
- Roofline analysis.
ncu --set full ./my_app
Nsight UI에서 각 커널의 상세 메트릭 + 개선 제안.
14.3 PyTorch Profiler
with torch.profiler.profile() as prof:
model(input)
print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=10))
PyTorch 레벨에서 어느 연산이 시간 소비.
14.4 Roofline
각 커널이 compute-bound인지 memory-bound인지 판단:
- Arithmetic Intensity = FLOPs / bytes 접근.
- GPU의 peak FLOPS와 peak bandwidth로 roof.
- 커널이 roof의 어디에 위치?
Compute-bound: Tensor core 활용, occupancy. Memory-bound: Coalescing, shared memory, 데이터 reuse.
15. 경쟁자
15.1 AMD ROCm / HIP
AMD의 CUDA 대응. HIP (Heterogeneous-Compute Interface for Portability):
// CUDA
__global__ void kernel() { ... }
// HIP (거의 동일)
__global__ void kernel() { ... }
hipify 도구가 CUDA 코드를 자동 변환. MI300은 H100 대비 유사한 하드웨어 사양.
문제: 생태계. 라이브러리, 툴링, 커뮤니티가 NVIDIA에 한참 밀림.
15.2 Intel oneAPI / SYCL
Intel의 대응. SYCL = 표준 기반 (Khronos). DPC++ (Data Parallel C++).
queue q;
q.submit([&](handler& h) {
h.parallel_for(range<1>(n), [=](id<1> i) {
c[i] = a[i] + b[i];
});
});
NVIDIA, AMD, Intel 모두 백엔드. 이론적으로 portable. 현실은 각 벤더 성능 차이.
15.3 Apple Metal
Apple Silicon의 GPU. Metal Performance Shaders (MPS).
PyTorch MPS backend로 macOS에서 GPU 훈련 가능. M-series 칩의 유니파이드 메모리로 일부 워크로드에 효율적.
15.4 WebGPU
브라우저에서 GPU 접근. Compute shader 지원. WebGL보다 훨씬 강력.
WebGPU + Wasm + LLM: 브라우저에서 소형 LLM 실행 가능.
15.5 Google TPU
Google 자체 ASIC. JAX/XLA 통합. 특정 워크로드(transformer)에 매우 효율적. Google Cloud 전용.
16. 학습 로드맵
1단계: 기본
- CUDA C++ Programming Guide (NVIDIA 공식).
- 간단한 커널 작성: vector add, matrix mul.
nvcc컴파일,cudaMemcpy,<<<grid, block>>>.
2단계: 최적화
- Memory coalescing 실습.
- Shared memory 활용.
- Bank conflict 찾기.
- Nsight Compute 사용.
3단계: 고급
- Warp-level primitive (
__shfl_sync). - Tensor Core (
wmma). - CUDA Graph.
- Multi-GPU (NCCL).
4단계: 생태계
- cuBLAS, cuDNN 사용.
- Triton으로 커스텀 커널.
- PyTorch extension (
torch.utils.cpp_extension). - FlashAttention, cutlass 읽기.
책:
- "Programming Massively Parallel Processors" — Hwu, Kirk.
- "CUDA by Example" — Sanders, Kandrot.
- "Professional CUDA C Programming" — Cheng et al.
온라인:
- NVIDIA Developer Blog.
- "GPU MODE" Discord 및 YouTube.
- PMPP 강의 (University of Illinois).
샘플:
- NVIDIA
cuda-samplesGitHub. - Triton tutorial.
- FlashAttention 소스.
17. 요약 — 한 장 정리
┌─────────────────────────────────────────────────────┐
│ CUDA Cheat Sheet │
├─────────────────────────────────────────────────────┤
│ 하드웨어: │
│ GPU = N × SM │
│ SM = Cores + Tensor Cores + RegFile + SharedMem │
│ H100: 132 SM │
│ │
│ 실행 모델 (SIMT): │
│ Grid → Block → Warp (32) → Thread │
│ 프로그래머는 스칼라 코드 │
│ HW가 32 threads를 묶어 실행 │
│ │
│ 메모리 계층: │
│ Register (가장 빠름) │
│ Shared Memory / L1 (수십 cycles) │
│ L2 (~200 cycles) │
│ HBM (수백 cycles, 수십 GB) │
│ Host (PCIe, 느림) │
│ │
│ 최우선 최적화: │
│ Memory coalescing! │
│ Thread i → array[i] │
│ Warp 32 threads → 연속 128 bytes │
│ │
│ 함정: │
│ Warp divergence (분기) │
│ Bank conflict (shared memory) │
│ Register spill │
│ Uncoalesced access │
│ │
│ Tensor Core: │
│ Volta 2017+ │
│ Matrix multiply 전용 │
│ FP16/BF16/FP8/FP4 │
│ Mixed precision (autocast) │
│ cuBLAS/cuDNN 자동 사용 │
│ │
│ Streams: │
│ 독립적 연산 시퀀스 │
│ copy/compute 겹침 │
│ pinned memory 필수 │
│ │
│ CUDA Graph: │
│ Launch overhead 제거 │
│ 소형 커널 많을 때 필수 │
│ │
│ Multi-GPU: │
│ NVLink (900 GB/s H100) │
│ NCCL (AllReduce, etc) │
│ P2P, RDMA │
│ │
│ 라이브러리: │
│ cuBLAS/cuDNN (행렬/DL primitive) │
│ Thrust (STL-like) │
│ cutlass (GEMM 빌딩) │
│ Triton (Python-like 커널) │
│ FlashAttention │
│ │
│ 도구: │
│ nvcc (컴파일러) │
│ Nsight Systems (timeline) │
│ Nsight Compute (kernel deep) │
│ cuda-gdb │
│ │
│ 대안: │
│ ROCm/HIP (AMD) │
│ SYCL/oneAPI (Intel) │
│ Metal (Apple) │
│ WebGPU │
│ TPU (Google) │
└─────────────────────────────────────────────────────┘
18. 퀴즈
Q1. SIMT와 SIMD의 차이는?
A. 프로그래밍 모델 수준의 차이. SIMD(예: AVX)는 프로그래머가 명시적으로 vector 타입(__m256)을 사용해 "8개 float을 한 번에"를 써야 한다. 분기 처리가 어렵고 컴파일러 도움 없이는 최적화가 복잡. SIMT(CUDA)는 프로그래머가 하나의 스레드처럼 스칼라 코드를 쓰고, 하드웨어가 32 threads를 warp로 묶어 같은 명령어를 다른 데이터에 실행. "작성은 스칼라, 실행은 vector". 분기도 자연스럽게 쓸 수 있고(단, divergence 대가), GPU의 수천 코어를 활용하기 훨씬 쉽다. SIMT는 SIMD의 효율 + 스칼라 프로그래밍의 생산성을 결합 — CUDA가 OpenCL과 pure SIMD를 이긴 결정적 설계.
Q2. Memory coalescing이 왜 CUDA의 1순위 최적화인가?
A. 메모리 대역폭의 32배 차이. Warp의 32 threads가 연속된 128 바이트에 접근하면 하드웨어가 이를 1 memory transaction으로 묶는다. 같은 32 threads가 흩어진 주소에 접근하면 32 transactions가 발생하고 각 transaction은 128 바이트 중 실제 사용하는 4 바이트만 가져옴 → 나머지 124 바이트 낭비. 결과: 32배 적은 effective bandwidth. HBM은 이미 TB/s 수준이지만 uncoalesced 접근하면 수십 GB/s로 떨어짐. 해결은 간단하지만 엄격: "thread i는 array[i]에 접근"하라. 2D 배열은 row-major 순회. 이것이 왜 matrix transpose가 CUDA의 교과서 예제인지의 이유 — naive 구현은 uncoalesced고 최적화 버전은 shared memory를 중간 단계로 써서 read/write 모두 coalesced.
Q3. Warp divergence가 왜 성능을 해치는가?
A. Warp의 32 threads가 lockstep으로 실행되기 때문. Hardware는 32 threads에게 동시에 같은 명령어를 준다. if (tid < 16) A() else B() 같은 분기가 있으면 warp가 양쪽 경로를 모두 순차 실행한다: 먼저 threads 0-15이 A() 실행하는 동안 16-31은 idle, 그 다음 16-31이 B() 실행하는 동안 0-15가 idle. 결과: 병렬성 절반. 최악의 경우 32-way divergence는 32배 느려진다. 완화: (1) warp boundary에 맞춰 분기 (blockIdx.x % 2), (2) 분기 대신 산술(fabs, ternary), (3) 두 경로 모두 계산 후 select. Volta(2017) 이후 independent thread scheduling이 더 유연한 처리를 허용하지만 여전히 divergence 최소화가 원칙.
Q4. Shared memory bank conflict가 무엇인가?
A. Shared memory가 32개 bank로 나뉘어 있어 같은 bank 동시 접근 시 순차 처리. Shared memory는 32-bit word 단위로 stripe되어 bank 0이 words 0, 32, 64, ..., bank 1이 words 1, 33, 65, ...를 담당. Warp의 32 threads가 서로 다른 bank에 접근하면 1 cycle. 같은 bank의 다른 word에 접근하면 N-way conflict로 N cycle 걸림. 전형적 예: data[32][32] 2D 배열에서 column access. 모든 thread가 data[i][0]에 접근 → 모두 bank 0 → 32-way conflict. 해결: 패딩 — data[32][33]으로 크기를 33으로 해서 column access가 각자 다른 bank에 떨어지게. Broadcast(같은 word를 여러 thread가 읽기)는 OK — hardware가 한 번 읽고 분배.
Q5. Tensor Core가 AI 워크로드를 왜 10-100배 빠르게 하는가?
A. 행렬 곱을 전용 유닛으로 하드웨어화. 기존 CUDA core는 1 FMA(Fused Multiply-Add) per cycle. Tensor core(V100+)는 4×4 × 4×4 행렬 곱을 한 명령어로 수행 — 16 MACs per cycle. 더 중요한 것은 낮은 정밀도를 지원: FP16(V100), BF16/TF32/INT8(A100), FP8(H100), FP4(B200). 정밀도를 낮추면 메모리 대역폭 절반 + 더 많은 연산 처리. 결과: H100 기준 FP32 = 67 TFLOPS vs Tensor Core FP8 = 2000 TFLOPS. 30배 차이. Transformer 추론/훈련이 거의 완전히 matmul이므로 이 속도가 그대로 실제 성능에 반영. "CPU는 왜 못 쫓아오는가"의 답 — CPU에 비슷한 유닛을 만들 수 없는 건 아니지만 workload와 투자 차이. NVIDIA가 2017년 한 이 결정이 AI 가속기 시장을 정의.
Q6. CUDA Graph가 해결하는 문제는?
A. Kernel launch overhead. 매 kernel<<<>>>() 호출마다 CPU↔GPU 통신, driver 오버헤드로 수십 μs 걸림. 큰 커널에선 무시 가능하지만 Transformer 추론처럼 수백 개의 작은 커널(layer마다 여러 연산)을 연속 호출하면 overhead만 수 ms — 실제 compute time에 근접. CUDA Graph는 "커널 호출 시퀀스를 미리 기록해서 하나의 graph launch로 submit". 수백 개의 kernel launch가 하나의 launch로 감축. Transformer 추론 token generation에서 2-3배 속도 향상. PyTorch 2.0+의 torch.compile + CUDAGraphs 조합이 자동 적용. 핵심: "launch 자체가 병목인가"를 판단해야 효과적 — compute-heavy workload는 큰 효과 없지만 latency-sensitive small kernel workload는 극적.
Q7. AMD ROCm이 CUDA를 대체 못 하는 이유는?
A. 기술보다 생태계. 하드웨어는 AMD MI300이 H100에 근접하다(일부 지표는 더 우수). HIP은 CUDA 코드를 자동 변환해주는 도구(hipify)까지 제공. 그런데도 실제 채택이 느린 이유: (1) 라이브러리 성숙도 — cuBLAS/cuDNN/NCCL에 해당하는 rocBLAS/MIOpen/RCCL이 기능 제한 또는 성능 차이, (2) 프레임워크 지원 — PyTorch가 공식 지원하지만 일부 연산만 최적화, 최신 기능은 CUDA 먼저, (3) 개발자 커뮤니티 — 책, 튜토리얼, Stack Overflow 답변, 블로그 압도적으로 CUDA 우세, (4) 핵심 라이브러리 저자 — Triton, FlashAttention, cutlass 같은 혁신이 CUDA 타겟, AMD는 항상 뒤따름, (5) 기업 관성 — 엔지니어 훈련 비용과 이미 작성된 CUDA 코드베이스의 sunk cost. "Hardware가 같아도 생태계가 다르면 사용자가 움직이지 않는다"는 교훈. AMD가 따라잡으려면 수년간의 투자가 필요.
이 글이 도움이 됐다면 다음 포스트도 확인해 보세요:
- "Transformer Architecture Deep Dive" — GPU에서 실제 돌아가는 것.
- "Diffusion Models Deep Dive" — 또 다른 GPU-heavy 워크로드.
- "RDMA & NCCL" — 멀티 GPU 통신.
- "LLVM Compiler Infrastructure" — Triton이 MLIR로 컴파일되는 배경.
현재 단락 (1/631)
- **CUDA**는 NVIDIA의 GPU 프로그래밍 플랫폼. 2007년 출시 이후 **AI/HPC의 사실상 표준**. ChatGPT, Stable Diffusion 같은 모든 주...