DGX Spark ×2 · 발열 추적

LLM 추론에서 가장 뜨거운 것은 GPU가 아니었다

In English (condensed) →

GB10 2노드에 DeepSeek V4 Flash 0731을 올려 쓰는 동안 부하 중 SoC 센서가 96°C까지 올라갔다. 센서가 104.8°C에 닿으면 OS가 기기를 그 자리에서 꺼 버린다(강제 종료 온도). 실제로는 그 전인 87°C 부근에서 꺼졌다는 사용자 보고가 여럿이다. 팬을 달고, 코어별로 계측하고, 프로파일러로 스택을 뜨고, 마지막에 vLLM 소스의 기본값 한 줄에 도달했다.

결론. 열원은 GPU가 아니라 대기 중인 CPU 성능코어 네 개였다. vLLM의 프로세스 간 통신 대기가 기본 설정에서는 사실상 무한 스핀이 되어, 요청 한 건을 처리하는 동안에도 코어 3~4개가 최대 클럭으로 헛돈다.

수정. 대기 유예를 1초에서 2ms로 바꾸는 한 줄로 vLLM CPU 점유가 333% → 89%, SoC가 11°C 내려갔다. 처리량과 지연은 변하지 않았다. 클럭 상한과 팬까지 합치면 부하 중 SoC 최고가 97°C → 73°C다.

발단: 팬 하나가 만든 12°C, 그리고 이상한 순서

두 노드 중 하나에만 하단 흡기 팬(녹투아)을 달아 두고 온도 존 일곱 개를 15초 간격으로 기록했다. TP=2는 같은 연산을 두 노드에 분배하므로 부하가 대칭이고, 실제로 GPU 레일 전력이 24.4W 대 24.3W로 같았다.

센서A · 팬 없음
평균
최고 B · 녹투아
평균
최고평균 격차
TSOC · SoC 종합87.59675.28012.4
TS1P · 클러스터1 성능코어86.59664.67621.9
TS0P · 클러스터0 성능코어84.38974.8809.5
TUNC · 언코어·메모리77.28167.7729.6
TGPU · GPU72.77665.1697.6
NVMe57.06046.64910.4
ConnectX-772.17663.2678.9

부하 구간(GPU 사용률 50% 이상) 평균과 최고. 기본 설정 상태, 양 노드 동시 측정, 표본 각 54·53개.

그림 1. 위 표의 평균값. 팬 효과(12°C)는 예상대로였지만, 순서가 이상했다.

팬 이야기보다 눈에 걸린 것은 순서다. LLM 추론인데 가장 뜨거운 곳이 GPU(72.7°C)가 아니라 CPU 성능코어 클러스터(86.5°C)다. 최고치로는 GPU 76°C 대 성능코어 96°C — 20°C 차이다. GPU 온도만 보고 있었다면 아무 문제도 없는 기계다.

참고로 /sys/class/thermal/*/type은 존 일곱 개를 전부 acpitz로만 알려준다. 정체는 device/firmware_node/path를 읽어야 나온다 — \_TZ_.TSOC(SoC 종합), TS0E/TS0P/TS1E/TS1P(클러스터별 효율/성능 코어), TGPU, TUNC(언코어 — 메모리 컨트롤러 등 코어 밖 부분).

추적 1: 코어 네 개가 계속 100%다

디코드 중 코어별 점유를 재 봤다. 전체 CPU 사용률은 19.3%로 평온해 보이는데, 코어별로 갈라 보면 이야기가 다르다.

그림 2. 요청 한 건을 디코드하는 동안의 코어별 점유. 네 코어가 100%, 전부 3.9GHz 성능코어다. 바쁜 코어 세 개가 클러스터 1에 몰려 있고, 그 클러스터의 센서(TS1P)가 가장 뜨겁다.

바쁜 스레드는 vLLM의 EngineCoreWorker_TP다. 상태는 계속 R(실행 중), 커널 대기 지점은 없음, 커널 시간이 27~35%. 계산을 하는 게 아니라 무언가를 기다리며 돌고 있다. 클럭을 깎아도(3.9→2.8→2.0GHz) 디코드 속도가 안 떨어지는 것도 같은 이야기다 — 이 코어들은 병목이 아니다. 최대 클럭으로 기다리기만 한다.

추적 2: py-spy로 코드 줄까지

부하가 걸린 상태에서 프로파일러로 스택을 떴다.

Thread (active): "MainThread"
    sched_yield  (vllm/distributed/utils.py:48)
    wait         (…/shm_broadcast.py:196)   ← SpinCondition.wait
    acquire_read (…/shm_broadcast.py:698)
    dequeue      (…/shm_broadcast.py:779)
    worker_busy_loop

엔진 프로세스와 GPU 워커는 공유메모리 링버퍼로 일감을 주고받는데, 받는 쪽의 대기가 이 SpinCondition이다. 구현을 읽으면 설계 의도가 그대로 적혀 있다.

def wait(self, timeout_ms=None):
    ...
    if current_time <= self.last_read + self.busy_loop_s:   # 기본 1초
        sched_yield()                        # 스핀: CPU 를 양보만 하고 즉시 복귀
    else:
        self.poller.poll(timeout=timeout_ms) # 잠듦: zmq 소켓으로 깨움

하이브리드다. 마지막 메시지 후 busy_loop_s(기본 1초) 동안은 스핀하고, 그 뒤에야 잠든다. 잠들 장치 — notify 소켓, CONFLATE, poller — 는 전부 만들어져 있다.

문제는 이 기본값이 실제 사용 패턴과 어긋난다는 것이다. 디코드 중에는 메시지가 수 ms 간격으로 오므로 1초 유예에 절대 도달하지 못한다. 잠드는 길이 있어도 한 번도 쓰이지 않고, 깨어서 도는 쪽만 남는 것이다. 수백 W 데이터센터 서버라면 코어 몇 개는 공짜지만, CPU와 GPU가 한 다이를 쓰는 GB10에서는 이 대기 열이 SoC를 꺼짐이 보고되는 온도까지 밀어 올린다. sched_yield()가 시스템콜이라 커널 시간이 높았던 것도 이걸로 설명된다.

추적 3: 정말 이 코어들이 열원인가 — 결정적 확인

스택은 잡았지만, 코드를 고치기 전에 물리적으로 확인하고 싶었다. 스핀하는 성능코어가 정말 열원이라면, 그 코어들의 클럭만 낮췄을 때 온도 하락이 그 자리를 중심으로 나타나야 한다. 성능코어 상한을 3.9→2.8GHz로 내리고(다른 조건 동일, 같은 부팅에서 교대 3세트) 센서별로 온도를 비교했다.

그림 3. 성능코어 클럭만 낮췄을 때의 센서별 온도 변화. 하락폭이 성능코어 −14°C에서 언코어 −3.7°C, GPU −1.7°C로 멀어질수록 줄어들다 NVMe에서는 0이 된다. 두 노드에서 같은 모양이다.

이게 결정적이었다. 만약 전체 부하가 줄어 시원해진 것이라면 모든 센서가 고르게 내려갔을 것이다. 그런데 하락이 성능코어를 중심으로 거리에 따라 줄어든다 — 특정 지점의 발열이 줄었고, 거기서 다이를 타고 퍼지던 열이 함께 준 모양이다. 코드 분석(py-spy)과 무관한 물리 측정이 같은 결론을 독립적으로 가리킨다: 열원은 스핀하는 성능코어다. 이 확신이 있었기에 vLLM 코드를 고치는 쪽에 시간을 걸 수 있었다.

덧붙여 이 실험에서 대화형 성능이 전혀 안 떨어진다는 것도 확인됐다 — 이 코어들은 계산의 병목이 아니라는 뜻이고, 스핀 가설과 정확히 맞는다. (클럭 상한 자체를 완화책으로 쓰는 이야기는 부록에.)

안 통한 해법들

수정 전에, 기존 손잡이로 되는지 먼저 시험했다. 모델 출력이 바뀌지 않는 것만 골라 재기동 2회씩 측정했다.

손잡이무엇을 바꾸나성능 (c8)SoC 온도
VLLM_USE_SPINLOOP_EXT=1sched_yield 대신 전용 네이티브 스핀구분 못 함구분 못 함
TORCH_NCCL_BLOCKING_WAIT=1NCCL 집합통신 대기를 재움구분 못 함구분 못 함 (+2.5° 방향)
컨테이너를 효율코어에 고정2.8GHz 코어로 스핀을 옮김−8%−11.3°

스핀을 다르게 돌리거나(1행) GPU 통신을 재우는(2행) 것으로는 아무것도 안 바뀐다 — 열을 만드는 지점이 거기가 아니기 때문이다. 코어 고정은 온도에 통하지만 병렬 처리량을 8% 내주고, 컨테이너 재생성 때마다 사라진다.

통한 해법: 한 줄

기본값을 바꿨다. busy_loop_s: float = 10.002. 2ms만 깨어 기다리다 잠들고, 쓰기 쪽 notify가 수십 µs에 깨운다. 대기 정책만 바뀌므로 모델 출력에는 영향이 없다.

기본 (1초 스핀)2ms
vLLM 프로세스 CPU 합계 (부하 중)333.6%88.7%
SoC (팬 없는 노드)77°C66°C
디코드 c=1 / c=8 (tok/s)52~63 / 154~17358.0 / 157.8
GPU 사용률95%96%

같은 부하(동시 4건, GPU 96%). 처리량·첫 토큰 지연은 재기동 간 노이즈 범위 안이고, CPU 시간과 열만 사라졌다.

왜 이 한 줄이 통하나

핵심은 기다리는 동안 코어가 무엇을 하느냐다.

스핀 중의 코어는 겉보기엔 노는 것 같지만 회로 입장에서는 전속력이다. "플래그 확인 → 아니네 → 양보 → 복귀"를 초당 수십만 번 돌며 명령어를 쉼 없이 실행하므로, 실제 연산을 할 때와 같은 동적 전력을 쓴다. 반면 poll로 잠든 스레드는 커널이 실행 큐에서 내려 버리고, 할 일이 없어진 코어는 하드웨어가 클럭을 끊는다(유휴 상태 진입). 전력이 사실상 0으로 떨어진다. 같은 "대기"라도 스핀은 최대 전력, 잠들기는 0에 가깝다 — 이 차이가 그대로 14~20°C다.

그러면 왜 속도는 안 떨어지나. 시간 축을 보면 된다. 디코드 한 스텝은 수십 ms이고, 그 대부분은 GPU가 일하는 동안 CPU 쪽 메시지가 없는 구간이다. 2ms 유예면 스레드는 각 스텝의 거의 전부를 잠들어 보내고, 메시지가 오면 상대가 소켓으로 깨운다. 깨우는 비용은 수십 µs — 스텝당 한두 번이면 스텝 시간의 1% 미만이라 측정 노이즈에 묻힌다. 그리고 메시지가 2ms 안에 연달아 오는 바쁜 순간에는 여전히 스핀하므로, 원래 설계가 노린 "연타 구간의 빠른 반응"도 유지된다.

즉 이 수정은 대기 방식을 바꾼 게 아니라, 이미 코드에 있던 "잠들기" 경로가 실제로 쓰이도록 문턱만 옮긴 것이다. 그래서 안전하고, 그래서 성능이 그대로다.

수정 후 남은 89%는 진짜 일이다 — 스케줄링, 샘플링, 그리고 CUDA 스트림 동기화 스레드(이쪽은 PyTorch 내부의 별개 스핀이라 이 수정의 대상이 아니다). 스핀이 사라지자 성능코어 쪽에 몰려 있던 열점 자체가 없어져서, 전 존이 60~68°C에 고르게 모인다.

참고로 코드 수정 전에는 성능코어 클럭 상한(3.9→2.8GHz)으로 온도만 낮춰 두고 있었다. SoC −9°C가 나오지만 이는 같은 낭비를 더 낮은 전력으로 계속하는 것이지 없애는 게 아니다 — 실험 기록은 부록에 뒀다.

배포는 컨테이너 안 파일을 sed로 고치는 핫픽스 스크립트로 만들어 기동 스크립트에 통합했다(레시피의 기존 핫픽스 체계와 같은 방식이라 재시작도 한 번으로 끝난다). 업스트림 vLLM main에도 같은 코드가 하드코딩돼 있어, 이 값을 환경변수로 노출하는 패치를 만들어 두었고 검증을 더 거친 뒤 제안할 예정이다.

총효과: 최악과 최적의 거리

이 기록의 결론은 이 비교 하나다. 기본 상태(팬 없음, vLLM 기본값 — 즉 GB10을 사서 커뮤니티 레시피대로 띄우면 놓이는 상태)와 최적 구성(하단 흡기 팬 + 스핀 수정 + 보조 클럭 상한)을 같은 부하에서 재면:

그림 4. 센서별 부하 중 평균 온도의 이동. 짙은 점이 기본 상태, 빈 점이 최적 구성. 점선은 사용자들이 실제 꺼짐을 보고한 87°C와 기본 상태의 최고치 97°C.

센서 기본
평균 / 최고
최적
평균 / 최고
평균 차최고 차
TSOC · SoC 종합89.1 / 9764.9 / 73−24.2−24
TS1P · 성능코어87.9 / 9760.1 / 73−27.8−24
TS0P · 성능코어(0)85.1 / 9159.6 / 62−25.5−29
TUNC · 언코어78.2 / 8563.9 / 66−14.3−19
TGPU · GPU74.2 / 8063.8 / 66−10.4−14
NVMe59.4 / 6447.5 / 49−12.0−15

부하 구간 평균/최고, 조건당 83~85 표본. 기본 = 팬 없음 · vLLM 기본값 · 3.9GHz. 최적 = 팬 + 스핀 수정(주 기여) + 클럭 상한(보조).

이 차이가 뜻하는 것

기본 상태는 언제 꺼져도 이상하지 않다. 부하 중 SoC 평균이 89°C — 사용자들이 실제 꺼짐을 보고하는 87°C를 평균이 넘는다. 최고는 97°C로 강제 종료(104.8°C)까지 8°C 남는다. 여름철 실온이 몇 도만 올라도, 먼지가 조금만 쌓여도 그 8°C는 사라지는 여유다. 긴 추론 작업을 밤새 걸어 두는 이 기기의 용도를 생각하면 더 그렇다.

최적 구성은 그 그림을 바꾼다. 평균 60~65°C, 최고 73°C. 강제 종료까지 32°C, 꺼짐 보고 대역까지도 14°C의 여유다. 온도가 전 센서에서 고르게 내려간 것도 중요하다 — 특정 지점이 튀는 열점이 없어졌다는 뜻이고, NVMe(−12°C)처럼 수명이 온도에 민감한 부품도 함께 내려간다.

비용은 사실상 없다. 기여를 가르면 스핀 수정이 −11°C(성능 무손실, 코드 한 줄), 팬이 −12°C(하드웨어, 성능 무관), 클럭 상한이 나머지(보조 — 동시 8건 −5%가 유일한 대가이고, 빼도 된다). 24°C를 내리는 데 치른 것이 팬 하나와 기본값 수정 하나다.

같은 부하, 같은 하드웨어다. 달라진 것은 기다리는 방법과 공기의 길뿐이다.

어디의 문제인가 — 정확한 책임 소재

고치려면 어느 층의 문제인지가 분명해야 한다. 층별로 가르면 이렇다.

계층해당 범위근거
스핀 자체
(CPU 3~4코어 낭비)
멀티프로세스 executor("mp")로 도는 모든 vLLM 배포 — TP 2 이상(멀티 GPU·멀티 노드)이면 이것이 기본값이다. 일반 x86 멀티 GPU 서버 포함원인은 vllm/distributed/device_communicators/shm_broadcast.pySpinCondition이고, 업스트림 main에도 같은 코드가 하드코딩돼 있다(두 호출부 모두 값을 넘기지 않음). 특정 배포판·커뮤니티 빌드의 문제가 아니다. 정정(8/17): 단일 GPU(world_size=1) 기본 설정은 uni(단일 프로세스) executor를 쓰기 때문에 이 대기 경로 자체가 생기지 않는다 — 단일 GPU·단일 Spark 기본 구성에는 해당하지 않는다. 처음 발행본에는 GPU 한 장짜리 구성도 해당한다고 썼는데, 과대 서술이었다.
열 문제로의 발현GB10 패키지를 쓰는 모든 기기 — NVIDIA DGX Spark, ASUS Ascent GX10, 그 외 OEM 변형Grace CPU와 GPU가 한 패키지를 쓰는 소형기라, 스핀하는 성능코어의 열이 곧 SoC 온도가 된다. 코어가 수십 개인 일반 서버에서는 3~4코어 낭비가 온도로 드러나지 않고, 그래서 이 기본값이 지금까지 문제가 안 됐던 것으로 보인다.
이 글의 수치ASUS Ascent GX10 ×296°C 등 구체 수치는 이 섀시 기준. 다른 OEM 은 방열 설계에 따라 정도가 다를 수 있으나 구조는 같다.
모델·서빙 구성모델 무관DeepSeek·DSpark 고유가 아니다. 어떤 모델이든 디코드 중 메시지 간격이 1초보다 짧으면(사실상 항상) 스핀한다. TP 노드 수도 상관없다 — 2대 이상이기만 하면 노드마다 각자 스핀한다.

왜 일반 서버에서는 안 드러나고 GB10에서 드러나나

같은 스핀이 두 환경에서 완전히 다른 결과를 낳는 이유는 산수다.

일반 GPU 서버에서는 CPU와 GPU가 물리적으로 다른 패키지다. CPU는 수십~백 개 코어에 수백 W급 방열기가 따로 붙어 있다. 코어 서너 개가 스핀하며 쓰는 전력(대략 10~20W)은 그 방열 용량의 몇 %라 온도계에 거의 안 잡히고, 코어 예산으로 봐도 128개 중 4개 — 3%다. 낭비는 같지만 아무도 아프지 않다. 이 기본값이 여태 문제가 안 됐던 이유다.

GB10은 조건이 전부 반대다. CPU 20코어와 GPU가 한 패키지에 있고, 1리터급 섀시의 방열기 하나를 같이 쓴다. 이 기기에서 부하 중 GPU 레일이 24W인데, 성능코어 4개가 3.9GHz로 도는 전력은 그와 같은 자릿수다 — 즉 스핀이 전체 열 예산의 절반 규모를 차지하고, 그 열이 GPU·SoC 센서에서 몇 mm 옆에서 난다. 열용량이 작으니 온도로 즉시 드러난다. 실제로 스핀만 없애도 성능코어 센서가 14~20°C 내려간다는 것이 이 전력의 크기를 보여준다(이 기기는 CPU 전력 센서를 노출하지 않아 온도로만 확인된다).

코어 예산으로 봐도 20개 중 4개 — 20%다. 같은 낭비가 큰 서버에서는 반올림 오차이고, GB10에서는 열과 자원 양쪽에서 주요 항목이 된다.

요약하면 vLLM의 기본값이 원인이고, 통합 SoC 소형기가 그것을 열 문제로 바꾼다. 수정 위치도 vLLM 파일 한 곳이라 어느 설치에서든 같은 방법이 통한다.

GB10에서 같은 문제를 겪는다면

순서대로: 먼저 자기 기기에서 실제로 일어나는지 확인하고, 고치고, 보험을 건다. (단일 Spark 기본 구성에는 이 스핀 경로 자체가 없다 — 1번 진단에서 바로 드러난다.)

# 1) 진단 — 부하 중 코어별 점유와 SoC 존
mpstat -P ALL 3 1          # 소수 코어만 100% 인지
for z in /sys/class/thermal/thermal_zone*; do
  echo "$(cat $z/device/firmware_node/path): $(($(cat $z/temp)/1000))°C"
done

# 2) 스핀 수정 — 컨테이너 안 vLLM 파일 한 줄
#    vllm/distributed/device_communicators/shm_broadcast.py
#    busy_loop_s: float = 1   →   0.002
docker exec $CT sed -i 's/busy_loop_s: float = [0-9.]*/busy_loop_s: float = 0.002/' \
  /usr/local/lib/python3.12/dist-packages/vllm/distributed/device_communicators/shm_broadcast.py
docker restart $CT          # TP 구성이면 모든 노드에서

이걸로 끝이다. 클럭 제한은 필요 없다 — 원인이 사라졌기 때문이다.

커뮤니티 도구. drowzeys/vllm-gb10-spin-wait-fix가 이 수정을 자동 패칭 스크립트로 도구화했다. -spinfix 파생 이미지를 만들어 검증까지 해 주므로, 컨테이너를 다시 만들어도 패치가 유지된다. TP=1에는 해당하지 않는다는 사실을 먼저 짚은 것도 이 저장소다.

부록: CPU 클럭 상한을 완화책으로 쓰는 경우

코드 수정에 도달하기 전, 성능코어 클럭을 3.9→2.8GHz로 제한하는 실험을 했다(같은 부팅에서 교대 3세트). 결과는 SoC −9°C, 대화형 성능 무손실, 동시 8건 −5%. 증상 완화로는 유효하지만 스핀 자체는 그대로라 근본 해결이 아니다 — 같은 낭비가 더 낮은 전력으로 이어질 뿐이다.

이 실험의 온도 분포는 본문 그림 3(추적 3)에 있다 — 완화책으로서는 부차적이지만, 열원 진단으로서는 결정적이었다.

스핀 수정이 들어간 지금은 클럭 상한의 온도 기여가 몇 도 수준으로 줄었다. 우리는 수정이 빠진 채 뜨는 경우의 안전장치로 systemd 유닛을 남겨 뒀지만, 선택 사항이다.

측정 조건

2노드 ASUS Ascent GX10(GB10, 128GB LPDDR5X 각), ConnectX-7 200GbE 직결, vLLM TP=2 mp 백엔드(0.25.2 계열 커뮤니티 빌드), DeepSeek V4 Flash 0731 원본 FP8, 추론(thinking) 켠 실사용 부하. 온도는 양 노드에서 15초 간격 상시 기록을 측정 창 시각으로 잘라 집계. GPU 클럭은 전 구간 300~2000MHz 상한(별도 유닛). 전력은 GPU 레일 값만 노출되는 기기라 벽면 소비전력은 재지 못했다.

성능 판정은 재기동 반복의 [최소~최대] 범위 비겹침 기준. 이 스택은 같은 설정 재기동만으로 ±13~15% 흔들린다 — 자세한 방법론과 성능 튜닝 전체는 별도 기록에 있다.