지난 글까지는 전부 카드 한 장 안에서 벌어지는 이야기였다. 모델이 그 안에 들어간다는 전제가 깔려 있었는데, 그 전제가 깨지는 순간부터 완전히 다른 문제가 시작된다.
카드 한 장에 안 들어갈 때
가중치가 차지하는 자리는 어림하기 쉽다. 파라미터 수에 값 하나의 바이트 수를 곱하면 된다. fp16이면 파라미터당 2바이트다.
- 80억 파라미터 → 약 16GB. 80GB 카드에 넉넉히 들어간다
- 700억 파라미터 → 약 140GB. 80GB 카드 한 장에 안 들어간다
- 4,000억 파라미터 → 약 800GB. 카드 열 장으로도 모자란다
여기에 KV 캐시 자리까지 남겨야 하므로 실제 여유는 더 빠듯하다. 양자화로 파라미터당 바이트를 줄이는 것이 첫 번째 답이지만, 그것으로도 부족한 크기가 있고 품질을 따로 재야 한다는 부담도 있다.
그다음 답이 여러 장에 나눠 담는 것이고, 나누는 축이 셋이다.
| 축 | 무엇을 나누나 | 성격 |
|---|---|---|
| 텐서 병렬 | 층 하나의 가중치 행렬을 조각내 여러 카드가 나눠 든다 | 층마다 통신, 지연이 준다 |
| 파이프라인 병렬 | 층들을 그룹으로 잘라 카드마다 다른 층을 맡는다 | 통신이 적고 스케줄링이 까다롭다 |
| 데이터 병렬 | 모델을 통째로 복제하고 요청을 나눠 받는다 | 통신 없음, 메모리 문제는 안 풀린다 |
셋은 배타적이지 않고 대개 함께 쓴다. 이번 글은 텐서 병렬이고, 파이프라인 병렬은 따로 다룬다. 데이터 병렬은 모델이 이미 한 장에 들어갈 때 처리량을 늘리는 방법이라 성격이 다르다.
세로로 자르고 가로로 자른다
트랜스포머 블록의 MLP 부분을 예로 보자. 하는 일은 이렇다.
은 차원을 네 배쯤 키우는 행렬이고 는 도로 줄이는 행렬이다. 이 둘이 파라미터의 3분의 2쯤을 차지하므로 여기를 나누는 것이 핵심이다.
소박하게 생각하면 두 행렬을 아무렇게나 반씩 잘라 나눠 들면 될 것 같지만, 자르는 방향에 따라 필요한 통신량이 완전히 달라진다. 텐서 병렬의 요령은 첫 행렬은 세로로, 둘째 행렬은 가로로 자르는 것이다.
그러면 전체 결과가 이렇게 나온다.
각 항이 카드 하나가 혼자 계산할 수 있는 형태다. 마지막에 둘을 더하기만 하면 된다.
GELU가 중간에 끼어도 괜찮다는 점이 이 방식의 핵심이다. GELU는 원소마다 따로 계산하는 함수라, 내가 든 열에 해당하는 값들만 있으면 옆 카드 값이 없어도 정확히 계산된다. 만약 을 가로로 잘랐다면 각 카드가 부분합만 들고 있게 되어 GELU 전에 한 번 더 합쳐야 했다. 통신이 두 배가 된다는 뜻이다.
마지막에 둘을 더해 모든 카드가 같은 결과를 갖게 하는 연산을 All-Reduce라고 부른다 — 여러 장비의 값을 모아 하나로 합치고 그 결과를 전부에게 돌려주는 집합 통신이다.
어텐션은 헤드로 나눈다
어텐션 쪽은 더 자연스럽다. 멀티헤드 어텐션은 애초에 헤드마다 독립적으로 계산하고 마지막에 이어 붙이는 구조라, 헤드를 나눠 맡기면 그대로 병렬이 된다. 32개 헤드를 카드 두 장이 16개씩 든다.
이어 붙인 뒤 곱하는 출력 행렬은 MLP의 와 같은 자리라 가로로 자른다. 그래서 여기도 All-Reduce 한 번으로 끝난다.
여기서 실무 제약이 하나 나온다. 헤드 수가 카드 수로 나눠떨어져야 한다. 헤드가 32개면 2, 4, 8, 16, 32장까지 되고 3이나 5는 안 된다. 텐서 병렬 크기를 2의 거듭제곱으로 잡는 관행이 여기서 왔다.
요즘 모델이 쓰는 GQA처럼 KV 헤드 수가 훨씬 적은 구조에서는 조건이 더 빡빡하다. KV 헤드가 8개인데 카드가 16장이면 나눌 수가 없어서, 엔진이 KV 헤드를 복제해 들고 있는 식으로 처리한다. 그만큼 캐시 메모리 이득이 줄어든다.
통신이 얼마나 붙는가
블록 하나에 All-Reduce가 두 번이다 — 어텐션 뒤 한 번, MLP 뒤 한 번. 층이 80개면 토큰 한 걸음에 160번이다.
한 번에 오가는 크기는 이번 걸음의 토큰 수 × 은닉 차원 × 값의 바이트 수다. 동시 요청 32개에 은닉 차원 8,192, fp16이면 한 번에 512KB쯤이고, 160번이면 걸음마다 수십 MB가 카드 사이를 오간다.
이 양 자체보다 중요한 것은 각 통신이 계산을 멈춰 세운다는 점이다. All-Reduce가 끝나야 다음 층으로 갈 수 있으므로, 통신이 느리면 GPU가 그 시간 동안 논다.
| 연결 | 어림 대역폭 | 결과 |
|---|---|---|
| NVLink(한 서버 안) | 수백 GB/s | 통신 시간이 계산에 묻힌다 |
| PCIe(한 서버 안, NVLink 없음) | 수십 GB/s | 걸음마다 눈에 띄게 붙는다 |
| 이더넷(서버 사이) | 그보다 훨씬 적고 지연도 크다 | 텐서 병렬로 쓸 자리가 아니다 |
그래서 텐서 병렬은 한 서버 안에서만 쓴다는 것이 사실상의 규칙이다. 서버를 넘어야 하는 크기라면 그 경계는 파이프라인 병렬로 넘고, 서버 안을 텐서 병렬로 채운다.
카드를 늘려도 그만큼 안 빨라진다
텐서 병렬은 메모리를 늘리는 수단이면서 지연을 줄이는 수단이기도 하다. 층 하나의 계산이 반으로 나뉘니 걸음이 빨라진다. 그런데 두 배로 빨라지지는 않는다.
- 통신은 카드가 늘수록 더 많아진다. All-Reduce 참여자가 늘기 때문이다
- 카드마다 맡는 조각이 작아지면 행렬 곱이 GPU를 다 못 채운다. 커널 실행 비용 같은 고정 비용의 비중이 커진다
- 나눌 수 없는 부분(정규화, 활성 함수의 일부, 스케줄링)은 그대로 남는다
실무에서 자주 보는 모습이 이렇다. 2장에서는 1.7~1.9배쯤 붙고, 4장에서는 3배 언저리, 8장에서는 5배를 넘기기 어렵다. 정확한 값은 모델·연결·부하에 따라 다르므로 자기 환경에서 재 봐야 한다.
여기서 갈리는 판단이 있다.
- 카드 한 장에 모델이 들어간다면 텐서 병렬을 쓰는 이유는 지연 하나뿐이다. 처리량만 필요하면 카드마다 독립 인스턴스를 띄우는 편(데이터 병렬)이 거의 항상 낫다. 통신이 0이기 때문이다
- 안 들어간다면 선택의 여지가 없다. 필요한 최소 장수를 쓴다
- 대화형 서비스라 첫 토큰이 급하다면 처리량을 조금 손해 보더라도 장수를 늘려 지연을 줄이는 것이 맞을 수 있다
실무에서 만지는 지점
vLLM 계열에서는 옵션 하나다.
vllm serve meta-llama/Llama-3.3-70B-Instruct \
--tensor-parallel-size 4 \
--gpu-memory-utilization 0.90 \
--max-model-len 8192
확인할 것들이 있다.
- 장수는 2의 거듭제곱으로. 헤드 수 나눠떨어짐 조건 때문이다
- 가중치만 겨우 들어가는 장수는 피한다. 남은 자리가 KV 캐시 몫이라, 딱 맞게 잡으면 동시 처리량이 거의 안 나온다. 메모리 튜닝에서 이 계산을 먼저 한다
- NVLink가 있는지 확인한다.
nvidia-smi topo -m으로 카드 사이 연결을 본다. PCIe로만 붙어 있으면 장수를 늘려도 이득이 금방 사라진다 - 한 서버를 넘지 않는다. 넘어야 하면 파이프라인 병렬과 섞는다
- 출력이 미세하게 달라질 수 있다. 부분합을 더하는 순서가 장수에 따라 바뀌므로 부동소수점 결과가 완전히 같지는 않다. 회귀 테스트를 정확한 문자열 일치로 짜 두면 장수를 바꿀 때마다 깨진다
정리
- 가중치 자리는 파라미터 수 × 값의 바이트 수다. 700억 파라미터를 fp16으로 두면 140GB라 80GB 카드 한 장에 안 들어간다
- 나누는 축이 셋이다 — 텐서 병렬(층 하나를 조각냄), 파이프라인 병렬(층을 그룹으로 나눔), 데이터 병렬(모델을 복제)
- 텐서 병렬은 첫 행렬을 세로로, 둘째 행렬을 가로로 잘라 카드마다 완결된 항을 계산하게 만든다
- GELU가 원소별 함수라 중간에 통신이 필요 없다. 자르는 방향을 반대로 하면 통신이 두 배가 된다
- 어텐션은 헤드를 나눠 맡긴다. 그래서 헤드 수가 카드 수로 나눠떨어져야 하고, 장수를 2의 거듭제곱으로 잡는 관행이 여기서 나왔다
- 블록마다 All-Reduce가 두 번이다. 층이 80개면 걸음마다 160번이고, 각각이 계산을 멈춰 세운다
- 그래서 연결이 결정적이다. NVLink면 묻히고 PCIe면 붙으며 이더넷이면 쓸 자리가 아니다. 텐서 병렬은 한 서버 안에서만 쓴다
- 카드를 두 배 늘려도 두 배 안 빨라진다. 통신이 늘고, 조각이 작아져 GPU를 다 못 채우기 때문이다
- 모델이 한 장에 들어가고 처리량만 필요하면 독립 인스턴스를 여럿 띄우는 편이 거의 항상 낫다
- 가중치만 겨우 들어가는 장수는 KV 캐시 자리를 남기지 않아 동시 처리량이 안 나온다
읽어주셔서 감사합니다. 😊

