L003 의 학습 사다리 위에 한 단을 더 올린다 — shared memory tiling. naive matmul 이 같은 row/column 을 N번씩 다시 읽는 비효율을 어떻게 잡는가, dynamic shared memory 의 사용법, __syncthreads() 가 박히는 자리, 그리고 같은 커널을 Python 으로 먼저 흉내낸 뒤 CUDA 로 옮기는 패턴을 동일하게 유지. tiled matmul 한 예제를 끝까지 깊게 따라가면서 “shared memory 가 어떻게 GPU 의 메모리 계층을 활용하는가” 를 손에 넣는다.
L003 의 마지막 예제는 단순 CUDA matmul 이었다. 결과는 정확했지만 — Jeremy 자신이 강의 끝에 인정한 사실 — “cuBLAS 와 비교하지 않았다, CUDA expert 가 아니라서.” L005 가 그 자리에서 시작한다. 같은 matmul 을 한 단계 더 빠르게 만드는 한 가지 도구 — shared memory — 를 끝까지 깐다. 그 도구를 통해 PMPP Ch.5 의 메모리 모델 (specifically tiling) 이 실전에서 어떻게 사용되는지를 보여준다.
강의가 답하는 질문 두 개.
이 강의의 framing 은 정확히 L003 의 연장이다 — 같은 알고리즘을 Python 으로 먼저 짜고, CUDA 로 옮긴다. 새로 추가되는 한 차원만 “shared memory 와 그것을 둘러싼 동기화”. L004 의 “memory hierarchy 와 arithmetic intensity” 가 이 강의의 정량적 motivation 을 깐다.
matmul C = A·B 에서 A 가 (M×K), B 가 (K×N) 이면 C 는 (M×N). naive 커널은 한 thread = 한 출력 element 패턴. thread (i, j) 가 inner loop k 를 돌면서 A[i, k] 와 B[k, j] 를 K 번 읽는다. 이 사실을 출력 전체로 펼쳐 보면.
L003 의 naive 커널이 cuBLAS 보다 “많이” 느린 첫 이유가 여기에 있다 — HBM 의 같은 자리를 N번 다시 가져온다. data reuse 가 있는데 그걸 잡지 않은 것. 이걸 잡으려면 한 번 가져와서 on-chip 에 저장하고 N번 재사용이 답이다. 그게 shared memory 의 역할.
강의에서 Jeremy 가 깐 정의 — “shared memory 는 한 SM 위에서 도는 한 block 안의 thread 들에게만 보이는 on-chip SRAM 이다.” 이 한 줄이 모든 디테일을 따라온다.
__shared__ float tile[16][16]. 동적: launch 시 size 지정 + extern __shared__ float buf[].__syncthreads() 로 block 안 thread 들의 진행을 맞춤. shared memory 에 쓰고 읽기 전에 필수.이 정의에서 직접 따라 나오는 사용 패턴 — 한 block 의 thread 들이 협력해서 HBM 의 일부를 shared 로 가져오고, 그 위에서 inner-loop 를 돌리고, 끝나면 결과만 HBM 으로 다시 쓴다. 이게 “tiling” 의 본질.
tiled matmul 의 분해. 출력 C 를 TILE×TILE 짜리 tile 들로 자르고, 한 block 이 한 출력 tile 을 책임진다. 한 출력 tile 을 계산하려면 A 의 한 row-band 와 B 의 한 column-band 가 필요한데, 그 band 들을 한꺼번에 shared 로 가져올 순 없다 (너무 큼). 그래서 TILE 크기로 잘라 phase 를 돈다 — phase 마다 A 의 한 tile, B 의 한 tile 을 shared 로 load 하고, 그 위에서 partial sum 을 누적.
shA[…] = A[bi*TILE+ti, 0*TILE+tj] · shB[…] = B[0*TILE+ti, bj*TILE+tj]
load 만
for (k=0; k<TILE; ++k) acc += shA[ti][k] * shB[k][tj]
compute
shA[…] = A[…, 1*TILE+tj] · shB[…] = B[1*TILE+ti, …] · sync · compute · sync
반복
C[bi*TILE+ti, bj*TILE+tj] = acc
한 번만
// 강의의 tiled matmul 골격 (요약)
__global__ void matmul_tiled(float* A, float* B,
float* C, int M, int N, int K)
{
__shared__ float shA[TILE][TILE];
__shared__ float shB[TILE][TILE];
int bi = blockIdx.y, bj = blockIdx.x;
int ti = threadIdx.y, tj = threadIdx.x;
int row = bi * TILE + ti;
int col = bj * TILE + tj;
float acc = 0.0f;
for (int p = 0; p < (K + TILE - 1) / TILE; ++p) {
// phase: load A tile, B tile
int aCol = p * TILE + tj;
int bRow = p * TILE + ti;
shA[ti][tj] = (row < M && aCol < K) ? A[row * K + aCol] : 0.0f;
shB[ti][tj] = (bRow < K && col < N) ? B[bRow * N + col] : 0.0f;
__syncthreads();
// inner: dot product on shared
for (int k = 0; k < TILE; ++k)
acc += shA[ti][k] * shB[k][tj];
__syncthreads();
}
if (row < M && col < N) C[row * N + col] = acc;
}
이 코드의 모든 디테일이 의미 있다.
shA[ti][tj] = .... block 의 모든 thread 가 자기 한 element 를 책임짐.__syncthreads() — 하나는 shared 에 쓰기 끝났는지 (load 후), 다른 하나는 shared 에서 읽기 끝났는지 (compute 후 다음 phase 가 덮어쓰기 전).aCol < K 로 0 padding.acc 에 누적된 후 끝에 한 번.강의의 학습 패턴은 L003 그대로. tiled matmul 도 Python 으로 먼저 흉내낸다. shared memory 는 Python dict 또는 mutable list 로, __syncthreads 는 “일단 외부 loop 가 모든 thread 의 step 을 마친 뒤 다음으로 넘어간다” 의 구조로 모델링.
# L005 notebook (요약) — shared 를 dict 로 mocking
def matmul_tiled_pyk(blockidx_x, blockidx_y, threadidx_x, threadidx_y,
blockdim, A, B, C, M, N, K):
shared_A = torch.zeros(blockdim, blockdim) # block 별로 따로
shared_B = torch.zeros(blockdim, blockdim)
bi, bj = blockidx_y, blockidx_x
ti, tj = threadidx_y, threadidx_x
row, col = bi*blockdim + ti, bj*blockdim + tj
acc = 0.0
for p in range(math.ceil(K / blockdim)):
# load phase — shared 에 자기 한 element 채움
aCol = p*blockdim + tj
bRow = p*blockdim + ti
shared_A[ti, tj] = A[row, aCol] if row < M and aCol < K else 0
shared_B[ti, tj] = B[bRow, col] if bRow < K and col < N else 0
# 외부 loop 가 모든 thread 를 한 step 끝낸 뒤 다음으로 가는 구조 = sync
for k in range(blockdim):
acc += shared_A[ti, k] * shared_B[k, tj]
if row < M and col < N:
C[row, col] = acc
이 Python mock 의 의미는 L003 의 blk_kernel 과 똑같이 — 알고리즘을 익숙한 환경에서 검증한다.
Python 에서는 모든 step 이 sequential 이다 — sync 의 의미가 자연스럽게 따라 나온다. 하지만 CUDA 에서는 thread 들이 진짜 동시에 진행, 그래서 sync 가 명시적이지 않으면 race. 이 한 가지 차이가 가장 흔한 버그의 원천이라는 게 강의의 메시지.
그리고 이 Python 코드를 ChatGPT 또는 손으로 CUDA C 로 옮긴다 — 이미 본 mechanical 변환. 변수명을 그대로 유지하면 거의 한 글자도 안 바뀐다 (shared_A → shA 정도). 그게 이 강의의 학습 사다리의 가치.
__syncthreads() 는 block 안의 모든 thread 가 이 자리에 도달할 때까지 기다린다는 barrier. 다른 block 의 thread 와는 무관. shared memory 에 쓰고 다른 thread 가 읽기 전, 그리고 shared 의 한 자리를 다음 phase 가 덮어쓰기 전 — 이 두 자리가 표준.
① load 후 sync 빠뜨림 — 어떤 thread 가 다른 thread 가 아직 안 적은 shared 자리를 읽는다. 결과는 race — 작은 입력에서는 맞아 보이다가 큰 입력에서 wrong. ② compute 후 sync 빠뜨림 — 다음 phase 의 load 가 아직 끝나지 않은 read 를 덮어쓴다. 같은 race.
강의에서 Jeremy 가 강조하는 한 줄 — “그래서 sync 의 위치는 ‘shared 에 write 한 직후, 그리고 shared 에서 read 끝낸 직후’ 두 자리.” 이 두 자리만 외워두면 거의 모든 tiled 커널이 race-free.
__syncthreads 가 conditional 안에 있으면 위험하다. 예: if (i < n) { ...; __syncthreads(); } 같은 코드는 i >= n 인 thread 가 barrier 에 도달 못 해 deadlock. sync 는 항상 모든 thread 가 통과하는 자리에 박는다.
강의의 tiled matmul 코드는 boundary 가 있더라도 sync 자체는 모든 thread 가 통과한다 — boundary 는 load 의 값 만 0 으로 처리. 이 패턴이 CUDA tiled 커널의 표준.
정적 shared (__shared__ float tile[16][16]) 는 컴파일 시점에 크기가 박힌다. 강의에서 Jeremy 가 추가로 깐 dynamic shared memory 는 launch 시 크기를 결정한다 — 같은 커널이 여러 tile 사이즈에 대응 가능.
// dynamic shared 의 선언과 launch
__global__ void matmul_dyn(float* A, float* B,
float* C, int M, int N, int K, int TILE)
{
extern __shared__ float buf[]; // 크기는 launch 시 지정
float* shA = &buf[0];
float* shB = &buf[TILE * TILE]; // 한 buffer 를 둘로 split
// ... 이전과 동일
}
// host launch — 3번째 인자가 dynamic shared 크기 (bytes)
int sharedBytes = 2 * TILE * TILE * sizeof(float);
matmul_dyn<<<blocks, threads, sharedBytes>>>(
A, B, C, M, N, K, TILE);
이 패턴의 장점 — 같은 커널 binary 가 여러 tile 사이즈에 대응. autotune 에서 tile 사이즈를 sweep 할 때 유용. 단점은 — compiler 가 사이즈를 모르니 register 할당 등 일부 최적화가 보수적.
dynamic shared 는 하나의 큰 buffer로 잡힌다. 여러 logical 변수 (예: shA, shB) 로 쓸 때는 host 에서 size 를 합쳐 잡고 device 에서 pointer 산술로 split. 강의에서 Jeremy 가 이 “주소 분리” 패턴을 한 셀에서 직접 보여준다.
강의의 한 가지 부수 메시지 — “최근 NVIDIA GPU 들은 모두 같은 양의 shared 를 가진다, 그래서 dynamic shared 의 portability 이점이 옛날만큼 크지 않다.” 정적 shared 가 종종 더 깔끔.
강의 후반의 작은 운영 팁. load_inline 의 name 을 함수마다 다르게 주면 — 각 함수가 자기 cache 디렉터리에 저장되어 다른 셀에서 바뀐 다른 함수를 빌드해도 이 함수는 재컴파일 안 함. Jeremy 가 L005 에서 추가한 패턴.
# L003 의 load_cuda 가 name="inline_ext" 고정이었던 것에서 변경
def load_cuda(cuda_src, cpp_src, funcs, name=None,
opt=False, verbose=False):
if name is None:
name = funcs[0] # 함수 이름을 module 이름으로
return load_inline(
cuda_sources=[cuda_src],
cpp_sources=[cpp_src],
functions=funcs,
extra_cuda_cflags=["-O2"] if opt else [],
verbose=verbose,
name=name,
)
이 작은 변경의 효과는 — naive matmul 커널과 tiled matmul 커널이 다른 cache 디렉터리에 저장. 두 커널을 번갈아 수정해도 한 쪽 수정이 다른 쪽 cache 를 invalidate 하지 않음. notebook 의 iterate 속도가 한 단 빨라진다.
~/.cache/torch_extensions/... 또는 build_directory 로 지정한 자리. 안에 자동 생성된 main.cpp, build.ninja, .so 가 있다. L001 §05 와 같은 자료.
강의에서 Jeremy 가 솔직하게 인정하는 한 자리. “우리 tiled matmul 을 cuBLAS / cuSPARSE / cuTLASS 와 직접 비교하지 않았다, 정확히 얼마나 차이나는지 모른다.” 이 한 줄이 “직접 짠 커널이 어디까지 갈 수 있는가” 의 솔직한 좌표.
위 표는 (1024, 1024, 1024) FP32 matmul 의 대략적 시간 (A100 기준 추정). 정확한 수치는 자기 GPU 에서 측정 필요. 메시지는 — shared memory 한 단을 추가하는 것만으로도 6배 가까운 가속이 가능하지만, cuBLAS 까지의 100배 격차에는 한참 못 미친다는 사실.
나머지 격차의 출처들.
float4, __ldg 등으로 한 instruction 에 여러 element load. memory bandwidth 의 효율 ↑.cp.async (Hopper) 또는 software pipelining.강의의 결론적인 톤은 — “production 에서는 cuBLAS / cuTLASS 를 쓴다. 직접 짜는 건 학습용이거나, cuBLAS 가 잘 안 다루는 shape (예: tall-skinny matmul, fused attention) 때문에.” 그게 L012 FlashAttention, L023 CUTLASS 의 출발점.
tiled matmul 의 정수와 — 직접 손에 박아야 하는 코드 자료.
__shared__ float tile[16][16]. 동적: extern __shared__ + launch 의 3번째 인자로 size.name 으로 cache 분리. 한 함수 수정이 다른 함수 cache 를 invalidate 하지 않게.__syncthreads 는 conditional 안에 박지 않는다 — 일부 thread 가 도달 못 하면 deadlock.matmul_l5.ipynb 가 자료.
matmul_l5.ipynb 그대로 돌려 (1024, 1024, 1024) 의 시간을 측정. naive 와 tiled 의 비율이 책에서 말하는 ~6배 와 일치하는지.torch.allclose.torch.matmul 의 시간과 자기 tiled 커널 시간을 직접 비교. 격차가 강의의 추정과 비슷한지.tiled matmul 의 패턴 (load → sync → compute → sync) 이 GPU 시리즈의 거의 모든 후속 강의에서 다른 옷을 입고 다시 등장한다.
이 학습 사다리가 의도적으로 비워둔 자리들과 — 자기 GPU 에서 직접 봐야 손에 박히는 사실들.
shA[ti][k] 패턴이 bank conflict 를 일으키는가? 답은 stride 에 따라. L008 가 이 문제의 본론.cp.async 와 double buffering — Hopper 의 async copy 가 다음 phase load 와 현재 compute 의 overlap 을 가능하게. 강의에서 다루지 않음. CUTLASS / FlashAttention 에서.