B300 GPU 16장에서 2.4조 파라미터 MoE, 병목은 어디였나
지난 글에서 GPU 한 장 위의 디코드가 메모리 대역폭은 89.7%를 쓰면서 연산은 0.22%밖에 안 쓴다는 걸 봤다. 디코드가 연산이 아니라 대역폭에 묶여 있다는 이야기였다.
다만 그건 GPU 한 장 안에서 본 결과다. 모델이 한 장에 안 들어가면 어떻게 되나. 한 대에도 안 들어가면?
이번에는 B300 16장, 2노드짜리 환경에서 2.4조 파라미터 모델을 돌려봤다. 이 모델은 단일 노드에 안 올라간다. 노드를 넘는 것 말고 선택지가 없으니, 노드와 노드 사이 통신이 성능에 어떻게 잡히는지도 같이 확인할 수 있었다.
1. 무엇을 어떻게 쟀나
| 항목 | 구성 |
|---|---|
| GPU | B300 8장 × 2노드 = 16장 |
| 노드 내 연결 | NVLink |
| 노드 간 연결 | 이더넷 (RoCEv2) |
| 모델 | Qwen3.8-2.4T-A95B-FP8 (2.4조 파라미터 MoE, 토큰당 활성 950억) |
| KV 캐시 | Off (비활성화) |
MoE가 네트워크를 쓰는 이유
MoE는 전체 파라미터를 매번 다 쓰지 않는다. 토큰마다 어느 전문가로 보낼지를 라우터가 정하고, 그 전문가가 올라가 있는 GPU로 토큰을 보낸다(dispatch). 계산이 끝나면 결과를 원래 자리로 모은다(combine).
레이어마다 이 두 번이 일어나고, 이때 16개 GPU가 서로에게 동시에 보내고 동시에 받는다. All-to-All이라고 부르는 게 이거다.
전문가가 두 노드에 나뉘어 있으면 이 통신의 절반이 이더넷을 탄다. 레이어마다, 토큰마다 그렇다.
한쪽만 부하 주기
추론은 두 단계다. 입력을 한 번에 읽는 프리필, 토큰을 하나씩 뱉는 디코드. 성격이 완전히 다른데 평소엔 섞여 돌아간다. 섞인 채로 재면 무엇 때문에 느린지 구분이 안 된다.
그래서 한쪽만 극단으로 때리는 워크로드를 만들었다.
| 프로파일 | 입력/출력 토큰 | 원리 |
|---|---|---|
| 프리필만 | 8192 또는 16384 / 1 | 출력을 1토큰으로 고정하면 디코드가 사라진다 |
| 디코드만 | 128 / 2048 | 입력을 극단적으로 짧게 하면 프리필이 무시할 수준이 된다 |
| 혼합 | 4096 / 512 | 실제에 가까운 조합 |
의도대로 갈렸는지는 측정 후에 확인했다. 프리필 프로파일에서 TPOT와 ITL이 전 구간 0으로 나왔다. 디코드가 없으니 토큰 사이 간격 자체가 생기지 않았다. 디코드 프로파일은 생성 토큰 수가 요청 수 × 출력 길이와 정확히 맞았다.
측정 도구
두 가지를 썼는데 목적이 다르다.
하나는 aiperf 기반 표준 측정으로, 일반적인 서빙 성능을 재는 용도다.
다른 하나는 패브릭에 부하를 더 주려고 따로 짠 스트레스 테스트다. 위 표의 프리필 격리, 디코드 격리 프로파일이 여기서 나왔다. 네트워크를 얼마나 쓰는지 보는 게 목적이었으니, 일부러 트래픽이 많이 나오는 쪽으로 워크로드를 짰다.
목적은 달라도 혼합 프로파일(4096/512) 조건은 겹친다. 겹치는 구간에서 숫자가 맞는지 봤다.
| concurrency | aiperf (tok/s) | 스트레스 (tok/s) | 편차 |
|---|---|---|---|
| 16 | 2,343 | 2,257 | 3.7% |
| 32 | 3,444 | 3,255 | 5.5% |
| 64 | 4,769 | 4,672 | 2.0% |
| 128 | 5,905 | 6,013 | 1.8% |
5.5% 안으로 맞았다. 도구가 달라도 같은 조건이면 같은 값이 나온다는 걸 확인하고 나서 나머지 숫자를 믿기로 했다.
2. 프리필은 12,900에서 멈춘다
입력 8192, 출력 1토큰으로 프리필만 때렸다.
| concurrency | 입력 tok/s | 평균 TTFT | 직전 대비 |
|---|---|---|---|
| 8 | 8,022 | 7.4초 | — |
| 16 | 12,376 | 10.4초 | 1.54배 |
| 32 | 12,950 | 19.7초 | 1.05배 |
| 64 | 12,831 | 39.6초 | 0.99배 |
그림으로 보면 뭐가 일어났는지 바로 보인다.
처리량 (tok/s) TTFT (초)
c8 ████████████▌ 8,022 ███ 7.4
c16 ███████████████████ 12,376 ████ 10.4
c32 ████████████████████ 12,950 ████████ 19.7
c64 ███████████████████▉ 12,831 ████████████████ 39.6
└── 여기서 멈춤 ──┘ └─ 이것만 계속 늘어남 ─┘
concurrency 16에서 이미 상한의 96%다. 그 뒤로는 concurrency를 4배 올려도 처리량이 그대로고, TTFT만 10초에서 40초로 정직하게 4배가 된다.
익숙한 곡선이다. 링크가 포화한 뒤에 트래픽을 더 밀어 넣으면 처리량은 안 늘고 큐만 깊어진다. 지연은 쌓인 만큼 늘어난다. 여기서 늘어난 30초는 일한 시간이 아니라 줄 서 있는 시간이다.
입력을 16384로 늘리니 더 이상한 결과가 나왔다.
| concurrency | 입력 tok/s | 평균 TTFT |
|---|---|---|
| 8 | 9,967 | 13.0초 |
| 16 | 11,344 | 22.4초 |
| 32 | 12,307 | 41.1초 |
| 64 | 10,351 | 98.2초 |
concurrency 64에서 처리량이 16% 떨어진다. 대기 시간은 41초에서 98초로 2.4배가 됐다. 단순 포화라면 처리량은 유지되고 지연만 늘어야 하는데 처리량 자체가 무너졌다.
버퍼가 넘치기 시작할 때 재전송이 끼어들어 실효 처리량이 오히려 떨어지는 것과 비슷한 모양이다. 다만 엔진 내부 지표를 못 봐서 원인까지는 확정하지 못했다.
정리하면 이렇다. 프리필이 지배적인 서비스면 concurrency를 16 근처에서 끊는 게 낫다.
3. 디코드는 아직 끝이 아니었다
입력 128, 출력 2048로 디코드만 돌렸다.
| concurrency | 출력 tok/s | TPOT | p99 TPOT | 사용자당 tok/s |
|---|---|---|---|---|
| 32 | 665 | 46.7ms | 52.3ms | 21.4 |
| 64 | 1,217 | 52.2ms | 52.4ms | 19.2 |
| 128 | 2,100 | 60.3ms | 60.9ms | 16.6 |
출력 처리량 (tok/s)
c32 ██████▎ 665
c64 ███████████▌ 1,217
c128 ████████████████████ 2,100
아직 꺾이지 않았다 ──▶
concurrency 4배에 처리량 3.16배. 효율 79%면 아직 포화 전이다. 시간이 없어서 256과 512는 못 돌렸다. 그러니 2,100 tok/s는 상한이 아니라 하한으로 읽어야 한다.
눈에 띈 건 p99가 평균에 거의 붙어 있다는 점이다. concurrency 64에서 평균 52.2ms, p99가 52.4ms. 디코드만 돌 때는 지연이 이 정도로 안정적이다. 다음 절에서 이 값이 다시 나온다.
지난 글에서 GPU 한 장으로 확인한 것과 방향은 같다. 배치를 키우면 처리량은 거의 선형으로 오르고 TPOT는 조금씩만 나빠진다. 가중치를 한 번 읽어오는 비용을 여러 요청이 나눠 갖기 때문이다. 열여섯 장이 되어도 이 성질은 그대로였다.
4. 섞으면 무슨 일이 일어나는가
실제와 비슷하게 섞었다. 입력 4096, 출력 512.
| concurrency | median ITL | p99 ITL | 배수 | 평균 TPOT |
|---|---|---|---|---|
| 16 | 39.8ms | 42.0ms | 1.1배 | 47.5ms |
| 32 | 45.7ms | 1,336.9ms | 29배 | 68.0ms |
| 64 | 50.9ms | 1,733.9ms | 34배 | 96.2ms |
| 128 | 59.2ms | 1,755.0ms | 30배 | 154.9ms |
median ITL vs p99 ITL
c16 ▏40ms ▏42ms 정상
c32 ▏46ms ████████████████ 1,337ms ← 여기서 무너짐
c64 ▏51ms ████████████████████▊ 1,734ms
c128 ▏59ms █████████████████████ 1,755ms
이 표를 처음 봤을 때는 측정이 잘못된 줄 알았다. median이 45ms인데 p99가 1,337ms라니.
답은 왼쪽 숫자에 있었다. concurrency 128의 median ITL이 59.2ms인데, 디코드만 돌렸을 때 같은 concurrency의 TPOT가 60.3ms였다. 거의 같다. 전반적으로 느려진 게 아니라는 뜻이다. 평소엔 정상 속도로 돌다가 특정 순간에만 완전히 멈춘다.
범인은 프리필이다.
프리필 상한 12,900 tok/s로 역산하면 4096 토큰짜리 프리필 하나가 GPU를 독점할 때 0.32초쯤 걸린다. 관측된 1.75초 정지는 대략 22,000 토큰 분량의 프리필이 중간에 안 끊기고 연속으로 처리됐다는 계산이 나온다.
head-of-line blocking이다. 큰 프레임 하나가 포트를 잡고 있는 동안 뒤에 줄 선 작은 프레임들이 통째로 기다리는 그 상황. 여기서는 프리필 배치가 큰 프레임이고, 진행 중이던 디코드 토큰들이 뒤에 선 작은 프레임이다.
concurrency가 오를수록 더 나빠진다. 128에서는 응답 지연의 62%가 이 정지 시간이었다. 사용자가 기다리는 시간의 절반 이상이 생성이 아니라 대기다.
정지가 시작되는 지점도 꽤 선명했다. concurrency 16에서는 멀쩡하다가 32부터 튄다.
다만 이 현상 자체는 정상이다. 프리필과 디코드가 같은 GPU를 쓰는 한 누군가는 기다려야 한다. 프리필을 잘게 쪼개는 기술이나 아예 다른 서버로 분리하는 기술이 존재하는 이유가 여기 있다.
다만 1.75초라는 크기는 물리 법칙이 아니라 설정값이다. 프리필을 작게 쪼개면 정지는 짧아지고 대신 TTFT가 나빠진다. 이 트레이드오프 곡선을 그려봐야 합쳐 쓰는 구성의 최선이 어디인지 나오는데, 이번엔 못 했다.
5. 그래서 네트워크는
워크로드가 도는 동안 스위치에서 백엔드 포트 사용률을 같이 봤다. 프로파일마다 파형이 확연히 달랐다.
| 프로파일 | 파형 | 평상시 | 피크 |
|---|---|---|---|
| 프리필 | 두꺼운 고원이 이어지고 런 사이에만 0으로 떨어짐 | 6.3% | 7.5% |
| 혼합 | 0.5%와 6% 사이를 오가는 진동 | 2.5% | 6.2% |
| 디코드 | 낮은 바닥에 얇은 스파이크가 얹힘 | 1.75% | 2.4% |
평상시 값을 나란히 놓으면 성격 차이가 보인다.
[프로파일 비교 · 0~8% 구간 확대]
프리필 ████████████████████████████████ 6.3%
혼합 ████████████▌ 2.5%
디코드 ████████▊ 1.75%
그런데 축을 링크 용량 전체로 바꾸면 그림이 이렇게 된다. 이번엔 평상시가 아니라 피크 값이다.
[피크 · 링크 용량 100% 기준]
프리필 ███▌ 7.5%
혼합 ███ 6.2%
디코드 █▏ 2.4%
├───────────────────────────────────────────────┤
0% 100%
가장 높았던 프리필 피크가 7.5%였다. 8%를 안 넘었다.
2.4조 파라미터 모델을 두 노드에 걸쳐 돌리면서, 레이어마다 All-to-All을 두 번씩 돌면서, 백엔드 대역폭의 10분의 1도 안 쓰고 있었다. 게다가 이건 트래픽을 뽑으려고 일부러 짠 스트레스 워크로드에서 나온 숫자다. 실제 서비스 트래픽이면 더 낮게 나올 가능성이 크다.
그래서 지금 발목 잡고 있는 건 네트워크가 아니다. 프리필이 12,900에서 멈춘 것도, 디코드가 2,100인 것도 이더넷 탓이 아니었다. 튜닝을 어디에 해야 할지는 이걸로 정해졌다.
쏠림도 없었다. 프리필 구간에서 GPU 포트들이 6.38%, 6.24%로 거의 같은 값을 보였다. 트래픽이 특정 경로로 몰리지 않고 고르게 퍼졌다는 뜻이다.
6. 배치가 커지면 토큰당 네트워크 비용이 내려간다
디코드 그래프에서 예상 못 한 게 하나 나왔다. 사용률 바닥이 concurrency에 따라 세 단으로 올라갔는데, 그 높이가 처리량 증가와 비례하지 않았다.
| concurrency | 바닥 사용률 | 출력 tok/s | 토큰당 네트워크 비용 |
|---|---|---|---|
| 32 | 1.00% | 665 | 1.00 |
| 64 | 1.25% | 1,217 | 0.68 |
| 128 | 1.75% | 2,100 | 0.55 |
처리량 사용률 토큰당 비용
c32 ██████▎ ████████████ ████████████████████ 1.00
c64 ███████████▌ ███████████████ █████████████▌ 0.68
c128 ████████████████████ █████████████████████ ███████████ 0.55
3.2배 증가 1.75배 증가 45% 감소
처리량은 3.2배가 됐는데 사용률은 1.75배만 올랐다. 토큰 하나 만드는 데 드는 네트워크 비용이 45% 줄었다.
이유는 뜯어보면 당연하다. 디코드는 스텝마다 All-to-All을 한 번 돈다. 이 횟수는 배치 크기와 무관하다. 배치가 커지면 늘어나는 건 통신 횟수가 아니라 한 번에 실어 보내는 양이다.
concurrency 32면 32개 토큰이 16개 GPU로 흩어진다. GPU당 두 개. 이 정도 크기면 전송 오버헤드가 페이로드만큼 든다. concurrency 128이면 GPU당 여덟 개가 되고 그만큼 효율이 오른다. 64바이트 프레임으로 링크를 채우는 것과 점보 프레임으로 채우는 것의 차이와 같은 구조다.
배치를 키우면 GPU 효율만 오르는 게 아니라 토큰당 네트워크 비용도 같이 내려간다. 이번에 처음 알게 된 부분이다.
7. 노드를 늘리면 트래픽도 늘어날까
여기까지 오면 당연한 질문이 남는다. 2노드는 이 모델을 돌릴 수 있는 최소 구성이다. 네 대, 여덟 대로 늘리면 8%였던 숫자는 어떻게 되나.
먼저 밝혀둘 게 있다. 이 절은 측정이 아니라 계산이다. 이번 테스트는 2노드 구성이라 4노드 이상은 확인할 수 없었다.
총량은 거의 안 늘어난다
옮겨야 할 총 바이트는 대략 이렇게 정해진다.
총 바이트 ≈ 토큰 수 × 전문가 선택 개수 × 벡터 크기 × 2(왕복)
이 식에 GPU 수도 노드 수도 안 들어간다. 노드를 늘려도 옮길 데이터의 총량은 그대로다. 달라지는 건 그게 NVLink로 가느냐 이더넷으로 가느냐의 비율뿐이다.
전문가가 M개 노드에 고르게 흩어져 있다면 어떤 전문가가 내 노드 밖에 있을 확률은 (M-1)/M이다. 노드당 처리량이 일정하다고 보면 노드 하나가 이더넷으로 내보내는 양은 이렇게 된다.
| 노드 수 | 밖으로 나가는 비율 | 노드당 트래픽 | 측정치 대입 |
|---|---|---|---|
| 1 | 0% | — | 0% |
| 2 | 50% | 1.00 | 6% |
| 4 | 75% | 1.50 | 9% |
| 8 | 87.5% | 1.75 | 10.5% |
| 16 | 93.8% | 1.88 | 11.3% |
| ∞ | 100% | 2.00 | 12% |
노드당 트래픽 (2노드 = 1.00)
2노드 ████████████████████ 1.00
4노드 ██████████████████████████████ 1.50
8노드 ███████████████████████████████████ 1.75
16노드 █████████████████████████████████████▌ 1.88
∞ ████████████████████████████████████████ 2.00 ← 천장
노드를 무한히 늘려도 2노드의 두 배를 못 넘는다. 이미 절반이 밖으로 나가고 있어서 남은 여지가 절반뿐이다. 우리 측정치를 대입하면 천장이 12% 근처.
평균 대역폭만 보면 노드를 아무리 늘려도 여유롭다는 결론이 나온다. 그런데 이걸 그대로 믿을 생각은 없다.
대역폭보다 먼저 깨질 것들
위 계산은 평균 대역폭 하나만 본 거다. 실제로는 다른 축이 먼저 한계에 닿는다.
메시지가 다시 작아진다. 6절에서 배치를 키우니 토큰당 비용이 45% 줄었다. concurrency 32에서 128로 가면서 GPU당 토큰이 2개에서 8개로 늘어서였다. 노드를 늘리면 정확히 반대 일이 벌어진다. 같은 배치가 더 많은 GPU로 쪼개지니 GPU당 토큰이 줄고, 한 번에 실어 보내는 양이 다시 줄어든다. 바이트 총량은 그대로인데 나르는 비용이 올라간다. EP를 넓게 가져가면 배치도 같이 키워야 한다는 뜻이다.
incast가 심해진다. M개 노드면 각 목적지는 M-1곳에서 동시에 받는다. 평균 점유가 12%에서 멈춰도 동시에 몰려드는 방향의 수는 계속 늘어난다. 딥버퍼와 흐름 제어가 실제로 일할 차례가 오는 지점이 여기다. 2노드에서는 아직 그 국면이 아니었다.
인기 전문가에 쏠린다. 위 표는 고르게 흩어졌다고 가정했지만 전문가 인기도는 균등하지 않다. 인기 있는 전문가가 올라간 GPU의 포트는 평균보다 훨씬 높은 트래픽을 받는다. GPU 수가 늘수록 이 편차가 특정 포트로 몰릴 거다.
그리고 이게 제일 중요할 것 같은데, All-to-All은 대역폭 문제가 아니라 동기화 문제다. 모두가 가장 느린 링크를 기다리는 구조다. 노드가 늘면 홉이 늘고 tail latency가 늘어나며, 스텝 비용은 평균이 아니라 tail이 지배한다. 트래픽이 12%에서 멈춘다는 게 성능이 안 나빠진다는 뜻은 전혀 아니다. 대역폭은 남는데 레이턴시 때문에 스케일이 안 나오는 게 흔히 망하는 패턴이다.
토폴로지도 바뀐다. 지금 2노드는 스위치 한 대 안에서 끝난다. 노드가 늘어 스파인을 타기 시작하면 패브릭의 오버서브스크립션 비율이 실질 상한이 된다. 위 표의 12%는 논블로킹 전제 위에서만 성립한다.
정리
| 질문 | 답 |
|---|---|
| 총 트래픽량이 느나 | 거의 안 는다. 총 바이트는 토큰 수가 정한다 |
| 노드당 트래픽이 느나 | 는다. 다만 2노드 대비 두 배에서 수렴한다 |
| 그럼 안심해도 되나 | 아니다. 대역폭보다 메시지 크기, incast, tail latency가 먼저 걸린다 |
늘어나는 건 트래픽의 양이 아니라 성격이다. 같은 양의 데이터가 더 잘게, 더 많은 방향에서, 더 동시에 도착한다. 규모가 커질수록 스위치에 필요한 건 대역폭이 아니라 버퍼와 지연 안정성이 된다는 이야기다.
이번에 순간 부하를 못 본 게 아쉬운 이유가 여기 있다. 위에 적은 것들 중 어느 하나도 2노드 데이터로는 안 보인다. 노드를 늘렸을 때 뭐가 먼저 무너지는지는 4노드 이상에서만 보일 거다.
8. 요약
혼합 트래픽에서 걸린 건 프리필과 디코드가 서로를 막는 구간이었다. 네트워크는 아니었다.
-
환경 — B300 16장(8장 × 2노드), 2.4조 파라미터 MoE. 단일 노드에 안 올라가서 노드를 넘는 것 외에 선택지가 없는 구성이다.
-
측정 신뢰도 — 목적이 다른 도구 두 개가 겹치는 조건에서 5.5% 이내로 일치했다. 부하 격리도 확인했다. 프리필 프로파일은 TPOT와 ITL이 전 구간 0, 디코드 프로파일은 생성 토큰 수가 요청 수 × 출력 길이와 정확히 맞았다.
-
프리필 상한은 12,900 tok/s — concurrency 16에서 이미 96%에 도달한다. 그 뒤로 4배를 더 올려도 처리량은 그대로고 TTFT만 10.4초에서 39.6초로 늘어난다. 늘어난 건 일한 시간이 아니라 큐에 서 있는 시간이다.
-
ISL 16384, concurrency 64에서 처리량이 16% 역전 — TTFT는 41초에서 98초로 2.4배가 됐다. 단순 포화라면 처리량은 유지돼야 하는데 그렇지 않았다. 엔진 내부 지표를 못 봐서 원인은 확정하지 못했다.
-
디코드는 concurrency 128에서 2,100 tok/s, 아직 포화 전 — concurrency 4배에 처리량 3.16배로 효율 79%다. 256과 512는 못 돌렸으니 이 값은 상한이 아니라 하한이다.
-
디코드 단독일 때 지연은 매우 안정적 — concurrency 64에서 평균 TPOT 52.2ms, p99 52.4ms. 거의 붙어 있다.
-
섞으면 최대 1.75초 정지가 발생한다 — p99 ITL이 median의 30배까지 벌어진다. 정지가 시작되는 임계는 concurrency 16과 32 사이다. 프리필 배치가 GPU를 잡고 있는 동안 디코드가 기다리는 head-of-line blocking이다.
-
concurrency 128에서 응답 지연의 62%가 이 정지 시간 — 다만 1.75초라는 크기는 물리 법칙이 아니라 프리필 배치 설정에서 나온 값이다. 잘게 쪼개면 줄어들고 대신 TTFT가 나빠진다.
-
백엔드 포트 피크는 8% 미만 — 가장 높았던 프리필이 7.5%다. 트래픽을 뽑으려고 일부러 짠 스트레스 워크로드에서 나온 숫자이고, 레일 쏠림도 없었다(6.38%, 6.24%).
-
배치를 키우면 네트워크 효율도 오른다 — concurrency 32에서 128로 갈 때 토큰당 네트워크 비용이 45% 줄었다. 계산상 노드를 늘려도 노드당 트래픽은 2노드 대비 두 배에서 수렴한다. 늘어나는 건 트래픽의 양이 아니라 성격이다.