← Day 2 Phase 0 · Week 1 · Day 3 Day 4 →

Day 3 — 계산 그래프와 reverse-mode AD

어제의 VJP를 계산 그래프(DAG) 위의 알고리즘으로 조립한다. forward는 Wengert list를 만들며 중간값을 기록하고, backward는 위상 역순으로 각 노드에서 VJP를 적용하며 adjoint $\bar v = \partial L/\partial v$를 전파한다. fan-out(노드 공유)에서의 gradient 누적을 VJP의 선형성으로 증명하고, 메모리/재계산 트레이드오프를 정량화한다. 오늘 손으로 돌린 알고리즘이 내일 코드의 한 줄 한 줄이 된다.

🎯 오늘의 목표

📖 개념 (수업)

1. Wengert list — 계산을 정규화

임의의 식은 원시연산(primitive)들의 순차열로 펼쳐진다. 각 줄이 하나의 중간변수를 정의하는 이 형태를 Wengert list(또는 evaluation trace, tape)라 한다. 예: $L = \big(a b + \exp(c)\big)\cdot a$

v1 = a
v2 = b
v3 = c
v4 = v1 * v2        # ab
v5 = exp(v3)        # exp(c)
v6 = v4 + v5        # ab + exp(c)
v7 = v6 * v1        # (...)·a   ← a가 v4와 v7 두 곳에 쓰임 (fan-out!)
L  = v7

그래프의 노드 = 변수 $v_i$, 엣지 = 직접 의존. forward는 위→아래로 $v_i$를 채운다. 이때 backward에 필요한 중간값(예: $v_1, v_6$)을 tape에 보관한다 — 이게 메모리 $O(T)$의 출처다.

2. Adjoint와 backward 방정식

각 변수의 adjoint를 $\bar v_i \equiv \dfrac{\partial L}{\partial v_i}$로 정의한다. backward는 $\bar L = 1$에서 시작해 위상 역순으로, 각 변수가 자기가 쓰인 모든 곳에서 받은 기여를 합산한다. 변수 $v_i$가 연산 $v_j = \phi_j(\dots, v_i, \dots)$들에 쓰였다면 다변수 연쇄법칙으로:

$$\bar v_i = \sum_{j:\, v_i \to v_j} \bar v_j \,\frac{\partial v_j}{\partial v_i}$$

이 합의 각 항 $\bar v_j \frac{\partial v_j}{\partial v_i}$가 바로 연산 $\phi_j$의 VJP가 $v_i$로 흘리는 기여다. 즉 backward는 "각 노드에서 local VJP를 부모로 보내고, 부모는 자식들로부터 온 것을 더한다"로 요약된다.

3. fan-out에서 왜 더하는가 — 증명

Day 1 버전에서는 "여러 경로면 더한다"고 직관으로 넘어갔다. 이제 정당화하자. 위 식에서 $v_i$가 두 연산 $v_j, v_k$에 쓰였다면:

$$\bar v_i = \bar v_j\frac{\partial v_j}{\partial v_i} + \bar v_k\frac{\partial v_k}{\partial v_i}$$

이건 다변수 연쇄법칙 그 자체다. $L$을 $v_i$의 함수로 볼 때 $v_i$는 $v_j$와 $v_k$ 두 경로로 $L$에 도달하고, 전미분은 경로별 기여의 이다($df = \sum \partial_j f\, dv_j$). 따라서 fan-out 노드의 adjoint는 합이며, 구현에서 +=로 누적하는 것이 이 합의 직역이다. =로 덮으면 한 경로를 버려 다변수 연쇄법칙을 위반한다.

av1 b c v4=ab v5=exp c v6=+ v7=·a a는 v4와 v7 양쪽으로 → ā = (v4 경로) + (v7 경로)

4. reverse-mode 알고리즘 (의사코드)

# forward: tape 구축 + 중간값 저장
tape = []
for each primitive op producing v from inputs (u1..uk):
    v.value = phi(u1..uk)
    tape.append((v, op, [u1..uk]))   # backward용 기록

# backward: adjoint 전파
for all v: v.grad = 0
output.grad = 1                       # L̄ = 1
for (v, op, inputs) in reversed(tape):
    for u, local_vjp in op.vjp(v.grad, inputs):   # ū += v̄ · ∂v/∂u
        u.grad += local_vjp           # ★ 누적(fan-out 합)

위상 역순이 핵심: 노드 $v$의 backward를 실행할 때 $\bar v$가 이미 완성돼 있어야 한다. 즉 $v$를 소비하는 모든 노드가 먼저 처리됐어야 한다. tape를 만든 순서(위상순)의 역순이 정확히 이 조건을 만족한다. Day 5에서는 명시적 위상정렬(DFS)로 같은 보장을 만든다.

5. 손 backward — fan-out 포함 완전 예제

$L = (ab + e^c)\cdot a$, $a=2, b=-3, c=0$에서 모든 adjoint를 채워보자. forward: $v_4=-6,\ v_5=1,\ v_6=-5,\ v_7=L=-10$.

단계VJP 규칙갱신
$\bar v_7=1$출력$\bar L=1$
$v_7=v_6 v_1$곱: $\bar v_6{+}{=}\bar v_7 v_1,\ \bar v_1{+}{=}\bar v_7 v_6$$\bar v_6=2,\ \bar v_1=-5$
$v_6=v_4+v_5$합: 양쪽에 $\bar v_6$$\bar v_4=2,\ \bar v_5=2$
$v_5=e^{v_3}$$\bar v_3{+}{=}\bar v_5 e^{v_3}$$\bar v_3=2\cdot1=2$
$v_4=v_1 v_2$곱: $\bar v_1{+}{=}\bar v_4 v_2,\ \bar v_2{+}{=}\bar v_4 v_1$$\bar v_1=-5+2(-3)=-11,\ \bar v_2=4$

결과: $\dfrac{\partial L}{\partial a}=\bar v_1=-11,\ \dfrac{\partial L}{\partial b}=4,\ \dfrac{\partial L}{\partial c}=2$. $\bar v_1$이 두 번 누적된 것(-5 then -11)이 fan-out 합의 실제 동작이다. 해석적으로 $\partial_a[(ab+e^c)a] = 2ab + e^c = 2(2)(-3)+1 = -11$ — 정확히 일치.

6. 메모리 vs 재계산 — checkpointing 분석

naive reverse-mode는 모든 중간값을 보관해 메모리 $O(T)$. 깊은 트랜스포머에선 활성값이 GPU 메모리를 압도한다. gradient checkpointing은 일부 노드만 저장하고, backward 때 빠진 구간을 forward 재계산한다. $L$개 층을 $\sqrt{L}$개 세그먼트로 나눠 세그먼트 경계만 저장하면 메모리 $O(\sqrt{T})$, 추가 계산 1회($\approx$2배 시간).

정량 $k$개 균등 checkpoint를 두면 메모리 $O(T/k + k)$(세그먼트 활성 + 경계), 재계산 비용 $O(T)$ 추가. $k=\sqrt{T}$에서 메모리 $O(\sqrt{T})$ 최적. 이게 Chen et al. 2016의 핵심이고, 더 일반적인 최적 스케줄은 Griewank–Walther의 revolve(이항계수 기반)다. Phase 3에서 토큰·배치를 키울 때 이 다이얼을 직접 돌린다.

✍️ 직접 해보기 (강도 ↑)

과제 1 — Wengert + 손 backward. $L = \log\!\big(1+e^{w x + b}\big)$ (로지스틱 손실의 한 항)을 Wengert list로 펼치고, 모든 adjoint를 손으로 전파해 $\partial L/\partial w,\ \partial L/\partial b,\ \partial L/\partial x$를 구해라. 시그모이드 $\sigma(z)$가 자연히 등장함을 확인하고 ($\partial L/\partial z = \sigma(z)$) 그 이유를 한 줄로.
과제 2 — fan-out 누적 증명(글). 변수 $v$가 $k$개 연산에 쓰일 때 $\bar v = \sum_{j=1}^k \bar v_j \partial v_j/\partial v$ 임을 다변수 연쇄법칙으로 증명하고, 구현에서 = 대신 +=여야 하는 이유를 이 식으로 설명해라.
과제 3 — tape 인터프리터(코드). 연산을 (op, inputs) 튜플 리스트(tape)로 받아 forward로 값을 채우고, 위 의사코드대로 backward로 adjoint를 채우는 미니 인터프리터를 짜라 (지원 연산: +, *, exp, log). 과제 1을 이걸로 돌려 손계산과 일치 검증. ※ 이건 Day 4 객체지향 micrograd의 절차형 예열이다.
과제 4 — checkpointing 시뮬. 길이 $T$의 선형 체인에서, $k$개 checkpoint를 둘 때 저장 노드 수와 재계산 횟수를 $k$의 함수로 계산하고, 메모리×시간 비용을 $k$에 대해 플롯해 $k\approx\sqrt{T}$ 근처 최적을 확인해라.
도전 reverse-mode를 두 번 적용(또는 forward-over-reverse)해 Hessian-vector product $Hv$를 $O(T)$에 얻는 스킴을 과제 3의 tape 위에서 구현해라. 작은 2차 함수로 $Hv$를 해석적으로 검증.

✅ 스스로 확인

📝 오늘의 기록

docs/00_day03.md:
다음 (Day 4): 오늘의 절차형 tape를 객체지향 Value로 캡슐화한다. 연산자 오버로딩으로 그래프가 forward 중 자동 구축되게 하고(define-by-run, PyTorch 방식), 각 노드가 자기 VJP를 클로저로 들게 한다. tape-based(autograd) vs graph-based(TF1) 설계 차이도 짚는다.

← Day 2 Day 3 끝 Day 4 — micrograd ① →