분산 서빙과 통신

4,784 단어·10 분·원문(.md)

학습 목표 #

  • 집합 통신 연산이 실제로 무엇을 주고받는지를 계산하고 all-reduce, all-to-all의 전송량을 텐서크기와 gpu수에서 유도하고 그 값이 지연에 얼마나 영향을 미치는지 환산한다.
  • nvidia-smi topo -m 출력을 읽고 어느 gpu 쌍이 빠른 경로로 붙어 있는지 판정한다 그 결과로 병렬화 배치 값을 정할 수 있는 방법을 알아보자
  • NCCL이 링과 트리중 무엇을 골랐는가? 왜 그걸 골랐는가 같은 개념을 알아볼 것이다
  • 노드 간 kv 전송이 왜 별도인지 문제를 이해하고 전송 백엔드를 골라본다.
  • EP를 노드 안에 가두려는 이유를 전송량 계산 관련하여 설명해본다.

대역폭 절벽 #

대역폭 계층과 노드 경계 #

gpu에서 데이터를 옮길 대 목적지가 멀어질 수록 대역폭이 계단식으로 떨어지게 된다.

                        대역폭            비고
GPU 내부 HBM           3,350~8,000 GB/s   자기 메모리
다이 간 NV-HBI         10,000 GB/s        Blackwell 듀얼 다이
NVLink 5               1,800 GB/s         같은 노드 안 GPU끼리
PCIe 5.0 x16              64 GB/s         CPU ↔ GPU
InfiniBand NDR            50 GB/s         노드 간 (400Gb/s 포트)
이더넷 100GbE             12.5 GB/s       노드 간

GPU끼리 데이터를 주고받는 경로만 보면 노드 안이 1,800 GB/s 노드 밖이 50GB/s로 36배 차이가 난다 PCIe도 64GB/s 수준이라 노드를 벗어나는 순간 어느 경로를 타든 느리다.

인접한 경로끼리 배수를 내보면 노드 경계에서 크게 벌어지는걸 볼 수 있다

HBM → NVLink         2~4배
NVLink → PCIe        28배      ← 노드 경계
PCIe → InfiniBand    1.3배

노드 경계에서 36배 지연 증가 #

텐서 병렬로 예를 들어보자 레이어마다 all-reduce가 한 번씩 일어나고, 8 gpu에서 100mb 텐서를 다룬다고 해보자

전송량은 뒤에서 유도하겠지만 결과만 먼저 사용하면 gpu 하나가 175mb를 주고받게 된다.

NVLink 5 (1,800 GB/s)      175 MB ÷ 1,800 GB/s = 0.097 ms
InfiniBand NDR (50 GB/s)   175 MB ÷    50 GB/s = 3.5 ms

80레이어 모델이면 토큰 하나당 이게 80번 반복되는것이다

NVLink 경로     0.097 × 80 =  7.8 ms
InfiniBand 경로 3.5   × 80 = 280 ms

디코드 한 스텝이 수밀리초인 상황에서 통신에 280ms를 쓴다? 이러면 계산은 의미가 없어지고 텐서 병렬을 노드 안에 가두라는 규칙이 왜 중요한지 여기서 알 수 있게 되는것이다.

지침 - 통신 빈도 기준 병렬화 배치 #

어쨋든 핵심은 통신이 잦은 병렬화는 노드 안에 두면 된다.

통신이 잦음    TP    레이어마다 all-reduce      → NVLink 안쪽
              EP    MoE 레이어마다 all-to-all   → 가능하면 NVLink 안쪽

통신이 드묾    PP    구간 경계에서 한 번         → 노드를 넘어도 됨
              DP    어텐션 레이어는 통신 없음    → 노드를 넘어도 됨

GPU 8장 짜리 서버 한대에서 NVLink 배선이 그 섀시 안에서 끝나기 때문에 9번째 gpu에서 다른 섀시에 있고 거기로 가려면 InfiniBand를 타야한다

GB200 NVL72같은 랙 규모 구성은 이 범위를 넓혀 랙 안에 72장을 NVSwitch로 전부 묶어서 72장 중 언 쌍이든 NVLink로 직접 통신한다.

일반 8장 서버    NVLink로 묶이는 범위  8장     9번째부터 InfiniBand
NVL72 랙         NVLink로 묶이는 범위  72장    73번째부터 InfiniBand

집합 통신 #

집합 통신의 정의 #

여러 GPU가 함께 참여해서 하나의 결과를 만드는 통신이다 한쪽이 보내고 한쪽이 받는 점대점 통신과 달리 모든 참여자가 동시에 보내고 받게 된다

병렬화마다 쓰는 연산이 다른데 아래와 같다

연산            무엇을 하나                          쓰는 곳
all-reduce      각자의 값을 전부 더해 모두가 같은 결과   TP
all-gather      각자의 조각을 모아 모두가 전체를 보유    TP의 열 분할, DP
reduce-scatter  전부 더한 뒤 각자 자기 몫만 보유        all-reduce의 전반부
all-to-all      각자가 각자에게 서로 다른 데이터 전송    EP
broadcast       한 GPU의 데이터를 전부에게 복사         가중치 배포

all-reduce #

TP에서 가장 자주 쓰이는데 각 gpu가 부분합을 갖고있고 전부 더해서 모두가 같은 완성값을 갖게 만든다

시작
  GPU0: [a0]   GPU1: [a1]   GPU2: [a2]   GPU3: [a3]

끝
  GPU0: [S]    GPU1: [S]    GPU2: [S]    GPU3: [S]
  S = a0 + a1 + a2 + a3

TP로 행렬을 잘랐을때 각 gpu가 계산한 것은 부분합이라 이 연산을 거쳐야 완성된다

all-to-all #

각 gpu가 각 gpu에게 서로 다른 데이터를 보낸다

시작
  GPU0: [→G0][→G1][→G2][→G3]     각 칸이 다른 목적지용
  GPU1: [→G0][→G1][→G2][→G3]
  GPU2: [→G0][→G1][→G2][→G3]
  GPU3: [→G0][→G1][→G2][→G3]

끝
  GPU0: [G0←][G1←][G2←][G3←]     전부에게서 받은 것
  ...

MoE에서 토큰 담당 전문가에게 보낼 때 이 패턴이 나오는데 어느 토큰이 어느 전문가로 갈지 라우터가 정하므로 목적지가 제각각이다

all-reduce vs all-to-all #

TP는 all reduce, EP는 all to all을 쓰고 선택은 병렬화 방식이 정하지만 다만 성격이 달라서 운영에서 신경 쓸 지점이 다르다.

all-to-all이 더 나은 부분은

전송량       all-reduce  GPU당 약 2S 이동
            all-to-all  GPU당 약 S 이동            절반

참여 범위    all-reduce  전원이 동기화. 한 랭크가 늦으면 전부 대기
            all-to-all  보낼 곳과 받을 곳만 관여

옮기는 것    all-reduce  계산 결과. 모두가 같은 값을 갖게 만들어야 함
            all-to-all  토큰. 필요한 곳에만 보내면 끝

전문가 256개 중 토큰당 8개만 쓰는 구조인데 전문가를 잘라서 모든 gpu에 흩어놓고 all-reduce로 합치면 32장 전부가 계산에 동원된다 전문가를 통째로 배정하고 all to all로 토큰을 보내면 그 8개를 가진 gpu만 일을 하고 나머지는 다른 토큰을 처리한다 일부만 켜서 계산을 아끼자는 MoE 설계가 이 연산 덕에 유지된다.

문제 - 두 연산의 성격차이 #

전송량만 보면 all to all이 더 가볍다. 근데 여기서 고민해야할 포인트는

크기 예측     all-reduce  텐서 크기 고정. 미리 버퍼를 잡고 스케줄 가능
             all-to-all  어느 GPU에 얼마나 갈지 라우터가 정할 때까지 모름

대칭성        all-reduce  모두가 같은 양을 주고받음
             all-to-all  인기 전문가를 가진 GPU에 몰림. 가장 느린 링크가 전체를 결정

계산과 겹치기  all-reduce  겹치기 쉬움
             all-to-all  라우터 출력을 기다려야 시작 가능

통신량이 많아서가 아니라 예측 불가능하고 불균형함의 숙제가 존재해서 이 문제를 해결해야한다.

해법 #

  1. 크기 예측이 안되는 문제: 전용 통신 라이브러리가 다루는데 DeepEP같은 구현 라우터 출력이 나오는 즉시 전송을 시작하도록 커널을 짜고 서버 내부는 NVLink로 서버 간은 RDMA로 경로를 나ㅝ 동시에 흘린다 범용 집합 통신 라이브러리로는 이 패턴을 표현할 수 없어 별도 구현ㅇㅣ 나왔다.
  2. 부하가 기우는 문제: 전문가 배치를 다시 계산해 완화한다 라우팅 통곌ㄹ 보고 인기 전문가를 여러 gpu에 복제하면 그쪽으로 갈 토큰이 나뉜다 SGLang --enable-eplb가 이 기능이다.
--enable-eplb --eplb-algorithm deepseek --ep-num-redundant-experts 32

전문가 슬롯이 256개에서 288개로 늘어 가중치 메모리가 12.5%로 증가하는 대신 편중이 완화되어 가장 느린 gpu를 기다리는 시간을 줄인다.

  1. 계산과 겹치기 어려운 문제는 배치를 쪼개서 우회한다 배치를 둘로 나눠 한쪽이 통신하는 동안 다른쪽이 계산하면 라우터 출력을 기다리는 구간이 다른 배치의 계산으로 채워진다 배치가 충분히 클때 사용할 수 있는방법
쪼개지 않으면   [라우터][all-to-all 대기][전문가 계산][all-to-all 대기]
쪼개면          배치A [라우터][통신    ][계산    ][통신    ]
               배치B        [라우터][통신    ][계산    ][통신 ]
                            ↑ 서로의 대기 구간을 메움

링과 트리 #

단일 GPU 집중 방식의 병목 #

all-reduce를 가장 단순하게 구현하면 모두가 0번 gpu에 데이터를 보내고 0번이 전부 더해서 다시 뿌리는 것이다

N개 GPU, 버퍼 크기 S

GPU0이 받는 양   (N-1) × S
GPU0이 보내는 양 (N-1) × S

N=8, S=100MB → GPU0 혼자 1.4GB를 주고받음
              나머지 7장은 각자 200MB

0번 gpu의 링크가 병목이 될거고 gpu를 늘리면 오히려 나빠질것이다. (N이 커지니가)

링 알고리즘 #

GPU를 논리적 원형으로 배치하여 데이터를 N조각으로 쪼개 옆으로만 넘겨 두 바퀴를 돌게 한다

1단계 reduce-scatter (N-1회)
  각 GPU가 오른쪽 이웃에게 조각 하나씩 보내며 누적
  끝나면 각 GPU가 서로 다른 조각의 완성값을 하나씩 보유

2단계 all-gather (N-1회)
  완성된 조각을 다시 한 바퀴 돌려 모두에게 배포

각 단계에서 gpu 하나가 보내는 양은 조각 하나 즉 S/N 이다

총 전송량 = 2(N-1) 단계 × S/N = 2(N-1)/N × S

N=8,  S=100MB → 175 MB
N=32, S=100MB → 194 MB

N이 커져도 2S에 수렴한다 단일 gpu 집중 방식이 (N-1)S와 달리 gpu수에 거의 무관해졌다. gpu가 많아진다고 특정 gpu에 병목이 늘지 않는다는것

소형 메시지에서의 링 열세 #

링의 전송량은 gpu 수와 거의 무관하지만 여전히 단계 수는 gpu수에 비례하게 된다

reduce-scatter에 N - 1회 all-gather에서 N-1회 총 2(N-1)

N=8   → 14단계
N=32  → 62단계

각 단계는 앞 단계가 끝나야 시작된다 옆 gpu에서 받을 조각을 누적해서 닷 ㅣ옆으로 넘기는 구조라 순소를 건너뛸수없어 그래서 단계마다 고정비용이 붙는다 붙는다

한 단계의 고정 비용
  커널 실행 준비
  이웃과의 동기화
  링크 전파 지연
  → 합쳐서 NVLink 기준 대략 2μs

전체 소요시간을 두 항으로 나눠보면 이렇다

소요 시간 = 단계 비용 + 전송 비용
          = 2(N-1) × 2μs  +  2(N-1)/N × S ÷ 1,800 GB/

N=32에 버퍼 크기를 바꿔가며 넣어보자

버퍼 크기 S    단계 비용    전송 비용    지배하는 쪽
    64 KB      124 μs      0.07 μs     단계 비용 (1,800배)
     1 MB      124 μs      1.1  μs     단계 비용 (110배)
   100 MB      124 μs      108  μs     비슷
     1 GB      124 μs     1,078 μs     전송 비용 (8.7배)

버퍼가 작으면 실제로 데이터를 옮기는 시간보다 단계를 넘기는 시간이 훨신 크다

64kb를 옮기는데 62번의 동기화를 거치면서 통신의 99퍼를 대기하게 된다.

트리 알고리즘 해법 #

단계 수를 줄이려면 옆으로만 넘기는 구조를 버려야하고 트리는 gpu를 이진 트리로 배치하고 위 아래로 오간다.

reduce 단계 (아래에서 위로)

        GPU0
       ╱    ╲
    GPU1    GPU2
    ╱  ╲    ╱  ╲
  G3   G4  G5   G6

  깊이 3 → 3단계면 GPU0에 전부 모임
  같은 깊이의 GPU들은 동시에 진행

broadcast 단계 (위에서 아래로)
  같은 트리를 반대 방향으로 3단계

이진 트리의 깊이는 log₂N으로 링이 이웃을 하나씩 거치는동안 트리는 절반씩 줄여 올라가게 된다.

링   단계 2(N-1)      N=32 → 62단계 → 124 μs
트리 단계 2log₂N      N=32 → 10단계 →  20 μs

앞의 표에서 64KB일 때 링이 124μs를 쓰는데, 트리는 20μs로 끝이난다

그러나 문제점 하나 트리는 링크를 다 못사용함 #

트리가 항상 나은것은 아니다 링은 매 단계에서 모든 gpu가 동시에 보내고 받으므로 링크를 전부 활용한다

트리는 같은 깊이의 gpu만 움직이므로 일부 링크가 놀게 된다

링    32장이 각자 오른쪽 이웃에게 전송     32개 링크 전부 활성
트리  깊이 1에서는 2개 링크만 활성        나머지는 대기
      깊이 2에서는 4개, 깊이 3에서는 8개

그래서 버퍼가 커지면 전송 비용이 지배하는데 그 구간에서는 링크를 다 쓰는 링이유리하다 NCCL은 이중 이진 트리로 이 손실을 절반쯤 회복하지만 링을 완전히 따라잡지는 못한다

버퍼가 작다 단계 비용이 지배된다 -> 트리
버퍼가 크다 전송 비용이 지배된다 -> 링

NCCL은 메시지 크기를 보고 알아서 고르는데 경계는 대략 256KiB이고, 위 표에서 단계 비용과 전송 비용이 뒤집히는 지점 근처다

실측 대역폭 #

DGX H100 8 GPU 기준 실측값

링 (NVLink 직결)        약 700 GB/s
NVSwitch 전이분할 활용   약 900 GB/s

링 700 GB/s는 인접 gpu끼리 주고받는 구조에서 나온값이다 NVSwitch는 모든 쌍을 동시에 연결할 수 있는데 링은 그 능력을 다 쓰지 못한다 스위치를 활용하는 알고리즘으로 900 GB/s까지 나온다

측정은 nccl-tests로 한다.

$ ./build/all_reduce_perf -b 8 -e 1G -f 2 -g 8
#  size(B)  time(us)  algbw(GB/s)  busbw(GB/s)
   1048576    58.2      18.02        31.53
  16777216   238.4      70.38       123.16
 268435456  3021.7     121.36       212.38
  • -b 8: 시작 버퍼 크기 8바이트
  • -e 1G: 끝 버퍼 크기 1gb
  • -f 2: 크기를 두배씩 늘려가며 측정
  • -g 8: gpu 8장 사용

두 대역폭 열의 차이를 알아둬야한다

algbw   버퍼 크기 ÷ 시간. 사용자가 체감하는 값
busbw   실제 링크를 흐른 양 ÷ 시간. 하드웨어 활용도
        all-reduce는 busbw = algbw × 2(N-1)/N

링크 이론값과 비교할 대상은 busbw로 algbw만 보면 낮아 보여도 정상인 경우가 많다.


NCCL과 토폴리지 #

NCCL(Nvidia Collective Communications Library)은 집합통신을 gpu에서 실행하는 라이브러리다. pytorch에 init_process_group(backend='nccl') 이 이걸 쓴다

토폴로지 탐지   어느 GPU가 어떤 링크로 붙어 있는지 조사
알고리즘 선택   메시지 크기와 토폴로지를 보고 링/트리 중 선택
커널 실행      통신을 GPU 커널로 수행. SM을 일부 소비

세 번째가 서빙에서 의미를 가지는데. 통신이 SM을 먹으므로 계산과 자원을 다툰다 통신중 계산이 완전히 멈추지는 않지만 서로 느려진다

선택된 알고리즘 조회법 #

$ NCCL_DEBUG=INFO python train.py 2>&1 | grep -E "NCCL INFO (Ring|Trees|Channel|NET)"
NCCL INFO Channel 00/04 : 0 1 2 3 4 5 6 7
NCCL INFO Ring 00 : 7 -> 0 via NVLINK
NCCL INFO Trees [0] 1/-1/-1->0->-1
NCCL INFO NET/IB : Using [0]mlx5_0:1/RoCE
  • Ring 00 : 7 -> 0 via NVLINK: 링 구성과 각 홉이 쓰는 링크 via P2P, via SHM 같이 나오면 같은 서버에 gpu인데도 NVLink를 못 쓰는 상태다. 카드가 NVLink 브리지 없이 꽂혀있거나 컨테이너에 일부 gpu만 주입되어 링을 못 만드는 경우다
  • Channel 00/04: 병렬 채널 수. 많을수록 링크를 잘 채운다
  • NET/IBL Using [0]mlx5_0: 서버간 경로와 사용중인 NIC, NET/Socket이 나오면 InfiniBand를 못찾아 이더넷으로 떨어진 것이다.

환경 변수 상시 고정 #

NCCL은 토폴리지를 보고 알아서 고르는데 다음 변수들이 진단용으로 쓰인다

  • NCCL_ALGO=Ring: 알고리즘 강제. 링으로
  • NCCL_P2P_DISABLE=1: P2P 비활성, 문제 격리용
  • NCCL_IB_DISABLE=1: InifniBand 비활성, 이더넷으로 떨어짐
  • NCCL_DEBUG=INFO: 로그 출력. 성능에 영향은 없

상시 설정에 필요한 경우는 둘로

  • NCCL_SOCKET_IFNAME=eth0: 서버에 인터페이스가 여러개고 NCCL이 관리용 인터페이스를 골라버릴때 지정
  • NCCL_IB_HCA=mlx5_0,mlx5_1: NIC가 여러개인데 일부만 쓸때 명시
MLOps/distribute_serving.md