PyTorch 프로그램 안에 커스텀 CUDA 커널을 끼워넣는 가장 작은 길, 그리고 그게 실제로 빠른지 — 더 빠르게 만들 여지가 있는지 — 를 어떤 도구로 어떻게 확인하는가. Mark Saroufim 이 GPU Mode 첫 강의에서 깐 프로파일링 워크플로를 도구별 진입점 과 의사결정 사다리 두 축으로 다시 정리한 학습 노트.
대부분의 사람이 CUDA 를 시작할 때 부딪히는 문제는 문법이 아니라 경로의 문제다 — 어디서 시작해서 무엇으로 검증하고 어디까지 내려갈지 모른다는 것. 이 강의는 그 경로를 도구 단위로 깐다.
강의가 풀려고 하는 질문은 두 개로 압축된다.
Mark 의 입장은 명시적으로 실용주의다. “GPU 가 어떻게 도는지 모든 디테일을 이해하지 않아도, 프로파일러로 black box 를 시각적으로 보면서 유용한 일을 할 수 있다.” 이 시각이 강의 전체를 끌고 간다 — 교과서식 bottom-up (메모리 모델 → warp → SM → kernel) 대신, top-down 으로 이미 도는 코드를 어디서 어떻게 보는지부터 깐다.
이 강의의 모든 도구는 같은 질문에 답한다 — “방금 launch 한 그 커널, 실제로 GPU 위에서 무슨 일이 벌어지고 있는가?” 첫 도구는 그저 이름과 시간을 보여주고, 마지막 도구(NCU)는 특정 라인이 메모리 latency 때문에 막혔는지 register 부족 때문에 막혔는지까지 본다.
그래서 이 강의의 끝에 도착했을 때 손에 잡혀 있어야 하는 건 4개 도구와 1개의 의사결정 사다리다 — autograd profiler, PyTorch profiler(Chrome trace), Triton interpret 모드, NCU. 그리고 “언제 torch 에서 Triton 으로, Triton 에서 CUDA 로 내려갈지” 의 판단 기준.
time.time() 으로 GPU 코드를 재면 안 된다강의에서 Mark 가 “CUDA 에 대해 가장 먼저 알아야 할 것 하나만 고르라면” 이라고 못 박은 사실 — CUDA 는 비동기다. 호스트가 kernel<<<...>>> 을 호출해도 GPU 가 끝나기를 기다리지 않는다. python 의 time.time() 으로 측정하는 건 launch overhead 일 뿐, 커널이 실제로 도는 시간이 아니다.
제대로 재려면 세 가지를 조합해야 한다.
torch.cuda.Event — GPU 타임라인 위에 직접 마커를 찍는다. start.record() 와 end.record().torch.cuda.synchronize() — end 이벤트가 실제로 GPU 에서 기록될 때까지 기다린다.이 셋이 빠진 측정은 거의 항상 잘못된 숫자를 준다 — 빨라 보이거나(launch 만 잰 경우) 들쭉날쭉(첫 호출의 context init 이 섞인 경우)이다.
torch.cuda.Event(enable_timing=True) 두 개를 만들어 start/end 로 쓰고, 사이를 GPU 가 워치하게 한 뒤 마지막에 synchronize(). start.elapsed_time(end) 가 ms 단위로 진짜 커널 시간을 돌려준다.
# Mark 의 time_pytorch_function — pytorch_square.py 그대로
def time_pytorch_function(func, input):
# CUDA IS ASYNC — python time 모듈 쓰면 안 된다
start = torch.cuda.Event(enable_timing=True)
end = torch.cuda.Event(enable_timing=True)
# Warmup — 첫 호출에 context init 비용이 섞임
for _ in range(5):
func(input)
start.record()
func(input)
end.record()
torch.cuda.synchronize()
return start.elapsed_time(end)
강의에서 Mark 가 보여준 첫 번째 작은 실험 — torch.square(b) vs b * b vs b ** 2 — 도 이 세팅 위에서만 의미가 있다. 결과: torch.square 와 b ** 2 는 똑같이 aten::pow 를 부르고, b * b 는 aten::mul 을 부르며 미세하게 더 빠르다. 같은 수학 연산이 백엔드에서 다른 op 로 디스패치된다는 사실이 이 첫 측정에서 이미 드러난다.
첫 프로파일러는 autograd profiler. context manager 안에 코드를 넣으면, GPU 위에서 어떤 op 들이 호출됐고 각자 얼마나 걸렸는지 표로 떨어진다. 코드 한 줄 더 추가하지 않아도 “내 함수는 aten::pow 를 부르는구나”, “aten::mul 이 더 빠르네” 같은 사실이 드러난다.
# torch.square / a*a / a**2 가 각자 어떤 op 를 부르는지 본다
with torch.profiler.profile() as prof:
torch.square(b)
print(prof.key_averages().table(
sort_by="cuda_time_total",
row_limit=10,
))
--- 출력 (핵심 줄만) ---
Self CPU time total: 211.000us
Self CUDA time total: 162.001us
aten::pow 7.66% 16.000us ... 2 CUDA pow_tensor_scalar
aten::result_type 0.00% 0.000us ... 2
vectorized_elementwise_kernel
100.00% 162.001us ... 2 CUDA
표에서 읽히는 사실 두 가지.
aten::pow 가 호출된다 — torch.square 라는 이름이 직접 op 가 아니라 pow(x, 2) 로 디스패치된다는 뜻. 즉 PyTorch 안에 “square” 라는 별도의 fused 커널은 없다.vectorized_elementwise_kernel 안에서 다 잡힌다 — pointwise 연산은 거의 모두 이 templated 커널 하나로 모인다.이 정도만으로도 첫 가설을 세울 수 있다 — “PyTorch 의 torch.square 는 사실상 elementwise pow 다. 내가 직접 짜는 square 커널이 더 빨라질 가능성은, fused 한 다른 연산과 함께 묶을 때나 의미가 있다.”
autograd profiler 는 “이 함수가 어떤 op 들을 부르는가” 의 답을 가장 빠르게 준다. 항상 첫 도구로 쓴다.
표 위에서는 op 들이 같은 시간에 어떻게 겹치는지, memcpy 가 커널과 어떤 순서로 도는지가 안 보인다. PyTorch profiler 의 Chrome trace 가 그 자리를 채운다 — JSON 으로 export 해서 chrome://tracing 에 드래그하면 바로 timeline 뷰.
from torch.profiler import profile, ProfilerActivity
with profile(activities=[
ProfilerActivity.CPU,
ProfilerActivity.CUDA,
]) as prof:
for _ in range(10):
a = torch.square(torch.randn(10000, 10000).cuda())
prof.export_chrome_trace("trace.json")
# 그 다음 chrome://tracing 에 trace.json 을 드래그
실전에서는 schedule(wait=1, warmup=1, active=2, repeat=1) 와 on_trace_ready 콜백을 함께 써서 학습 루프 일부 step 만 잡는 패턴이 표준이다. 강의의 pt_profiler.py 가 그 형태.
이 뷰에서 처음 잡히는 새로운 정보가 두 가지 더 있다.
.cuda() 로 보낼 때 발생. 이게 길면 커널을 빠르게 짜봐야 의미가 없다. 강의의 큰 메시지 중 하나가 여기서 시작된다.vectorized_elementwise_kernel<4> — 4 는 block 수다. 같은 사이즈의 입력에 대해 PyTorch 가 자동으로 4 블록을 띄운다는 사실. 큰 입력에서 작은 grid 면 GPU 자원이 비어 있을 가능성이 높다 (§ 08 NCU 가 이 hint 를 직접 준다).커스텀 CUDA 커널을 PyTorch 에 넣을 때 전통적으로 만나는 두 벽 — pybind11 보일러플레이트와 setup.py / CMake / make. torch.utils.cpp_extension.load_inline 은 둘 다 우회한다. C++ 소스와 CUDA 소스를 문자열로 그냥 넣으면, 안에서 ninja 가 build 해서 .so 로 import 가능한 모듈을 돌려준다.
build_directory='./load_inline_cuda' 로 위치를 고정해두면 — pybind 가 어떻게 자동 생성됐는지, ninja 가 어떤 nvcc flag 로 빌드했는지 직접 볼 수 있다. 학습 자료로도 쓰인다.# load_inline.py — 강의 repo 그대로
cuda_source = '''
__global__ void square_matrix_kernel(
const float* matrix, float* result,
int width, int height) {
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
if (row < height && col < width) {
int idx = row * width + col;
result[idx] = matrix[idx] * matrix[idx];
}
}
torch::Tensor square_matrix(torch::Tensor m) {
auto h = m.size(0); auto w = m.size(1);
auto out = torch::empty_like(m);
dim3 t(16,16);
dim3 b((w+15)/16, (h+15)/16);
square_matrix_kernel<<<b,t>>>(
m.data_ptr<float>(),
out.data_ptr<float>(), w, h);
return out;
}
'''
cpp_source = "torch::Tensor square_matrix(torch::Tensor);"
from torch.utils.cpp_extension import load_inline
ext = load_inline(
name='square_matrix_extension',
cpp_sources=cpp_source,
cuda_sources=cuda_source,
functions=['square_matrix'],
with_cuda=True,
extra_cuda_cflags=["-O2"],
build_directory='./load_inline_cuda',
)
# 이제 그냥 파이썬 함수처럼 부른다
a = torch.tensor([[1.,2.,3.],
[4.,5.,6.]],
device='cuda')
print(ext.square_matrix(a))
“C++ 파일을 손에 들지 않고 CUDA 커널을 PyTorch 에 박는다” — 이 한 문장이 강의 전반부의 핵심.
처음 build 가 의외로 느리다. nvcc 가 한 번 다 돌아야 한다. flash attention 을 source 에서 build 할 때 시간이 오래 걸리는 이유와 같은 메커니즘 — 커널 하나하나가 nvcc 를 거친다. 매번 다시 컴파일되지는 않으니 두 번째부터는 빠르다. 캐싱이 자동이라는 점은 알아둘 것.
Triton 은 파이썬 안 DSL 이지만 CUDA 코드를 만들지 않는다 — 직접 PTX 까지 내려간다. 통합은 trivial: 그냥 파이썬 함수를 부른다. 그런데 강의에서 Mark 가 보여준 첫 실험에서 torch.square 보다 자기 Triton 커널이 더 느렸다. 원인이 뭐였나 — 그게 이 섹션의 본론.
강의의 시퀀스를 따라가 보자.
tl.load 로 한 row 를 SRAM 에 올리고 곱하고 tl.store 로 다시 쓴다.torch.square 보다 느렸다. torch.compile 도 비슷하게 느렸다 — torch.compile 도 결국 Triton 으로 lowering 하니 자연스러운 결과.BLOCK_SIZE = 1024 로 고정하니 트렌드가 완전히 뒤집혀서 Triton 이 빨라졌다.Triton 커널의 성능은 소스 코드가 아니라 launch 설정(BLOCK_SIZE, num_warps, num_stages) 으로 결정되는 부분이 크다. 같은 코드가 한 설정에서 1ms, 다른 설정에서 10ms. 새 커널은 항상 이 셋을 sweep 하면서 본다.
Triton 커널은 GPU 위에서 도는 동안 black box다. print 가 안 된다. 그래서 디버깅이 어렵다. 강의에서 Mark 가 강조한 게 @triton.jit(interpret=True) — 이 모드에서는 커널이 CPU 위에서 시뮬레이션 돌면서 breakpoint() 가 동작한다. “이 변수가 어떤 모양인지 step 마다 보고 싶다” 의 답.
환경변수 TRITON_INTERPRET=1 로도 켤 수 있다.
@triton.jit(interpret=True)
def square_kernel(out_ptr, in_ptr, n,
BLOCK: tl.constexpr):
pid = tl.program_id(0)
offs = pid * BLOCK + tl.arange(0, BLOCK)
mask = offs < n
x = tl.load(in_ptr + offs, mask=mask)
breakpoint() # ← 여기서 멈춘다
tl.store(out_ptr + offs, x * x, mask=mask)
# pdb 안에서 print(x.tensor) 같이 wrapped tensor 의 내부를 본다
강의 후반부에서 Mark 가 가장 여러 번 추천한 트릭. 새 커널을 처음부터 짜지 말고, torch.compile 이 같은 연산에 대해 만들어내는 Triton 커널을 먼저 읽어본다. 그 코드가 보통 충분히 읽을 만 한 시작점이고, 변수명만 바꾸면 그대로 쓸 수 있다.
# square_compile.py
import torch
def square(a):
return torch.square(a)
opt = torch.compile(square)
opt(torch.randn(10000, 10000).cuda())
# 실행 시 환경변수로 켜면 console 에 Triton 커널 dump
TORCH_LOGS=output_code python square_compile.py
# torch.compile 이 만들어낸 Triton 코드 (약식)
@triton.jit
def triton_(in_ptr0, out_ptr0, xnumel,
XBLOCK: tl.constexpr):
xoffset = tl.program_id(0) * XBLOCK
xindex = xoffset + tl.arange(0, XBLOCK)[:]
xmask = xindex < xnumel
tmp0 = tl.load(in_ptr0 + xindex, xmask)
tmp1 = tmp0 * tmp0 # ← elementwise mul
tl.store(out_ptr0 + xindex, tmp1, xmask)
코드의 변수명은 tmp0, tmp1 같이 익명이지만, 구조는 명확하다 — load → 연산 → store. 그리고 강의의 다음 관찰이 진짜 흥미로운 지점.
같은 입력에 torch.square 를 두 번 적용하는 함수를 컴파일하면 — 두 개의 커널이 launch 되는 게 아니라, 한 커널 안에 tmp1 = tmp0 * tmp0, tmp2 = tmp1 * tmp1 이 같이 들어간다. 이게 fusion 이다 — HBM 왕복 한 번을 절약. “fused 됐는지 안 됐는지” 가 코드 dump 만으로 검증된다는 사실이 강의의 큰 메시지.
TORCH_LOGS=output_code.”Mark Saroufim · 38:47PyTorch profiler 까지가 “커널 이름과 시간” 을 본다면, NCU 는 한 단계 더 — 한 커널 안에서 메모리 latency 가 발목을 잡고 있는지, register 가 부족한지, occupancy 가 모자라서 SM 이 비어 있는지를 직접 알려준다. 사용법은 한 줄.
ncu python train.py
그리고 시각 모드는 --set full -o trace 를 붙여서 실행한 뒤 Nsight Compute UI 에 trace 를 연다.
이 hint 들이 의미하는 바를 풀면 — 강의의 핵심 통찰이 여기 들어 있다.
BLOCK_SIZE 한 줄, 입력 padding 한 줄.NCU 가 “tail effect 50%, scoreboard stall 20%” 같은 식으로 hint 를 주면 — 50% 는 Triton 위에서 padding 으로 잡고, 20% 는 직접 CUDA 로 내려가서 shared memory 를 짜야 잡힌다. 이 갈림길이 자연스럽게 다음 섹션의 “언제 어디까지 내려갈 것인가” 로 이어진다.
강의의 마지막 메시지 중 하나. CUDA 부터 시작하지 마라. torch → Triton → CUDA 의 사다리를 NCU 가 hint 를 주는 시점에 한 단계씩 내려간다. 그 hint 가 trigger 가 된다.
torch.compile wrap. 커널을 직접 안 짠다.BLOCK_SIZE, num_warps, num_stages 를 sweep 한다.load_inline 으로 끼워넣는다 — 빌드 시스템 안 만진다.
여기서만 추가로 따는 것 — coalescing 직접 통제, shared memory 직접 stage, register 사용 조정, vector load (float4).
이 사다리에서 한 가지 더 짚어야 하는 점 — Numba 는 강의에서 의도적으로 추천 경로에서 빠진다. 개념적으로는 CUDA 와 동일한 모델인데 (grid, block, threadIdx), 강의의 인지적 부담을 줄이기 위해 “이 클래스에서는 C++/CUDA 또는 Triton 만 쓴다” 고 못 박는다. 산업에서도 Triton 과 직접 CUDA 가 사실상 표준이 됐다.
강의에서 6개월 뒤에 다시 돌아왔을 때 가장 빨리 복원해야 하는 사실들과 — 직접 손에 박아야 하는 코드 자료들.
cuda.synchronize(). time.time() 으로 GPU 코드 측정 절대 금지.cuda_sources 와 cpp_sources 에 문자열로 넣고 build_directory 지정. main.cpp 와 build.ninja 가 디스크에 남아 학습 자료로도 쓰임.BLOCK_SIZE · num_warps · num_stages 셋을 항상 sweep. 같은 소스가 설정 따라 10배 차이.@triton.jit(interpret=True) 또는 TRITON_INTERPRET=1. CPU 시뮬레이션 + Python breakpoint() 로 GPU 커널 디버깅.torch.compile 이 만든 Triton 커널을 dump. fusion 검증 + 새 커널의 시작점으로 활용.ncu python train.py — 기본 hint 받기. 시각화는 ncu --set full -o trace python … 후 GUI 에서.pytorch_square.py 를 그대로 돌려 torch.square, a*a, a**2 의 GPU 시간을 측정한다. 작은 차이가 어떤 op 디스패치에서 오는지 표로 확인.load_inline.py 를 돌리고 ./load_inline_cuda/ 안의 자동 생성된 main.cpp 와 build.ninja 를 직접 읽는다. pybind 가 어떻게 박혀 있는지 손에 잡힐 때까지.torch.profiler.profile() 을 둘러 trace.json 을 export 하고 chrome://tracing 에 드래그한다. memcpy H→D 와 커널 사이의 갭을 직접 본다.@triton.jit(interpret=True) 와 breakpoint() 로 row-wise square 커널을 한 step 씩 들여다본다. tl.arange, tl.load 의 결과 모양을 직접 print.BLOCK_SIZE ∈ {64, 128, 256, 512, 1024, 2048} 로 측정. torch.square 와 비교. 그래프가 U자나 단조 감소가 아니면 다른 변수가 섞인 것.TORCH_LOGS=output_code 로 torch.square 와 torch.square(torch.square(x)) 두 케이스를 dump. fusion 이 일어났는지 코드 안에서 직접 확인.ncu python pytorch_square.py. 첫 hint 가 무엇인지 적어둔다. cloud 환경에서는 NCU 가 막혀 있을 수 있으니 자기 데스크탑이나 NCU 가 허용된 인스턴스에서.L001 의 도구들이 시리즈 안에서 어떻게 다시 호출되는지를 묶어둔다 — 같은 도구가 다른 문맥에서 재등장하는 패턴이 GPU Mode 시리즈 전체의 학습 곡선이다.
학습 노트로 정리하면서 의도적으로 비워둔 자리들 — 강의 안에서 부분적으로만 등장한 주제, 또는 후속 강의에서 본격적으로 다뤄지는 주제.
nsys_square.py 가 repo 에 있지만 본문에서는 NCU 만 본다. system-level 타임라인(NCCL, CPU, multi-GPU)은 nsys 의 영역이고 NCU 는 단일 커널 깊이파기. 분리 잘 해두기.square 커널 위에서 도구를 깐다. SAM-fast / GPT-fast 같은 사례에서 같은 도구 시퀀스가 어떻게 쓰였는지 — Mark 가 마지막에 잠깐 언급. 별도로 추적할 필요.이 노트의 모든 NCU 수치 예시(occupancy 38%, tail 0.4 wave 등)는 강의에서 Mark 가 보여준 화면을 재구성한 예시 값이다. 자기 GPU 에서 직접 NCU 를 돌려 보고 hint 의 실제 형태를 한 번 봐야 깊이 들어간다.