1. 개요[편집]
| 계산 그래프 Computational Graph | |
|---|---|
| 정체 | 계산을 표현한 유향 비순환 그래프(DAG) |
| 정점 / 간선 | 기본 연산 / 데이터 의존 |
| 실행 순서 | 위상 정렬이 허용하는 아무 순서 |
| 전진 모드 | 위상 순서대로 훑기 — 그래프를 안 남겨도 된다 |
| 역방향 모드 | 역위상 순서로 훑기 — 그래프를 물질화해야 한다 |
| 두 진영 | 정적(define-and-run) vs 동적(define-by-run) |
| 최적화 | 상수 접기 · 공통부분식 제거 · 죽은 코드 제거 · 연산 융합 |
| 기원 | 벵거트 목록(1964) · 바우어의 계산 그래프(1974) |
계산 그래프(computational graph)는 하나의 계산을 정점이 기본 연산, 간선이 데이터 의존을 뜻하는 유향 비순환 그래프로 표현한 자료구조다. y = (a*b) + sin(a) 라는 한 줄은 곱셈 노드 하나, 사인 노드 하나, 덧셈 노드 하나와 그 사이를 잇는 간선 넷이 되고, 입력 a 에서 출력 y 로 가는 경로가 두 갈래 생긴다. 이게 전부다.
이 시시해 보이는 표현이 왜 한 편의 문서를 받아야 하냐면, 자동 미분과 텐서 컴파일러가 둘 다 이 그래프 위에서 산다는 사실 때문이다. 미분은 그래프를 한 방향으로 훑는 일이고, 최적화는 그래프를 다른 그래프로 바꿔치는 일이다. 프로그램을 텍스트나 추상 구문 트리가 아니라 DAG로 다시 본 순간 둘 다 그래프 알고리즘 문제로 바뀌며, 그래서 오늘날 심층 학습 프레임워크와 미분 가능 시뮬레이션 인프라의 밑바닥은 예외 없이 그래프 엔진이다.
미분의 수학 자체는 자동 미분·역전파 문서가 다룬다. 여기서는 그 미분이 올라타는 자료구조, 그리고 그 자료구조를 언제 만들고 언제 없애느냐는 공학적 선택을 다룬다.
2. 그래프에 정확히 무엇이 담기는가[편집]
주의할 것은, 계산 그래프는 소스 코드의 그림이 아니라 실제로 실행된 연산의 기록이라는 점이다. 이 구별이 뒤에 나오는 거의 모든 함정의 뿌리다.
가장 오래된 형태는 벵거트 목록(Wengert list)이다.1 프로그램의 모든 중간값에 이름을 붙여 기본 연산 하나씩만 하는 대입문의 나열로 펼친 것으로, 앞의 예는 이렇게 된다.
이 선형 목록에 “누가 누구를 먹었는가”를 간선으로 그리면 그대로 DAG다. 비순환인 이유도 여기서 나온다 — 각 중간값은 딱 한 번 정의되고, 정의되기 전에는 쓰일 수 없다. 순환 신경망처럼 반복이 있는 계산은 그래프에 고리를 만드는 게 아니라 시간축으로 펼쳐서(unrolling) 여전히 DAG로 만든다. BPTT(시간 역전파)가 그냥 역전파인 이유다.
간선 하나마다 국소 도함수 가 붙어 있다고 보면 미분은 경로 위의 곱과 경로들 사이의 합으로 환원된다. 정점을 실행하는 순서는 위상 정렬이 허용하는 범위 안에서 자유롭고, 이 자유도가 곧 병렬 실행·메모리 재사용·스케줄링의 여지다.
3. 같은 그래프, 두 방향의 순회[편집]
전진 모드와 역방향 모드의 차이는 알고리즘이 다른 게 아니라 이 그래프를 훑는 방향이 다른 것이다.
- 전진 모드 — 위상 순서대로 노드를 방문하며 값과 방향도함수를 같이 나른다. 방문이 끝난 노드는 더 볼 일이 없으므로 그래프를 저장할 필요가 없다. 메모리는 원래 함수 평가와 같은 수준이고, 그래프는 사실상 존재하지 않아도 된다.
- 역방향 모드 — 먼저 위상 순서로 전부 계산해 값을 남기고, 그 다음 역위상 순서로 거슬러 올라가며 수반값을 누적한다. 거슬러 올라가려면 간선이 어디로 연결됐는지와 국소 도함수에 필요한 중간값이 남아 있어야 한다. 즉 그래프를 실제로 메모리에 물질화해야 한다.
이 비대칭이 실무에서 갖는 무게는 절대적이다. 입력이 수억 개(파라미터)이고 출력이 스칼라 하나(손실)인 문제에서는 역방향이 압도적으로 싸고 — 바우어·슈트라센이 보인 대로 전체 기울기를 함수 평가 비용의 상수배(연산 모형에 따라 3~5배)에 얻는다 — 그 대가로 중간값 전부를 붙들고 있어야 한다. 학습이 추론보다 훨씬 많은 메모리를 먹는 이유가 정확히 이것이고, 그래서 이 비용을 재계산으로 되사는 그래디언트 체크포인팅이 그래프와 한 몸으로 따라다닌다.
프레임워크 관점에서는 노드마다 두 개의 규칙만 등록하면 된다. 전진용 야코비-벡터 곱(JVP)과 역방향용 벡터-야코비 곱(VJP)이다. 새 연산을 추가한다 = 노드 타입 하나와 그 두 규칙을 등록한다로 정리되며, 야코비 행렬 자체를 만들 일은 없다.2
4. 정적 그래프 대 동적 그래프[편집]
그래프를 언제 만드느냐로 생태계가 두 진영으로 갈렸다. 이 논쟁이 2016~2020년 프레임워크 전쟁의 실질적 내용이었다.
정적 그래프(define-and-run) 는 그래프를 먼저 다 짓고 나중에 데이터를 흘려보낸다. 테아노와 텐서플로 1.x의 placeholder + Session.run 이 전형이다. 장점은 전부 “미리 볼 수 있다”에서 나온다.
- 그래프 전체를 보고 최적화할 수 있다(다음 절).
- 모든 텐서의 형상과 수명을 미리 알 수 있으니 메모리 계획을 정적으로 세우고 버퍼를 재사용할 수 있다.
- 파이썬 없이 배포할 수 있다. ONNX 같은 교환 포맷이 성립하는 것도 그래프가 데이터이기 때문이다 — 학습한 그래프를 파일로 떠서 전혀 다른 런타임에 얹는다.
단점은 딱 하나인데 치명적이다. 디버깅이 지옥이다. 에러가 그래프를 정의한 줄이 아니라 실행하는 줄에서 나고, 중간값을 찍어 보려면 전용 연산을 심어야 하며, 제어 흐름을 파이썬 if/for 로 못 쓰고 tf.cond·tf.while_loop 같은 전용 노드로 써야 한다.
동적 그래프(define-by-run) 는 반대로, 연산이 실제로 실행될 때마다 테이프에 한 줄씩 기록해 그래프를 사후에 완성한다. 체이너가 이 이름을 붙였고 파이토치의 autograd 가 이 방식으로 판을 뒤집었다. 파이썬 if 와 for 를 그냥 쓰면 그 실행 경로가 곧 그래프가 되고, 스택 트레이스가 정직하게 나오며, pdb 가 먹는다. 연구자들이 대거 넘어간 이유가 성능이 아니라 디버깅 가능성이었다는 점은 기억할 만하다. 대가는 매 반복마다 그래프를 새로 짓는 오버헤드와, 전역 최적화를 할 시야가 없다는 것.
2020년대의 결론은 “둘 다 한다”이다. 평소에는 동적으로 돌리다가 필요한 구간만 추적(tracing)해서 정적 그래프로 굳힌다. 텐서플로 2의 tf.function, JAX의 jit(추적 결과가 jaxpr 이라는 중간 표현으로 나온다), 파이토치의 TorchScript를 거쳐 2.0의 TorchDynamo 로 이어지는 계보가 그것이다. 다이나모는 파이썬 바이트코드 수준에서 그래프를 뜯어내되, 추적할 때 가정한 조건(형상·타입·분기)을 가드로 붙여 두고 가드가 깨지면 다시 추적한다. 그래도 못 잡는 부분은 그래프 브레이크로 잘라 파이썬에 되돌린다.
여기서 반드시 알아야 할 함정 하나. 추적은 실행된 경로만 기록한다. 데이터에 따라 분기하는 파이썬 if x > 0: 을 추적하면 그 입력에서 간 쪽만 그래프에 남고, 다음 입력에서 조용히 틀린다. JAX가 lax.cond·lax.scan 을 따로 요구하고, 추적 중에는 값을 들여다보는 연산이 에러를 내는 것이 바로 이 사고를 앞당겨 터뜨리기 위해서다.3
5. 그래프 최적화 — 그래프를 그래프로 바꾸기[편집]
일단 그래프가 데이터가 되면, 컴파일러가 수십 년 써 온 변환들이 그대로 적용된다.
- 상수 접기(constant folding) — 입력에 의존하지 않는 부분 그래프를 미리 계산해 상수 노드로 치환한다. 정규화 상수, 마스크, 위치 인코딩 같은 것들이 여기서 사라진다.
- 공통부분식 제거(CSE) — 같은 연산이 같은 입력에 대해 두 번 나오면 노드를 하나로 합친다. 사용자가 짠 코드보다 미분이 만들어 낸 그래프에 훨씬 많다. 역방향 패스는 같은 중간값을 여러 경로에서 재사용하기 때문이다.
- 죽은 코드 제거(DCE) — 출력에서 역방향으로 도달 불가능한 노드를 지운다. 손실에 기여하지 않는 브랜치, 기울기를 요구하지 않는 텐서의 역방향 노드가 이렇게 정리된다.
- 대수적 단순화 — , , , 연속된 전치·reshape 접기. 미분이 자동 생성한 그래프에는 곱하기 1이 널려 있으므로 실질 이득이 크다.
- 연산자 융합(fusion) — 이게 제일 크다. 원소별 연산 여러 개를 커널 하나로 합치면 중간 텐서를 메모리에 쓰고 다시 읽는 왕복이 통째로 사라진다. GPU에서 원소별 연산은 거의 전부 메모리 대역폭 한계라, 산술을 줄이는 게 아니라 왕복을 줄이는 것이 성능의 거의 전부다. XLA가 파는 물건의 본체가 이 융합이고, TVM·TorchInductor·Triton 계열도 같은 과녁을 노린다.
- 레이아웃과 메모리 계획 — 텐서 배치(NCHW/NHWC)를 그래프 전체에 걸쳐 일관되게 고르고, 노드의 수명 구간을 보고 버퍼를 재사용한다. 정적 그래프의 마지막 남은 확고한 우위.
융합에는 미분과의 미묘한 긴장도 있다. 융합된 커널은 중간 텐서를 안 남기므로, 역방향에서 그 중간값이 필요하면 재계산하거나 융합을 포기해야 한다. 플래시어텐션이 하는 일이 정확히 “역방향에 필요한 것만 골라 남기고 나머지는 재계산하는 융합”이라는 점에서, 융합과 그래디언트 체크포인팅은 사실 같은 장부의 양변이다.
6. 시뮬레이션에서의 계산 그래프[편집]
물리 솔버를 그래프에 얹으면 생기는 문제는 딥러닝과 결이 다르다.
- 반복 솔버를 통째로 펼치면 안 된다. 뉴턴 반복이나 공액구배를 번 돌린 것을 그대로 그래프에 남기면 노드가 배로 늘고, 수렴 전 중간 반복의 미분까지 섞여 들어온다. 정석은 수렴한 해에서 음함수 정리로 미분해 그래프에 노드 하나(선형계 풀이)만 남기는 것이다. 이것이 수반법·민감도 해석의 언어로 말하는 이산 수반과 같은 물건이다.
- 시간 적분은 길다. 스텝 수천 개짜리 시뮬레이션은 그래프 깊이가 그만큼 되고, 역방향 모드의 메모리가 스텝 수에 비례해 늘어난다. 여기가 체크포인팅이 원래 태어난 자리다.
- 희소성이 그래프 모양에 드러난다. 격자 위 스텐실 연산은 그래프에서 국소 연결로 나타나며, 야코비를 실제로 조립해야 할 때는 이 구조를 색칠로 압축해 희소행렬로 만든다.
- 프레임워크의 배열 연산으로 표현되지 않는 알고리즘(불규칙 이웃 탐색, 비정형 메시 조립)은 노드 수가 폭발하거나 아예 그래프에 안 들어간다. 미분 가능 시뮬레이션 문서가 다루는 실패 모드의 상당수가 여기서 나온다.
7. 현업 감각[편집]
- 그래프가 안 보이면 성능 이야기를 할 수 없다. 최적화 전후 그래프를 덤프해 노드 수와 융합 그룹을 세어 보는 것이 첫걸음이다. 파이토치 2 계열은 그래프 브레이크 개수를, JAX는 재추적 횟수를 먼저 본다. “컴파일했는데 안 빨라졌다”의 90%는 그래프가 잘게 쪼개진 것이다.
- 형상이 매 스텝 바뀌면 정적화의 이득이 증발한다. 가변 길이 입력은 패딩·버킷팅으로 형상 종류를 유한하게 묶는 게 국룰. 안 그러면 추적을 매번 다시 한다.
- 테이프가 안 죽는 것이 가장 흔한 메모리 누수다. 기울기가 필요 없는 텐서를 로그나 리스트에 그냥 쌓아 두면 그래프 전체가 참조에 매달려 안 풀린다. 평가 루프에
no_grad를 안 걸어 OOM 나는 사고는 매주 어딘가에서 재현된다. - 미분 불가능점은 그래프가 안 알려 준다.
max,abs, 클램프는 노드로서는 멀쩡하고 한쪽 도함수를 성실하게 내놓는다. 그 값이 물리적으로 의미가 있는지는 사람이 판단해야 한다. - 그래프는 검증 대상이기도 하다. 융합·재배치·재계산은 부동소수점 결합법칙을 건드리므로 비트 단위 재현성이 깨질 수 있다. 결과가 미묘하게 달라지면 컴파일 플래그부터 의심하는 게 부동소수점 연산 쪽의 오랜 교훈과 정확히 같다.
8. 관련 문서[편집]
- 자동 미분 · 역전파 · 그래디언트 체크포인팅
- 미분 가능 시뮬레이션 · 수반법 · 민감도 해석
- 위상 정렬 · 희소행렬 · 동적 계획법
- 심층 학습 · 그래프 신경망 · GPU 컴퓨팅
- 부동소수점 연산 · 자코비안 행렬 · 혼합 정밀도
9. Footnotes[편집]
-
Wengert, R. E. (1964). “A simple automatic derivative evaluation program”. CACM 7(8). 두 쪽짜리다. 60년 뒤 수십억 달러짜리 산업의 자료구조가 될 거라고는 저자도 몰랐을 것이다. 역방향으로 훑는 아이디어는 린나인마(1970)의 석사 논문에, 기울기 전체를 한 번에 뽑는 형태는 스펠펜닝(1980)의 박사 논문에 나온다. 딥러닝이 “발명”한 것은 이 중 하나도 없다. ↩
-
그래서 프레임워크에 새 연산을 기여할 때 “야코비를 구해 오세요”라고 하지 않고 “VJP를 구현하세요”라고 한다. 행렬을 만들지 말고 그 행렬과 벡터의 곱만 계산하라는 뜻인데, 이 구별을 놓치고 성실하게 야코비를 조립했다가 메모리를 태워 먹는 신입이 주기적으로 등장한다. ↩
-
JAX에서 추적 중인 값을
if조건이나print에 넣으면 나오는 그 유명한TracerBoolConversionError가 이것이다. 처음 만나면 프레임워크가 심술을 부린다고 느끼지만, 사실은 “네가 방금 그래프에 굽고 있는 그 분기는 이 입력에서만 맞다”고 미리 비명을 질러 주는 것이다. 조용히 틀린 그래프를 받는 것보다 백배 낫다. ↩