Jeremy Howard 가 제안한 학습 순서 — Python 안에서 같은 알고리즘을 “kernel 모양” 으로 먼저 짜고, 그 코드를 거의 글자 그대로 CUDA C 로 옮긴다. 디버깅이 어려운 GPU 위에서 헤매기 전에, 익숙한 Python 의 print/breakpoint 위에서 알고리즘을 손에 익히는 학습 사다리. RGB→grayscale 과 (5×784)·(784×10) 짜리 작은 matmul 두 예제로 세 단계 (single-thread → block-of-threads → CUDA) 를 차례로 깐다.
CUDA 의 학습 곡선이 가파른 이유는 문법이 아니다. 알고리즘과 GPU 모델 두 개를 동시에 처음 보기 때문이다. Jeremy 의 진단이 정확하다 — “Python 으로 이미 짤 수 있는 알고리즘을, GPU 위에서 다시 짜면서 동시에 indexing/launch/메모리 모델까지 처음 만나니, 어디서 막힌 건지가 안 잡힌다.”
강의가 푸는 두 질문.
L001 은 “이미 도는 코드를 어떻게 보는가” 의 강의, L002 는 “CUDA 프로그램이 무엇으로 구성되는가” 의 강의. L003 은 “그 사이를 학습자가 어떻게 건너가는가” 의 강의. 다음 단계인 L005 가 같은 사다리에 shared memory 를 한 단 더 올린다.
Jeremy 가 pmpp.ipynb 에서 깐 학습 패턴은 RGB→grayscale 과 matmul 두 예제에 똑같이 반복된다. 같은 알고리즘 → 세 단계의 변환: ① 일반 Python loop, ② 한 thread 시점의 “kernel” 함수 + 외부 loop, ③ block-aware 한 “kernel” 함수 + 이중 loop. 이 셋을 끝낸 다음 비로소 CUDA C 로 mechanical 하게 옮긴다.
for i in range(n):
res[i] = 0.30*x[i] + 0.59*x[i+n] + 0.11*x[i+2n]
— 외부 loop, kernel 개념 없음 —~3 s
def k(i, x, out, n):
out[i] = 0.30*x[i] + ...
run_kernel(k, n, x, res, n)
한 thread 시점만 짠다. loop 는 runner 가.~3 s
def k(bi, ti, bdim, x, out, n):
i = bi*bdim + ti
if i < n: out[i] = ...
blk_kernel(k, blocks, 256, x, ...)
CUDA 의 indexing 한 줄을 Python 으로 흉내냄.~3 s
— 위 함수를 ChatGPT 에 붙여 변환 →
__global__ void k(uchar* x, ...) {
int i = blockIdx.x*blockDim.x + threadIdx.x;
if (i<n) out[i] = 0.30*x[i] + ...
}
~0.3 ms
이 사다리의 진짜 가치는 L1 과 L2 에서 print, breakpoint 가 그대로 동작한다는 점이다. i 가 어떤 값에서 잘못됐는지, blockidx*blockdim + threadidx 가 어떻게 계산되는지를 Python 안에서 직접 확인할 수 있다. CUDA C 로 옮긴 뒤에는 같은 디버깅이 printf(§09) 또는 cuda-gdb 의 영역.
예제는 (C, H, W) shape 의 RGB tensor 를 (H, W) shape 의 grayscale 로 변환. 가중치는 0.2989·R + 0.5870·G + 0.1140·B 의 luma 공식. notebook 의 첫 셀이 PIL/torchvision 으로 강아지 사진을 가져와 (3, 1066, 1600) tensor 를 만든다.
# pmpp.ipynb cell 9 — naive Python
def rgb2grey_py(x):
c, h, w = x.shape
n = h * w
x = x.flatten()
res = torch.empty(n, dtype=x.dtype,
device=x.device)
for i in range(n):
res[i] = (0.2989*x[i]
+ 0.5870*x[i+n]
+ 0.1140*x[i+2*n])
return res.view(h, w)
코드를 펼쳐 보면 한 가지 디테일이 의도적이다 — tensor 를 .flatten() 으로 1D 로 만들어 놓고, 채널은 메모리 위 offset (x[i+n], x[i+2n]) 으로 본다. 이렇게 1D 로 깔아놓는 게 다음 단계의 “한 thread = 한 출력 element” 패턴과 정확히 매핑된다. (c, h, w) 에 대한 3중 loop 가 1중 loop 가 되는 자리.
Rec.601 luma 가중치. 인간 시각이 G 에 가장 민감하고 B 에 가장 둔감하다는 측정에서 나옴. L002 의 0.21·R + 0.71·G + 0.07·B (Rec. 709) 와 약간 다른 표준. 두 가중치 다 grayscale 로 보면 차이가 미미하다.
이 단계에서 %%time 으로 측정한 시간이 약 3초 — 1066×1600 = 약 1.7M element 의 Python loop. 느린 게 핵심이 아니다, 알고리즘이 정확히 무엇을 계산하는지가 손에 잡히는 게 핵심.
두 번째 단계가 학습 사다리의 진짜 도구다. kernel 함수를 “한 thread 의 시점” 만으로 짜고, loop 는 외부 run_kernel 이 책임진다. 이 함수의 시그니처가 정확히 CUDA kernel 의 메탈 모델이다.
# pmpp.ipynb cell 13–17
def run_kernel(f, times, *args):
for i in range(times):
f(i, *args) # 한 thread 의 호출
def rgb2grey_k(i, x, out, n):
out[i] = (0.2989*x[i]
+ 0.5870*x[i+n]
+ 0.1140*x[i+2*n])
def rgb2grey_pyk(x):
c, h, w = x.shape; n = h * w
x = x.flatten()
res = torch.empty(n, dtype=x.dtype, device=x.device)
run_kernel(rgb2grey_k, h*w, x, res, n) # N번 launch 흉내
return res.view(h, w)
이 코드의 모든 사실이 의미 있다.
__global__ void 와 정확히 매핑.i. 나머지 인자는 모두 buffer 또는 scalar.i 번째다, 내 일만 한다”. 다른 thread 가 무엇을 하는지 모른다.이 시그니처를 손에 익히면 — CUDA 로 옮길 때 indexing 한 줄(i = bIdx*bDim + tIdx) 만 추가하면 된다. 알고리즘은 그대로다. 코드 변환 비용이 거의 0 이 되는 자리.
L0(naive Python) 와 L1(kernel mockup) 의 시간이 비슷하다 — Python loop 가 dominant 하니까. 시간을 줄이는 게 이 단계의 목표가 아니다. thread-시점의 함수를 손에 두는 게 목표.
L1(run_kernel) 은 “한 thread 시점” 만 모델링한다 — block 이 없다. 다음 단계가 block 을 추가한 blk_kernel. CUDA 의 launch shape <<<blocks, threads>>> 와 thread 의 indexing 식 blockIdx*blockDim + threadIdx 를 Python 으로 그대로 흉내낸다.
# pmpp.ipynb cell 21–23
def blk_kernel(f, blocks, threads, *args):
for i in range(blocks):
for j in range(threads):
f(i, j, threads, *args) # (blockIdx, threadIdx, blockDim)
def rgb2grey_bk(blockidx, threadidx, blockdim, x, out, n):
i = blockidx * blockdim + threadidx
if i < n: # boundary guard — CUDA 와 동일
out[i] = 0.2989*x[i] + 0.5870*x[i+n] + 0.1140*x[i+2*n]
def rgb2grey_pybk(x):
c, h, w = x.shape; n = h*w
x = x.flatten(); res = torch.empty(n, dtype=x.dtype, device=x.device)
threads = 256
blocks = math.ceil(h*w / threads)
blk_kernel(rgb2grey_bk, blocks, threads, x, res, n)
return res.view(h, w)
이 코드의 의미는 직접적이다.
cdiv(n, 256), 블록 당 thread 수 256 — CUDA 에서 흔한 default. thread 수는 32 의 배수 (warp 크기) 가 권장됨.(blockidx, threadidx, blockdim, ...) — CUDA 의 blockIdx.x, threadIdx.x, blockDim.x 와 정확히 1:1.if i < n 가 필요한 이유는 n = h*w 가 256 으로 나눠 떨어지지 않을 가능성. 마지막 block 에 일부 thread 는 i >= n.Jeremy 의 markdown 셀이 명시적으로 깐다 — “2^31 max blocks for dim 0, 2^16 max for dims 1 & 2”, “1024 max threads per block (use a multiple of 32)”. 이 한계는 Python blk_kernel 안에서는 강제되지 않지만, CUDA 에서는 강제. 알고 가야 한다.
이 시점에서 학습자가 손에 가지고 있는 건 — CUDA 와 정확히 같은 모양의 알고리즘이다. 두 차이만 남았다: ① Python 의 indexing 산술이 GPU 의 32-bit register 산술로, ② Python 의 외부 loop 가 GPU 의 SM 위 병렬 실행으로. 두 변환 모두 mechanical 하다.
CUDA 코드를 끼워넣는 도구는 L001 §05 에서 본 torch.utils.cpp_extension.load_inline 그대로다. Jeremy 는 자주 쓰는 옵션을 묶어 한 줄짜리 wrapper 로 만든다.
# pmpp.ipynb cell 28
def load_cuda(cuda_src, cpp_src, funcs,
opt=False, verbose=False):
return load_inline(
cuda_sources=[cuda_src],
cpp_sources=[cpp_src],
functions=funcs,
extra_cuda_cflags=["-O2"] if opt else [],
verbose=verbose,
name="inline_ext",
)
# 모든 CUDA 셀 머리에 붙는 boilerplate
cuda_begin = r'''
#include <torch/extension.h>
#include <stdio.h>
#include <c10/cuda/CUDAException.h>
#define CHECK_CUDA(x) TORCH_CHECK(x.device().is_cuda(), #x " must be a CUDA tensor")
#define CHECK_CONTIGUOUS(x) TORCH_CHECK(x.is_contiguous(), #x " must be contiguous")
#define CHECK_INPUT(x) CHECK_CUDA(x); CHECK_CONTIGUOUS(x)
inline unsigned int cdiv(unsigned int a, unsigned int b) {
return (a + b - 1) / b;
}
'''
여기서 한 가지 미묘한 trick — name="inline_ext" 가 항상 같다. 같은 이름이면 PyTorch 가 build cache 를 재사용해서 두 번째 호출부터 빠르다. 다른 셀에서 다른 커널을 짜더라도 같은 “module” 안에 함수만 추가되는 패턴.
(노트북 셀에서 cuda_src 를 수정해서 재빌드할 때, 같은 name 이면 PyTorch 가 디스크의 .so 를 재로드. 의도적으로 다른 이름을 줘서 cache 를 무효화하는 것도 가능.)
CHECK_CUDA · CHECK_CONTIGUOUS 매크로는 잘못된 입력을 빨리 catch 하기 위함. CUDA tensor 가 아니거나 메모리 layout 이 비연속이면 raw data_ptr 가 우리 가정과 다를 수 있다. cdiv 는 ceiling division — block 수 계산의 표준.
학습 사다리의 마지막 단. Python rgb2grey_bk 를 그대로 ChatGPT 또는 손으로 CUDA C 로 옮긴다. 변환의 “보일러플레이트” 는 cuda_begin 가 다 써놨고, 함수 본체는 거의 한 글자도 안 바뀐다.
// pmpp.ipynb cell 33 — load_cuda 가 빌드하는 본체
__global__ void rgb_to_grayscale_kernel(
unsigned char* x, unsigned char* out, int n)
{
int i = blockIdx.x * blockDim.x + threadIdx.x; // ← 이 한 줄만 추가
if (i < n)
out[i] = 0.2989f*x[i] + 0.5870f*x[i+n]
+ 0.1140f*x[i+2*n];
}
torch::Tensor rgb_to_grayscale(torch::Tensor input) {
CHECK_INPUT(input); // 매크로의 가치
int h = input.size(1);
int w = input.size(2);
auto output = torch::empty({h, w}, input.options());
int threads = 256;
rgb_to_grayscale_kernel<<<cdiv(w*h, threads), threads>>>(
input.data_ptr<unsigned char>(),
output.data_ptr<unsigned char>(),
w*h);
C10_CUDA_KERNEL_LAUNCH_CHECK();
return output;
}
변환 매핑을 펼쳐 보면.
def → __global__ voidi = blockidx * blockdim + threadidx → CUDA int i = blockIdx.x * blockDim.x + threadIdx.xif i < n: → CUDA if (i < n)blk_kernel(fn, blocks, threads, …) → fn<<<blocks, threads>>>(…)x.flatten() 의 의미 → data_ptr<unsigned char>() + 1D offset 산술이 매핑이 한 줄도 빠지지 않고 mechanical이라는 점이 학습 사다리의 가치다. 알고리즘에 대한 이해는 Python 단계에서 다 끝났고, CUDA 단계는 변환 작업.
Python 단계 ~3초, CUDA 단계 ~0.3ms. 약 1만 배의 점프. 같은 알고리즘, 같은 결과 (byte 단위로 비교 가능). 알고리즘에 대한 자신감과 launch 패턴에 대한 자신감이 분리된다는 사실이 이 강의의 핵심 메시지.
Jeremy 가 명시적으로 추천 — “Python kernel 을 그대로 ChatGPT 에 붙이고 ‘이걸 CUDA C 로 변환해 달라, 이름 같게’.” 매핑이 mechanical 하니 LLM 이 안정적으로 잘 한다. 사람이 직접 쓰는 것보다 빠르고 오류가 적다는 게 강의의 입장.
강의 후반부는 같은 학습 패턴을 matmul 에 적용. 입력은 MNIST 의 작은 sample (m1.shape = (5, 784)) 와 random 가중치 (m2.shape = (784, 10)). 출력은 (5, 10). 작은 사이즈를 쓰는 이유 — Python 단계에서 %time 으로 측정 가능한 범위에 두기 위해.
# pmpp.ipynb cell 56 — naive Python matmul
def matmul(a, b):
(ar, ac), (br, bc) = a.shape, b.shape
c = torch.zeros(ar, bc)
for i in range(ar):
for j in range(bc):
for k in range(ac):
c[i,j] += a[i,k] * b[k,j]
return c
# 2D Python kernel — 한 출력 element 의 시점
def matmul_k(i, j, a, b, c, ac):
s = 0.0
for k in range(ac):
s += a[i,k] * b[k,j]
c[i,j] = s
여기서 새로 들어오는 패턴.
그리고 같은 사다리로 CUDA 단계까지 — 2D launch (dim3(16, 16) threads, dim3(cdiv(bc,16), cdiv(ar,16)) blocks) 로 띄우고, kernel 안에서 row = blockIdx.y*blockDim.y + threadIdx.y 와 col = blockIdx.x*blockDim.x + threadIdx.x 두 줄로 좌표를 계산. 나머지는 Python 의 inner loop 가 그대로.
이 단순 CUDA matmul 은 shared memory 를 쓰지 않는다 — 매 element 마다 a 의 한 row, b 의 한 column 을 global memory 에서 다시 읽는다. data reuse 가 보이지만 잡지 않은 상태가 의도적이다. L005 가 이 코드 위에 shared memory tiling 을 한 단 더 얹어 “Going Further” 의 의미가 된다.
notebook 환경에서 CUDA 디버깅을 하면 흔히 만나는 두 문제 — error 가 어디서 났는지 위치가 비동기 launch 때문에 어긋난다, kernel 안의 printf 출력이 stdout 에 안 잡힌다. Jeremy 가 두 가지 환경 설정을 셀 머리에 박는다.
os.environ['CUDA_LAUNCH_BLOCKING'] = '1'
모든 CUDA launch 를 동기로 만든다 — error 위치가 정확히 launch 줄에서 잡힌다. cudaDeviceSynchronize() 가 자동으로 launch 직후에 박히는 것과 같음. 디버깅용; production 에선 끈다 (성능 떨어진다).
%load_ext wurlitzer
kernel 안의 printf 출력을 Jupyter cell 의 output 으로 redirect. 안 켜면 stdout 이 어디로 가는지 모른 채 사라진다. 작은 입력에 대해 if (i == 0) printf("...") 패턴이 가능해진다.
verbose=True option in load_inline
nvcc 의 컴파일 명령과 그 출력을 다 보여준다. 빌드 실패 시 어떤 flag 가 들어갔는지, 어떤 라인이 syntax error 인지 직접 확인.
CHECK_INPUT(x)
kernel launch 전에 “이 tensor 가 정말 CUDA 위에 있고, contiguous 한가” 를 검증. 잘못된 가정으로 raw pointer 를 dereferencing 하기 전에 catch.
img2 = tvf.resize(img, 150, antialias=True) 처럼 입력을 작게. 같은 kernel 이 작은 입력에서 정확하면 큰 입력에서도 거의 항상 정확.
Jeremy 의 notebook 모든 셀의 머리에 위 1·2가 박혀 있고, load_cuda(..., verbose=True) 가 켜져 있고, 작은 입력으로 먼저 검증한 뒤 큰 입력. “CUDA 가 디버깅이 어렵다” 라는 신화를 깨는 가장 단순한 한 묶음의 환경 설정이 이 자리.
이 강의에서 6개월 뒤 다시 돌아왔을 때 가장 먼저 복원해야 하는 사실들과 — 직접 손에 박아야 하는 코드 자료들.
run_kernel) → Python block kernel(blk_kernel) → CUDA. 같은 알고리즘을 같은 변수명으로 4번.def k(i, *args) 또는 def k(bIdx, tIdx, bDim, *args). kernel can not return. buffer 를 mutate 한다.i = blockIdx.x * blockDim.x + threadIdx.x + if i < n guard. 1D 커널의 표준 첫 두 줄.CHECK_CUDA · CHECK_CONTIGUOUS · CHECK_INPUT · cdiv. 모든 셀 머리에 붙는 보일러플레이트.printf 를 보려면 필수. %load_ext wurlitzer.load_inline 의 자주 쓰는 옵션 묶기. name="inline_ext" 고정으로 build cache 재사용.dim3(16, 16) threads, dim3(cdiv(W,16), cdiv(H,16)) blocks. matmul 과 image 처리의 표준.pmpp.ipynb 가 슬라이드 역할.
pmpp.ipynb 를 그대로 돌려 4단계 (naive → kernel → block kernel → CUDA) 의 결과가 byte 단위로 동일 한지 검증.IndexError 가 나고, CUDA 단계에서는 illegal memory access. 같은 버그가 두 환경에서 어떻게 다르게 보이는지.%timeit 으로 시간 변화. n_warps 와의 관계 추측.torch.matmul 과 torch.allclose. 그 다음 (1024, 1024) × (1024, 1024) 로 키워 시간 비교 — naive 가 cuBLAS 와 얼마나 차이나는가.CUDA_LAUNCH_BLOCKING 의 효과 보기 — 일부러 잘못된 indexing (예: i+5) 을 넣어서 launch 했을 때, BLOCKING=1 과 0 에서 traceback 위치가 어떻게 다른지.if (i < 5) printf("i=%d, val=%f\n", i, x[i]); 를 박고 wurlitzer 로 볼 수 있는지 확인. 작은 입력에 한정.L003 의 “Python 위에서 알고리즘을 손에 잡는다” 패턴이 시리즈의 여러 강의에서 한 단씩 확장된다.
학습 사다리의 framing 안에서 의도적으로 비워둔 자리들과 — 직접 자기 환경에서 확인해야 손에 박히는 사실들.
nvcc -ptx 또는 cuobjdump 로 같은 알고리즘이 어떤 instruction sequence 가 되는지는 별도로 검증.CUDA_LAUNCH_BLOCKING=1 의 성능 비용 — “디버깅용” 이라고 했지만, 정량적인 성능 차이는 강의에서 측정하지 않는다. 실제로 얼마나 느려지는지 자기 GPU 에서 측정 필요.