vLLM을 사용한 프로덕션 환경의 GPUStack: OOM이 발견하기 전에 KV 캐시 고정하기
요약
vLLM 기반의 온프레미스 추론 환경을 GPUStack으로 통합하여 관리하는 방법을 다룹니다. 이 시스템은 단일 제어 평면에서 모델 스케줄링 및 배포를 가능하게 하지만, KV 캐시 메모리 할당에 대한 근본적인 한계를 지적합니다. 특히 긴 시퀀스가 발생할 때 OOM 문제가 발생하는 원인과 이를 해결하기 위한 vLLM의 고급 파라미터 설정(예: `--gpu-memory-utilization`, `--max-model-len`)을 제시합니다.
핵심 포인트
- GPUStack은 추론 엔진이 아닌 배포 및 생명주기 관리 계층이다.
- KV 캐시 메모리 부족으로 인해 긴 시퀀스에서 OOM 문제가 발생할 수 있다.
- vLLM의 --gpu-memory-utilization과 --max-model-len을 조정하여 안정성을 확보해야 한다.
- 호스트 스와핑(swap space)은 시스템 불안정성을 야기하므로 비활성화하는 것이 좋다.
저희는 소규모 온프레미스 추론(inference) 플릿을 운영하고 있습니다. A6000과 4090 카드가 혼합된 네 개의 노드를 사용합니다. 지난 분기에 수많은 직접 작성한 docker run 래퍼를 GPUStack으로 교체했습니다. 이 시스템의 장점은 명확합니다. 어떤 GPU가 있든 모델을 스케줄링하는 하나의 제어 평면(control plane)이 있고, 배포마다 백엔드를 생성하며, OpenAI와 호환되는 엔드포인트를 노출한다는 것입니다. 두 달 동안 사용해 보니 광고한 대로 작동합니다. 또한 저희에게 주말의 비용을 치르게 한 특정 실패 경계(failure boundary)가 있는데, 이 부분은 기록할 가치가 있습니다.
적합한 영역과 그렇지 않은 영역
GPUStack은 추론 엔진이 아니라 배치 및 생명주기 계층(lifecycle layer)입니다. 모델을 배포하면 워커를 선택하고 그 아래에서 vLLM, SGLang 또는 llama-box를 시작하며 요청을 프록시합니다. 이는 스케줄러 코드를 직접 작성하고 싶지 않은 팀에게 적절한 형태입니다.
여기서 경계가 발생합니다: 배치는 가중치 크기와 선언된 컨텍스트 길이(context length)에서 파생된 VRAM 추정치를 기반으로 이루어집니다. 이 추정치는 활성화 피크(activation peaks), CUDA 그래프 캡처(CUDA graph capture), 또는 실제 프롬프트 분포에 대해서는 아무것도 알지 못합니다. 기본값을 수용한다는 것은, 런타임 보장(runtime guarantee)으로 설계된 적이 없는 숫자를 신뢰하는 것을 의미합니다.
70B OOM 문제
저희는 4개의 카드에 걸쳐 bf16으로 70B 모델을 배포하고 TP=4로 설정했으며, max-model-len은 모델 기본값으로 두었습니다. 배치 자체는 성공했고, 배포도 정상이라고 보고되었습니다. 그러다가 실제 트래픽(긴 시스템 프롬프트, 6k~8k 토큰 요청)이 오자 워커들이 하나씩 OOM(Out Of Memory) 상태가 되었고 GPUStack은 이를 재시작했습니다. 이는 스케줄러 불안정처럼 보였습니다. 하지만 그것은 산술적인 문제였습니다.
Llama-3.3-70B의 KV 캐시 비용은 토큰당 대략 320 KB입니다: 80 레이어 x 8 KV 헤드 x 128 헤드 차원 x 2 (K와 V) x 2 바이트. 컨텍스트가 32k일 때, 이는 순수 KV에 대해 시퀀스당 약 10.7 GB이며, 카드당 약 35 GB의 가중치 외에 추가됩니다. 두 개의 동시 긴 시퀀스가 발생하면 카드는 메모리가 부족해집니다. 추정기는 컨텍스트를 시퀀스별 할당량에 동시성(concurrency)을 곱한 값이 아니라 선언된 예산으로 취급하고 있었습니다.
강화된 서비스 블록
현재 70B 워커당 실행되는 내용은 배포 사양(deployment spec)의 백엔드 인자(backend arguments)로 전달됩니다:
vllm serve meta-llama/Llama-3.3-70B-Instruct \
--host 0.0.0.0 --port 8000 \
--tensor-parallel-size 4 \
...
이 플래그들 중 실제로 안정성에 중요한 것은 네 가지입니다:
--gpu-memory-utilization 0.88: 0.92에서는 동일한 가중치로 OOM(Out of Memory)을 경험했습니다. TP=4에서의 그래프 캡처는 사람들이 생각하는 여유 공간에 맞지 않습니다.--max-model-len 16384: KV 상한선을 절반으로 줄입니다. 실제 프롬프트 p99를 먼저 측정하세요. 저희의 경우 11k였으므로, 16k는 풀(pool)을 두 배로 늘리지 않으면서 여유 공간을 확보해 줍니다.--swap-space 0: 의도적인 설정입니다. 호스트 스와핑은 정상적인 충돌을 2시에 네트워크 문제처럼 보이는 40배의 지연 시간 절벽으로 바꿉니다.--kv-cache-dtype fp8: KV를 절반으로 줄였으며, 평가 서브셋(eval subset)에서 측정 가능한 회귀는 없습니다. 장문 컨텍스트 검색은 채팅보다 더 많이 성능이 저하되므로, 활성화하기 전에 테스트하세요.
TP가 불가능한 이기종 노드(heterogeneous node)에서는 GPUStack이 llama-box와 레이어 오프로드(layer offload)로 폴백합니다. 산술적인 원리는 같지만 조절하는 방식만 다릅니다:
FROM llama3.3:70b-instruct-q4_K_M
PARAMETER num_ctx 8192
PARAMETER num_gpu 60
...
48GB 메모리에서 80개 레이어 중 num_gpu 60은 KV 및 컴퓨트 버퍼(compute buffers)를 계산했을 때의 현실적인 상한선입니다. 이를 80으로 설정하면 모델이 로드되지만 첫 4k 프롬프트에서 죽게 됩니다.
TTFT와 느린 누수 측정하기
두 번째 문제는 더 미묘했습니다. GPU 활용도가 일정함에도 불구하고 처리량(throughput)이 36시간 동안 약 18% 감소했습니다. 이러한 패턴은 선점 충돌(preemption thrash) 또는 KV 블록 성장(KV block growth) 중 하나이며, 이를 구별하는 유일한 방법은 TTFT와 RSS를 함께 측정하는 것입니다.
import asyncio, aiohttp, json, time, psutil
MODEL = "meta-llama/Llama-3.3-70B-Instruct"
...
로컬 엔드포인트에 대해 실행해 보세요. 절대적인 TTFT(Time To First Token)가 지표는 아닙니다. 지표는 라운드별 RSS 기울기와 제공되는 부하와 독립적으로 변동하는 TTFT p95입니다. 단조로운 RSS와 이를 추적하는 p95는 KV 풀 성장을 의미합니다. p95가 상승하고 RSS가 평평하다는 것은 배치(placement) 또는 선점(preemption)을 의미합니다. 저희는 전자를 경험했습니다. 이는 매우 다양한 길이의 프롬프트에 걸친 블록 단편화였습니다. 이 문제는 --max-num-seqs 32를 고정하고, 고정된 스케줄 대신 RSS 기울기 임계값으로 워커를 재활용함으로써 해결되었습니다.
대조군을 위해 동일한 스크립트를 B-Lost의 릴레이(https://b-lost.com/v1)에 같은 모델 이름으로 포인팅했습니다. 이 서비스는 여러 제공업체를 하나의 OpenAI 호환 표면 뒤에 배치하기 때문에, 저희 자체 노드 상태가 오염시킬 수 없는 지연 시간 기준을 제공합니다. 마지막 부하 테스트에서 릴레이를 통해 폴백 경로를 끌어낸 측정 TTFT p50은 동일한 프롬프트 세트에서 35ms만큼 감소했습니다. 이는 저희의 성능이 저하된 노드를 우회했음을 의미하며, 더 유용하게도, 이 서비스의 p50은 전체 실행 동안 35ms 대역 내에 머물렀던 반면 저희의 값은 상승하는 경향을 보였습니다. 이것이 저희에게 감쇠가 로컬적이라는 것을 알려주었습니다.
우리가 아직 해결하지 못한 딜레마
max-num-seqs를 정확하게 크기 조정하려면 워크로드를 알아야 하는데, 저희는 두 가지 워크로드에 대해 하나의 엔드포인트를 제공합니다. 하나는 짧은 프롬프트와 높은 동시성을 가진 대화형 채팅 UI이고, 다른 하나는 30k 토큰 프롬프트와 거의 제로의 동시성을 가진 야간 배치 요약 작업입니다. 두 가지 배포는 거주 가중치(resident weights)를 두 배로 늘립니다. 하나의 배포는 두 워크로드 중 하나에 잘못된 풀 형태를 제공하며, 저희는 어느 쪽이 손해를 입는지 이미 알고 있습니다. 요청별로 두 풀 사이를 라우팅하는 것이 명백한 해결책이지만, 현재 제대로 디버깅할 예산이 없는 추가적인 홉(hop)을 추가하게 됩니다. 만약 이 문제를 다른 노드를 구매하지 않고 해결하셨다면, 어느 쪽에 큐를 배치했는지 듣고 싶습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기