- Published on
Transformers 로컬 LLM OOM, vLLM·PagedAttention로 끝내기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙/개발 환경에서 transformers로 로컬 LLM을 띄우다 보면, 모델 로딩은 되는데 몇 요청만 받아도 CUDA OOM 이 터지는 상황을 자주 만납니다. 특히 스트리밍 응답, 긴 컨텍스트, 동시 요청이 겹치면 재현이 쉽습니다.
이 글은 “왜 OOM이 나는가”를 KV 캐시 기준으로 해부하고, vLLM의 PagedAttention 으로 어떻게 메모리 효율과 동시성을 함께 잡는지, 그리고 실제로 마이그레이션할 때 필요한 설정/검증 포인트를 정리합니다.
아래 내용은 단일 GPU 로컬 환경(예: 24GB, 48GB)부터, 여러 사용자에게 API 형태로 제공하는 소규모 서빙까지 그대로 적용됩니다.
로컬 LLM OOM의 진짜 범인: 가중치가 아니라 KV 캐시
대부분 “모델이 커서 OOM” 이라고 생각하지만, 로컬 서빙에서 갑자기 터지는 OOM의 주범은 대개 KV 캐시(Key/Value cache) 입니다.
KV 캐시가 왜 커지나
디코딩(토큰 생성) 과정에서 Transformer는 매 스텝마다 이전 토큰들의 attention 정보를 재사용하기 위해, 각 레이어에 대해 Key/Value 텐서를 누적 저장합니다. 이게 KV 캐시입니다.
KV 캐시 메모리 사용량은 대략 다음에 비례합니다.
batch_size(동시 처리 요청 수)num_layersnum_heads또는num_kv_headshead_dimsequence_length(프롬프트 길이 + 생성 길이)- dtype(
fp16,bf16) 및 구현
즉, 컨텍스트가 길어지고 동시 요청이 늘면 선형으로 커집니다.
Transformers에서 OOM이 더 잘 나는 이유
transformers의 일반적인 서빙 방식은 다음 특성을 가집니다.
- 각 요청의 KV 캐시가 연속된 큰 텐서 로 할당되기 쉬움
- 요청마다 길이가 다르면, GPU 메모리에서 단편화(fragmentation) 가 심해짐
- 동시 요청이 오면, “남는 공간이 있는데도” 연속 블록을 못 잡아 OOM이 날 수 있음
- 배치 최적화가 제한적이라, 처리량을 올리려다
batch_size를 키우면 KV 캐시가 급증
이런 패턴은 인프라 운영에서도 흔히 보는 “리소스는 남는데 실패하는” 유형입니다. 예를 들어 타임아웃/지연이 누적되어 장애로 보이는 케이스는 GCP Cloud Run 504와 콜드스타트 지연 해결 가이드 같은 글에서 다루는 것처럼, 겉으로 드러나는 현상과 실제 병목이 다른 경우가 많습니다.
증상 체크리스트: 이러면 KV 캐시 OOM을 의심
다음 중 2개 이상이면 KV 캐시/단편화 이슈일 확률이 큽니다.
- 모델 로딩은 성공하지만, 몇 요청 후
CUDA out of memory발생 - 짧은 프롬프트는 괜찮은데, 긴 문서/대화가 들어오면 급격히 터짐
- 동시 요청(예: 4~16)에서만 재현
torch.cuda.empty_cache()를 호출해도 근본 해결이 안 됨- GPU 사용량이 들쭉날쭉하고, 특정 순간에만 스파이크로 OOM
단기 처방: Transformers에서 할 수 있는 OOM 완화
vLLM로 가기 전에, transformers로도 “덜 터지게” 만드는 방법은 있습니다. 다만 근본적 해결이라기보다는 운영 타협안 에 가깝습니다.
1) 컨텍스트/생성 길이 상한을 강제
서빙에서 무제한 컨텍스트는 곧 무제한 KV 캐시입니다.
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
model_id = "meta-llama/Llama-2-7b-chat-hf"
tok = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
model_id,
torch_dtype=torch.float16,
device_map="cuda",
)
prompt = "요약해줘: ..."
inputs = tok(prompt, return_tensors="pt").to("cuda")
# 핵심: max_new_tokens, max_length를 보수적으로
out = model.generate(
**inputs,
max_new_tokens=256,
do_sample=False,
)
print(tok.decode(out[0], skip_special_tokens=True))
2) 동시성은 프로세스/큐로 제어
동시 요청을 무작정 GPU에 밀어 넣으면 KV 캐시가 폭증합니다. 애플리케이션 레벨에서 큐를 두고 concurrency를 제한하는 방식이 안전합니다.
이때 “요청이 밀려서 타임아웃” 문제가 함께 생기기 쉬운데, 타임아웃은 LLM 서빙에서 매우 흔한 운영 이슈입니다. 비슷한 관점의 트러블슈팅은 OpenAI Responses API 408 타임아웃 재현과 해결 실전 가이드도 참고할 만합니다.
3) 양자화(Quantization)
가중치 메모리는 줄어도 KV 캐시는 그대로라서, “몇 요청 후 OOM” 유형이 완전히 사라지진 않습니다. 그래도 여유 VRAM이 늘어나면 버틸 확률은 올라갑니다.
bitsandbytes4bit/8bit- GPTQ/AWQ
하지만 동시성 + 긴 컨텍스트 조합이 목표라면, 결국 KV 캐시 관리가 핵심입니다.
근본 해결: vLLM과 PagedAttention이 하는 일
vLLM은 LLM 서빙에 특화된 런타임입니다. 핵심은 PagedAttention 이라는 KV 캐시 메모리 관리자에 있습니다.
PagedAttention 한 줄 요약
KV 캐시를 “요청마다 큰 연속 텐서”로 잡는 대신, 페이지(블록) 단위로 쪼개서 필요할 때만 할당하고, 재사용 가능한 형태로 관리합니다.
이 방식의 효과는 크게 3가지입니다.
- 메모리 단편화 감소
- 다양한 길이의 요청이 섞여도 페이지 단위로 할당/반납하므로 연속 블록 부족 문제가 줄어듭니다.
- 높은 GPU utilization
- 요청을 효율적으로 묶어 처리하는 스케줄링과 함께, KV 캐시가 발목을 잡지 않게 됩니다.
- 동시성 확장
- 같은 VRAM에서 더 많은 동시 요청을 처리할 가능성이 커집니다.
즉, transformers에서 “운영 레벨 제한”으로 해결하던 문제를, vLLM은 “런타임 레벨 메모리 관리”로 풀어줍니다.
vLLM로 로컬 서빙 시작하기
가장 쉬운 시작은 OpenAI 호환 API 서버 모드입니다.
설치
pip install -U vllm
CUDA/드라이버 호환성에 따라 추가 패키지가 필요할 수 있고, 환경에 따라 torch 버전 조합이 중요합니다.
OpenAI 호환 서버 실행
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-2-7b-chat-hf \
--dtype half \
--gpu-memory-utilization 0.90 \
--max-model-len 4096
--gpu-memory-utilization은 VRAM을 얼마나 공격적으로 쓸지 결정합니다. 너무 높이면 다른 CUDA 컨텍스트/오버헤드 때문에 오히려 불안정해질 수 있어0.85~0.92사이에서 튜닝하는 편이 많습니다.--max-model-len은 KV 캐시 상한과 직결됩니다. “길면 좋다”가 아니라 “운영 가능한 범위”로 잡는 게 중요합니다.
요청 예시(curl)
curl http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "meta-llama/Llama-2-7b-chat-hf",
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "KV 캐시가 뭐야?"}
],
"temperature": 0,
"max_tokens": 256
}'
기존에 OpenAI SDK를 쓰고 있었다면, base URL만 바꿔 로컬로 붙일 수 있어 마이그레이션 비용이 낮습니다.
vLLM에서 OOM을 더 줄이는 실전 튜닝 포인트
vLLM이 만능은 아니고, 설정을 잘못 잡으면 여전히 OOM이 납니다. 다만 같은 조건에서 transformers 대비 “운영 가능한 영역”이 훨씬 넓어집니다.
1) 모델 길이 정책: max-model-len 을 먼저 정하라
조직/서비스 요구사항에 맞춰 다음을 먼저 결정하세요.
- 입력 컨텍스트 최대 길이
- 출력 최대 토큰
- 동시 요청 목표치
그리고 그 합을 기준으로 --max-model-len 을 잡습니다. 무제한으로 열어두면 “언젠가” 터집니다.
2) 동시성 목표가 있으면 배치/스케줄링을 관찰
vLLM은 내부적으로 요청을 효율적으로 스케줄링하지만, 워크로드가 다음과 같으면 메모리 압박이 커집니다.
- 모두가 긴 컨텍스트를 사용
- 모두가 긴 출력을 생성
- 스트리밍으로 오래 붙잡고 있음
이 경우에도 “큐잉 + 타임아웃 정책”이 필요합니다. 타임아웃은 단순히 클라이언트 문제가 아니라, 서버 자원 보호 장치이기도 합니다.
3) gpu-memory-utilization 을 과신하지 않기
이 값은 “여기까지는 써도 된다”가 아니라 “이 정도까지를 목표로 잡는다”에 가깝습니다.
- 너무 낮으면 KV 캐시 풀 자체가 작아져 처리량이 떨어짐
- 너무 높으면 피크 순간에 다른 오버헤드와 겹쳐 OOM 가능
현실적으로는 nvidia-smi로 여유 VRAM을 1~2GB 정도 남기는 방향이 안정적입니다.
(비교) 왜 PagedAttention이 특히 긴 컨텍스트에 강한가
긴 컨텍스트에서 가장 무서운 건 “요청 길이의 분산”입니다.
- 어떤 사용자는 200 토큰
- 어떤 사용자는 6000 토큰
- 어떤 사용자는 1500 토큰 + 1500 토큰 생성
이렇게 섞이면, 연속 메모리 할당 기반에서는 “큰 요청이 들어올 때마다” 단편화가 누적됩니다. PagedAttention은 KV 캐시를 페이지로 쪼개 관리하므로, 큰 요청이 들어와도 페이지를 이어붙이는 방식으로 대응할 수 있어 단편화에 덜 취약합니다.
장애 대응 관점: OOM 이후 재기동만으로 끝내지 말 것
OOM이 한 번 나면 프로세스가 죽거나, CUDA 컨텍스트가 불안정해져 이후 요청도 연쇄 실패할 수 있습니다. 운영에서는 다음을 함께 준비하는 게 좋습니다.
- 헬스체크와 자동 재시작(프로세스 매니저 또는 컨테이너)
- 요청 큐/레이트리밋
- 컨텍스트 길이 제한 정책
- 메트릭: GPU 메모리, 활성 시퀀스 수, 평균 입력/출력 토큰, p95 지연
컨테이너 환경이라면, 장애 원인을 빠르게 좁히는 기본기(이벤트/로그/리소스 확인)는 LLM 서빙에도 그대로 적용됩니다. 비슷한 방식의 빠른 진단 흐름은 K8s CrashLoopBackOff 원인 10분내 찾는 법과도 맥이 닿아 있습니다.
마이그레이션 가이드: Transformers 코드에서 vLLM로 옮길 때
정리하면 다음 순서가 가장 시행착오가 적습니다.
- 요구사항을 토큰으로 환산
- 최대 입력 토큰, 최대 출력 토큰, 동시 요청 목표
vLLM서버 모드로 먼저 띄우기
- OpenAI 호환 API로 붙여서 애플리케이션 변경 최소화
max-model-len과gpu-memory-utilization튜닝
- 안정 동작을 먼저 확보한 뒤 처리량을 올리기
- 관측과 정책 추가
- 긴 요청 거절/축약, 큐잉, 타임아웃
- 필요하면 양자화/더 큰 GPU/텐서 병렬 검토
결론
transformers로 로컬 LLM을 서빙할 때 터지는 OOM은, 단순히 “모델이 커서”라기보다 KV 캐시와 메모리 단편화 문제가 핵심인 경우가 많습니다. 이때 vLLM의 PagedAttention 은 KV 캐시를 페이지 단위로 관리해 단편화를 줄이고, 동시성과 처리량을 현실적인 수준으로 끌어올립니다.
- 짧은 데모/단일 요청 위주라면
transformers도 충분 - 긴 컨텍스트, 스트리밍, 동시 요청이 목표라면
vLLM이 운영 난이도를 크게 낮춤
다음 단계로는, 실제 워크로드에서 평균/최대 토큰 분포를 뽑아 max-model-len 과 동시성 정책을 수치로 설계해보는 것을 권합니다.