- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 왜 지금 GPU를 깊이 이해해야 하는가
- 1. CPU vs GPU: 근본적 설계 철학의 차이
- 2. GPU 내부 계층 구조: Device부터 CUDA Core까지
- 3. SIMT: Single Instruction, Multiple Threads
- 4. CUDA 메모리 계층: 레이턴시와 대역폭의 트레이드오프
- 5. 행렬 곱셈의 병렬화: CUDA 커널의 핵심
- 6. Tensor Core: 행렬 곱셈을 위한 전용 하드웨어
- 7. Grid, Block, Thread 계층: CUDA 프로그래밍 모델
- 8. CUDA의 역사: 왜 NVIDIA가 AI를 독점했는가
- 9. 실전 최적화: H100에서 GEMM 최대 성능 뽑기
- 10. 정리: GPU가 AI를 지배하는 이유
왜 지금 GPU를 깊이 이해해야 하는가
GPT-4를 추론하는 데 A100 GPU 수백 장이 동시에 돌아간다. LLaMA-3를 파인튜닝하려면 H100 클러스터가 필요하다. 이 모든 계산의 심장부에 NVIDIA GPU와 CUDA가 있다. 그런데 정작 "왜 GPU가 이렇게 빠른가"를 정확히 설명할 수 있는 엔지니어는 드물다. 이 글에서는 H100 스펙, SIMT 실행 모델, Warp divergence, 공유 메모리 타일링, Tensor Core까지 실제 CUDA 코드와 함께 완전히 해부한다.
1. CPU vs GPU: 근본적 설계 철학의 차이
CPU와 GPU의 차이는 단순히 "코어 수"가 아니다. 설계 철학 자체가 다르다.
CPU: 레이턴시 최적화
CPU는 단일 스레드를 최대한 빠르게 실행하도록 설계되었다. 이를 위해 다음 장치들을 탑재한다:
- 분기 예측(Branch Prediction):
if/else분기를 미리 예측해 파이프라인 스톨을 줄임 - 비순서 실행(Out-of-Order Execution): 의존성이 없는 명령어를 순서와 무관하게 병렬 실행
- 투기적 실행(Speculative Execution): 분기 결과를 예측하고 미리 계산 (Spectre 취약점의 원인이기도 함)
- 거대한 L1/L2/L3 캐시: 단일 스레드의 메모리 레이턴시를 숨기기 위한 수십 MB 캐시
GPU: 처리량 최적화
GPU는 수천 개의 스레드가 동시에 실행될 때 전체 처리량을 극대화하도록 설계되었다. 개별 스레드의 레이턴시는 포기하는 대신, 레이턴시가 발생할 때 다른 스레드로 즉시 컨텍스트 스위칭한다.
CPU 아키텍처 (레이턴시 최적화):
┌────────────────────────────────────────────┐
│ 코어 0 │ 코어 1 │ 코어 2 │ 코어 3 │
│ (강력) │ (강력) │ (강력) │ (강력) │
│ OOO실행 │ OOO실행 │ OOO실행 │ OOO실행 │
├──────────┴──────────┴──────────┴───────────┤
│ L3 캐시 (32MB+) │
├────────────────────────────────────────────┤
│ 메인 메모리 (DDR5, ~50 GB/s) │
└────────────────────────────────────────────┘
GPU 아키텍처 (처리량 최적화) — H100 기준:
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│SM│SM│SM│SM│SM│SM│SM│SM│SM│SM│...│ 132개 SM
│ │ │ │ │ │ │ │ │ │ │ │ 각 SM = 128 CUDA 코어
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴───┘
L2 캐시 (50MB)
HBM3 (80GB, 3.35 TB/s)
핵심 차이: CPU는 1개 스레드를 1ns 만에 처리하려 하고, GPU는 1개 스레드가 700ns 걸려도 10만 개 스레드가 동시에 나아가므로 전체 처리량이 압도적이다.
2. GPU 내부 계층 구조: Device부터 CUDA Core까지
H100의 내부는 계층적으로 구성된다. 최상위 Device에서 최하위 CUDA Core까지 계층을 내려가 보자.
GPU Device (H100 SXM5)
│
├── SM 0 (Streaming Multiprocessor)
│ ├── 4개 Warp Scheduler
│ ├── 4개 Dispatch Unit
│ ├── 128개 CUDA Core (FP32)
│ ├── 64개 FP64 Core
│ ├── 4개 Tensor Core (4세대, FP8/FP16/BF16/TF32 지원)
│ ├── 1개 특수함수 유닛 (SFU: sin, cos, sqrt 등)
│ ├── LD/ST 유닛 (Load/Store)
│ ├── L1 캐시 + 공유 메모리 = 228KB (비율 설정 가능)
│ └── 레지스터 파일 (65536개 x 32비트 레지스터)
│
├── SM 1 ...
├── SM 2 ...
│ ...
├── SM 131 ...
│
├── L2 캐시 (50MB)
└── HBM3 메모리 (80GB, 3.35 TB/s 대역폭)
핵심 수치 (H100 SXM5):
| 항목 | 수치 |
|---|---|
| SM 개수 | 132개 |
| SM당 CUDA Core | 128개 (FP32) |
| 총 CUDA Core | 16,896개 |
| SM당 Tensor Core | 4개 (4세대) |
| 총 Tensor Core | 528개 |
| L2 캐시 | 50MB |
| HBM3 메모리 | 80GB |
| 메모리 대역폭 | 3.35 TB/s |
| FP16 Tensor Core 성능 | 989 TFLOPS |
3. SIMT: Single Instruction, Multiple Threads
GPU의 핵심 실행 모델은 **SIMT(Single Instruction Multiple Threads)**다. CPU의 SIMD(Single Instruction Multiple Data)와 유사하지만, 핵심 차이가 있다.
Warp: GPU의 기본 실행 단위
CUDA에서 Warp는 32개 스레드의 묶음으로, 이 32개 스레드가 항상 동일한 명령어를 동시에 실행한다. 즉, SM의 Warp Scheduler는 Warp 단위로 명령어를 디스패치한다.
Thread Block (예: 256개 스레드)
├── Warp 0: Thread 0~31 → 동일 명령어 실행 (동기화됨)
├── Warp 1: Thread 32~63 → 동일 명령어 실행 (동기화됨)
├── Warp 2: Thread 64~95 → 동일 명령어 실행 (동기화됨)
├── ...
└── Warp 7: Thread 224~255
Warp Divergence: 성능 킬러
Warp 내 스레드들이 서로 다른 분기를 취하면 Warp Divergence가 발생한다. GPU는 SIMT이기 때문에, 분기를 직렬화한다.
// 이 코드는 Warp Divergence를 유발한다
__global__ void divergent_kernel(float* data, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx % 2 == 0) {
// Warp 내 짝수 스레드만 실행
data[idx] = data[idx] * 2.0f; // Step 1
} else {
// Warp 내 홀수 스레드만 실행
data[idx] = data[idx] + 1.0f; // Step 2
}
// 결과: Step 1 실행 시 홀수 스레드는 idle
// Step 2 실행 시 짝수 스레드는 idle
// 실제 처리량: 이론값의 50%
}
반면, 모든 스레드가 같은 분기를 취하면 divergence가 없다:
// Divergence 없음: 모든 스레드가 같은 경로
__global__ void coherent_kernel(float* data, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
// blockIdx.x 전체가 짝수이거나 홀수이면 divergence 없음
if (blockIdx.x % 2 == 0) {
data[idx] = data[idx] * 2.0f;
} else {
data[idx] = data[idx] + 1.0f;
}
}
실무 팁: 분기 조건을 threadIdx 기반이 아닌 blockIdx 기반으로 설계하면 divergence를 줄일 수 있다.
4. CUDA 메모리 계층: 레이턴시와 대역폭의 트레이드오프
CUDA 커널 성능의 대부분은 메모리 접근 패턴에서 결정된다. 계층별 특성을 정확히 알아야 한다.
메모리 계층 (빠름 → 느림):
┌─────────────────────────────────────────────────────┐
│ 레지스터 (32-bit, 65536개/SM) │
│ 레이턴시: ~1 사이클 대역폭: 극대 (로컬 접근) │
│ 범위: 단일 스레드만 접근 가능 │
├─────────────────────────────────────────────────────┤
│ L1 캐시 / 공유 메모리 (228KB/SM on H100) │
│ 레이턴시: ~32 사이클 대역폭: ~19 TB/s │
│ 범위: Thread Block 내 모든 스레드 │
├─────────────────────────────────────────────────────┤
│ L2 캐시 (50MB on H100) │
│ 레이턴시: ~200 사이클 대역폭: ~12 TB/s │
│ 범위: 모든 SM 공유 │
├─────────────────────────────────────────────────────┤
│ 글로벌 메모리 HBM3 (80GB on H100) │
│ 레이턴시: ~700 사이클 대역폭: 3.35 TB/s │
│ 범위: 모든 스레드 접근 가능 │
└─────────────────────────────────────────────────────┘
| 메모리 종류 | 레이턴시 | 대역폭 | 크기 | 범위 |
|---|---|---|---|---|
| 레지스터 | ~1 사이클 | 극대 | 256KB/SM | 단일 스레드 |
| 공유 메모리 | ~32 사이클 | ~19 TB/s | 228KB/SM | Thread Block |
| L2 캐시 | ~200 사이클 | ~12 TB/s | 50MB | 전체 GPU |
| HBM3 (글로벌) | ~700 사이클 | 3.35 TB/s | 80GB | 전체 GPU |
공유 메모리(Shared Memory)는 프로그래머가 직접 제어하는 캐시다. __shared__ 키워드로 선언하면 해당 Thread Block의 모든 스레드가 접근할 수 있고, HBM보다 20배 이상 빠르다.
5. 행렬 곱셈의 병렬화: CUDA 커널의 핵심
딥러닝의 핵심 연산은 행렬 곱셈(GEMM: General Matrix Multiply)이다. 이를 GPU에서 어떻게 병렬화하는지 단계별로 살펴본다.
Naive 구현: 글로벌 메모리 폭격
// Naive 행렬 곱셈 커널 — 느린 이유를 이해하기 위한 버전
__global__ void matmul_naive(float* A, float* B, float* C, int N) {
// 각 스레드가 출력 행렬의 한 원소를 담당
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
if (row < N && col < N) {
float sum = 0.0f;
for (int k = 0; k < N; k++) {
// 매 반복마다 글로벌 메모리 접근 2번 → 레이턴시 ~700 사이클
sum += A[row * N + k] * B[k * N + col];
}
C[row * N + col] = sum;
}
}
// 호출 예시
dim3 blockDim(16, 16); // 256 스레드/블록
dim3 gridDim((N + 15) / 16, (N + 15) / 16);
matmul_naive<<<gridDim, blockDim>>>(A, B, C, N);
이 구현의 문제: N=4096인 행렬이면 각 스레드가 4096번 글로벌 메모리에서 읽는다. 총 글로벌 메모리 읽기 횟수 = N^3 = 68 billion번. HBM 레이턴시 700 사이클 × 68B = 처참한 성능.
Tiled 구현: 공유 메모리로 글로벌 메모리 접근 최소화
#define TILE_SIZE 16
// 타일링된 행렬 곱셈 — 공유 메모리 활용
__global__ void matmul_tiled(float* A, float* B, float* C, int N) {
// 공유 메모리에 타일 선언 (L1 수준 레이턴시)
__shared__ float tile_A[TILE_SIZE][TILE_SIZE];
__shared__ float tile_B[TILE_SIZE][TILE_SIZE];
int tx = threadIdx.x, ty = threadIdx.y;
int row = blockIdx.y * TILE_SIZE + ty;
int col = blockIdx.x * TILE_SIZE + tx;
float sum = 0.0f;
// K 차원을 타일 단위로 순회
for (int t = 0; t < N / TILE_SIZE; t++) {
// 단계 1: 각 스레드가 타일 하나씩 공유 메모리에 로드
tile_A[ty][tx] = A[row * N + t * TILE_SIZE + tx];
tile_B[ty][tx] = B[(t * TILE_SIZE + ty) * N + col];
// 단계 2: 모든 스레드가 로드 완료할 때까지 동기화
__syncthreads();
// 단계 3: 공유 메모리에서 타일 내 내적 계산
// 이 루프는 전부 공유 메모리(~32 사이클) 접근
for (int k = 0; k < TILE_SIZE; k++) {
sum += tile_A[ty][k] * tile_B[k][tx];
}
// 단계 4: 다음 타일 로드 전에 동기화
__syncthreads();
}
C[row * N + col] = sum;
}
왜 빠른가:
타일 크기 16×16 기준:
- Naive: 글로벌 메모리 접근 = 2 × N^3 번
- Tiled: 글로벌 메모리 접근 = 2 × N^3 / TILE_SIZE 번 = 16배 감소
타일을 공유 메모리에 한 번 올려두면, 타일 내 16개 스레드가 같은 데이터를 공유해서 재사용한다. 메모리 대역폭 효율이 극적으로 개선된다.
타일링 원리:
A 행렬: B 행렬:
┌──┬──┬──┬──┐ ┌──┬──┬──┬──┐
│T0│T1│T2│T3│ │T0│T1│T2│T3│
├──┼──┼──┼──┤ ├──┼──┼──┼──┤
│ │ │ │ │ │ │ │ │ │
└──┴──┴──┴──┘ └──┴──┴──┴──┘
각 Thread Block은 C의 16×16 타일 하나를 담당.
A의 해당 행 타일들과 B의 해당 열 타일들을 순서대로
공유 메모리에 올려서 계산 → 글로벌 메모리 접근 최소화.
6. Tensor Core: 행렬 곱셈을 위한 전용 하드웨어
CUDA Core가 스칼라 FP32 연산을 1개씩 처리한다면, Tensor Core는 행렬 전체를 단 하나의 명령어로 처리한다.
4세대 Tensor Core (H100):
| 연산 | Tensor Core | CUDA Core | 배속 |
|---|---|---|---|
| FP16 행렬 곱 | 1 사이클에 256 ops | 1 사이클에 2 ops | 128배 |
| BF16 행렬 곱 | 1 사이클에 256 ops | 지원 안 함 | — |
| FP8 행렬 곱 | 1 사이클에 512 ops | 지원 안 함 | — |
| TF32 행렬 곱 | 1 사이클에 128 ops | 지원 안 함 | — |
4세대 Tensor Core는 한 명령어로 16×16 행렬 곱셈을 수행한다. WMMA(Warp Matrix Multiply-Accumulate) API로 직접 호출할 수 있다:
// WMMA API를 이용한 Tensor Core 직접 호출
#include <mma.h>
using namespace nvcuda::wmma;
__global__ void tensor_core_matmul(half* a_ptr, half* b_ptr,
float* c_ptr, int M, int N, int K) {
// 각 Warp가 16x16 출력 타일 하나를 담당
// fragment = Tensor Core가 다루는 행렬 조각
fragment<matrix_a, 16, 16, 16, half, row_major> a_frag;
fragment<matrix_b, 16, 16, 16, half, col_major> b_frag;
fragment<accumulator, 16, 16, 16, float> c_frag;
// 누산기 초기화
fill_fragment(c_frag, 0.0f);
// K 차원 순회하며 타일 로드 후 곱셈
for (int k = 0; k < K; k += 16) {
// 글로벌 메모리에서 fragment 로드
load_matrix_sync(a_frag, a_ptr + /* offset */, K);
load_matrix_sync(b_frag, b_ptr + /* offset */, N);
// 단 하나의 명령어로 16x16x16 행렬 곱 누산
// 내부에서 Tensor Core 하드웨어 직접 호출
mma_sync(c_frag, a_frag, b_frag, c_frag);
}
// 결과를 글로벌 메모리에 저장
store_matrix_sync(c_ptr + /* offset */, c_frag, N, mem_row_major);
}
실제로는 WMMA API보다 cuBLAS, cuBLASLt, 또는 CUTLASS가 Tensor Core를 더 효율적으로 활용하지만, 이 코드는 Tensor Core가 어떻게 동작하는지 이해하는 데 적합하다.
7. Grid, Block, Thread 계층: CUDA 프로그래밍 모델
CUDA 커널을 호출할 때 프로그래머는 **실행 구성(Execution Configuration)**을 지정한다.
CUDA 실행 계층:
Grid (전체 커널 실행)
├── Block (0,0): 256 스레드
│ ├── Warp 0: Thread 0~31 ── SM에서 동시 실행
│ ├── Warp 1: Thread 32~63
│ └── ...
├── Block (1,0): 256 스레드
├── Block (2,0): 256 스레드
└── ...
각 Block은 하나의 SM에 할당됨.
SM은 동시에 여러 Block을 처리할 수 있음 (occupancy).
PyTorch는 이 모든 것을 감춘다:
import torch
A = torch.randn(4096, 4096, device='cuda', dtype=torch.float16)
B = torch.randn(4096, 4096, device='cuda', dtype=torch.float16)
# 이 한 줄 내부에서 일어나는 일:
# 1. cuBLAS 또는 CUTLASS 커널 선택
# 2. Grid: (256, 256), Block: (16, 16) 등으로 구성
# 3. Tensor Core 활용한 tiled GEMM 실행
# 4. 총 ~4096개 Thread Block, 각 16*16=256 스레드
result = torch.matmul(A, B) # → 내부적으로 cublasSgemmEx 호출
Occupancy 최적화: SM 하나가 동시에 처리할 수 있는 Warp 수는 레지스터와 공유 메모리 사용량에 따라 결정된다. Block 크기가 너무 크면 한 SM에 한 Block만 올라가 SM 활용률이 낮아진다.
SM 리소스 한계 (H100 기준):
- 최대 동시 Thread Block: 32개
- 최대 동시 스레드: 2048개
- 레지스터: 65536개 (스레드 수 × 스레드당 레지스터로 나눔)
- 공유 메모리: 228KB (Block들이 나눠 씀)
Occupancy = 실제 활성 Warp 수 / 최대 가능 Warp 수
목표: 50% 이상 (메모리 레이턴시 숨기기 위해)
8. CUDA의 역사: 왜 NVIDIA가 AI를 독점했는가
CUDA의 탄생과 성장을 이해하면, 왜 2024년에도 NVIDIA가 AI 칩 시장의 80% 이상을 차지하는지 알 수 있다.
2006년 이전: OpenGL 셰이더 해킹의 시대
GPU가 빠르다는 것은 알았지만, GPU를 범용 계산에 쓰려면 OpenGL 텍스처 샘플링이나 셰이더 언어로 수식을 "위장"해야 했다. 행렬 곱셈을 이미지 필터로 속여서 GPU에 올리는 방식이었다.
2006년: CUDA 1.0 출시
Jensen Huang은 "GPU를 C 언어로 프로그래밍할 수 있게 한다"는 도박을 했다. 당시 NVIDIA 이사회는 게임 GPU에 컴퓨팅 하드웨어를 추가하는 결정을 승인해야 했다.
초기 CUDA 사용자: 물리 시뮬레이션 연구자, 유체역학 HPC 팀, 분자 동역학 연구자. 딥러닝은 아직 등장 전이었다.
2012년: AlexNet의 순간
Alex Krizhevsky가 GTX 580 두 장으로 AlexNet을 학습시켜 ImageNet에서 압도적 1위를 차지했다. 이 사건이 딥러닝 혁명의 시작이었다. GTX 580은 소비자용 게임 GPU였지만, CUDA 덕분에 병렬 컴퓨팅 플랫폼이 되었다.
CUDA vs OpenCL: 생태계 전쟁
Khronos Group의 OpenCL은 AMD, Intel, NVIDIA 모두를 지원하는 개방 표준이었다. 그럼에도 CUDA가 이긴 이유:
- 개발자 도구: cuDNN, cuBLAS, Nsight 프로파일러 등 NVIDIA의 생태계가 압도적으로 풍부했다
- 선점 효과: 연구자들이 CUDA 코드를 공유하면서 논문 코드가 전부 CUDA 기반이 됨
- 하드웨어 최적화: Tensor Core, NVLink 등 NVIDIA 전용 기능은 CUDA로만 접근 가능
결과: PyTorch, TensorFlow, JAX 모두 기본적으로 CUDA를 사용한다. AMD의 ROCm, Intel의 oneAPI가 따라오고 있지만 아직 격차가 크다.
9. 실전 최적화: H100에서 GEMM 최대 성능 뽑기
H100의 이론상 FP16 Tensor Core 성능은 989 TFLOPS이다. 실제로 접근하려면:
import torch
import time
# 1. dtype 선택: FP16 또는 BF16 사용 (Tensor Core 활성화)
A = torch.randn(8192, 8192, device='cuda', dtype=torch.bfloat16)
B = torch.randn(8192, 8192, device='cuda', dtype=torch.bfloat16)
# 2. cuBLAS를 위한 워밍업 (첫 실행은 커널 컴파일/선택에 시간 소요)
for _ in range(5):
_ = torch.matmul(A, B)
torch.cuda.synchronize()
# 3. 측정
start = time.perf_counter()
for _ in range(100):
C = torch.matmul(A, B)
torch.cuda.synchronize()
end = time.perf_counter()
# 4. TFLOPS 계산
# 8192x8192 행렬 곱: 2 * N^3 = 2 * 8192^3 = 1.1e12 FLOP
flops = 2 * 8192**3 * 100
elapsed = end - start
tflops = flops / elapsed / 1e12
print(f"달성 성능: {tflops:.1f} TFLOPS")
# 목표: 700+ TFLOPS (이론값 989의 70%+ 달성 가능)
성능을 좌우하는 요인들:
- 메모리 정렬: 행렬이 128바이트 경계에 정렬되어야 최적 메모리 접근
- 행렬 크기: 16의 배수여야 Tensor Core 패딩 없이 사용 가능
torch.backends.cudnn.benchmark = True: cuDNN이 최적 알고리즘을 자동 선택- NVLink: 멀티 GPU 환경에서 GPU 간 통신 대역폭 (H100 NVLink = 900 GB/s)
10. 정리: GPU가 AI를 지배하는 이유
GPU가 AI 계산을 지배하는 이유는 단순히 "코어가 많아서"가 아니다. 세 가지가 결합된 결과다:
- 하드웨어 설계: 수천 개의 단순 코어 + HBM 고대역폭 메모리 + Tensor Core 행렬 전용 하드웨어
- 프로그래밍 모델: CUDA의 SIMT 모델 + 계층적 메모리(공유 메모리, 레지스터) + 정밀한 제어
- 생태계: 18년간 쌓인 cuDNN, cuBLAS, NCCL, Nsight, PyTorch/TF 통합
딥러닝 연산의 95%는 행렬 곱셈으로 귀결되고, H100의 Tensor Core는 이를 위해 완전히 최적화된 하드웨어다. 이것이 바로 NVIDIA가 AI 인프라의 핵심을 쥐고 있는 이유다.
다음 포스트에서는 cuDNN이 컨볼루션을 내부에서 어떻게 처리하는지, Winograd 알고리즘과 im2col 변환, FlashAttention의 타일링 트릭을 해부한다.