LLM을 자체 서빙할 때 값비싼 GPU를 사놓고도 처리량이 기대만큼 나오지 않는 경험은 흔합니다. 원인은 대개 연산이 아니라 메모리와 스케줄링입니다. 트랜스포머 추론은 매 토큰마다 이전 토큰들의 키·값 캐시(KV 캐시)를 다시 읽는 메모리 바운드 작업이고, 요청마다 길이가 제각각이라 순진하게 배치를 묶으면 GPU가 놀거나 메모리가 낭비됩니다.
vLLM은 이 두 문제를 PagedAttention과 연속 배칭(continuous batching)으로 정면 돌파합니다. 전자는 KV 캐시를 운영체제의 가상 메모리처럼 페이지 단위로 관리해 파편화를 없애고, 후자는 요청을 배치가 끝날 때까지 기다리지 않고 토큰 단위로 갈아 끼워 GPU를 쉬지 않게 합니다. 이 글에서는 두 메커니즘의 원리와, 처리량을 튜닝할 때 건드릴 파라미터·트레이드오프를 실무 관점에서 정리합니다.
병목은 연산이 아니라 KV 캐시다
자기회귀(autoregressive) 생성은 토큰을 하나씩 만듭니다. 프롬프트를 한 번에 처리하는 prefill은 연산 집약적(compute-bound)이지만, 뒤이어 토큰을 붙이는 decode는 매 스텝 새 토큰 하나에 대해 전체 KV 캐시를 훑는 메모리 대역폭 바운드 작업이라, 메모리 대역폭이 상한을 정합니다.
메모리 바운드 상황에서 처리량을 늘리는 유일한 방법은 동시에 처리하는 요청 수(배치 크기)를 키우는 것입니다. 같은 가중치를 한 번 읽어 여러 시퀀스의 토큰을 함께 계산하면 GPU 이용률이 오르지만, 배치를 키우려면 그만큼 많은 KV 캐시를 메모리에 올려야 합니다. 결국 처리량은 KV 캐시를 얼마나 알뜰하게 담느냐로 귀결됩니다. 한 시퀀스의 KV 캐시 크기는 2 * 레이어 수 * KV헤드 수 * 헤드차원 * 시퀀스길이 * dtype바이트로 결정되며, 8B급 모델에서 수천 토큰이면 시퀀스 하나에 수백 MB가 들 수 있습니다.
vLLM 이전의 전형적 서빙 엔진은 요청마다 최대 생성 길이만큼 연속 메모리 블록을 미리 예약했습니다. 최대 2048토큰까지 만들 수 있는 요청이 실제로 30토큰만 생성해도 2048토큰짜리 자리를 통째로 잡아, 쓰지 않는 자리가 재사용되지 못하는 내부 파편화와 서로 다른 크기의 예약·해제가 반복돼 큰 연속 블록을 못 잡는 외부 파편화가 함께 생깁니다. 실측 연구에서 이 방식은 잡아둔 KV 캐시 메모리의 상당 부분(60~80%)이 실제로 쓰이지 않고 낭비된다고 알려졌고, 낭비되는 만큼 배치 크기가 줄어 처리량이 깎입니다.
PagedAttention: KV 캐시를 페이지로 나눈다
PagedAttention의 아이디어는 운영체제의 가상 메모리와 페이징을 그대로 가져온 것입니다. KV 캐시를 고정 크기 블록(block)으로 쪼개 각 블록에 정해진 토큰 수(기본 16토큰)의 K·V를 담고, 한 시퀀스의 캐시는 연속 메모리가 아니라 블록들의 리스트로 관리하며, 논리 블록 번호를 물리 블록에 매핑하는 블록 테이블이 그 연결을 담당합니다.
효과는 명확합니다. 시퀀스는 필요한 만큼만 블록을 점진적으로 받으므로 미리 최대 길이를 예약할 필요가 없어 내부 파편화가 사라지고, 물리 블록 크기가 모두 같아 빈 블록이면 어떤 시퀀스에나 배정할 수 있으니 외부 파편화도 사라집니다. 남는 낭비는 마지막 블록에서 비는 몇 토큰분뿐이라, 30토큰만 생성한 요청은 블록 2개만 점유하고 끝납니다.
여기에 더해 페이징 구조는 블록 공유(prefix sharing)도 가능하게 합니다. 여러 요청이 같은 시스템 프롬프트나 few-shot 예시로 시작하면 그 공통 프리픽스 블록을 참조 카운트로 공유할 수 있어, 병렬 샘플링이나 빔 서치에서 큰 메모리 절감이 나옵니다.
연속 배칭: 배치를 기다리지 않는다
전통적 정적 배칭(static batching)은 요청들을 한 배치로 묶어 모든 요청이 끝날 때까지 다음 배치를 시작하지 않습니다. 생성 길이가 요청마다 다르므로, 20토큰만 필요한 요청이 500토큰짜리와 같은 배치에 묶이면 480스텝 동안 이미 끝났는데도 자리만 차지하며 GPU를 놀립니다.
연속 배칭(iteration-level scheduling)은 배치의 단위를 요청이 아니라 토큰 생성 스텝(iteration)으로 바꿉니다. 매 스텝마다 스케줄러가 실행 배치를 다시 구성해, 끝난 요청은 즉시 방출하고 그 빈자리에 새 요청을 곧바로 끼워 넣어 GPU가 항상 채워진 배치로 돕니다.
- 정적 배칭: 배치 전체가 가장 긴 요청에 맞춰 대기 → 짧은 요청이 자리를 낭비.
- 연속 배칭: 스텝마다 완료분 방출·신규 투입 → GPU 이용률과 처리량이 크게 상승.
이 스케줄링은 엔진에 내장돼 별도 설정 없이 동작하고, 튜닝으로 조절하는 것은 “한 스텝에 몇 요청·몇 토큰을 넣을까”와 “메모리 부족 시 어떻게 물러설까”뿐입니다.
처리량을 좌우하는 핵심 파라미터
튜닝의 출발점은 KV 캐시에 쓸 메모리를 최대한 확보하는 것이고, 가장 영향이 큰 손잡이는 gpu_memory_utilization입니다. GPU 메모리 중 가중치를 뺀 나머지 얼마까지를 KV 캐시 블록 풀로 쓸지를 정하는 값입니다.
from vllm import LLM, SamplingParams
llm = LLM(
model="meta-llama/Llama-3.1-8B-Instruct",
gpu_memory_utilization=0.92, # KV 블록 풀↑ → 동시 요청 수↑
max_model_len=8192, # 짧게 잡으면 요청당 예약 상한↓ → 동시성↑
max_num_batched_tokens=16384, # 한 스텝 총 토큰 상한. 크면 처리량↑, 지연 변동↑
max_num_seqs=256, # 동시 실행 시퀀스 수 상한
block_size=16,
)
outputs = llm.generate(["프롬프트를 여기에"],
SamplingParams(temperature=0.7, max_tokens=256))
gpu_memory_utilization을 높이면 블록 풀이 커져 배치 여유가 늘지만, 너무 높이면 실행 중 활성화 메모리 급증으로 OOM이 날 수 있어 보통 0.85~0.95에서 잡습니다. max_num_batched_tokens는 처리량과 지연의 정면 트레이드오프로, 크게 잡을수록 처리량이 오르지만 큰 prefill이 끼면 그 스텝이 길어져 토큰 간 지연(ITL)이 튑니다.
기동 시 로그로 블록 수와 동시성 확인하기
튜닝이 먹혔는지는 기동 로그에서 곧바로 확인됩니다. vLLM은 시작할 때 확보한 KV 캐시 블록 수와 그로부터 계산된 최대 동시 시퀀스 용량을 출력하며, 이것이 처리량의 실질적 상한입니다.
# OpenAI 호환 서버로 띄우고 기동 로그 확인
python -m vllm.entrypoints.openai.api_server
--model meta-llama/Llama-3.1-8B-Instruct
--gpu-memory-utilization 0.92
--max-model-len 8192
--max-num-batched-tokens 16384
--max-num-seqs 256
# 로그 예시(요지):
# # GPU blocks: 21376, # CPU blocks: 2048
# Maximum concurrency for 8192 tokens per request: 41.75x
# -> 8192토큰짜리 요청 기준 약 41개까지 동시 수용 가능하다는 뜻
여기서 max_model_len을 실제 워크로드에 맞게 낮추는 것이 종종 가장 효과적인 레버입니다. 요청이 실제로 2K를 넘지 않는데 기본값 32K로 열어두면 동시성을 보수적으로 잡게 되며, Maximum concurrency 배수가 요청당 토큰 수에 반비례함을 로그에서 확인할 수 있습니다.
메모리가 부족할 때: 선점과 스와핑
연속 배칭은 요청을 계속 밀어 넣으므로 진행 중인 시퀀스가 자라나 블록 풀이 고갈되는 순간이 옵니다. 이때 vLLM은 일부 시퀀스를 선점(preemption)하며, 선점된 KV 캐시는 두 가지로 처리됩니다.
- 재계산(recompute): KV 캐시를 버리고 자리가 나면 프롬프트+생성분을 다시 prefill 해 복구. 기본 전략이며 대체로 스와핑보다 빠릅니다.
- 스와핑(swap): KV 블록을 CPU 메모리로 옮겼다가 되돌림.
swap_space로 용량을 지정합니다.
선점이 잦다는 것은 동시성을 과하게 밀어 넣고 있다는 신호이므로, 메트릭을 보며 max_num_seqs를 낮추거나 메모리 여유를 늘립니다.
# Prometheus 메트릭 엔드포인트에서 관찰할 핵심 지표와 판단 규칙
# num_requests_running 실행 중 요청 수(배치 크기)
# num_requests_waiting 대기 큐 길이 — running 낮은데 크면 메모리 병목
# num_preemptions_total 누적 선점 — 급증하면 과투입, max_num_seqs↓
# gpu_cache_usage_perc 블록 풀 점유율 — 상시 100% 근접이면 포화(스케일아웃)
prefill과 decode의 간섭, 그리고 chunked prefill
연속 배칭의 미묘한 함정은 prefill과 decode가 한 스텝에 섞일 때입니다. 긴 프롬프트의 prefill은 무겁고 오래 걸리는데, 그 스텝에 묶인 다른 요청들의 decode 토큰은 그동안 나오지 못해, 새 요청이 들어올 때마다 기존 요청들의 토큰 간 지연이 튑니다.
chunked prefill은 긴 prefill을 max_num_batched_tokens 예산에 맞춰 여러 스텝으로 잘게 쪼개 처리하고 남는 예산에 decode를 채웁니다. 한 스텝이 지나치게 길어지지 않아 토큰 간 지연이 안정되고 GPU 이용률도 좋아집니다.
llm = LLM(
model="meta-llama/Llama-3.1-8B-Instruct",
enable_chunked_prefill=True,
# 지연 안정이 목표면 배치 토큰 예산을 작게(예: 2048~4096)
# -> 긴 prefill이 잘게 쪼개져 decode 요청의 ITL 튐이 줄어듦
max_num_batched_tokens=4096,
gpu_memory_utilization=0.90,
)
# 대화형(챗) 서비스: 작은 예산 + chunked prefill 로 부드러운 스트리밍
# 오프라인 배치 처리: 큰 예산 + chunked prefill off 로 순수 처리량 극대화
정리하면 max_num_batched_tokens는 chunked prefill과 짝을 이뤄 처리량 대 지연의 다이얼이 됩니다. 오프라인 대량 처리는 예산을 키워 처리량을, 실시간 챗은 예산을 줄여 TTFT·토큰 간 지연 안정을 택하는 것이 기본입니다.
측정 없는 튜닝은 없다
모든 파라미터는 워크로드에 따라 최적점이 달라지므로, 대표 트래픽으로 부하 테스트를 돌려 수치를 비교하는 것이 유일하게 믿을 수 있는 방법입니다. 이때 요청 도착 분포와 입력·출력 길이를 실제와 비슷하게 맞추는 것이 중요합니다.
# 대표 트래픽으로 처리량/지연 벤치마크
python benchmarks/benchmark_serving.py
--model meta-llama/Llama-3.1-8B-Instruct
--dataset-name random
--random-input-len 1024 --random-output-len 256
--request-rate 20
--num-prompts 2000
# 주로 볼 지표:
# Output token throughput (tok/s) 전체 처리량 (오프라인 튜닝의 목표)
# Mean/P99 TTFT 첫 토큰 지연 (대화형의 체감 반응성)
# Mean/P99 ITL 토큰 간 지연 (스트리밍 부드러움)
실무 순서는 이렇습니다. 먼저 gpu_memory_utilization을 OOM 직전까지 올리고 max_model_len을 실제 최대 길이에 맞춰 동시성을 확보한 뒤, 서비스 성격에 따라 max_num_batched_tokens와 chunked prefill로 처리량-지연 균형을 잡고, 마지막으로 선점 메트릭을 보며 max_num_seqs를 조정합니다. 그래도 블록 풀이 상시 포화라면 튜닝이 아니라 KV 캐시 양자화(fp8)나 텐서 병렬/스케일아웃 같은 구조적 확장을 검토할 단계입니다.
마무리
vLLM의 성능은 화려한 커널 하나가 아니라 메모리를 알뜰하게 쓰고 GPU를 쉬지 않게 하는 두 아이디어에서 나옵니다. PagedAttention은 KV 캐시를 페이지로 쪼개 파편화를 없애 같은 메모리에 더 많은 요청을 담고, 연속 배칭은 스텝 단위 스케줄링으로 끝난 요청을 즉시 방출해 배치를 항상 채웁니다. 덕분에 튜닝 포인트는 “블록 풀을 얼마나 키우고, 한 스텝에 얼마를 몰아넣고, 포화 시 어떻게 물러설 것인가”로 좁혀집니다. 파라미터를 감으로 만지지 말고 기동 로그의 블록 수와 벤치마크의 처리량·지연 곡선을 함께 보며 워크로드에 맞는 지점을 찾는 것이, GPU를 제값 하게 만드는 확실한 길입니다.