Programming Massively Parallel Processors 첫 세 챕터를 한 시간 안에 다시 깔아주는 reading-group 버전. Andreas Köpf 가 “병렬 사고가 시퀀셜 사고와 어떻게 다른가” 부터 시작해서 vector add → RGB→grayscale → mean filter 의 세 커널로 grid/block/thread 와 host↔device 메모리 모델을 한 번에 보여주는 강의. PMPP 책을 처음 펴는 사람을 위한 안내선이자, 이미 책을 읽은 사람을 위한 코드-우선 요약.
GPU Mode 의 reading-group 흐름은 Programming Massively Parallel Processors (Hwu, Kirk, El Hajj 의 PMPP) 를 같이 읽는 데서 시작했다. L002 는 그 책의 처음 세 챕터를 한 시간으로 깐다 — 처음 책을 펴는 사람에게는 가이드라인을 주고, 이미 읽은 사람에게는 코드 위주의 요약을 준다.
강의가 답하려는 질문은 명확하다.
cudaMalloc, cudaMemcpy 의 첫 사용.blockIdx · blockDim · threadIdx 로 indexing 하는 그 익숙한 한 줄의 의미.이 세 질문을 추상으로 답하지 않고 — Andreas 가 깐 방식은 세 개의 커널을 차례로 보여주는 것이다. vector_addition.cu (1D, 일반 CUDA C 프로그램), RGB→grayscale (2D, PyTorch extension), mean filter (한 thread 가 여러 입력을 읽는 stencil). 각 커널이 직전 커널이 답하지 못한 새 차원을 하나씩 추가한다.
L001 이 “이미 도는 코드를 어떻게 보는가”(profiling) 의 강의였다면, L002 는 그 코드 안에 어떤 객체들이 들어 있는가의 강의다. thread, block, grid, host pointer, device pointer, __global__, kernel launch <<<…>>> — 이 단어들이 처음 등장해서 자기 자리를 잡는다.
PMPP 가 제일 먼저 못 박는 사실. 한 thread 가 한 출력 element 를 만든다는 분해가 GPU 의 자연스러운 첫 번째 패턴이다. vector add, RGB→grayscale 모두 이 모델 안에 들어간다 — 각 thread 가 자기 좌표에 해당하는 output 하나만 책임진다. 입력끼리 의존성이 없는 (embarrassingly parallel) 문제일 때 이 분해가 그대로 통한다.
그런데 이 framing 안에서 두 가지가 빠르게 따라 나온다.
dim3 가 그 도구.if (i < n) … 의 boundary check 를 넣는다. 빠뜨리면 segfault, 넣어두면 가만히 NOP.강의에서 Andreas 가 명시적으로 말한 점 — “모든 알고리즘이 embarrassingly parallel 한 건 아니다. 의존성 있는 알고리즘 (예: prefix sum, reduction) 은 다른 분해가 필요하다.” 이건 L009 Reductions 가 본격 다루는 주제로 넘어간다. L002 의 framing 으로는 “일단 의존성 없는 케이스부터 손에 익힌다” 가 답.
GPU 는 자체 DRAM 을 가지고 있다. CPU 의 가상주소를 GPU 가 그대로 dereference 할 수 없다. 그래서 모든 “naive CUDA” 프로그램의 첫 5줄은 device 쪽 메모리를 잡고 (cudaMalloc), CPU 데이터를 옮기고 (cudaMemcpy H→D), 커널을 launch 하고, 결과를 다시 받고 (D→H), free 의 의식이다. 이 의식의 의미를 한 번 잡고 가야 그 다음의 모든 강의가 의미를 갖는다.
_d 와 _h 를 붙이는 관행이 PMPP 책에서 강조된다 — device pointer 와 host pointer 를 같은 함수 안에서 섞으면 거의 항상 segfault. 이름으로 자기를 보호한다.이 흐름이 PyTorch 안에서는 거의 보이지 않는다 — x.cuda() 한 줄이 L1+L2 를, op 호출이 L3 을 자동으로 한다. 하지만 강의 후반의 RGB→grayscale 과 mean filter 가 PyTorch extension 으로 깔리는 이유 중 하나가 이 의식을 손에서 빼주기 때문이다 — data_ptr<float>() 가 device pointer 를 그대로 돌려준다. 직접 짠 vector_add 와의 비교가 학습의 핵심.
처음 짠 CUDA 코드가 “실행은 되는데 결과가 다 0” 으로 나오면 거의 항상 L4(D→H 복사) 를 잊었거나, L2 를 잊고 device 쪽이 초기화 안 된 상태로 launch 한 경우. 이 의식의 한 단계가 빠지면 결과가 silent 하게 망가진다.
CUDA 의 좌표계는 3차원 grid 안에 3차원 block, 그 block 안에 3차원 thread 의 nested 구조다. 처음 보면 복잡한 듯하지만, 하나의 직관적인 설계 결정에서 나온다 — thread 들은 동시에 실행되니까 launch shape 을 만들 때부터 각 thread 가 자기 좌표를 직접 계산할 수 있게 한다. 그게 다.
1D 인덱싱의 표준식은 다음 한 줄.
// PMPP Ch.2 Figure 2.6 의 표준식
int i = blockIdx.x * blockDim.x + threadIdx.x;
해석은 분해된다 — blockIdx.x 는 “나는 몇 번째 block 안에 있는가”, blockDim.x 는 “block 하나의 thread 수”, threadIdx.x 는 “block 안에서 내 자리”. 셋을 곱하고 더하면 전역 thread index. 이 한 줄이 vector add, grayscale, mean filter 의 첫 줄에 모두 똑같이 등장한다.
왜 block 이라는 중간 단계가 있는가 — 이게 PMPP 가 빠르게 답하는 다음 질문. block 안의 thread 들끼리만 fast 한 통신 (shared memory + __syncthreads) 이 보장된다. 다른 block 의 thread 와는 통신하지 않는다. block 단위로 SM(streaming multiprocessor) 위에 스케줄되기 때문이다. block 사이의 실행 순서나 동시성에 대해 어떤 가정도 할 수 없다 — “CUDA 가 block 들을 SM 들에 임의로 배정한다” 가 PMPP 의 명제.
강의의 첫 코드. PyTorch 의식 (load_inline, ninja) 없이, 그냥 nvcc vector_addition.cu 한 줄로 빌드되는 가장 작은 CUDA C 프로그램. 모든 것이 한 페이지에 들어 있어 “CUDA 프로그램이 무엇으로 구성되는가” 를 처음 본다.
// vector_addition.cu — 강의 repo 그대로 (요약)
__global__ void vecAddKernel(float* A, float* B,
float* C, int n) {
int i = threadIdx.x + blockDim.x * blockIdx.x;
if (i < n) {
C[i] = A[i] + B[i];
}
}
void vecAdd(float* A, float* B,
float* C, int n) {
float *A_d, *B_d, *C_d;
size_t size = n * sizeof(float);
cudaMalloc(&A_d, size);
cudaMalloc(&B_d, size);
cudaMalloc(&C_d, size);
cudaMemcpy(A_d, A, size, cudaMemcpyHostToDevice);
cudaMemcpy(B_d, B, size, cudaMemcpyHostToDevice);
const unsigned int numThreads = 256;
unsigned int numBlocks = cdiv(n, numThreads);
vecAddKernel<<<numBlocks, numThreads>>>(
A_d, B_d, C_d, n);
gpuErrchk(cudaPeekAtLastError());
gpuErrchk(cudaDeviceSynchronize());
cudaMemcpy(C, C_d, size, cudaMemcpyDeviceToHost);
cudaFree(A_d); cudaFree(B_d); cudaFree(C_d);
}
코드의 모든 사실을 풀어보자.
__global__ — “이 함수는 GPU 위에서 실행되고, host 에서 launch 된다” 는 qualifier. (§08 에서 다른 두 qualifier 와 같이 본다.)<<<blocks, threads>>> — CUDA C 의 특수 문법. C++ 의 일부가 아니다. nvcc 가 이걸 cudaLaunchKernel 의 호출로 lower 한다.cdiv(n, 256) — ceiling division. n 이 256 으로 나눠 떨어지지 않아도 모든 element 를 cover 하기 위해 한 block 더 띄운다. 그 마지막 block 의 일부 thread 는 i >= n guard 에서 NOP 처리된다.cudaPeekAtLastError + cudaDeviceSynchronize — launch 가 비동기라서 (L001 §02 와 같은 이야기), error check 도 sync 도 명시적으로 해야 한다.강의의 의도다 — vector add 만큼은 “CUDA 프로그램이 무엇으로 구성되는가” 의 표본을 보여주려고 일부러 raw nvcc 빌드. 다음 두 커널 (RGB→grayscale, mean filter) 부터는 PyTorch extension 으로 깐다. 두 형태의 차이를 보고 “PyTorch 가 무엇을 자동화해 주는가” 를 명시적으로 이해하라는 의도.
dim3 가 들어오는 자리두 번째 커널은 HxW 짜리 RGB 이미지를 grayscale 로 변환. 한 출력 pixel 마다 한 thread 를 띄운다. 입력은 (H, W, 3) bytes, 출력은 (H, W) bytes. 같은 “한 thread = 한 출력” 패턴이지만 — 좌표가 2D 가 된다.
2D launch 의 핵심은 dim3.
dim3 threads_per_block(16, 16); // 256 threads, 16x16
dim3 number_of_blocks(
cdiv(width, 16),
cdiv(height, 16));
rgb_to_grayscale_kernel<<<
number_of_blocks, threads_per_block, 0,
torch::cuda::getCurrentCUDAStream()
>>>(
result.data_ptr<unsigned char>(),
image.data_ptr<unsigned char>(),
width, height
);
kernel 안의 indexing 도 두 줄로 길어진다.
int col = blockIdx.x * blockDim.x + threadIdx.x;
int row = blockIdx.y * blockDim.y + threadIdx.y;
if (col < width && row < height) {
int outOff = row * width + col;
int inOff = (row * width + col) * 3;
unsigned char r = input[inOff + 0];
unsigned char g = input[inOff + 1];
unsigned char b = input[inOff + 2];
output[outOff] = (unsigned char)
(0.21f*r + 0.71f*g + 0.07f*b);
}
여기서 새로운 디테일이 두 가지 들어온다.
data_ptr<T>() — PyTorch tensor 가 들고 있는 device pointer 를 그대로 돌려준다. cudaMalloc · cudaMemcpy 의식이 사라졌다는 뜻. 입력은 이미 GPU 위에 있고, 출력은 torch::empty(...) 로 만들어 놓고 그 ptr 만 넘긴다.torch::cuda::getCurrentCUDAStream() — PyTorch 가 자기 stream 위에서 모든 op 을 진행하니, 우리 커널도 같은 stream 에 끼워야 동기화가 자연스럽게 맞는다. <<<…, 0, stream>>> 의 4번째 인자가 stream.그리고 흥미로운 사실 — Y = 0.21·R + 0.71·G + 0.07·B 는 Rec. 601 luma 공식이다. 인간 시각이 채널마다 민감도가 다르다는 사실에서 나온다. 이건 PMPP 책이 직접 깔지 않고 강의에서 Andreas 가 짚는 부분.
같은 커널이지만 build 방식이 다르다. raw nvcc 면 main() 안에서 직접 cudaMalloc·cudaMemcpy 를 쓴다. PyTorch extension 이면 torch::Tensor rgb_to_grayscale(torch::Tensor) 를 export 하고 — Python 에서 module.rgb_to_grayscale(img) 가 그대로 부른다. 같은 GPU 코드의 두 packaging.
강의에서 이 시점에 흘러가는 부수적인 메시지 — PyTorch 와 함께 쓰는 CUDA 코드의 표준 패턴이 이미 여기서 다 나타난다. data_ptr, current stream, C10_CUDA_KERNEL_LAUNCH_CHECK(). 이 세 줄은 L010 의 production-ready CUDA library 강의까지 그대로 이어진다.
세 번째 커널은 mean filter — 각 출력 pixel 이 자기 주변 (2r+1)×(2r+1) window 의 평균이다. “한 출력당 한 thread” 의 패턴은 그대로지만, 한 thread 가 여러 입력을 읽는다는 새 차원이 추가된다. 이게 stencil 패턴의 가장 단순한 형태이고, 이후 모든 convolution, attention 의 부모 형태다.
// mean_filter_kernel.cu — 강의 repo (요약)
__global__ void mean_filter_kernel(
unsigned char* output, unsigned char* input,
int width, int height, int radius)
{
int col = blockIdx.x * blockDim.x + threadIdx.x;
int row = blockIdx.y * blockDim.y + threadIdx.y;
int channel = threadIdx.z;
int baseOff = channel * height * width;
if (col < width && row < height) {
int pixVal = 0, pixels = 0;
for (int br = -radius; br <= radius; ++br) {
for (int bc = -radius; bc <= radius; ++bc) {
int r2 = row + br, c2 = col + bc;
if (r2 >= 0 && r2 < height &&
c2 >= 0 && c2 < width) {
pixVal += input[baseOff + r2 * width + c2];
pixels += 1;
}
}
}
output[baseOff + row * width + col] =
(unsigned char)(pixVal / pixels);
}
}
새로 들어온 디테일들.
threadIdx.z 가 처음 등장 — block 이 3D 다 (dim3(16, 16, channels)). RGB 의 세 채널을 z 축으로 분리. radius 가 작을 때 channel 별로 thread 를 더 늘려 occupancy 를 채우는 trick.같은 입력을 N번 다시 읽는다는 건 data reuse 가 있다는 뜻. 이걸 global memory 에서 매번 다시 가져오면 메모리 bandwidth 가 낭비된다. 한 번 가져와서 on-chip 에 잡아 두고 N번 재사용하는 게 답 — 이게 shared memory 와 tiling이다. PMPP Ch.4–5 의 본론이고 L004, L005 의 주제.
Andreas 는 mean filter 를 “naive 한 형태” 로 보여주고 멈춘다 — “이걸 더 빠르게 만드는 방법은 다음 강의에서.” 이 의도적 cliffhanger 가 GPU Mode 시리즈가 PMPP 를 따라 가는 이유.
CUDA C 의 모든 함수는 셋 중 하나다 — host 에서만 도는 일반 함수 (__host__, 기본값), GPU 위에서만 도는 함수 (__device__), host 에서 launch 되어 GPU 에서 도는 entry-point 함수 (__global__). 이 qualifier 가 nvcc 에게 “이 함수의 binary 를 어디에 둘 것인가” 를 알려준다.
이 qualifier 들이 PMPP 책의 figure 와 직접 매칭된다. __global__ 함수는 GPU entry point — host 코드가 kernel<<<…>>>() 로 부른다. __device__ 함수는 GPU 안에서만 도는 helper. 둘 다 nvcc 가 PTX 로 컴파일해서 GPU executable 안에 넣는다. __host__ 는 일반 g++ 처럼 처리.
강의에서 Andreas 가 짧게 짚는 사실 — PTX (Parallel Thread Execution) 는 NVIDIA 의 가상 ISA. nvcc 는 CUDA C 를 PTX 로, PTX 는 다시 SASS (각 SM 의 실제 머신코드) 로 lower 한다. PTX 는 arch 독립이라서 forward compatibility 의 단위. 강의에서 본격적으로 PTX 를 까지는 않지만, “여기까지가 PTX 단계” 라는 좌표를 잡아둔다. L001 §06 의 lowering 사다리에서 같은 그림이 다시 등장.
<<<…>>> 문법이다. 나머지는 거의 같다.”학습 노트 · L002 §08강의 후반에 Andreas 가 짧게 던진 한 마디가 시리즈 전체의 운영 원칙이다 — “데이터 모양이 매우 spontaneous 해서, 다른 모양에는 다른 커널이 최적일 수 있다.” 이 한 줄이 GPU Mode 시리즈가 같은 연산 (matmul, attention, reduction) 을 여러 강의에 걸쳐 반복하는 이유.
한 커널이 모든 모양에 답하려면 — autotune 이 답이다. Triton 의 @triton.autotune · CUTLASS 의 instance 기반 generation 이 그 표준 패턴이다. 그리고 PyTorch 자체가 op 마다 여러 구현을 들고 있고 dispatcher 가 모양에 따라 다른 걸 부른다 — L006 의 “dispatcher 통과 후 어떤 CUDA kernel 에 도달하는가” 가 이 사실의 다음 형태.
PMPP 의 첫 세 챕터는 의도적으로 “단순한 사이즈, 명확한 분해” 의 정석만 보여준다. 모양에 따라 다르다는 사실은 책 후반과 후속 강의의 표적. 이 강의의 cliffhanger 중 하나.
PMPP Ch.1–3 을 다시 펼치기 전에 머릿속에 있어야 하는 사실들과 — 직접 손에 박아야 하는 코드 자료.
i = blockIdx.x * blockDim.x + threadIdx.x + if (i < n) … guard. 모든 1D 커널의 첫 두 줄.__host__ __device__ 양쪽 다 컴파일.make 또는 nvcc vector_addition.cu -o vec. n=1000 의 결과를 직접 print 로 확인. 5단계 의식의 모든 줄을 손으로 따라 친다.if (i < n) 를 지우고 빌드해서 무엇이 일어나는지 본다. cuda-memcheck 또는 compute-sanitizer 가 어떻게 알려주는지 확인.rgb_to_grayscale.py 의 load_inline 호출을 추적, ./load_inline_cuda/ 디렉터리 안의 자동 생성된 main.cpp 를 직접 읽는다.__device__ __forceinline__ unsigned char rgb2y(...) 로 빼고 같은 결과가 나오는지 확인. nvcc 가 inline 시킨다.torchvision.transforms.Grayscale 과 비교 — 같은 입력에 두 결과가 byte 단위로 정확히 같은지. 다르면 어디가 다른지 (rounding, 채널 순서, 가중치).L002 의 framing (grid/block/thread, host↔device 의식, 한 thread = 한 출력) 이 시리즈 전체에 흩어져 있는 모든 강의의 출발 좌표가 된다.
PMPP Ch.1–3 의 한 시간 요약이 의도적으로 넘어간 자리들과 — 자기 GPU 위에서 직접 확인해야 손에 박히는 사실들.
getCurrentCUDAStream 이 등장은 했지만 stream 사이의 동시성, 다중 stream 패턴은 빠진다. 후속 강의 (multi-stream reduction in L009) 에서.nvcc 의 직접 실행은 일부만 보였다. 실제 영상에서 어느 커널까지 라이브 빌드를 보였는지 재확인 필요. (확인 필요)이 노트의 모든 코드 인용은 gpu-mode/lectures/main 의 lecture_002 디렉터리에서 fetch 한 실제 소스를 요약한 것. 강의 시점과 현재 repo 사이에 약간의 형식 차이는 있을 수 있음 (C10_CUDA_KERNEL_LAUNCH_CHECK 의 추가 등). 큰 의미 변화는 없음.