
NVIDIA HGX H200 서버 (출처: servethehome.com)
1편에서 모델 유형별 vllm serve 인자를 정리했다. 이번 글은 그 연장으로, H200 두 장에 LLM + Embedding + OCR 세 모델을 동시에 올리려다 겪은 문제들과 그 해결 방법을 정리한 것이다.
환경은 H200 80GB × 2장, 총 VRAM 160GB다. 모델은 세 가지였다.
- Qwen3.6-35B-A3B (LLM, MoE)
- Qwen3-Embedding-8B (Embedding)
- DeepSeek-OCR-2 (OCR, 멀티모달)
이 조합을 아무 계획 없이 기동하면 십중팔구 두 번째 또는 세 번째 모델 기동 시 OOM으로 멈춘다.
문제의 출발점: VRAM을 나눠 쓴다는 것의 의미
vLLM은 프로세스가 시작될 때 --gpu-memory-utilization에 지정한 비율만큼의 VRAM을 즉시 예약한다. 나중에 필요할 때 쓰는 게 아니라 시작하면서 선점하는 구조다.
세 프로세스가 모두 같은 GPU를 쓴다면, 각 프로세스의 gpu-memory-utilization 합이 1.0을 넘는 순간 OOM이 발생한다. 정확히는 세 프로세스가 선점하려는 메모리 총합이 물리 VRAM을 초과할 때다.
모델별 최소 VRAM 계산법
모델이 VRAM에서 차지하는 용량은 크게 두 가지로 나뉜다.
가중치 (Weights)
BF16 기준으로 파라미터 수 × 2바이트다.
- Qwen3-Embedding-8B: 8B × 2 = 16GB
- DeepSeek-OCR-2: 약 7B 규모, 16GB 안팎 (비전 인코더 포함 시 약간 더)
- Qwen3.6-35B-A3B: 35B × 2 = 70GB
세 번째가 핵심이다. Qwen3.6-35B-A3B는 MoE(Mixture of Experts) 구조라서 추론 시 실제로 활성화되는 파라미터는 3B에 불과하지만, 전체 35B 가중치가 VRAM에 올라가야 한다. "활성 파라미터가 3B니까 가볍겠지"라고 생각하면 틀린다.
KV 캐시 (Key-Value Cache)
KV 캐시는 --max-model-len, 모델의 레이어 수, 헤드 수에 따라 예약된다. --max-model-len을 높게 잡을수록 KV 캐시 예약량이 커진다. vLLM은 gpu-memory-utilization으로 잡은 메모리에서 가중치를 제외한 나머지를 KV 캐시로 쓴다.
따라서:
KV 캐시 가용량 = (gpu-memory-utilization × GPU_VRAM) - 가중치 크기
이 값이 0 이하면 기동 자체가 실패한다.
H200 × 2 기준 메모리 예산 짜기

모델 크기별 GPU 메모리 요구량 (출처: unfoldai.com)
tp=2(텐서 병렬 2장)로 세 모델을 모두 기동하면, 각 GPU 입장에서 보이는 구조는 다음과 같다.
GPU당(80GB) 가중치 점유:
| 모델 | tp=2 기준 GPU당 가중치 |
|---|---|
| Qwen3.6-35B-A3B | ~35GB |
| Qwen3-Embedding-8B | ~8GB |
| DeepSeek-OCR-2 | ~8GB |
| 합계 | ~51GB |
GPU당 여유 VRAM: 80 - 51 = 약 29GB
여기서 CUDA 컨텍스트 오버헤드를 빼야 한다. 세 프로세스가 같은 GPU를 쓰면 각각 약 12GB의 컨텍스트를 점유하므로 최소 36GB가 사라진다. 실질 KV 캐시 가용량은 GPU당 약 23~26GB 수준이다.
이 예산 내에서 gpu-memory-utilization을 설정해야 한다.
권장 배분 (tp=2 공유 방식):
| 모델 | gpu-memory-utilization | GPU당 예약 |
|---|---|---|
| Qwen3.6-35B-A3B | 0.60 | 48GB |
| Qwen3-Embedding-8B | 0.15 | 12GB |
| DeepSeek-OCR-2 | 0.15 | 12GB |
| **합계 | 0.90 | 72GB** |
여유 8GB가 CUDA 오버헤드를 감당해야 한다. 아슬아슬하게 작동하는 수준이다.
더 안정적인 방법: GPU 분리 전략
세 모델이 모두 tp=2로 양쪽 GPU를 동시에 사용하는 대신, 작은 모델들은 GPU 한 장씩 배정하는 방식이 더 안정적이다.
GPU 0: Qwen3.6-35B-A3B (LLM, tp=2의 절반) + Qwen3-Embedding-8B (tp=1)
GPU 1: Qwen3.6-35B-A3B (LLM, tp=2의 절반) + DeepSeek-OCR-2 (tp=1)
CUDA_VISIBLE_DEVICES 환경 변수로 프로세스별 GPU 접근을 제한할 수 있다.
# LLM — GPU 0, 1 모두 사용 (tp=2)
CUDA_VISIBLE_DEVICES=0,1 vllm serve Qwen/Qwen3.6-35B-A3B \
--host 0.0.0.0 \
--port 10010 \
--tensor-parallel-size 2 \
--max-model-len 65536 \
--gpu-memory-utilization 0.60 \
--served-model-name Qwen3.6 \
--enable-expert-parallel \
--enable-auto-tool-choice \
--tool-call-parser qwen3_coder
# Embedding — GPU 0만 사용 (tp=1)
CUDA_VISIBLE_DEVICES=0 vllm serve /path/to/Qwen3-Embedding-8B \
--host 0.0.0.0 \
--port 10020 \
--tensor-parallel-size 1 \
--max-model-len 16384 \
--gpu-memory-utilization 0.20 \
--enforce-eager
# OCR — GPU 1만 사용 (tp=1)
CUDA_VISIBLE_DEVICES=1 vllm serve deepseek-ai/DeepSeek-OCR-2 \
--host 0.0.0.0 \
--port 10030 \
--tensor-parallel-size 1 \
--max-model-len 8192 \
--max-num-batched-tokens 8192 \
--gpu-memory-utilization 0.20 \
--logits_processors vllm.model_executor.models.deepseek_ocr:NGramPerReqLogitsProcessor \
--no-enable-prefix-caching \
--mm-processor-cache-gb 0 \
--enforce-eager
GPU별 실제 점유량:
| GPU 0 | GPU 1 | |
|---|---|---|
| LLM (tp=2) | 48GB (0.60×80) | 48GB (0.60×80) |
| Embedding (tp=1) | 16GB (0.20×80) | - |
| OCR (tp=1) | - | 16GB (0.20×80) |
| **합계 | 64GB | 64GB** |
| **여유 | 16GB | 16GB** |
GPU당 여유가 16GB로 넉넉해져서 CUDA 오버헤드와 약간의 KV 캐시 확장에도 대응할 수 있다.
자주 만나는 에러들
OOM (Out of Memory)
torch.cuda.OutOfMemoryError: CUDA out of memory.
Tried to allocate X GiB.
가장 흔한 에러다. 원인은 대부분 gpu-memory-utilization 합산이 물리 VRAM을 초과했거나, --max-model-len이 너무 높아서 KV 캐시 예약량이 가중치를 잡아먹은 경우다.
해결 순서:
--max-model-len을 줄인다 (가장 효과가 크다)--gpu-memory-utilization을 낮춘다--enforce-eager를 추가해 CUDA 그래프 메모리를 아낀다
KV 캐시가 너무 작다는 경고
vLLM 기동 로그에서 이 줄을 확인할 수 있다.
INFO: # GPU blocks: 128, # CPU blocks: 0
GPU blocks 수가 100 미만이면 KV 캐시가 거의 없다는 뜻이다. 동시 요청이 조금만 몰려도 대기가 길어지거나 처리가 거부된다. --max-model-len을 낮추거나 --gpu-memory-utilization을 올려서 KV 캐시 가용량을 늘려야 한다.
DeepSeek-OCR-2 출력이 이상하다
OCR 모델을 --logits_processors 없이 올리면 텍스트가 무한 반복되거나 전혀 다른 내용이 출력된다. 이 인자는 선택이 아니라 필수다.
--logits_processors vllm.model_executor.models.deepseek_ocr:NGramPerReqLogitsProcessor
Qwen3.6-35B-A3B 처리량이 너무 낮다
MoE 모델을 --enable-expert-parallel 없이 올리면 전문가(expert) 레이어 병렬화가 안 돼서 처리량이 크게 떨어진다. tp=2 환경에서는 이 플래그를 반드시 추가한다.
--enable-expert-parallel
CUDA Graph 캡처 중 worker가 죽는다
기동 도중 다음 로그 조합이 뜨면서 프로세스가 죽는 패턴이 있다.
profiling CUDA graph memory: piecewise=51 large=512 full=51
...
worker proc vllmworker-1 died unexpectedly
vLLM이 CUDA Graph 캡처를 준비하는 단계에서 worker가 사망한 것이다. 에러 흐름은 대략 다음과 같다.
vLLM 시작
→ 모델 weight 로드
→ determine_available_memory() 실행
→ profile_cudagraph_memory() 실행
→ CUDA graph 캡처/워밍업 중 worker 사망
→ EngineCore가 응답을 못 받음
→ worker proc died unexpectedly
determine_available_memory()는 vLLM이 KV cache를 얼마나 잡을 수 있는지 계산하는 단계다. 여기서 죽는다는 건 모델 weight 로드는 됐지만 CUDA Graph capture를 위한 추가 메모리 산정 또는 캡처가 실패했다는 뜻이다.
의심할 수 있는 원인은 세 가지다.
1. CUDA Graph capture 자체가 현재 모델/vLLM 버전/H200 환경 조합에서 깨진 경우
--enforce-eager를 추가해서 CUDA Graph를 끄고 기동되는지 확인한다.
vllm serve deepseek-ai/DeepSeek-OCR-2 \
--host 0.0.0.0 \
--port 8000 \
--logits_processors vllm.model_executor.models.deepseek_ocr:NGramPerReqLogitsProcessor \
--no-enable-prefix-caching \
--mm-processor-cache-gb 0 \
--gpu-memory-utilization 0.1 \
--max-model-len 8192 \
--max-num-batched-tokens 8192 \
--tensor-parallel-size 2 \
--disable-custom-all-reduce \
--enforce-eager
이걸로 기동되면 원인은 CUDA Graph profiling/capture 경로에 있다는 뜻이다.
2. 여러 vLLM 인스턴스가 같은 GPU에서 동시에 메모리 프로파일링하는 경우
vLLM의 memory profiling은 시작 시점의 free memory를 기준으로 계산한다. 다른 vLLM 인스턴스가 동시에 기동 중이거나 이미 올라와 있으면 프로파일링 시작 시점 free memory와 CUDA graph capture 시점 free memory가 달라진다. 여러 프로세스가 동시에 weight load → KV cache reserve → CUDA graph capture → NCCL init을 진행하면 메모리와 IPC가 서로 충돌하면서 worker가 죽을 수 있다.
pkill -f "vllm serve"
nvidia-smi # 남은 python/vllm 프로세스 확인
프로세스를 완전히 정리한 뒤 문제가 된 모델 하나만 띄워서 확인한다.
CUDA_VISIBLE_DEVICES=0 vllm serve deepseek-ai/DeepSeek-OCR-2 \
--host 0.0.0.0 \
--port 8000 \
--logits_processors vllm.model_executor.models.deepseek_ocr:NGramPerReqLogitsProcessor \
--no-enable-prefix-caching \
--mm-processor-cache-gb 0 \
--gpu-memory-utilization 0.3 \
--max-model-len 8192 \
--max-num-batched-tokens 8192 \
--enforce-eager
이게 뜨면 --enforce-eager를 제거하고 CUDA Graph를 다시 켜서 어느 지점에서 문제가 재현되는지 좁혀간다.
3. --gpu-memory-utilization이 CUDA Graph 예산을 감당하지 못하는 경우
H200 80GB 기준 --gpu-memory-utilization 0.1이면 약 8GB다. 이 안에서 model weight, activation peak, CUDA graph memory estimate, KV cache, 멀티모달 processor buffer, communication buffer를 전부 감당해야 한다. 예산이 부족하면 CUDA graph profiling 단계에서 OOM으로 worker가 죽는다. 우선 --enforce-eager로 CUDA graph를 끄고, 그 다음 --gpu-memory-utilization을 올려서 확인하는 순서로 접근한다.
CUDA Graph와 --enforce-eager 이해하기
--enforce-eager가 등장하는 맥락에서 혼동하기 쉬운 용어들을 정리한다.
네 가지 개념의 층위
- CUDA: NVIDIA GPU에서 연산을 돌리는 기본 런타임/플랫폼이다. eager든 graph든 결국 CUDA 위에서 실행된다.
- CUDA Graph: CUDA 커널 호출 흐름을 미리 녹화(capture)해두고 반복 재생(replay)하는 성능 최적화다. 실제 계산이 달라지는 게 아니라 호출 방식이 달라진다.
- Eager mode (
--enforce-eager): CUDA Graph를 쓰지 않고 매 step마다 PyTorch가 즉석에서 커널을 호출하는 방식이다. - Eager attention: attention 연산을 FlashAttention/FlashInfer 같은 특화 backend가 아닌 일반 PyTorch 방식으로 계산하는 것이다.
--enforce-eager와는 층위가 다르다.
정확한 구분은 "CUDA vs eager mode"가 아니라 "CUDA Graph mode vs eager mode"다. 둘 다 CUDA를 쓴다.
CUDA Graph가 하는 일
LLM decode는 같은 형태의 작은 연산을 수백~수천 번 반복한다. 매번 Python/PyTorch/CUDA runtime이 커널을 하나씩 launch하면 overhead가 쌓인다. CUDA Graph는 이 호출 묶음을 capture해두고 재생해서 overhead를 줄인다.
일반 eager: 매 step → 커널 A launch → 커널 B launch → 커널 C launch
CUDA Graph: 처음 한 번만 A-B-C 흐름을 capture → 이후에는 graph replay
장점은 decode latency 감소, throughput 증가다. 단점은 capture 단계에서 메모리가 추가로 필요하고, attention backend가 지원하는 연산 shape여야 한다는 제약이 있다.
Piecewise vs Full CUDA Graph
로그에 뜨는 profiling CUDA graph memory: piecewise=51 large=512 full=51은 vLLM이 CUDA Graph를 어느 범위로 capture할지 준비하는 단계다.
- Piecewise: attention처럼 CUDA Graph와 맞지 않는 연산은 eager로 두고 MLP/norm 등 나머지만 graph로 묶는다. 호환성이 좋고 여러 attention backend에서 동작하지만 성능 이득은 Full보다 작다.
- Full: attention까지 포함해서 forward 경로를 더 크게 capture한다. 성능 이득이 크지만 attention backend가 지원해야 하고, 멀티모달/동적 shape 모델에서 깨지기 쉽다.
--enforce-eager 판단 기준
| 상황 | 판단 |
|---|---|
profile_cudagraph_memory()에서 worker 사망 | --enforce-eager 추가로 원인 분리 |
| 멀티모달/OCR 모델에서 CUDA Graph 불안정 | --enforce-eager 유지 |
| 메모리가 빠듯한 다중 모델 환경 | --enforce-eager 검토 |
| 안정성 확인 후 throughput을 회복해야 하는 경우 | --enforce-eager 제거 후 재시도 |
--enforce-eager에서도 worker가 죽는다면 CUDA Graph 문제가 아니라 모델 구현, attention backend, vLLM 버전, CUDA/NCCL, GPU worker 자체 문제로 범위가 이동한다.
성능 트레이드오프
| CUDA Graph | --enforce-eager | |
|---|---|---|
| decode latency | 낮음 | 높음 |
| throughput | 높음 | 낮음 |
| 초기화 메모리 | 더 많이 필요 | 적음 |
| 시작 안정성 | 낮음 (capture 실패 가능) | 높음 |
| 동적 shape / 멀티모달 | 취약 | 강함 |
다중 모델 환경에서 처음 기동할 때는 --enforce-eager로 안정성을 먼저 확보하고, 이후 CUDA Graph를 켜서 성능을 높이는 순서로 접근하는 것이 일반적이다.
기동 순서
여러 모델을 순차적으로 올릴 때는 큰 모델부터 시작한다. LLM이 자리를 잡은 뒤 나머지를 올려야 "기동 중 OOM" 상황을 피할 수 있다.
- LLM 기동 후 로그에서
Application startup complete확인 - Embedding 기동 후 같은 방식으로 확인
- OCR 기동
각 모델이 올라간 뒤에는 간단한 요청을 한 번 보내서 응답이 오는지 확인하는 것이 좋다.
# LLM 헬스 체크
curl http://localhost:10010/health
# Embedding 동작 확인
curl http://localhost:10020/v1/embeddings \
-H "Content-Type: application/json" \
-d '{"model": "Qwen3-Embedding-8B", "input": "test"}'
정리: H200 × 2 실전 배분 원칙

멀티 모델 배포 구성 개요 (출처: truefoundry.com)
- 모든 모델의
gpu-memory-utilization합은 GPU당 0.85~0.90 이하로 유지한다. 나머지는 CUDA 컨텍스트 오버헤드용이다. --max-model-len은 실제 사용 패턴에 맞게 낮춘다. Qwen3.6-35B-A3B의 경우 원래 262144를 지원하지만 60~65536으로 낮춰도 대부분의 태스크에서 충분하다.- 작은 모델(8B 이하)은
CUDA_VISIBLE_DEVICES로 GPU를 분리하고 tp=1로 올리면 LLM과의 간섭이 줄어든다. --enforce-eager는 메모리가 빠듯할 때 쓰는 비상 수단이다. CUDA 그래프를 건너뛰어 1~2GB를 아끼지만 첫 요청 레이턴시가 약간 늘어난다.- MoE 모델은 활성 파라미터 기준이 아닌 전체 파라미터 기준으로 VRAM을 계산한다.