서빙 중 nvidia-smi가 80%를 찍고 있는데 OOM이 터진다. 직관적으로 20%가 남아 있어야 할 것 같은데 할당에 실패한다. CUDA 메모리 할당자가 설계된 방식이 만드는 필연적인 결과다.
PagedAttention이 이 문제를 다 해결했다는 말이 자주 나온다. 외부 단편화(external fragmentation)는 실질적으로 제거했다. 그러나 내부 단편화, CUDA 할당자의 reserved/allocated 괴리, gpu_memory_utilization 오해는 여전히 서빙 현장에서 메모리를 잠식하고 있다.
단편화의 두 층위
LLM 서빙에서 메모리 단편화는 두 가지 맥락에서 독립적으로 발생한다.
외부 단편화는 가용 메모리 총합은 충분한데 연속된 빈 블록이 없어서 할당이 실패하는 상황이다. PagedAttention 이전 시스템은 KV 캐시를 요청별 연속 슬래브로 할당했기 때문에 이 문제가 심각했다. Kwon et al. 2023은 이 방식에서 메모리 낭비가 60–80%에 달했다고 측정했다. PagedAttention은 고정 크기 블록 단위로 KV 캐시를 분할하고 비연속 물리 메모리에 분산 배치해서 이 제약을 제거했다. 어떤 빈 블록이든 어떤 요청에든 배정할 수 있으니 연속성 요구가 사라진다.
내부 단편화는 할당된 블록 내부에서 발생하는 낭비다. 각 요청의 마지막 블록이 꽉 차지 않으면 남은 슬롯은 다른 요청이 쓸 수 없다. PagedAttention은 이것을 없애지 않았다. 줄였을 뿐이다. 이전 시스템은 최대 시퀀스 길이(예: 2,048 토큰)를 처음부터 예약해 실제 생성이 짧으면 대부분이 낭비됐다. 지금은 낭비가 마지막 블록 안으로 국한된다 — 최대 block_size - 1 슬롯. 그래도 워크로드 특성에 따라 무시하기 어려운 낭비가 남는다.
CUDA 할당자가 단편화를 숨기는 방식
PyTorch의 CUDA caching allocator는 cudaMalloc 호출 비용을 줄이기 위해 한 번 확보한 메모리를 CUDA에 바로 돌려주지 않는다. 텐서가 해제되면 Python 쪽에서는 사라지지만 PyTorch 내부 캐시에 남는다. 이것이 reserved 메모리다.
x = torch.randn(1024, 1024, device='cuda') # ~4MB 할당
del x # Python 해제 — CUDA에는 반환 안 함
print(torch.cuda.memory_allocated()) # 0
print(torch.cuda.memory_reserved()) # ~4MB 이상, 캐시에 보유 중
PyTorch CUDA caching allocator는 할당 요청이 오면 캐시에서 충분한 크기의 블록을 찾아 반환하고, 없으면 cudaMalloc을 호출한다. 모델이 다양한 크기의 텐서를 생성·해제하다 보면 캐시에는 크고 작은 블록 조각이 쌓인다. 총합은 커도 특정 크기의 연속 블록이 없으면 cudaMalloc 재시도가 발생하고, 그래도 안 되면 OOM이다.
nvidia-smi가 보여주는 숫자는 이 reserved 메모리를 포함한다. 세 지표가 각각 다른 것을 측정한다:
| 지표 | 측정 대상 |
|---|---|
nvidia-smi | 프로세스가 OS에서 확보한 총 GPU 메모리 (cuBLAS, NCCL 포함) |
torch.cuda.memory_reserved() | PyTorch caching allocator 보유 총 메모리 (캐시 포함, 타 라이브러리 제외) |
torch.cuda.memory_allocated() | 실제 텐서가 점유 중인 메모리 |
서빙 중 nvidia-smi 78GB, reserved 70GB, allocated 45GB라면 25GB가 캐시에 묶여 있고 일부는 할당 불가능한 단편으로 흩어진 상태다. 이 세 값이 크게 벌어질수록 단편화가 심한 상태다.
gpu_memory_utilization이 실제로 하는 일
gpu_memory_utilization=0.9는 "전체 GPU 메모리의 90%를 KV 캐시에 쓴다"는 의미가 아니다. 가중치와 CUDA 오버헤드를 제외한 나머지 중 KV 캐시에 쓸 비율이다. vLLM의 실제 동작 순서는 다음과 같다:
- 모델 가중치를 GPU에 로드
- 더미 입력으로 forward pass를 실행해 peak 메모리를 프로파일링
total_gpu_memory × gpu_memory_utilization을 상한으로 삼아 KV 캐시 할당량 산정(total_gpu_memory × gpu_memory_utilization) − (가중치 + CUDA 그래프 + activation)을 KV 캐시에 배정
Llama-3-8B(FP16), A100 80GB, gpu_memory_utilization=0.9, block_size=16 기준 계산:
target = 80GB × 0.9 = 72GB
가중치 ≈ 16GB (8B params × 2 bytes, FP16)
오버헤드 ≈ 2GB (CUDA 그래프, activation, NCCL 등)
KV 캐시 가용 = 72 − 16 − 2 = 54GB
Llama-3-8B는 32 레이어, GQA 8 KV heads, head_dim=128이다. 블록 하나의 메모리:
bytes_per_block
= 2(K+V) × block_size × num_layers × num_kv_heads × head_dim × dtype_bytes
= 2 × 16 × 32 × 8 × 128 × 2
= 2,097,152 bytes ≈ 2MB
가용 블록 수:
num_blocks = 54 × 1024 MB ÷ 2 MB ≈ 27,648
max_tokens = 27,648 × 16 = 442,368 토큰
vLLM 시작 로그에서 직접 확인할 수 있다:
INFO ... worker.py] # GPU blocks: 27648, # CPU blocks: 0
이 숫자에서 동시 처리 가능 시퀀스 수를 역산하면 num_blocks ÷ ceil(max_seq_len / block_size)다. max_seq_len=2,048이라면 27,648 ÷ 128 = 216이 이론 상한이다(스케줄러 정책에 따라 실제는 다름).
gpu_memory_utilization을 너무 높게 잡으면(0.97 이상) 프로파일링 단계에서 바로 OOM이 난다. 너무 낮으면 KV 캐시가 부족해 처리량이 떨어진다. 0.85–0.92가 대부분 환경에서 안전한 시작점이다.
block_size와 내부 단편화
각 요청의 마지막 블록은 꽉 차지 않는다. 기댓값으로 낭비율을 근사하면(시퀀스 길이 균등 분포 가정):
expected_waste ≈ (block_size − 1) / (2 × avg_seq_len + block_size − 1)
avg_seq_len과 block_size 조합별 이론 낭비율:
| avg_seq_len | block_size 8 | block_size 16 | block_size 32 | block_size 64 |
|---|---|---|---|---|
| 32 토큰 | ~10% | ~19% | ~33% | ~50% |
| 64 토큰 | ~5% | ~11% | ~20% | ~33% |
| 128 토큰 | ~3% | ~6% | ~11% | ~20% |
| 512 토큰 | <1% | ~1.4% | ~3% | ~6% |
짧은 시퀀스가 집중되는 워크로드에서 block_size가 크면 손실이 크다. avg_seq_len=32이고 block_size=64라면 KV 캐시 슬롯의 절반이 낭비된다. vLLM 기본값이 block_size=16인 이유다.
단일 요청 관점에서 최악의 경우: block_size=32인 환경에서 길이 33짜리 시퀀스가 들어오면 2블록(64 슬롯)을 차지하면서 33개만 채워 48%를 낭비한다. 이 최악의 경우는 짧은 시퀀스가 몰리는 API 서빙 환경에서 드물지 않다.
가용 KV 캐시 블록 수를 직접 추정하기
서빙 전 블록 수를 미리 계산하려면:
def estimate_kv_blocks(
total_gpu_gb: float,
gpu_memory_utilization: float,
model_weights_gb: float,
overhead_gb: float,
block_size: int,
num_layers: int,
num_kv_heads: int,
head_dim: int,
dtype_bytes: int = 2, # FP16
) -> int:
available_gb = (
total_gpu_gb * gpu_memory_utilization
- model_weights_gb
- overhead_gb
)
bytes_per_block = (
2 * block_size * num_layers * num_kv_heads * head_dim * dtype_bytes
)
return int(available_gb * (1024 ** 3) // bytes_per_block)
# Llama-3-8B, A100 80GB
n = estimate_kv_blocks(
total_gpu_gb=80,
gpu_memory_utilization=0.9,
model_weights_gb=16,
overhead_gb=2,
block_size=16,
num_layers=32,
num_kv_heads=8,
head_dim=128,
)
print(f"예상 블록 수: {n}") # ≈ 27,648
print(f"최대 캐시 토큰: {n * 16:,}") # ≈ 442,368
실제 vLLM 로그의 블록 수와 이 계산값이 수천 블록 이상 차이나면 CUDA 그래프 캡처 메모리나 NCCL 초기화가 예상보다 크거나, 다른 프로세스가 GPU 메모리를 선점한 경우다. torch.cuda.memory_reserved()를 프로파일링 전후로 비교하면 실제 오버헤드를 측정할 수 있다.
단편화가 지연 꼬리에 남기는 흔적
메모리 압박이 심해지면 vLLM 스케줄러는 실행 중인 요청의 KV 캐시 블록을 CPU로 swap하거나, 아직 prefill이 끝나지 않은 요청을 evict해 나중에 재계산(recompute)한다. 이 경로가 평균 지연은 건드리지 않으면서 꼬리 지연을 폭발시킨다.
정상 구간에서 TTFT(Time to First Token)는 안정적이다. 메모리 압박 순간에 eviction이 발생하면 해당 요청은 prefill을 처음부터 다시 실행해야 하므로 TTFT가 갑자기 튄다. p50, p95는 멀쩡한데 p99만 급등하는 패턴이 나타나면 이 경로를 의심해야 한다.
vLLM은 num_preempted를 Prometheus 메트릭으로 노출한다. 이 카운터가 증가 추세이면서 p99 스파이크가 동시에 관측되면 KV 캐시 단편화가 서빙 품질에 실제로 영향을 미치고 있는 것이다.
운영 레버
block_size 튜닝: 워크로드의 avg_seq_len을 먼저 측정하고, block_size를 avg_seq_len의 절반 수준으로 맞추면 내부 단편화율을 10% 아래로 유지할 수 있다. block_size는 서버 시작 시 고정되므로 변경하면 재시작이 필요하다.
max_num_seqs 제한: 동시 처리 요청 수를 줄이면 KV 캐시 압박이 낮아져 eviction 빈도가 줄어든다. 처리량 대신 지연 안정성을 우선할 때 효과적이다.
prefill-decode 분리: prefill과 decode를 별도 인스턴스에서 실행하면 두 단계가 서로의 KV 캐시 공간을 잠식하지 않는다. 단편화 자체를 줄이기보다 캐시 압박의 원인을 격리하는 접근이다.
어떤 레버를 당겨야 하는지는 워크로드의 시퀀스 길이 분포를 알기 전에는 정해지지 않는다. avg_seq_len=32인 채팅 API와 avg_seq_len=512인 문서 요약은 최적 설정이 완전히 다르다. 프로덕션에서 TTFT, num_preempted, torch.cuda.memory_allocated() 세 지표를 함께 관찰하면서 실제 압박 패턴을 파악하는 것이 튜닝보다 먼저다.