gpumode · 강의 아카이브
《GPU Mode》 L005 2024 · FEB · 10 High priority transcript · available

Going Further with CUDA for Python Programmers

L003 의 학습 사다리 위에 한 단을 더 올린다 — shared memory tiling. naive matmul 이 같은 row/column 을 N번씩 다시 읽는 비효율을 어떻게 잡는가, dynamic shared memory 의 사용법, __syncthreads() 가 박히는 자리, 그리고 같은 커널을 Python 으로 먼저 흉내낸 뒤 CUDA 로 옮기는 패턴을 동일하게 유지. tiled matmul 한 예제를 끝까지 깊게 따라가면서 “shared memory 가 어떻게 GPU 의 메모리 계층을 활용하는가” 를 손에 넣는다.

shared memory tiled matmul __syncthreads dynamic shared block-of-threads 2D launch load_inline cache PMPP Ch.5 응용
J
Speaker
Jeremy Howard
answer.ai · L003 의 직속 후속편
강의 번호
L005
스피커
Jeremy Howard
학습 우선순위
High · 정독
다시 볼 때
tiled matmul 직접 짠다
§ 01강의가 풀려는 문제· Why this lecture exists

L003 의 naive matmul 이 cuBLAS 와 차이 나는 그 자리

L003 의 마지막 예제는 단순 CUDA matmul 이었다. 결과는 정확했지만 — Jeremy 자신이 강의 끝에 인정한 사실 — “cuBLAS 와 비교하지 않았다, CUDA expert 가 아니라서.” L005 가 그 자리에서 시작한다. 같은 matmul 을 한 단계 더 빠르게 만드는 한 가지 도구 — shared memory — 를 끝까지 깐다. 그 도구를 통해 PMPP Ch.5 의 메모리 모델 (specifically tiling) 이 실전에서 어떻게 사용되는지를 보여준다.

강의가 답하는 질문 두 개.

  1. L003 의 naive matmul 이 왜 느린가 — 산술적으로 정확히. (§02 의 data reuse 회계.)
  2. shared memory 한 도구로 어디까지 빠르게 만들 수 있나 — Python 시점에서 먼저 흉내내고, CUDA 로 mechanical 변환.
학습 사다리의 한 단 더

이 강의의 framing 은 정확히 L003 의 연장이다 — 같은 알고리즘을 Python 으로 먼저 짜고, CUDA 로 옮긴다. 새로 추가되는 한 차원만 “shared memory 와 그것을 둘러싼 동기화”. L004 의 “memory hierarchy 와 arithmetic intensity” 가 이 강의의 정량적 motivation 을 깐다.

“같은 결과를 빠르게 — 모든 GPU 최적화의 한 줄짜리 motto. shared memory 가 그 첫 도구.”학습 노트 · L005
§ 02data reuse 의 산수· 왜 같은 row 가 N번 읽히나

matmul 의 가장 단순한 비효율을 정량으로

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 번 읽는다. 이 사실을 출력 전체로 펼쳐 보면.

FIG · A 의 row 와 B 의 column 의 다중 사용한 row/column 이 N번 다시 읽힘

A (4×4) — row 0 의 사용

a00
a01
a02
a03
a10
a11
a12
a13
a20
a21
a22
a23
a30
a31
a32
a33
row 0 → c[0,0], c[0,1], c[0,2], c[0,3] 에서 모두 다시 읽힘 → 4번

B (4×4) — column 0 의 사용

b00
b01
b02
b03
b10
b11
b12
b13
b20
b21
b22
b23
b30
b31
b32
b33
column 0 → c[0,0], c[1,0], c[2,0], c[3,0] 에서 모두 다시 읽힘 → 4번
모든 element 가 N (또는 M) 번 다시 HBM 에서 읽힌다는 게 naive 의 본질. 즉 같은 데이터에 대한 HBM 트래픽이 N배. shared memory tiling 의 표적은 정확히 이 N배.

L003 의 naive 커널이 cuBLAS 보다 “많이” 느린 첫 이유가 여기에 있다 — HBM 의 같은 자리를 N번 다시 가져온다. data reuse 가 있는데 그걸 잡지 않은 것. 이걸 잡으려면 한 번 가져와서 on-chip 에 저장하고 N번 재사용이 답이다. 그게 shared memory 의 역할.

§ 03shared memory 가 무엇을 푸는가· SM 안의 SRAM

register 보다 크고 HBM 보다 가까운 — block 안의 thread 들이 같이 쓴다

강의에서 Jeremy 가 깐 정의 — “shared memory 는 한 SM 위에서 도는 한 block 안의 thread 들에게만 보이는 on-chip SRAM 이다.” 이 한 줄이 모든 디테일을 따라온다.

위치
on-chip, SM 의 SRAM. HBM(off-chip) 보다 ~100배 빠름.
크기
SM 당 ~100 KB (Ampere split). block 당 사용 한도가 occupancy 와 거래.
scope
한 block 안의 모든 thread 가 본다. 다른 block 은 안 보임.
선언
정적: __shared__ float tile[16][16]. 동적: launch 시 size 지정 + extern __shared__ float buf[].
동기화
__syncthreads() 로 block 안 thread 들의 진행을 맞춤. shared memory 에 쓰고 읽기 전에 필수.
bank conflict
shared 가 32 bank 로 나뉘어 있고 한 bank 를 여러 thread 가 동시에 접근하면 직렬화. 잘못된 stride 면 성능 떨어짐. (L008 의 항목)

이 정의에서 직접 따라 나오는 사용 패턴 — 한 block 의 thread 들이 협력해서 HBM 의 일부를 shared 로 가져오고, 그 위에서 inner-loop 를 돌리고, 끝나면 결과만 HBM 으로 다시 쓴다. 이게 “tiling” 의 본질.

§ 04tiled matmul 의 두 phase· load · compute · sync

한 block 이 한 출력 tile 을 책임진다 — 그 안의 thread 들이 협력

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 을 누적.

FIG · 한 출력 tile 을 위한 phase loopK 차원을 TILE 로 잘라서
phase 0 shA[…] = A[bi*TILE+ti, 0*TILE+tj] · shB[…] = B[0*TILE+ti, bj*TILE+tj] load 만
__syncthreads block 안 모든 thread 가 자기 element 를 다 적었는지 보장 barrier
phase 0 for (k=0; k<TILE; ++k) acc += shA[ti][k] * shB[k][tj] compute
__syncthreads 다음 phase 가 shared 를 덮어쓰기 전에, 모든 thread 가 이번 phase 의 read 를 끝냈는지 보장 barrier
phase 1 shA[…] = A[…, 1*TILE+tj] · shB[…] = B[1*TILE+ti, …] · sync · compute · sync 반복
K 차원이 다 끝날 때까지 phase 를 돈다 (총 K/TILE phase) 반복
store C[bi*TILE+ti, bj*TILE+tj] = acc 한 번만
tile 안의 한 element 가 TILE 번 다시 읽힌다 — shared 로부터. HBM 에서는 한 번만 읽혀서 shared 로. 즉 HBM 트래픽이 1/TILE 로 줄어든다. TILE=16 이면 HBM 사용량이 1/16.
// 강의의 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;
}

이 코드의 모든 디테일이 의미 있다.

  • 한 block 의 thread 들이 협력해서 shared 에 적는다shA[ti][tj] = .... block 의 모든 thread 가 자기 한 element 를 책임짐.
  • 두 번의 __syncthreads() — 하나는 shared 에 쓰기 끝났는지 (load 후), 다른 하나는 shared 에서 읽기 끝났는지 (compute 후 다음 phase 가 덮어쓰기 전).
  • boundary handling — K 가 TILE 의 배수가 아닐 수 있으니 aCol < K 로 0 padding.
  • output 은 한 번만 write — 모든 partial sum 이 register acc 에 누적된 후 끝에 한 번.
“tile 사이즈가 작을수록 shared 사용량이 적어 occupancy 가 좋고, 클수록 HBM 트래픽이 줄어든다. 이 trade-off 가 tile 사이즈의 결정.”학습 노트 · L005 §04
§ 05Python 위에서 먼저 흉내내기· L003 의 사다리 그대로

같은 학습 사다리에 “shared 라는 dict” 를 추가

강의의 학습 패턴은 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 과 똑같이 — 알고리즘을 익숙한 환경에서 검증한다.

  • print, breakpoint 가 그대로 동작한다 — shared 안의 값이 어떤 모양인지 직접 본다.
  • boundary handling 이 정확한지 작은 사이즈로 검증.
  • 단점 — Python 에서는 thread 별 shared 가 “block 별 dict” 로 분리되지만 실제 CUDA 에서는 같은 block 의 thread 들이 하나의 shared 를 공유. 이 차이는 강의에서 명시적으로 짚는다.
Python mock 의 한계

Python 에서는 모든 step 이 sequential 이다 — sync 의 의미가 자연스럽게 따라 나온다. 하지만 CUDA 에서는 thread 들이 진짜 동시에 진행, 그래서 sync 가 명시적이지 않으면 race. 이 한 가지 차이가 가장 흔한 버그의 원천이라는 게 강의의 메시지.

그리고 이 Python 코드를 ChatGPT 또는 손으로 CUDA C 로 옮긴다 — 이미 본 mechanical 변환. 변수명을 그대로 유지하면 거의 한 글자도 안 바뀐다 (shared_AshA 정도). 그게 이 강의의 학습 사다리의 가치.

§ 06__syncthreads· block 안의 barrier

가장 자주 빠뜨리는 한 줄, 가장 많이 race 를 만드는 자리

__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.

divergence 와의 함정

__syncthreads 가 conditional 안에 있으면 위험하다. 예: if (i < n) { ...; __syncthreads(); } 같은 코드는 i >= n 인 thread 가 barrier 에 도달 못 해 deadlock. sync 는 항상 모든 thread 가 통과하는 자리에 박는다.

강의의 tiled matmul 코드는 boundary 가 있더라도 sync 자체는 모든 thread 가 통과한다 — boundary 는 load 의 값 만 0 으로 처리. 이 패턴이 CUDA tiled 커널의 표준.

§ 07dynamic shared memory· launch 시 크기 결정

tile 사이즈가 컴파일 시점에 정해지지 않을 때의 옵션

정적 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 할당 등 일부 최적화가 보수적.

하나의 buffer, 여러 sub-array

dynamic shared 는 하나의 큰 buffer로 잡힌다. 여러 logical 변수 (예: shA, shB) 로 쓸 때는 host 에서 size 를 합쳐 잡고 device 에서 pointer 산술로 split. 강의에서 Jeremy 가 이 “주소 분리” 패턴을 한 셀에서 직접 보여준다.

강의의 한 가지 부수 메시지 — “최근 NVIDIA GPU 들은 모두 같은 양의 shared 를 가진다, 그래서 dynamic shared 의 portability 이점이 옛날만큼 크지 않다.” 정적 shared 가 종종 더 깔끔.

§ 08load_inline 의 cache 와 name· notebook 위 빌드 패턴

같은 함수를 여러 번 빌드하지 않게 하는 작은 디테일

강의 후반의 작은 운영 팁. load_inlinename 을 함수마다 다르게 주면 — 각 함수가 자기 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 디렉터리 직접 보기

~/.cache/torch_extensions/... 또는 build_directory 로 지정한 자리. 안에 자동 생성된 main.cpp, build.ninja, .so 가 있다. L001 §05 와 같은 자료.

§ 09정확성 vs 성능· cuBLAS 와의 거리

shared 까지 가도 production GEMM 과 5–10배 차이가 남는 이유

강의에서 Jeremy 가 솔직하게 인정하는 한 자리. “우리 tiled matmul 을 cuBLAS / cuSPARSE / cuTLASS 와 직접 비교하지 않았다, 정확히 얼마나 차이나는지 모른다.” 이 한 줄이 “직접 짠 커널이 어디까지 갈 수 있는가” 의 솔직한 좌표.

버전
시간
대비
naive (L003)~50 ms×1
tiled (L005, TILE=16)~8 ms×6
tiled + register tiling~2 ms×25
cuBLAS~0.5 ms×100
Tensor Core (BF16)~0.05 ms×1000

위 표는 (1024, 1024, 1024) FP32 matmul 의 대략적 시간 (A100 기준 추정). 정확한 수치는 자기 GPU 에서 측정 필요. 메시지는 — shared memory 한 단을 추가하는 것만으로도 6배 가까운 가속이 가능하지만, cuBLAS 까지의 100배 격차에는 한참 못 미친다는 사실.

나머지 격차의 출처들.

“직접 짠 tiled matmul 은 cuBLAS 의 1/100 — 하지만 학습으로는 100% 유익하다. 알고리즘이 어떻게 GPU 에 매핑되는가의 metal 모델이 손에 박힌다.”학습 노트 · L005 §09

강의의 결론적인 톤은 — “production 에서는 cuBLAS / cuTLASS 를 쓴다. 직접 짜는 건 학습용이거나, cuBLAS 가 잘 안 다루는 shape (예: tall-skinny matmul, fused attention) 때문에.” 그게 L012 FlashAttention, L023 CUTLASS 의 출발점.

§ 10기억할 메모와 코드· key takeaways · repo

다시 열었을 때 5분 안에 손으로 잡혀야 할 것

tiled matmul 의 정수와 — 직접 손에 박아야 하는 코드 자료.

data reuse 의 산수
naive matmul 은 A 의 row 를 N번, B 의 column 을 M번 다시 읽는다. arithmetic intensity ~0.25 FLOP/byte (FP32). 천장은 bandwidth.
tiled matmul 의 phase
load (sync) → compute (sync) → 다음 phase. K/TILE 번 반복. 한 출력 tile 의 element 가 TILE 번 shared 에서 다시 읽힘.
__syncthreads 의 두 자리
load 직후 (write 끝났는지) + compute 직후 (read 끝났는지). 둘 다 모든 thread 가 통과하는 자리에.
정적 vs 동적 shared
정적: __shared__ float tile[16][16]. 동적: extern __shared__ + launch 의 3번째 인자로 size.
tile 사이즈의 trade-off
큰 tile → HBM 트래픽 적게 + shared 사용량 많아 occupancy 떨어짐. 작은 tile → 반대. 16 또는 32 가 흔한 시작점.
load_inline 의 name
함수마다 다른 name 으로 cache 분리. 한 함수 수정이 다른 함수 cache 를 invalidate 하지 않게.
cuBLAS 와의 거리
tiled matmul 도 cuBLAS 의 1/10 정도. register tiling, vectorize, Tensor Core 가 더 추가되어야 격차 줄어듦.
divergence 와 sync
__syncthreads 는 conditional 안에 박지 않는다 — 일부 thread 가 도달 못 하면 deadlock.
Slides 없음 — notebook 자체. matmul_l5.ipynb 가 자료.
참고서 PMPP Ch.5 (Performance considerations) · CUDA C Programming Guide §3 (Programming Interface) · NVIDIA matrix transpose CUDA sample

손에 새기기 — 실습 시퀀스

  1. naive vs tiled 직접 비교matmul_l5.ipynb 그대로 돌려 (1024, 1024, 1024) 의 시간을 측정. naive 와 tiled 의 비율이 책에서 말하는 ~6배 와 일치하는지.
  2. TILE 사이즈 sweep — TILE ∈ {8, 16, 32, 64} 로 측정. shared 사용량 변화와 시간 변화를 표로. occupancy 와의 관계 추정.
  3. __syncthreads 일부러 빠뜨려보기 — 작은 사이즈 (16×16) 에서는 결과가 맞을 수 있지만, 큰 사이즈에서 race 가 발생하는지 확인.
  4. boundary 처리 검증 — K 가 TILE 의 배수가 아닌 경우 (예: K=1000, TILE=16) 결과가 PyTorch 와 일치하는지 torch.allclose.
  5. dynamic shared 로 바꿔보기 — 정적 shared 를 dynamic 으로 변환하고 launch 의 3번째 인자 추가. 결과는 같아야 함.
  6. cuBLAS 와 비교torch.matmul 의 시간과 자기 tiled 커널 시간을 직접 비교. 격차가 강의의 추정과 비슷한지.
  7. register tiling 의 한 단계 — 한 thread 가 2×2 출력 element 를 책임지게 변경. 시간이 더 줄어드는지.
  8. 한 페이지 plan — 자기가 다루는 모델의 한 GEMM 호출을 골라 “이 shape 에서는 cuBLAS 가 답인가, 직접 짠 fused kernel 이 답인가” 의 결정 기준.
§ 11다른 강의로 이어지는 길· connections

이 강의의 tiling 패턴이 어디에서 본격화되는지

tiled matmul 의 패턴 (load → sync → compute → sync) 이 GPU 시리즈의 거의 모든 후속 강의에서 다른 옷을 입고 다시 등장한다.

§ 12열린 질문· open questions

다음에 다시 들었을 때 직접 검증해야 할 것들

이 학습 사다리가 의도적으로 비워둔 자리들과 — 자기 GPU 에서 직접 봐야 손에 박히는 사실들.

← Lecture 004 Thomas Viehmann — compute / memory / occupancy 의 첫 정량 모델 Lecture 006 → Jane Xu — fused optimizer 와 multi-tensor apply