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

Optimizing Optimizers

Adam, AdamW, SGD 같은 optimizer 들이 학습 step 의 시간을 어떻게 잡아먹는가 의 정량 분석. 모델이 큰 게 아니라 tensor 가 많은 것이 문제의 핵심 — 한 step 안에 수천 개의 tensor 에 똑같은 update 를 적용하면 launch overhead 가 dominant. PyTorch 가 이 문제를 푼 사다리 — foreach, fused, multi_tensor_apply, torch.compile(optimizer) — 를 차례로 깐다. Jane Xu 가 PyTorch optimizer team 의 일을 직접 보여주는 강의.

foreach optimizer fused optimizer multi_tensor_apply launch overhead tensor list APIs torch.compile(optimizer) unified memory apex 의 연장선
J
Speaker
Jane Xu
Meta · PyTorch optimizer team · foreach/fused 구현 메인테이너
강의 번호
L006
스피커
Jane Xu
학습 우선순위
High · 정독
다시 볼 때
자기 모델로 trace 비교
§ 01강의가 풀려는 문제· Why this lecture exists

“optimizer 는 빠르다” 는 직관이 큰 모델에선 깨지는 자리

개별 optimizer step 을 본다면 — Adam 한 줄은 w -= lr · m / (sqrt(v) + eps) 같이 가벼운 산술이다. 그런데 학습 루프 전체에서 봤을 때, 큰 모델에서 optimizer 가 한 step 의 10–30% 까지 차지하는 일이 흔하다. 왜? 모델의 모든 파라미터 텐서마다 같은 update 를 따로 launch 하기 때문.

강의가 답하는 질문 셋.

  1. 왜 optimizer 가 무거운가 — 산술이 무거운 게 아니라 launch 가 많은 것이 문제. (§02–§03 의 정량.)
  2. PyTorch 가 이 문제를 어떻게 풀어왔는가foreachfusedmulti_tensor_applytorch.compile 의 사다리.
  3. 큰 모델에서 optimizer state 자체가 메모리 한계인 자리 — CPU offload, unified memory 같은 후속 영역의 첫 그림.
시리즈 안의 위치

L001·L004커널 한 개의 시간의 강의였다면, L006 은 커널 N개를 어떻게 묶을 것인가 의 강의다. L018 Fusing Kernels 와 같은 가족이지만 — fusion 의 표적이 “여러 op” 가 아니라 “여러 tensor 에 같은 op”. tensor list 의 fusion이 새로운 차원.

“커널 launch 자체가 비싸다 — 같은 일을 더 많이 묶어 한 번 launch 하면, 코드가 그저 빨라진다.”Jane Xu · 학습 노트
§ 02Adam 의 한 step 을 펼친다· 파라미터 텐서 별로 4개 op

Python 으로 짠 Adam 에 무슨 launch 가 박혀 있는지

Adam 의 update 식. 한 파라미터 텐서 p 에 대해 첫째 모멘트 m, 둘째 모멘트 v 를 들고 있고, gradient g 가 들어왔을 때.

Adam · per-parameter step
m = β₁ * m + (1 - β₁) * g                # 1 mul + 1 mul + 1 add = ~3 launch
v = β₂ * v + (1 - β₂) * g²               # pow + 1 mul + 1 add = ~3-4 launch
m̂ = m / (1 - β₁^t)
v̂ = v / (1 - β₂^t)
p = p - lr * m̂ / (sqrt(v̂) + ε)            # 1 sqrt + 1 div + 1 mul + 1 sub = ~4 launch

이 한 줄짜리 식이 PyTorch eager 에서는 한 파라미터 텐서당 약 10개 안팎의 op로 풀린다 — pointwise mul, add, sub, sqrt, div 의 시퀀스. Inductor 또는 fused kernel 이 없으면 각자가 따로 launch.

강의에서 Jane 이 처음 보여주는 정량적 사실 — 모델이 LLaMA-7B 같은 규모면 파라미터 텐서가 수천 개다. 한 step 의 launch 수가 급격히 늘어난다.

작은 모델 (BERT-base)

파라미터 텐서~200
step 당 op~2000
launch overhead 가정~10 μs
총 overhead~20 ms

큰 모델 (LLaMA-7B 또는 그 이상)

파라미터 텐서~600 (LLaMA-7B)
step 당 op~6000
launch overhead 가정~10 μs
총 overhead~60 ms

이 표가 강의의 motivation. 실제 산술 시간은 거의 안 늘어났는데 (단순한 elementwise op), launch overhead 가 step 의 30~50% 까지 차지한다. fusion 또는 묶음으로 launch 수를 줄이면 그대로 해결.

§ 03launch overhead 의 산수· 10000개 tensor × 4 op

“많은 작은 일” 이라는 패턴이 GPU 의 가장 큰 적

강의의 정량. 한 launch 의 fixed cost ~10 μs (host driver 기준 추정), 작은 elementwise 커널의 실제 GPU 시간 ~5 μs. 즉 커널 자체보다 launch 가 더 길다. 이 비율이 작은 op + 많은 tensor 인 경우의 본질.

같은 일을 푸는 두 방향.

두 방향이 결합하면 — 한 step 의 모든 optimizer launch 를 한 두 개의 커널 launch 로. 이게 PyTorch 가 도달한 현재 상태.

왜 NVIDIA apex 가 먼저 만들었나

Jane 이 강의에서 인정 — “Nvidia apex 가 먼저 풀었다, 우리는 영감을 받아 따라갔다.” apex 의 FusedAdam 이 multi_tensor_apply 패턴의 원조. PyTorch 의 fused 가 같은 idea 를 mainline 으로.

§ 04foreach — Python 에서 묶기· torch._foreach_add_

같은 op 를 여러 tensor 에 한꺼번에 적용하는 첫 단계

PyTorch 의 첫 해결책. torch._foreach_add_(tensors, alpha=…) 같은 함수가 tensor 의 list 를 받아 같은 op 를 일괄 적용. 내부적으로는 한 launch (또는 매우 적은 수의 launch) 로 처리.

# Adam 의 한 step (foreach 스타일, 약식)
def adam_step_foreach(params, grads, m_list, v_list, lr, b1, b2, eps, t):
    torch._foreach_mul_(m_list, b1)
    torch._foreach_add_(m_list, grads, alpha=1 - b1)
    torch._foreach_mul_(v_list, b2)
    torch._foreach_addcmul_(v_list, grads, grads, value=1 - b2)

    bias1 = 1 - b1**t
    bias2 = 1 - b2**t
    step_size = lr / bias1

    denom = torch._foreach_sqrt(v_list)
    torch._foreach_div_(denom, math.sqrt(bias2))
    torch._foreach_add_(denom, eps)

    torch._foreach_addcdiv_(params, m_list, denom, value=-step_size)

이 코드의 사실들.

  • 모든 op 가 list 를 받는다 — 한 호출이 여러 tensor 를 동시에 처리.
  • 알고리즘은 그대로 — 한 tensor 짜리 코드의 mechanical 변환. for-loop 이 함수 호출 안으로 들어간 것.
  • 각 호출은 한 launch (또는 적은 수) — 내부 구현이 multi_tensor_apply 패턴 (§06).
  • Python 단에서 enableoptimizer = torch.optim.Adam(..., foreach=True) 로 켠다. 최근 버전은 default.
왜 in-place 인가

대부분의 foreach op 가 _ suffix (in-place). optimizer state (m, v) 와 params 가 매 step 같은 buffer 를 update 하기 때문에 새 buffer 할당이 비효율. 같은 메모리에 직접 쓰는 게 메모리/캐시 효율 ↑.

“foreach 의 메시지는 단순하다 — 같은 일을 한 launch 로 묶기. CUDA 안의 분배는 그 함수가 알아서.”학습 노트 · L006 §04
§ 05fused — CUDA 한 커널로· apex 의 idea, PyTorch 의 fused=True

Adam 의 모든 산술을 한 커널 안에 — 가장 완전한 형태

foreach 가 “한 op 를 여러 tensor 에” 묶었다면, fused 는 한 단 더 — Adam 의 모든 산술 (mul, add, sqrt, div, addcdiv) 을 한 CUDA 커널로. 한 step 의 모든 launch 가 사실상 한 두 개로 줄어든다.

# Python 에서는 한 줄
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4, fused=True)

# 내부적으로는 — 약간 단순화한 fused Adam kernel signature
__global__ void adam_kernel(
    TensorListMetadata<4> meta,    // params, grads, m, v 의 pointer list
    float lr, float b1, float b2, float eps, int t)
{
    // 한 thread 가 한 element 의 모든 산술을 끝낸다
    int tensor_idx = blockIdx.y;
    float* p = meta.tensors[0][tensor_idx];
    float* g = meta.tensors[1][tensor_idx];
    float* m = meta.tensors[2][tensor_idx];
    float* v = meta.tensors[3][tensor_idx];
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < meta.sizes[tensor_idx]) {
        m[i] = b1 * m[i] + (1 - b1) * g[i];
        v[i] = b2 * v[i] + (1 - b2) * g[i] * g[i];
        float mhat = m[i] / (1 - powf(b1, t));
        float vhat = v[i] / (1 - powf(b2, t));
        p[i] -= lr * mhat / (sqrtf(vhat) + eps);
    }
}

이 형태의 장점.

  • HBM 왕복 1회 / element — m, v, p 를 한 번 읽고 한 번 쓴다. unfused 면 같은 buffer 를 5–10 번 왔다 갔다.
  • launch 수 1 (또는 매우 적음) — Adam 한 step 이 launch overhead 면에서 매우 가벼움.
  • algorithm 정확도 동일 — 산술 순서가 약간 달라도 fp32 에서는 거의 차이 없음 (fp16 에서는 주의).
fused 의 한계

모든 optimizer 가 fused 가 있는 건 아니다. PyTorch 는 Adam, AdamW, SGD 의 fused 버전이 있고, sparse Adam 은 없다 (sparse tensor 의 indexing 이 까다로움). custom optimizer 는 이 사다리에 안 들어간다 — 이 자리가 torch.compile(optimizer) 의 표적 (§07).

§ 06multi_tensor_apply· pointer struct · CUDA 인자 한도와의 거래

“N개의 tensor 를 한 커널에 어떻게 전달할 것인가” 의 엔지니어링

강의에서 Jane 이 가장 흥미롭게 깐 자리. 한 커널에 “여러 tensor” 를 전달하려면 — 각 tensor 의 device pointer 를 launch 시점에 다 넘겨야 한다. 그런데 CUDA 의 kernel 인자는 4 KB 한도 (cudaLaunchKernel arg buffer). pointer 한 개가 8 byte 면, ~500 개 tensor 까지밖에 못 넘긴다. 큰 모델은 그 이상.

해결책 — pointer 를 struct 안에 묶고, struct 들을 chunk 로 나눠 여러 launch. 강의에서 보여준 패턴.

// PyTorch 의 multi_tensor_apply 의 본질 (단순화)
template<int N>
struct TensorListMetadata {
    void* tensors[N][CHUNK_SIZE];   // 각 tensor list 의 pointer 들
    int   sizes[CHUNK_SIZE];        // 각 tensor 의 element 수
};

void multi_tensor_apply(
    std::vector<std::vector<Tensor>>& tensor_lists,
    Op op)
{
    int n_tensors = tensor_lists[0].size();
    for (int chunk_start = 0; chunk_start < n_tensors; chunk_start += CHUNK_SIZE) {
        TensorListMetadata<N> meta;
        // fill meta with chunk_start..chunk_start+CHUNK_SIZE pointers
        op<<<blocks, threads>>>(meta, ...);   // 한 chunk 당 한 launch
    }
}
CHUNK_SIZE 의 결정

한 chunk 당 한 launch 면 — chunk 수 = launch 수. 커널 안 인자 4 KB 한도 안에서 가장 큰 chunk 를 잡는다. apex 와 PyTorch 모두 ~64 또는 ~128 정도. 큰 모델 (수천 개 tensor) 면 결국 launch 가 수십 개로 — 그래도 unfused 의 수만 개에서 크게 줄음.

이 한 디테일이 시리즈 안에서 자주 다시 등장 — “한 launch 에 어디까지 묶을 수 있는가” 가 항상 인자 한도와 같이. CUDA Graphs 가 또 다른 답 (§07 의 직전).

“같은 일을 모두 한 launch 에 — 가능하면 좋지만, CUDA 의 인자 한도가 거기 있다. 그래서 chunk 로 나눈다.”학습 노트 · L006 §06
§ 07torch.compile(optimizer)· Python 코드를 그대로 fuse

fused 가 없는 optimizer 도 — 컴파일러가 알아서

PyTorch 의 가장 최근 사다리. torch.compile 이 optimizer 의 Python 코드를 통째로 trace 해서 Triton fused 커널로 만들어 준다. 사용자가 직접 fused 버전을 짜지 않아도 — Inductor 가 같은 fusion 을 자동으로.

# 한 줄 추가로 모든 optimizer 가 “자동 fused”
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4)
optimizer.step = torch.compile(optimizer.step)

# custom optimizer 도 동일하게
class MyOptimizer(torch.optim.Optimizer):
    def step(self):
        # 평범한 Python 산술
        ...

opt = MyOptimizer(...)
opt.step = torch.compile(opt.step)   # 같은 fusion 을 받는다

이 사다리의 의미.

  • fused 가 없는 optimizer 도 자동으로 빠르게 — sparse Adam, custom Adam-like, Lion 등.
  • 유지보수 비용 감소 — fused C++/CUDA 코드를 PyTorch core 가 따로 들고 있을 필요 없음.
  • 당연히 trade-off 있음 — compile 시간 첫 step 에 ~10 초 정도. dynamic shape 면 graph break.
강의 시점의 status

Jane 이 강의에서 명시 — “compiled optimizer 는 현재 beta. 안 되면 issue 열어 달라.” 이후 PyTorch 2.x 에서 stable 화. 자세한 내부는 L053 torch.compile Q&A 의 영역.

이 4단 사다리 (eager → foreach → fused → compile) 가 같은 알고리즘에 대한 PyTorch 의 진화 패턴이다. matmul, attention, normalization 모두 비슷한 사다리를 따라간다 — 이 강의가 그 패턴의 가장 명확한 한 사례.

§ 08CPU offload 와 unified memory· FSDP / 큰 모델의 자리

optimizer state 자체가 GPU 메모리에 안 들어갈 때

강의 후반에 Jane 이 짚는 다음 영역. Adam 은 파라미터당 m, v 의 두 state 를 들고 있어 → 모델 메모리의 3배. 큰 모델 (LLaMA-7B FP32 = 28 GB · Adam state = 56 GB · 총 84 GB) 면 GPU 한 장에 안 들어간다.

해결책 셋.

우선순위는 fused 다음

강의의 사다리 — fused 까지가 “모든 모델에 적용되는” 일반 최적화. CPU offload 와 unified memory 는 “모델이 GPU 에 안 들어갈 때만” 의 특수 도구. 이 강의는 fused 까지 본론이고, 후자는 다음 강의 (FSDP, ZeRO 류) 의 자리.

“optimizer 를 빠르게 — 그 다음은 optimizer state 를 어디 둘 것인가의 질문이 따라온다.”학습 노트 · L006 §08
§ 09실측 — 어디서 얼마나 빨라지나· profiler 위에서

같은 모델, 같은 step 에서 4가지 모드 비교

강의의 마지막 데모. 같은 모델 (작은 transformer block) 의 한 Adam step 을 네 가지 모드로 측정 — eager, foreach, fused, compile. PyTorch profiler 의 chrome trace 위에서 직접 본다.

eager (구버전) 파라미터당 ~10 launch · trace 에 작은 막대 수만 개step 의 50% 가 launch overhead ~80 ms
foreach=True tensor list 가 묶여서 launch 수 1/N막대가 굵어지고 짧아진다 ~20 ms
fused=True 한 chunk 당 한 launch — Adam 전체가 한 (또는 두) 커널chrome trace 에서 굵은 한 막대 ~5 ms
torch.compile Inductor 가 같은 fusion 자동 — fused 와 비슷첫 step 만 compile 시간 ~5 ms (steady)

이 표가 PyTorch optimizer team 이 한 일의 압축. 같은 알고리즘이 ~80 ms → ~5 ms 로 16배 가속. 산술 자체는 한 글자도 안 바뀜.

profiler 로 직접 보기

L001 §04 의 chrome trace 가 이 강의의 검증 도구. foreach=Falsefused=True 의 trace 를 같은 모델에서 export 해 비교하면 — “많은 작은 막대 → 적은 굵은 막대” 의 차이가 시각적으로 잡힌다.

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

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

PyTorch optimizer 의 4단 사다리와 그 motivation 을 다시 펼치기 전 기억할 사실들.

launch overhead
한 launch ~10 μs. 파라미터 텐서 수천 개 × op 수개 = 수만 launch → 전체의 큰 비중.
tensor 차원 fusion
같은 op 를 N 개 tensor 에 한 launch. torch._foreach_*.
op 차원 fusion
한 tensor 의 여러 op 를 한 커널. fused=True 또는 torch.compile.
multi_tensor_apply
CUDA 인자 4 KB 한도와의 거래. tensor pointer 들을 struct 에 묶고, chunk 로 나눠 launch. apex/PyTorch 의 표준.
사다리
eager → foreach → fused → compile. 같은 알고리즘이 4번 다른 형태. 16배 가속.
in-place
대부분 foreach op 가 _ in-place. m, v, p 같은 buffer 가 매 step 같은 자리 update — 새 alloc 안 함.
CHUNK_SIZE
한 launch 가 처리할 tensor 수의 한도. ~64 또는 ~128. 4 KB / 8 B / N (N = list 수) 로 결정.
compile(optimizer.step)
fused 가 없는 optimizer 도 자동 가속. custom Adam-like 의 답.
큰 모델의 다음 자리
FSDP / CPU offload / unified memory — optimizer state 가 GPU 에 안 들어갈 때만의 특수 도구.
Slides Google Slides
Code repo 디렉터리는 강의 시점에 공개되지 않음. 본문의 ref code 는 PyTorch 의 torch/optim/_multi_tensorFusedAdamW.cu.
Speaker repo github.com/janeyx99
참고 패키지 NVIDIA/apex (FusedAdam 의 원조) · PyTorch torch.optimforeach · fused 옵션

손에 새기기 — 실습 시퀀스

  1. 같은 모델로 4모드 비교 — 작은 transformer block 1 step 의 시간을 eager, foreach, fused, compile 로 각각 측정. 자기 GPU 에서 강의의 16배 가속이 재현되는가.
  2. chrome trace 로 launch 수 세기 — 4 모드의 trace 를 export 해서 optimizer step 안의 막대 수를 직접 센다. ~수만 → ~수십 의 차이.
  3. 파라미터 수 vs 시간 — 모델 크기를 4단 (small/base/large/xl) 로 변화시키면서 각 모드의 시간 추세. eager 는 거의 linear, fused 는 거의 flat.
  4. custom optimizer 에 compile 적용 — 자기만의 optimizer (예: Lion, Tiger) 를 만들고 opt.step = torch.compile(opt.step) 로 wrap. 가속 비율 측정.
  5. multi_tensor_apply 의 chunk 직접 보기 — PyTorch source 의 aten/src/ATen/native/cuda/MultiTensorApply.cuh 를 읽어 CHUNK_SIZE 와 metadata struct 의 정의 확인.
  6. foreach 의 in-place 검증torch._foreach_add_ 호출 전후 tensor 의 data_ptr 가 같은지 확인. 새 alloc 안 일어남.
  7. fused 와 numerical 차이 — eager 와 fused 가 정확히 같은 결과를 주는가? torch.allclose(rtol=…). fp32 면 거의 동일, fp16 면 미세한 차이.
  8. 한 페이지 plan — 자기 학습 코드의 한 step 에서 “optimizer 가 차지하는 비율” 을 측정하고, 어느 단계 (foreach/fused/compile) 가 가장 큰 효과인지 결정.
§ 11다른 강의로 이어지는 길· connections

이 강의의 fusion 패턴이 어디서 다시 등장하는지

tensor list fusion · launch overhead 의 정량 · multi_tensor_apply 패턴이 시리즈 여러 강의의 핵심 어휘.

§ 12열린 질문· open questions

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

강의 안에서 흐릿하게 지나간 자리들과, 자기 환경에서 직접 측정해야 손에 박히는 사실들.

← Lecture 005 Jeremy Howard — shared memory tiling 으로 matmul 가속 Lecture 007 → Charles Hernandez — quantized GEMM, CUDA vs Triton