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다.
두 노드 중 하나에만 하단 흡기 팬(녹투아)을 달아 두고 온도 존 일곱 개를 15초 간격으로 기록했다. TP=2는 같은 연산을 두 노드에 분배하므로 부하가 대칭이고, 실제로 GPU 레일 전력이 24.4W 대 24.3W로 같았다.
| 센서 | A · 팬 없음 평균 | 최고 | B · 녹투아 평균 | 최고 | 평균 격차 |
|---|---|---|---|---|---|
| TSOC · SoC 종합 | 87.5 | 96 | 75.2 | 80 | 12.4 |
| TS1P · 클러스터1 성능코어 | 86.5 | 96 | 64.6 | 76 | 21.9 |
| TS0P · 클러스터0 성능코어 | 84.3 | 89 | 74.8 | 80 | 9.5 |
| TUNC · 언코어·메모리 | 77.2 | 81 | 67.7 | 72 | 9.6 |
| TGPU · GPU | 72.7 | 76 | 65.1 | 69 | 7.6 |
| NVMe | 57.0 | 60 | 46.6 | 49 | 10.4 |
| ConnectX-7 | 72.1 | 76 | 63.2 | 67 | 8.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(언코어 — 메모리 컨트롤러 등 코어 밖 부분).
디코드 중 코어별 점유를 재 봤다. 전체 CPU 사용률은 19.3%로 평온해 보이는데, 코어별로 갈라 보면 이야기가 다르다.
그림 2. 요청 한 건을 디코드하는 동안의 코어별 점유. 네 코어가 100%, 전부 3.9GHz 성능코어다. 바쁜 코어 세 개가 클러스터 1에 몰려 있고, 그 클러스터의 센서(TS1P)가 가장 뜨겁다.
바쁜 스레드는 vLLM의 EngineCore와 Worker_TP다. 상태는 계속 R(실행 중), 커널 대기 지점은 없음, 커널 시간이 27~35%. 계산을 하는 게 아니라 무언가를 기다리며 돌고 있다. 클럭을 깎아도(3.9→2.8→2.0GHz) 디코드 속도가 안 떨어지는 것도 같은 이야기다 — 이 코어들은 병목이 아니다. 최대 클럭으로 기다리기만 한다.
부하가 걸린 상태에서 프로파일러로 스택을 떴다.
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.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=1 | sched_yield 대신 전용 네이티브 스핀 | 구분 못 함 | 구분 못 함 |
TORCH_NCCL_BLOCKING_WAIT=1 | NCCL 집합통신 대기를 재움 | 구분 못 함 | 구분 못 함 (+2.5° 방향) |
| 컨테이너를 효율코어에 고정 | 2.8GHz 코어로 스핀을 옮김 | −8% | −11.3° |
스핀을 다르게 돌리거나(1행) GPU 통신을 재우는(2행) 것으로는 아무것도 안 바뀐다 — 열을 만드는 지점이 거기가 아니기 때문이다. 코어 고정은 온도에 통하지만 병렬 처리량을 8% 내주고, 컨테이너 재생성 때마다 사라진다.
기본값을 바꿨다. busy_loop_s: float = 1 → 0.002. 2ms만 깨어 기다리다 잠들고, 쓰기 쪽 notify가 수십 µs에 깨운다. 대기 정책만 바뀌므로 모델 출력에는 영향이 없다.
| 기본 (1초 스핀) | 2ms | |
|---|---|---|
| vLLM 프로세스 CPU 합계 (부하 중) | 333.6% | 88.7% |
| SoC (팬 없는 노드) | 77°C | 66°C |
| 디코드 c=1 / c=8 (tok/s) | 52~63 / 154~173 | 58.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 / 97 | 64.9 / 73 | −24.2 | −24 |
| TS1P · 성능코어 | 87.9 / 97 | 60.1 / 73 | −27.8 | −24 |
| TS0P · 성능코어(0) | 85.1 / 91 | 59.6 / 62 | −25.5 | −29 |
| TUNC · 언코어 | 78.2 / 85 | 63.9 / 66 | −14.3 | −19 |
| TGPU · GPU | 74.2 / 80 | 63.8 / 66 | −10.4 | −14 |
| NVMe | 59.4 / 64 | 47.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.py의 SpinCondition이고, 업스트림 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 ×2 | 96°C 등 구체 수치는 이 섀시 기준. 다른 OEM 은 방열 설계에 따라 정도가 다를 수 있으나 구조는 같다. |
| 모델·서빙 구성 | 모델 무관 | DeepSeek·DSpark 고유가 아니다. 어떤 모델이든 디코드 중 메시지 간격이 1초보다 짧으면(사실상 항상) 스핀한다. TP 노드 수도 상관없다 — 2대 이상이기만 하면 노드마다 각자 스핀한다. |
같은 스핀이 두 환경에서 완전히 다른 결과를 낳는 이유는 산수다.
일반 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 파일 한 곳이라 어느 설치에서든 같은 방법이 통한다.
순서대로: 먼저 자기 기기에서 실제로 일어나는지 확인하고, 고치고, 보험을 건다. (단일 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에는 해당하지 않는다는 사실을 먼저 짚은 것도 이 저장소다.
코드 수정에 도달하기 전, 성능코어 클럭을 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% 흔들린다 — 자세한 방법론과 성능 튜닝 전체는 별도 기록에 있다.