같은 곱셈, 두 성능 세계
GEMM (C = A·B)은 '그냥 thread 던져' 가 안 통하는 지점. naive 버전은 thread 하나를 출력 원소 하나에 할당하고 K 차원 loop. tiled 버전은 block 안에서 협력해 A랑 B 청크를 shared memory로 load하고 다음 DRAM trip 전에 각 청크를 여러 번 재사용. 같은 답, 극적으로 다른 속도.
Naive GEMM — 고통을 느껴
각 thread가 C[m][n] 하나 계산:
for k in 0..K: C[m][n] += A[m][k] * B[k][n]모든 FMA가 global 메모리 load 두 번 요구 (A 한 번, B 한 번). 재사용 없음, shared memory 없음. 커널이 bandwidth-bound, peak FLOPs의 ~5%로 돔.
Tiled GEMM — hot 데이터를 shared memory에
출력 tile당 block의 thread들이 협력해서 A-tile + B-tile을 shared memory에 load. 각 thread가 shared memory에서 read해서 자기 partial 출력 계산 — global load 한 번에 cheap read 여러 번. Inner loop:
- 다음 A-tile이랑 B-tile load (global → shared).
__syncthreads()— 모든 thread가 load된 데이터 보도록.- Register에 multiply-accumulate:
Csub += As[ty][k] * Bs[k][tx]. - 다음 K-slice로 넘어가서 반복.
RTX 4090 숫자 (FP32, non-Tensor-Core path):
| 커널 | 시간 (ms) | TFLOP/s | 비고 |
|---|---|---|---|
| gemm-naive (512³) | 0.65 | 0.41 | FMA당 DRAM read 한 번 |
| gemm-tiled (2048³) | 4.50 | 3.8 | Shared-mem 재사용, register blocking |
| cuBLAS sgemm (2048³) | — | ~7.0 | Multi-stage tiling, cublasGemmEx로 Tensor Core |
Naive→tiled 9× 차이는 달성 throughput 기준이고 (0.41 → 3.8 TFLOP/s), 순전히 데이터 재사용 덕분이야. 두 row는 측정 크기가 다르니까 같은 크기에서의 wall-clock 가속이 아니라 throughput 비교라는 건 기억해 — 아래 연습에서 같은 크기로 재보면 대략 5–10× 나와. cuBLAS는 Tensor Core랑 multi-stage software pipelining으로 여기서 또 ~2× 더 붙여 (Track 8).