vLLM 서빙 내부 구조 — PagedAttention, Continuous Batching, Chunked Prefill
2026년 vLLM이 보여주는 압도적인 점유율은 단일한 묘수 하나에서 나오지 않습니다. 서로 맞물려 강화되는 세 가지 기본 동작(default) 위에 있습니다. PagedAttention은 언제나 켜져 있고, 연속 배칭(Continuous Batching)은 디코딩(decode) 반복 사이에 새 요청을 활성 배치(active batch) 안으로 끼워 넣으며, 청크 단위 프리필(Chunked Prefill)은 긴 프롬프트를 잘게 썰어 디코딩 토큰이 굶주리지 않게 합니다. 세 가지를 모두 켜면 H100 SXM5 한 장에서 Llama 3.3 70B FP8 모델이 동시 요청 128개 기준으로 초당 2,2002,400 토큰을 처리합니다. 이는 vLLM 자체 기본 설정 대비 약 25% 높고, 단순한 PyTorch 루프 대비 34배 높은 수치입니다. 이 강의에서는 스케줄러(scheduler)와 어텐션 커널(attention kernel)을 직접 도식화할 수 있을 정도로 깊이 읽고, 마지막에는 code/main.py에 vLLM이 프리필과 디코딩을 스케줄링하는 방식을 흉내 낸 장난감 연속 배처(toy continuous batcher)를 만듭니다.
유형: Learn
언어: Python(표준 라이브러리, 장난감 continuous batching scheduler)
선수 지식: Phase 17 · 01(Model Serving), Phase 11(LLM Engineering)
예상 시간: 약 75분
학습 목표
- PagedAttention을 KV 캐시 할당기(KV cache allocator)로 설명할 수 있어야 합니다. 블록(block), 블록 테이블(block table)의 역할과 더불어, 프로덕션 부하에서 단편화(fragmentation)가 어떻게 4% 미만으로 유지되는지를 함께 설명합니다.
- 반복(iteration) 수준에서 연속 배칭(Continuous Batching)을 도식으로 그려 낼 수 있어야 합니다. 완료된 시퀀스(sequence)가 배치를 빠져나가고 새로운 시퀀스가 배치를 비우지 않은 채 합류하는 흐름을 설명합니다.
- 청크 단위 프리필(Chunked Prefill)을 한 문장으로 설명하고, 이 기능이 어떤 지연시간 지표를 보호하는지 말할 수 있어야 합니다. 힌트: 평균 처리량이 아니라 TTFT 꼬리(tail) 지연입니다.
- 모든 최적화를 한꺼번에 켰을 때 팀을 물어뜯는 2026년 vLLM v0.18.0의 주의점(gotcha)을 말할 수 있어야 합니다.
문제
단순한 PyTorch 서빙 루프는 요청을 한 번에 하나씩만 처리합니다. 토크나이징(tokenizing), 프리필(prefill), EOS까지 디코딩, 응답 반환 순서로 흘러갑니다. 사용자가 한 명일 때는 잘 동작합니다. 하지만 사용자가 백 명이 되면, 그저 인내심 많은 사람들이 줄지어 기다리는 큐(queue)가 되어 버립니다. 표면적으로 가장 명확해 보이는 해법인 정적 배칭(Static Batching)은 일정한 시간 창(window) 안에서 모든 요청을 가장 긴 프롬프트 길이에 맞춰 패딩(padding)하고, 가장 긴 예상 출력 길이에 맞춰 모든 디코딩도 패딩하며, 가장 느린 시퀀스가 끝날 때까지 배치 전체를 멈춰 세웁니다. 사용하지 않는 패딩 분량까지 비용으로 지불해야 하고, 빠른 요청은 느린 요청을 하염없이 기다리게 됩니다.
vLLM은 이 세 가지 문제를 동시에 풀어냅니다. PagedAttention은 전통적인 연속 메모리 할당(contiguous allocation) 방식이 GPU 메모리의 60~80%를 잡아먹던 KV 캐시 단편화를 막아 줍니다. 연속 배칭은 각 디코딩 반복 사이에 요청이 합류하고 빠져나갈 수 있게 해 주므로, 배치는 언제나 실제 작업으로 가득 차 있게 됩니다. 청크 단위 프리필은 32k 토큰 프롬프트를 약 512 토큰 단위 조각으로 잘라 디코딩과 번갈아 실행되게 만들기 때문에, 긴 프롬프트 하나가 GPU 위의 모든 디코딩 토큰을 얼어붙게 만드는 사태가 사라집니다.
2026년의 프로덕션 표준은 세 가지를 모두 켜는 것입니다. 각 기능이 정확히 무엇을 하는지 이해해야 합니다. 실패 모드(failure mode)는 모델이 아니라 모두 스케줄러 위에서 발생하기 때문입니다.
개념
가상 메모리 시스템으로서의 PagedAttention
KV 캐시(KV cache)는 시퀀스 하나당 num_layers × 2 × num_heads × head_dim × seq_len × bytes_per_element 만큼의 크기를 차지합니다. Llama 3.3 70B 모델에서 8192 토큰을 BF16으로 처리한다면, 시퀀스 하나당 대략 1.25GB에 이릅니다. 만약 모든 요청에 대해 8192 슬롯을 미리 예약해 두지만 실제 평균 요청은 1500 토큰만 사용한다면, 예약한 HBM의 약 82%를 낭비하는 셈입니다. 고전적인 배칭 방식은 이 낭비를 고스란히 비용으로 떠안습니다.
PagedAttention은 운영체제(Operating System; OS)의 가상 메모리(virtual memory) 아이디어를 그대로 빌려 옵니다. KV 캐시는 더 이상 시퀀스마다 연속적인 한 덩어리로 잡히지 않습니다. 대신 고정 크기 블록(block), 기본값 16 토큰 단위로 할당됩니다. 각 시퀀스는 자신의 논리적 토큰 위치(logical token position)를 물리적 블록 번호(physical block ID)로 매핑하는 블록 테이블(block table)을 갖습니다. 시퀀스가 이미 할당된 블록을 넘어 자라나면 블록 하나가 더 붙고, 시퀀스가 끝나면 해당 블록들은 풀(pool)로 반환됩니다.
단편화 비율은 고전 방식의 60~80%에서 PagedAttention의 4% 미만으로 떨어집니다. PagedAttention은 별도의 플래그로 켜는 기능이 아닙니다. vLLM이 제공하는 유일한 할당기입니다. 운영자가 만질 수 있는 조정 손잡이(knob)는 --gpu-memory-utilization이며, 기본값은 0.9입니다. 이 값은 가중치(weight)와 활성값(activation)을 적재한 뒤 남은 HBM 중 얼마만큼을 KV 블록 용도로 예약할지 vLLM에 알려 줍니다.
반복(iteration) 수준의 연속 배칭
예전의 "동적 배칭(dynamic batching)"은 배치가 채워질 때까지 일정한 시간 창을 기다리는 방식이었습니다. 예를 들어 10ms 동안 기다린 뒤, 모든 시퀀스가 끝날 때까지 프리필, 디코딩, 디코딩, 디코딩을 이어서 실행했습니다. 빠른 시퀀스는 일찍 끝나도, GPU가 가장 느린 시퀀스를 마칠 때까지 그저 놀고 있었습니다.
연속 배칭은 각 디코딩 단계(decode step) 사이에서 동작합니다. 현재 실행 중인 시퀀스 집합을 RUNNING 목록이라고 부르겠습니다. 각 반복마다 다음과 같은 일이 일어납니다.
RUNNING 안에서 방금 EOS 또는 max_tokens에 도달한 시퀀스를 제거합니다.
- 스케줄러가 대기 큐(waiting queue)를 살핍니다. 비어 있는 KV 블록이 충분히 있다면 새로운 시퀀스(신규 프리필이거나 재개된 시퀀스)를 받아들입니다.
- 그 시점의
RUNNING에 들어 있는 모든 시퀀스에 대해 순전파(forward pass)를 실행하고, 시퀀스마다 새 토큰을 하나씩 만들어 냅니다.
배치 크기는 어떤 고정된 숫자에 맞춰 패딩되지 않습니다. 서로 다른 출력 위치에 있는 시퀀스들이 단 한 번의 융합 순전파(fused forward)를 공유합니다. 2026년 vLLM에서는 이 구조를 V1 scheduler라고 부릅니다. 핵심 불변식(invariant)은 다음 한 줄입니다. 스케줄러는 요청마다 한 번이 아니라, 디코딩 반복마다 한 번 실행됩니다.
청크 단위 프리필은 TTFT 꼬리 지연을 보호합니다
프리필은 계산 자원에 묶여 있는(compute-bound) 작업입니다. Llama 3.3 70B에서 32k 토큰 프롬프트를 H100 한 장으로 처리하면, 순수 프리필에만 약 800ms가 걸립니다. 프리필이 돌고 있는 동안 같은 배치에 들어 있는 다른 모든 시퀀스의 디코딩 토큰은 멈춰 서서 기다리게 됩니다. 서빙 루프에서는 긴 프롬프트 하나의 첫 토큰 지연시간(Time To First Token; TTFT)이, 함께 묶인 수십 명 사용자에게 토큰 간 지연시간(Inter-Token Latency; ITL)이 잠깐 튀는 현상(blip)으로 전파됩니다.
청크 단위 프리필은 프리필을 고정 크기 조각(chunk), 기본값 512 토큰 단위로 잘라 각 조각을 하나의 작업 단위로 스케줄링합니다. 조각과 조각 사이에서 스케줄러는 디코딩 시퀀스를 한 토큰씩 앞으로 진행시킬 수 있습니다. 결과적으로 조각마다 몇 ms 정도의 작은 절대 프리필 지연을 추가로 감수하는 대신, 디코딩 시점의 흔들림(jitter)을 훨씬 낮추게 됩니다. 공개된 벤치마크 결과를 보면, 혼합 부하 기준 P99 ITL은 청크 단위 프리필이 없을 때 약 50ms이지만, 켰을 때는 약 15ms까지 떨어집니다.
세 가지 기본 동작은 서로를 전제로 한다
이 세 가지 기능은 사실 서로를 전제로 합니다. PagedAttention은 스케줄러가 손쉽게 주고받을 수 있는 세밀한 KV 자원(resource)을 만들어 줍니다. 연속 배칭은 새 시퀀스를 받아들일 때마다 전역 재배치(global reshuffle)를 강제하지 않도록 그 세밀한 자원을 필요로 합니다. 청크 단위 프리필은 별도 시스템이 아니라, 동일한 RUNNING 목록 위에서 스케줄러가 내리는 결정의 한 종류일 뿐입니다. 즉 또 하나의 스케줄러 정책에 불과합니다.
모든 플래그를 다 외울 필요는 없습니다. 정작 알아야 할 것은 스케줄러가 무엇을 최적화하느냐입니다. 스케줄러는 KV 블록 예산 안에서 굿풋(goodput)을 최적화하며, 그 위에 청크 단위 프리필의 잘라 내기(slicing) 제약을 얹습니다.
2026년 v0.18.0 주의점
vLLM v0.18.0에서는 --enable-chunked-prefill과 드래프트 모델 기반 추측 디코딩(speculative decoding)의 --speculative-model을 함께 켜서 사용할 수 없습니다. 문서화된 유일한 예외는 V1 scheduler에서 동작하는 N-gram GPU 추측 디코딩입니다. 릴리스 노트(release note)를 읽지 않은 채 모든 플래그를 한꺼번에 켜는 팀은, 미묘하게 성능이 떨어지는 정도가 아니라 시작 시점부터 런타임 에러(run-time error)를 만나게 됩니다. 추측 디코딩의 이득이 청크 단위 프리필을 켜면서까지 챙겨야 할 만큼 컸다면, 그 선택을 다시 들여다봐야 합니다. 2026년 현재의 올바른 정답은, 컴파일조차 되지 않는 드래프트 모델 + 청크 단위 프리필 조합이 아니라, 청크 단위 프리필을 끄고 EAGLE-3를 사용하는 경우가 많습니다.
기억해 둘 숫자
- Llama 3.3 70B FP8, H100 SXM5, 동시 요청 128, 세 기능 모두 켬: 초당 2,200~2,400 토큰.
- 동일 모델, vLLM 기본값(청크 단위 프리필 없음): 초당 약 1,800 토큰.
- 동일 모델, 단순 PyTorch 순전파 루프: 초당 약 600 토큰.
- 프로덕션 부하 기준 PagedAttention의 KV 단편화 낭비율: 4% 미만.
- 혼합 부하에서의 P99 ITL: 청크 단위 프리필을 켰을 때 약 15ms, 껐을 때 약 50ms.
스케줄러는 이렇게 생겼습니다
while True:
finished = [s for s in RUNNING if s.is_done()]
for s in finished:
release_blocks(s)
RUNNING.remove(s)
while WAITING and have_free_blocks_for(WAITING[0]):
s = WAITING.pop(0)
allocate_initial_blocks(s)
RUNNING.append(s)
batch = []
for s in RUNNING:
if s.in_prefill:
batch.append(next_prefill_chunk(s))
else:
batch.append(decode_one_token(s))
run_forward(batch)
code/main.py는 가상의 토큰 수와 가상의 순전파 지연시간을 사용해, 위 루프를 표준 라이브러리 Python만으로 그대로 구현해 둔 예제입니다. 실행해 보면 청크 단위 프리필이 긴 프리필이 도는 와중에도 디코딩 시퀀스를 계속 살려 두는 모습을 직접 확인할 수 있습니다.
사용해보기
code/main.py는 기능을 켜고 끌 수 있는 vLLM 스타일의 스케줄러를 시뮬레이션합니다. 실행하면 다음을 비교해 볼 수 있습니다.
NAIVE 모드: 한 번에 요청 하나씩만 처리하며 배칭이 없는 경우.
STATIC 모드: 패딩으로 길이를 맞추고 가장 느린 요청을 기다리는 고전적인 배칭 방식.
CONTINUOUS 모드: 반복 수준에서의 어드미션(admission)과 릴리스(release).
CONTINUOUS + CHUNKED 모드: 디코딩과 번갈아 끼워 넣은 프리필 조각.
출력은 전체 처리량(가상 초당 토큰 수, tokens per virtual second), 평균 TTFT, P99 ITL을 함께 보여 줍니다. 혼합 트래픽이라면 CONTINUOUS + CHUNKED 행이 가장 좋은 결과를 보여야 정상입니다.
산출물 만들기
이 강의는 outputs/skill-vllm-scheduler-reader.md를 산출물로 만들어 냅니다. 서빙 설정값, 즉 배치 크기(batch size), KV 메모리 사용률(KV memory utilization), 청크 단위 프리필 크기(chunked prefill size), 추측 디코딩 설정(speculative config)을 입력으로 받으면, 세 가지 기본 동작 중 무엇이 병목(bottleneck)인지, 그리고 무엇을 조정해야 하는지를 안내하는 스케줄러 진단(scheduler diagnosis)을 생성하는 스킬입니다.
연습문제
- 쉬움 —
code/main.py를 실행하세요. 짧은 요청과 긴 요청이 뒤섞인 워크로드 위에서 STATIC 모드와 CONTINUOUS 모드를 비교하세요. 처리량 격차는 프리필 효율, 디코딩 효율, 꼬리 지연(tail latency) 중 어디에서 비롯되나요?
- 중간 — 장난감 스케줄러에
--max-num-batched-tokens 옵션을 추가해 보세요. H100에서 Llama 3.3 70B FP8을 실행한다고 가정할 때, 이 값으로 적절한 숫자는 무엇일까요? 힌트: 단순한 HBM 용량이 아니라 KV 블록 크기와 사용 가능한 빈 블록 수의 함수입니다.
- 중간 — vLLM v0.18.0 릴리스 노트를 다시 읽어 보세요. 서로 배타적인 플래그 조합에는 어떤 것들이 있나요? 목록으로 정리해 보세요.
- 어려움 — 평균 출력 1,500 토큰, 표준편차 600 토큰인 요청 1,000개로 이루어진 트레이스(trace)를 기준으로 KV 캐시 단편화 낭비량을 계산해 보세요. (a) 요청별로 최대 8192 토큰까지 연속 할당하는 경우, (b) 16 토큰 블록 기반의 PagedAttention을 쓰는 경우 두 가지를 비교합니다.
- 어려움 — 청크 단위 프리필이 단독으로는 처리량(throughput)을 직접 끌어올리지 못하고 P99 ITL을 보호하는 이유를 한 문단으로 설명하세요. 그리고 실무에서 처리량 이득이 실제로 어디에서 들어오는지도 함께 설명해 보세요.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| PagedAttention | "KV 묘수" | KV 캐시를 위한 고정 크기 블록 할당기. 단편화 4% 미만 |
| Block table | "페이지 테이블" | 논리적 토큰 위치를 물리적 KV 블록으로 매핑하는 시퀀스별 표 |
| Continuous batching | "제대로 된 동적 배칭" | 매 디코딩 반복마다 어드미션과 릴리스를 결정하는 배칭 방식 |
| Chunked prefill | "프리필 쪼개기" | 긴 프리필을 512 토큰 조각으로 잘라 디코딩과 번갈아 실행하는 방식 |
| TTFT | "첫 토큰까지의 시간" | 프리필, 큐, 네트워크 지연을 더한 첫 토큰 지연. 긴 프롬프트에서는 프리필이 지배함 |
| ITL | "토큰 간 지연" | 연속된 두 디코딩 토큰 사이의 시간. 배치 크기의 영향을 크게 받음 |
| Goodput | "SLO를 만족하는 처리량" | 모든 요청이 TTFT와 ITL 목표를 함께 충족한 상태에서의 초당 토큰 수 |
| V1 scheduler | "새 스케줄러" | vLLM의 2026년 스케줄러. N-gram 추측 디코딩이 청크 단위 프리필과 호환되는 경로임 |
--gpu-memory-utilization | "메모리 손잡이" | 가중치와 활성값을 적재한 뒤 KV 블록 용도로 예약할 HBM 비율 |
더 읽을거리