같은 곱셈, 두 성능 세계
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 | ~3.9 | ~7.0 | Multi-stage tiling, cublasGemmEx로 Tensor Core |
Naive→tiled 9× 가속은 순전히 데이터 재사용. cuBLAS는 Tensor Core랑 multi-stage software pipelining (Track 8)으로 또 ~2× 추가.