- 들어가며 — 80GB 카드 여덟 장에 7B 모델이 안 올라가는 이유
- 메모리 장부 — 파라미터당 16바이트, 그리고 그보다 큰 활성화
- 데이터 병렬과 ZeRO — 장부의 어느 항을 나눌 것인가
- 텐서 병렬 — 행렬을 쪼개고 매 레이어마다 통신한다
- 파이프라인 병렬 — 통신은 가장 싸고 버블은 가장 비싸다
- 시퀀스와 컨텍스트 병렬 — 네 번째 축
- 조합 고르기 — 크기와 GPU 수로 읽는 표
- 흔한 실패 모드
- 마치며 — 병렬화 선택은 대역폭 예산을 쓰는 일입니다
들어가며 — 80GB 카드 여덟 장에 7B 모델이 안 올라가는 이유
"7B 모델이면 bf16으로 14GB니까 80GB 카드 한 장에 넉넉하겠네요"라는 계산은 추론에서만 맞습니다. 같은 모델을 학습하려고 하면 카드 한 장으로는 시작조차 못 하고, 여덟 장을 붙여도 설정에 따라 여전히 터집니다.
이유는 학습이 파라미터 말고도 세 덩어리를 더 들고 있어야 하기 때문입니다. 그래디언트, 옵티마이저 상태, 그리고 활성화입니다. 이 네 덩어리 중 무엇을 어떻게 쪼개느냐가 병렬화 전략의 전부이고, 쪼갠 대가로 GPU들이 무엇을 얼마나 주고받아야 하는지가 성능의 전부입니다.
이 글은 그 두 축, 무엇을 쪼개는가와 얼마를 통신하는가로 네 가지 병렬화를 정리합니다. 숫자는 전부 직접 검산할 수 있는 형태로 제시합니다. 추론 쪽 메모리 계산은 LLM 추론 VRAM 계산법에서 이미 다뤘으니, 여기서는 학습만 봅니다.
메모리 장부 — 파라미터당 16바이트, 그리고 그보다 큰 활성화
상태 메모리는 파라미터당 16바이트
혼합정밀 학습에서 Adam 계열 옵티마이저를 쓸 때, 파라미터 하나가 차지하는 바이트는 다음과 같이 쪼개집니다.
| 항목 | 정밀도 | 파라미터당 바이트 |
|---|---|---|
| 파라미터 사본 | bf16 | 2 |
| 그래디언트 | bf16 | 2 |
| 마스터 가중치 | fp32 | 4 |
| Adam 1차 모멘트 | fp32 | 4 |
| Adam 2차 모멘트 | fp32 | 4 |
| 합계 | 16 |
이 16바이트는 ZeRO 논문이 세운 장부와 같습니다. 7B 모델이면 112GB, 70B면 1,120GB입니다. 80GB 카드 한 장으로는 7B도 학습이 안 된다는 결론이 여기서 나옵니다.
옵티마이저를 바꾸면 이 장부가 바뀝니다. SGD with momentum이면 fp32 상태가 마스터 가중치와 모멘텀 둘뿐이라 파라미터당 12바이트가 되고, 8비트 Adam이나 Adafactor를 쓰면 더 줄어듭니다. 다만 사전학습 규모에서 옵티마이저를 바꾸는 것은 메모리 문제가 아니라 수렴 문제이므로, 저정밀 옵티마이저는 파인튜닝에서 주로 씁니다.
활성화가 실제로 카드를 터뜨립니다
위 장부에는 활성화가 빠져 있습니다. 그런데 사전학습에서 OOM을 내는 주범은 대개 활성화입니다. Korthikanti 등의 Reducing Activation Recomputation in Large Transformer Models는 병렬화가 없을 때 트랜스포머 레이어 하나의 활성화 메모리를 시퀀스 길이 s, 배치 b, 히든 차원 h, 어텐션 헤드 수 a에 대해 s·b·h·(34 + 5as/h) 바이트로 정리합니다.
def act_gb(s: int, b: int, h: int, a: int, layers: int, mode: str) -> float:
sbh = s * b * h
per_layer = {
"none": sbh * (34 + 5 * a * s / h), # 전부 저장
"selective": sbh * 34, # 어텐션 행렬만 재계산
"full": sbh * 2, # 레이어 입력만 저장, 전부 재계산
}[mode]
return per_layer * layers / 1e9
s, b, h, a, L = 4096, 1, 4096, 32, 32 # 7B급 구성, 샘플 한 개
for mode in ("none", "selective", "full"):
print(f"{mode:10} {act_gb(s, b, h, a, L, mode):7.2f} GB / 샘플")
# none 104.13 GB / 샘플
# selective 18.25 GB / 샘플
# full 1.07 GB / 샘플
샘플 하나에 104GB입니다. 마이크로배치를 4로만 잡아도 416GB가 됩니다. 전부 재계산하면 1.07GB로 떨어지고, 대신 순전파를 한 번 더 도는 만큼 계산량이 대략 3분의 1 늘어납니다.
여기서 34와 함께 붙어 있는 5as/h 항을 보십시오. 이 항은 시퀀스 길이의 제곱에 비례합니다. s를 4K에서 32K로 늘리면 34 항은 8배가 되지만 5as/h 항은 64배가 됩니다. 긴 컨텍스트 학습이 갑자기 어려워지는 지점이 정확히 여기이고, 컨텍스트 병렬이 필요해지는 이유도 여기입니다.
데이터 병렬과 ZeRO — 장부의 어느 항을 나눌 것인가
가장 단순한 병렬화인 DDP는 모든 GPU가 위 16바이트를 통째로 복제해 갖습니다. 배치만 쪼개고 그래디언트를 all-reduce한 뒤 각자 같은 업데이트를 수행합니다. 구현은 가장 단순하지만 메모리는 전혀 절약되지 않습니다.
ZeRO는 이 복제를 단계적으로 걷어냅니다.
def per_gpu_state_gb(params_b: float, world: int, stage: int) -> float:
"""Adam 혼합정밀 기준, GPU 한 장이 드는 상태 메모리(GB, 10^9 기준).
stage 0 = DDP, 1 = 옵티마이저 분할, 2 = +그래디언트, 3 = +파라미터
활성화는 포함하지 않습니다."""
p = params_b * 1e9
param, grad, opt = 2 * p, 2 * p, 12 * p
if stage >= 1:
opt /= world
if stage >= 2:
grad /= world
if stage >= 3:
param /= world
return (param + grad + opt) / 1e9
for stage in (0, 1, 2, 3):
row = [round(per_gpu_state_gb(n, 8, stage), 1) for n in (7, 13, 70)]
print(f"stage {stage} world=8 7B/13B/70B -> {row} GB")
# stage 0 world=8 7B/13B/70B -> [112.0, 208.0, 1120.0] GB
# stage 1 world=8 7B/13B/70B -> [38.5, 71.5, 385.0] GB
# stage 2 world=8 7B/13B/70B -> [26.2, 48.8, 262.5] GB
# stage 3 world=8 7B/13B/70B -> [14.0, 26.0, 140.0] GB
읽는 법은 이렇습니다. 3단계는 16바이트 전체를 GPU 수로 나누므로, GPU를 늘리면 무한히 작아집니다. 반대로 1단계는 파라미터와 그래디언트 4바이트가 나눠지지 않고 남아, GPU를 아무리 늘려도 7B 모델은 28GB 아래로 내려가지 않습니다. GPU를 더 붙였는데 메모리가 안 줄어든다면 대개 ZeRO 단계가 낮은 것입니다.
PyTorch에서는 FSDP가 사실상 ZeRO 3단계에 해당합니다. 2026년 8월 2일 기준 PyTorch 공식 튜토리얼은 "FSDP1 is deprecated"라고 명시하고 torch.distributed.fully_shard 기반의 FSDP2를 권장합니다. 튜토리얼에 어느 버전부터 폐기인지는 적혀 있지 않으니, 이 부분은 사용하는 PyTorch 버전의 릴리스 노트에서 다시 확인하시는 편이 안전합니다. 확인한 PyTorch 안정 버전은 2.13.0이고 배포일은 2026년 7월 8일입니다.
# FSDP2 최소 형태 — 모듈을 감싸는 대신 제자리에서 샤딩합니다
import torch
from torch.distributed.device_mesh import init_device_mesh
from torch.distributed.fsdp import fully_shard, MixedPrecisionPolicy
mesh = init_device_mesh("cuda", (torch.distributed.get_world_size(),), mesh_dim_names=("dp",))
policy = MixedPrecisionPolicy(param_dtype=torch.bfloat16, reduce_dtype=torch.float32)
# 루트만이 아니라 서브모듈에도 적용해야 통신 단위가 잘게 쪼개집니다
for block in model.layers:
fully_shard(block, mesh=mesh, mp_policy=policy)
fully_shard(model, mesh=mesh, mp_policy=policy)
마지막 줄만 쓰고 루프를 빼먹으면 모델 전체가 하나의 통신 단위가 되어, 순전파 시작 시점에 파라미터 전체를 한 번에 모읍니다. 메모리 절감 효과가 사라지고 통신과 계산의 겹침도 없어집니다. FSDP를 켰는데 왜 안 줄어드느냐는 질문의 상당수가 이 실수입니다.
텐서 병렬 — 행렬을 쪼개고 매 레이어마다 통신한다
텐서 병렬은 하나의 행렬 곱을 여러 GPU가 나눠 계산합니다. Megatron-LM 논문의 고전적인 구성은 MLP의 첫 번째 행렬을 열 방향으로, 두 번째를 행 방향으로 쪼개서 중간에 통신 없이 이어 붙이고, 블록 끝에서만 all-reduce를 한 번 합니다. 어텐션 블록도 같은 방식으로 헤드 단위로 쪼갭니다.
결과적으로 트랜스포머 레이어 하나당 순전파 2회, 역전파 2회의 all-reduce가 발생합니다. 통신 대상은 파라미터가 아니라 활성화 텐서이고, 크기는 s·b·h입니다.
def tp_bytes_per_step(s, b, h, layers, tp, dtype_bytes=2):
"""텐서 병렬에서 GPU 한 장이 한 스텝에 옮기는 바이트(링 all-reduce 근사)."""
tensor = s * b * h * dtype_bytes
ring = 2 * (tp - 1) / tp # 링 all-reduce의 GPU당 이동량 계수
per_layer = 4 * tensor * ring # 순전파 2 + 역전파 2
return per_layer * layers
print(tp_bytes_per_step(4096, 1, 4096, 32, tp=8) / 1e9, "GB/step")
# 7.516192768 GB/step
스텝당 7.5GB를 옮겨야 합니다. NVLink 4를 쓰는 H100 SXM은 GPU당 양방향 900GB/s를 공표하므로 이 통신은 10밀리초 안팎에 끝납니다. 같은 통신을 400Gb/s 인피니밴드 포트 하나로 하면 50GB/s이므로 150밀리초가 됩니다. 텐서 병렬 그룹이 노드 경계를 넘는 순간 학습 속도가 한 자릿수 배로 떨어지는 이유가 이 산수입니다.
| 링크 | 공표 대역폭 | 위치 |
|---|---|---|
| NVLink 4 (H100 SXM) | GPU당 900 GB/s 양방향 | 노드 안 |
| NVLink 5 (B200) | GPU당 1.8 TB/s 양방향 | 노드 안 |
| PCIe Gen5 x16 | 약 128 GB/s 양방향 | NVLink 없는 노드 안 |
| InfiniBand NDR 포트 1개 | 400 Gb/s, 약 50 GB/s | 노드 간 |
위 값은 제조사 공표 수치이고 실측 유효 대역폭은 이보다 낮습니다. 정확한 값은 클러스터에서 nccl-tests의 all_reduce_perf를 직접 돌려 얻으시는 편이 낫습니다. 실무 규칙은 단순합니다. 텐서 병렬 차수는 노드 안 GPU 수를 넘기지 않습니다. 8-GPU 노드라면 TP는 8 이하입니다.
파이프라인 병렬 — 통신은 가장 싸고 버블은 가장 비싸다
파이프라인 병렬은 레이어를 스테이지로 잘라 서로 다른 GPU에 얹습니다. 스테이지 경계에서만 활성화를 주고받으므로 통신량이 압도적으로 적습니다. 32레이어를 4스테이지로 자르면 경계는 3개뿐이고, 각 경계에서 s·b·h 크기 텐서 하나가 점대점으로 오갑니다. 그래서 파이프라인은 노드 경계를 넘겨도 됩니다.
대가는 버블입니다. 첫 스테이지가 첫 마이크로배치를 처리하는 동안 나머지 스테이지는 놀고, 마지막 마이크로배치의 역전파가 끝날 때까지 앞 스테이지가 놉니다.
def bubble_ratio(stages: int, micro_batches: int) -> float:
return (stages - 1) / micro_batches
for m in (4, 8, 16, 32, 64):
print(f"micro_batches={m:3} 8스테이지 버블 {bubble_ratio(8, m):.1%}")
# micro_batches= 4 8스테이지 버블 175.0%
# micro_batches= 8 8스테이지 버블 87.5%
# micro_batches= 16 8스테이지 버블 43.8%
# micro_batches= 32 8스테이지 버블 21.9%
# micro_batches= 64 8스테이지 버블 10.9%
버블을 10퍼센트대로 누르려면 마이크로배치가 스테이지 수의 8배 이상 필요합니다. 그런데 마이크로배치를 늘리면 활성화 메모리가 늘고, 글로벌 배치 크기도 커져 학습률 스케줄에 영향을 줍니다. 이 세 값이 서로 묶여 있다는 점이 파이프라인 튜닝을 어렵게 만듭니다.
인터리브드 스케줄이나 DeepSeek-V3의 DualPipe처럼 버블을 줄이는 스케줄이 여럿 나와 있습니다. DualPipe는 순전파와 역전파의 계산-통신 구간을 서로 겹쳐 파이프라인 버블을 줄이는 방식으로, DeepSeek-V3 기술 보고서에 설명이 있습니다. PyTorch 쪽에서는 torch.distributed.pipelining이 표준 API로 제공됩니다.
시퀀스와 컨텍스트 병렬 — 네 번째 축
앞의 세 축은 전부 파라미터와 배치를 기준으로 쪼갭니다. 시퀀스 길이를 기준으로 쪼개는 축이 하나 더 있습니다. 용어가 혼용되므로 구분해서 쓰겠습니다.
- 시퀀스 병렬: Megatron 계열에서 텐서 병렬과 짝을 이루는 기법입니다. 텐서 병렬이 쪼개지 못하고 남겨 둔 LayerNorm과 dropout 구간의 활성화를 시퀀스 축으로 나눠 중복 저장을 없앱니다. 텐서 병렬과 같은 그룹 안에서 동작합니다.
- 컨텍스트 병렬: 어텐션 계산 자체를 시퀀스 축으로 나눕니다. 각 GPU가 시퀀스의 일부만 들고, 키와 값을 링 형태로 돌려가며 부분 어텐션을 누적합니다. 링 어텐션 계열이 여기 속합니다. DeepSpeed의 Ulysses는 비슷한 목표를 all-to-all 통신으로 달성하는 다른 구현입니다.
컨텍스트 병렬을 쓰는 기준은 명확합니다. 시퀀스 길이를 늘렸는데 마이크로배치를 1로 낮추고 활성화를 전부 재계산해도 여전히 OOM이라면, 그때가 컨텍스트 병렬을 켤 시점입니다. Llama 3 405B 학습이 4D 병렬화(TP, CP, PP, DP)를 쓴 이유도 128K 컨텍스트 확장 단계 때문입니다.
MoE 모델을 학습한다면 여기에 전문가 병렬이 하나 더 붙습니다. 전문가를 GPU에 나눠 얹고 토큰을 all-to-all로 라우팅하는 방식입니다. 통신 패턴이 all-reduce가 아니라 all-to-all이라 네트워크 토폴로지에 훨씬 민감하고, 로드 밸런싱이 무너지면 특정 GPU만 일하고 나머지가 기다립니다.
조합 고르기 — 크기와 GPU 수로 읽는 표
실무에서 결정할 것은 "어떤 병렬화가 좋은가"가 아니라 "이 규모에서 어떤 순서로 쌓는가"입니다. 일반적으로 통용되는 순서는 통신이 비싼 축을 안쪽에 배치하는 것입니다. TP를 노드 안에, CP를 그다음, PP를 노드 간에, DP를 가장 바깥에 둡니다.
| 모델 규모 | GPU 수 | 시작 조합 | 근거 |
|---|---|---|---|
| 1B~8B | 1~8 | ZeRO-2 또는 FSDP2 | 파라미터가 카드에 들어감. TP는 통신만 추가 |
| 8B~30B | 8~64 | FSDP2 + 활성화 체크포인팅 | 단일 축이라 디버깅이 쉬움 |
| 30B~100B | 64~512 | TP 2~8(노드 안) × FSDP2 | TP를 NVLink 안에 가둠 |
| 100B 이상 | 512~ | TP 8 × PP 2~16 × DP | PP로 노드 간 통신량을 낮춤 |
| 컨텍스트 32K 이상 | 무관 | 위 조합 + CP | 활성화의 시퀀스 제곱 항 때문 |
| MoE | 무관 | 위 조합 + EP | 전문가를 분산, all-to-all 감수 |
이 표는 출발점이지 정답이 아닙니다. 실제로는 원하는 글로벌 배치 크기가 조합을 강하게 제약합니다. 글로벌 배치는 마이크로배치 × 그래디언트 누적 × 데이터 병렬 차수인데, DP 차수는 전체 GPU 수를 TP, PP, CP 차수로 나눈 값으로 고정됩니다. TP를 8에서 4로 바꾸면 DP가 두 배가 되고 글로벌 배치도 두 배가 되어 학습률을 다시 잡아야 합니다. 병렬화 설정 변경은 순수한 인프라 작업이 아니라 하이퍼파라미터 변경입니다.
흔한 실패 모드
현장에서 반복해서 보는 것들만 적습니다.
- TP 그룹이 노드를 넘음. 16-GPU TP를 8-GPU 노드 두 대에 걸치면 속도가 무너집니다.
nvidia-smi topo -m으로 실제 링크를 확인하고, 런처가 랭크를 어떤 순서로 노드에 배치하는지 확인하십시오. - FSDP를 루트에만 적용. 앞에서 본 실수입니다. 서브모듈 단위로 감싸야 통신이 계산과 겹칩니다.
- 활성화 체크포인팅을 켜고도 OOM. 재계산 대상이 아닌 임베딩과 출력 레이어, 그리고 로짓 텐서가 남습니다. 어휘 크기가 큰 모델에서 로짓은 s·b·V 크기라 단독으로 수십 GB가 됩니다. 청크 단위 손실 계산으로 나눠 처리해야 합니다.
- 마이크로배치를 줄여 OOM을 피하고 버블을 방치. PP 환경에서 마이크로배치를 줄이면 메모리는 해결되지만 버블이 커져 처리량이 반토막 납니다. 둘은 같은 손잡이의 양쪽입니다.
- 그래디언트 누적과 정규화 불일치. 누적 스텝 수로 손실을 나누는 위치를 틀리면 실효 학습률이 달라집니다. 병렬 구성이 바뀔 때마다 짧은 런으로 손실 곡선이 이전 구성과 겹치는지 확인하십시오.
- NCCL 타임아웃을 늘려 문제를 덮음. 타임아웃이 나면 대개 특정 랭크가 죽었거나 느린 노드가 하나 있는 것입니다.
NCCL_DEBUG=INFO로 어느 랭크가 멈췄는지 먼저 봐야 합니다.
마치며 — 병렬화 선택은 대역폭 예산을 쓰는 일입니다
네 가지 병렬화는 서로 대체재가 아니라, 각각 다른 자원을 소모하는 도구입니다. ZeRO와 FSDP는 메모리를 대역폭으로 바꾸고, 텐서 병렬은 메모리를 노드 안 대역폭으로 바꾸며, 파이프라인은 메모리를 유휴 시간으로 바꾸고, 컨텍스트 병렬은 시퀀스 축의 메모리를 링 통신으로 바꿉니다.
그래서 설정을 고르기 전에 해야 할 일은 프레임워크 문서를 읽는 것이 아니라 클러스터의 대역폭 지도를 그리는 것입니다. 노드 안 GPU 간 실측 대역폭, 노드 간 실측 대역폭, 그리고 그 둘의 비율. 이 세 숫자를 알고 나면 위의 표는 거의 자동으로 결정됩니다. 다음 글에서는 이 결정을 실제로 코드로 옮겨 주는 학습 프레임워크 스택을 계보별로 정리합니다.
현재 단락 (1/105)
"7B 모델이면 bf16으로 14GB니까 80GB 카드 한 장에 넉넉하겠네요"라는 계산은 추론에서만 맞습니다. 같은 모델을 학습하려고 하면 카드 한 장으로는 시작조차 못 하고, 여...