서빙 서버가 죽는 OOM의 대부분은 KV 캐시 예산 초과에서 나온다. 웨이트는 로딩 직후 메모리에 고정되고, 이후 달라지는 것은 KV 캐시뿐이다. 배치를 늘리거나 컨텍스트를 길게 가져가는 순간 메모리가 폭발하는 이유를 사전에 계산하지 않고 실험으로 때우다가 GPU 시간을 날리는 패턴이 흔하다. 공식 자체는 단순하다.
공식과 실제 수치
KV 캐시 크기를 결정하는 변수는 딱 여섯 개다.
KV_cache_bytes = 2 × L × H_kv × d_head × S × B × P
2— Key와 Value를 각각 저장L— Transformer 레이어 수H_kv— KV 헤드 수 (GQA 모델에서는 Q 헤드보다 훨씬 적다)d_head— 헤드 차원 (head dimension)S— 현재까지 처리한 토큰 수 (seq_len)B— 동시 처리 요청 수 (batch size)P— 요소당 바이트 수 (FP16 → 2, FP8 → 1, INT4 → 0.5)
Llama-3 8B에 대입해보자. 이 모델은 32 레이어, 8 KV 헤드(GQA), 헤드 차원 128이다.
# Llama-3 8B, FP16, seq_len=4096, batch=32
2 × 32 × 8 × 128 × 4096 × 32 × 2 = 17,179,869,184 bytes ≈ 16 GB
A100 80GB에서 Llama-3 8B 웨이트는 FP16 기준 약 16 GB를 차지한다. 남은 64 GB 안에서 KV 캐시가 살아야 하므로, batch=32, seq=4096 조건은 여전히 여유가 있다. 그런데 컨텍스트가 길어지는 순간 이야기가 달라진다.
Llama-3 70B는 레이어 80개, Q 헤드 64개이지만 KV 헤드는 8개(GQA)다. batch=4, seq=4096에서:
# 실제 GQA (kv_heads=8)
2 × 80 × 8 × 128 × 4096 × 4 × 2 = 5,368,709,120 bytes ≈ 5 GB
# MHA로 설계했다면 (kv_heads=64)
2 × 80 × 64 × 128 × 4096 × 4 × 2 = 42,949,672,960 bytes ≈ 40 GB
GQA 덕분에 같은 조건에서 8배 차이다. MHA 그대로였다면 A100 2장(160 GB)에 70B 웨이트(약 140 GB)를 올리고 나서 KV 예산 40 GB를 추가로 확보하는 건 불가능에 가깝다.
컨텍스트 길이와 배치 크기: 제로섬 관계
공식에서 S와 B는 곱 관계다. GPU 메모리 예산이 고정되면 S × B의 상한도 고정된다.
Llama-3 8B, FP16, KV 예산 16 GB 기준:
| seq_len | max_batch |
|---|---|
| 4,096 | 32 |
| 8,192 | 16 |
| 32,768 | 4 |
| 131,072 | 1 |
seq_len을 4k에서 32k로 8배 늘리면 배치 크기는 32에서 4로 줄어든다. 처리량 관점에서 8배 손실이다. "long-context 서빙은 비싸다"는 말이 여기서 나온다. 컨텍스트와 배치가 동일 KV 예산 안에서 서로를 잡아먹기 때문이다.
이 관계는 실제 배포 결정에 직접 영향을 준다. 입력 길이를 평균 16k로 늘리는 것이 제품 요구사항에 들어왔다면, 처리량 목표를 유지하려면 GPU 수를 늘리거나, H_kv를 줄이거나(GQA → MQA 또는 MLA), P를 줄여야(양자화) 한다. 이 공식이 그 계산의 시작점이다.
MHA → GQA → MQA → MLA: H_kv를 줄이는 방향들
KV 메모리를 줄이는 구조적 방법은 H_kv를 줄이거나, 동치의 효과를 내도록 캐시 표현을 바꾸는 것이다.
GQA는 Ainslie et al. 2023에서 제안됐다. Q 헤드를 그룹으로 묶고 그룹당 하나의 KV 헤드를 공유한다. Llama-3 70B의 Q=64, KV=8은 8개 그룹이 각 8개의 Q 헤드를 공유하는 구조다. 절감 배율은 MHA 대비 H_q / H_kv, 즉 64/8 = 8배다.
MQA는 극단이다. KV 헤드를 1개로 줄인다. 메모리는 최대로 아끼지만 품질 저하가 나타난다. 실제 대규모 모델에서는 GQA가 균형점으로 채택됐고, MQA는 거의 사용되지 않는다.
MLA(Multi-head Latent Attention)는 DeepSeek-V2 테크니컬 리포트에서 공개됐다. 접근이 GQA와 다르다. K·V를 헤드별로 저장하는 대신, 모든 헤드가 공유하는 저차원 잠재 벡터(latent vector)를 캐시하고 추론 시 업프로젝션으로 각 헤드의 K·V를 복원한다. 수학적으로는 H_kv와 d_head를 동시에 줄이는 것과 동치인데, 공유 저차원 공간을 쓰기 때문에 품질 보존이 더 좋다. DeepSeek-V2는 이 구조로 KV 캐시를 전작 DeepSeek 67B 대비 93.3% 줄였다.
DeepSeek 팀의 비교 실험에서 GQA는 MHA보다 품질이 낮았지만 MLA는 MHA 수준을 유지했다. 세 방법 모두 공식의 H_kv 항(또는 동치 항)을 줄이는 전략이고, 절감 배율이 수식에서 정확히 계산된다는 점이 실용적 가치다.
KV 양자화: P를 줄이되, 오차가 나는 지점을 알고 써야 한다
P를 줄이는 것이 양자화다. 절감은 단순하다.
| dtype | P (bytes) | FP16 대비 절감 |
|---|---|---|
| FP16 / BF16 | 2 | 기준 |
| FP8 | 1 | 50% |
| INT4 | 0.5 | 75% |
vLLM에서 FP8 KV 캐시는 한 줄로 켠다.
vllm serve <model> --kv-cache-dtype fp8
내부적으로는 KV 텐서를 fp8_e4m3 포맷으로 저장하고, QK·ScoreV 행렬곱 자체도 FP8로 실행한다. CUDA 11.8 이상이면 지원되며, H100·B200에서는 하드웨어 FP8 연산이 직접 가속된다.
vLLM 공식 블로그(2026.04)에 실린 수치에 따르면, Llama-3.1-8B 기준으로 FP8은 BF16 대비 출력 처리량을 14.9% 높이고 inter-token latency 기울기를 BF16의 54%로 낮춘다. 1M 토큰짜리 long-context 벤치마크에서 baseline과 AUC가 동등했고, reasoning 태스크에서 최대 1~2점 손실이 보고됐다.
INT4까지 내려가면 절감은 크지만 오차 패턴이 달라진다. KIVI(ICML 2024)가 밝힌 것처럼, 2bit 수준에서 오차는 "attention sink 토큰" 주변에 집중된다. attention sink는 대부분의 토큰이 주목하는 소수의 위치(보통 문장 첫 번째 토큰)인데, 이 위치의 양자화 오차가 attention 결과 전반에 영향을 준다. KIVI 수치로는 2.6배 메모리 절감에 GSM8K 기준 약 3% 정확도 손실이 따른다.
컨텍스트가 짧을 때(<2k)는 sink 토큰 비중이 작아서 INT4도 비교적 안전하다. 32k를 넘어가면 누적 오차 가중치가 높아지면서 perplexity 저하가 뚜렷해진다. FP8이 현실적인 타협점으로 자리 잡은 배경이 여기 있다.
OOM이 터지는 세 가지 시나리오
시나리오 1 — 배치 고정, 컨텍스트 증가: prefill 단계에서 요청 하나의 입력이 예상보다 길어지는 경우다. 배치 안의 다른 요청들이 이미 KV를 채워놓은 상태에서 한 요청이 128k짜리 문서를 처리하려 하면 KV 예산이 순간 폭증한다. vLLM에서는 여유 페이지가 없으면 CUDA OOM 대신 요청 선점(preemption)이 발생하지만, torch를 직접 돌리는 경우에는 바로 죽는다.
메모리 상태 확인:
import torch
print(torch.cuda.memory_summary(abbreviated=True))
reserved_bytes.all.peak가 allocated_bytes.all.current보다 현저히 크면, 과거 어느 시점에 KV가 폭증했다가 해제된 적이 있다는 뜻이다.
시나리오 2 — 컨텍스트 고정, 배치 증가: 초기에 batch=16으로 잘 돌다가 트래픽이 몰리면서 동시 요청이 늘어 batch가 32, 48로 올라가는 패턴이다. 웨이트는 그대로인데 KV가 배치에 선형으로 증가하다가 남은 메모리를 초과한다.
nvidia-smi --query-gpu=memory.used,memory.free --format=csv -l 1
1초 간격으로 보면 배치가 쌓일수록 memory.used가 계단식으로 올라가는 것이 보인다.
시나리오 3 — prefill spike: decode 단계에서는 시퀀스가 한 토큰씩 늘어나므로 KV 증가 속도가 완만하다. prefill은 다르다. 긴 프롬프트 여러 개가 동시에 prefill에 들어오면 각 시퀀스의 KV가 한꺼번에 할당되면서 메모리가 순간적으로 치솟는다. 평상시에는 안정적이다가 특정 시점에 간헐적 OOM이 나오면 이 패턴을 의심한다. vLLM에서는 --max-num-batched-tokens 파라미터로 한 스텝당 처리 토큰 수를 제한해서 이 스파이크를 억제한다.
빠른 계산 시트
def kv_cache_gb(num_layers, num_kv_heads, head_dim, seq_len, batch_size, dtype_bytes=2):
total = 2 * num_layers * num_kv_heads * head_dim * seq_len * batch_size * dtype_bytes
return total / (1024 ** 3)
# Llama-3 8B, FP16, seq_len=4096, batch=32
kv_cache_gb(32, 8, 128, 4096, 32, 2) # → 16.0 GB
# seq_len=32768, batch=4 — 동일 16 GB 예산
kv_cache_gb(32, 8, 128, 32768, 4, 2) # → 16.0 GB
# FP8으로 전환 (P=1)
kv_cache_gb(32, 8, 128, 32768, 4, 1) # → 8.0 GB → batch를 8로 늘릴 수 있다
PagedAttention은 이 공식의 총량을 바꾸지 않는다. KV 페이지를 동적으로 할당해서 내부 단편화를 줄일 뿐이고, 예산 총량은 이 공식이 지배한다. GQA나 FP8이 공식의 항을 직접 줄이는 것과는 다르다.
GQA(kv_heads=8)에서 MLA로 전환했을 때 동일 품질 조건에서 배치 크기를 실제로 얼마나 더 늘릴 수 있는가는 직접 실험해봐야 알 수 있는 영역이다. DeepSeek-V2의 93.3% 절감 수치가 실제 서빙 배치 구성에 어떻게 변환되는지가 다음 궁금증이다.