왜 LLM 서빙에서 OOM이 잦은가

LLM 추론의 GPU 메모리는 크게 세 덩어리로 나뉜다. 모델 가중치, KV 캐시, 그리고 활성값(activation)과 프레임워크 오버헤드다. 가중치는 정적이라 예측이 쉽지만, KV 캐시는 동시 요청 수와 시퀀스 길이에 비례해 동적으로 늘어난다. 실제 장애의 대부분은 "평소엔 멀쩡한데 긴 프롬프트가 몰리면 죽는" 패턴이고, 원인은 KV 캐시가 순간적으로 남은 메모리를 초과하는 것이다.

13B 모델을 fp16으로 올리면 가중치만 약 26GB다. 여기에 요청당 KV 캐시가 붙는데, 이 크기는 2 × layers × 2(K,V) × hidden × dtype_bytes × seq_len로 계산된다. 시퀀스 길이가 길어질수록 선형으로 증가하므로, 40GB GPU에서 여유가 14GB뿐이라면 동시성 한계가 생각보다 빨리 온다.

먼저 실측: 메모리를 눈으로 확인한다

추정 전에 반드시 실측한다. 정적 스냅샷은 nvidia-smi로 충분하지만, 단계별 증가분은 PyTorch의 메모리 통계로 봐야 원인을 짚을 수 있다.

import torch

torch.cuda.reset_peak_memory_stats()
# ... 모델 로드 및 추론 실행 ...
alloc = torch.cuda.memory_allocated() / 1024**3
peak  = torch.cuda.max_memory_allocated() / 1024**3
reserved = torch.cuda.memory_reserved() / 1024**3
print(f"allocated={alloc:.2f}GB peak={peak:.2f}GB reserved={reserved:.2f}GB")

# 어디서 잡혔는지 상세 스냅샷
torch.cuda.memory._dump_snapshot("mem.pickle")

여기서 reserved와 allocated의 차이가 크면 단편화(fragmentation)를 의심한다. 캐싱 할당자가 큰 블록을 잡아두고 있어 실제 여유보다 가용 메모리가 적게 보이는 경우다.

KV 캐시 용량을 계산으로 잡는다

서빙 파라미터를 감이 아니라 수식으로 정한다. 아래는 요청 하나가 소모하는 KV 캐시를 계산하는 예시다.

def kv_cache_gb(layers, hidden, seq_len, dtype_bytes=2, batch=1):
    # K와 V 두 개, layer마다 저장
    bytes_total = 2 * layers * hidden * seq_len * dtype_bytes * batch
    return bytes_total / 1024**3

# 예: 40 layers, hidden 5120, 4096 토큰, fp16
print(kv_cache_gb(40, 5120, 4096))  # 약 3.1GB / 요청

이 값을 알면 "가용 메모리 ÷ 요청당 KV 캐시"로 최대 동시성이 나온다. 여기서 안전 마진 15~20%는 활성값과 단편화 몫으로 반드시 남긴다.

vLLM 기반 방지 설정

vLLM은 PagedAttention으로 KV 캐시를 페이지 단위로 관리해 단편화를 줄인다. 핵심은 gpu_memory_utilization과 max_model_len으로 상한을 명시하는 것이다.

from vllm import LLM

llm = LLM(
    model="meta-llama/Llama-2-13b-hf",
    gpu_memory_utilization=0.85,   # 여유 15% 확보
    max_model_len=4096,            # 시퀀스 상한 고정
    max_num_seqs=32,               # 동시 시퀀스 상한
    enforce_eager=False,           # CUDA graph로 오버헤드 절감
)

gpu_memory_utilization을 0.95처럼 높게 잡으면 처리량은 오르지만, 활성값 급증 시 방어막이 사라진다. 프로덕션에서는 0.85~0.90을 권장한다.

단편화와 할당자 튜닝

PyTorch 캐싱 할당자의 단편화는 환경 변수로 완화할 수 있다. expandable_segments는 블록을 확장 가능하게 만들어 큰 가변 텐서에서 특히 효과적이다.

export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True,max_split_size_mb:256

다만 이 설정이 모든 워크로드에서 이득은 아니다. 고정 길이 배치에서는 오히려 오버헤드가 될 수 있으니, 앞서 본 실측으로 reserved-allocated 격차가 줄었는지 확인 후 적용한다.

서빙 전략 비교

전략메모리 효율주의점
정적 배치낮음패딩 낭비, 긴 요청에 취약
연속 배치(continuous)높음스케줄러 복잡도 증가
PagedAttention매우 높음프레임워크 의존
양자화(INT8/4)가중치 절감 큼정확도 손실 검증 필요

운영 단계의 방어선

계산과 설정으로도 극단적 입력은 새어나온다. 최종 방어선은 게이트웨이 단의 입력 길이 제한과 큐잉이다. 최대 토큰 수를 초과하는 요청은 서빙 계층 진입 전에 거부하고, 동시성이 임계치를 넘으면 429로 백프레셔를 건다. OOM으로 프로세스 전체가 죽는 것보다, 일부 요청을 우아하게 거절하는 편이 가용성에 훨씬 유리하다.

마지막으로 max_memory_allocated를 상시 메트릭으로 수집해 임계치의 90% 도달 시 경보를 건다. OOM은 사후 로그로 원인을 찾기 어렵기 때문에, 죽기 전 추세를 보는 것이 가장 확실한 예방책이다.