← Day 2 Phase 0 · Week 1 · Day 3 Day 4 →
🎯 오늘의 목표
📖 개념 (수업)
임의의 식은 원시연산(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)$의 출처다.
각 변수의 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를 부모로 보내고, 부모는 자식들로부터 온 것을 더한다"로 요약된다.
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는 합이며,
구현에서 +=로 누적하는 것이 이 합의 직역이다. =로 덮으면 한 경로를 버려 다변수 연쇄법칙을 위반한다.
# 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)로 같은 보장을 만든다.
$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$ — 정확히 일치.
naive reverse-mode는 모든 중간값을 보관해 메모리 $O(T)$. 깊은 트랜스포머에선 활성값이 GPU 메모리를 압도한다. gradient checkpointing은 일부 노드만 저장하고, backward 때 빠진 구간을 forward 재계산한다. $L$개 층을 $\sqrt{L}$개 세그먼트로 나눠 세그먼트 경계만 저장하면 메모리 $O(\sqrt{T})$, 추가 계산 1회($\approx$2배 시간).
✍️ 직접 해보기 (강도 ↑)
= 대신 +=여야 하는 이유를 이 식으로 설명해라.
(op, inputs) 튜플 리스트(tape)로 받아
forward로 값을 채우고, 위 의사코드대로 backward로 adjoint를 채우는 미니 인터프리터를 짜라
(지원 연산: +, *, exp, log). 과제 1을 이걸로 돌려 손계산과 일치 검증.
※ 이건 Day 4 객체지향 micrograd의 절차형 예열이다.
✅ 스스로 확인
📝 오늘의 기록
docs/00_day03.md:
Value로 캡슐화한다.
연산자 오버로딩으로 그래프가 forward 중 자동 구축되게 하고(define-by-run, PyTorch 방식),
각 노드가 자기 VJP를 클로저로 들게 한다. tape-based(autograd) vs graph-based(TF1) 설계 차이도 짚는다.
← Day 2 Day 3 끝 Day 4 — micrograd ① →