[vLLM] Continuous Batching: 요청이 끝나는 즉시 새 요청을 채우는 동적 배칭
들어가며
전통적인 LLM 서빙은 static batching을 사용한다. 배치 내 모든 요청이 끝날 때까지 기다린 후 다음 배치를 처리하는 방식이다. 짧은 응답은 이미 완료되었는데도 긴 응답이 끝날 때까지 GPU가 유휴 상태로 대기하는 문제가 있다. Continuous Batching(iteration-level scheduling)은 매 디코딩 스텝마다 완료된 요청을 빼고 새 요청을 넣어 GPU 활용률을 극대화한다.
- 관련 논문: Orca: A Distributed Serving System for Transformer-Based Generative Models (OSDI 2022)
- 공식 문서: https://docs.vllm.ai
핵심 구조/코드 분석
Scheduler 클래스의 전체 구조
vLLM의 스케줄러는 vllm/v1/core/sched/scheduler.py에 구현되어 있다. 핵심 상태는 단 세 가지이다:
class Scheduler(SchedulerInterface):
def __init__(self, vllm_config, kv_cache_config, ...):
self.waiting = create_request_queue(self.policy) # 대기 큐
self.running: list[Request] = [] # 실행 중
self.finished_req_ids: set[str] = set() # 완료
self.max_num_running_reqs = self.scheduler_config.max_num_seqs
self.max_num_scheduled_tokens = (
self.scheduler_config.max_num_scheduled_tokens
if self.scheduler_config.max_num_scheduled_tokens
else self.scheduler_config.max_num_batched_tokens
)
schedule() 메서드: 매 스텝의 핵심 루프
schedule() 메서드의 주석이 continuous batching의 철학을 명확하게 보여준다:
def schedule(self) -> SchedulerOutput:
# NOTE(woosuk) on the scheduling algorithm:
# There's no "decoding phase" nor "prefill phase" in the scheduler.
# Each request just has the num_computed_tokens and
# num_tokens_with_spec. At each step, the scheduler tries to
# assign tokens to the requests so that each request's
# num_computed_tokens can catch up its num_tokens_with_spec.
"디코딩 페이즈도, 프리필 페이즈도 없다"는 선언이 핵심이다. 모든 요청은 단순히 num_computed_tokens와 num_tokens의 차이만큼 토큰을 스케줄링받는다.
1단계: RUNNING 요청 스케줄링
# First, schedule the RUNNING requests.
req_index = 0
while req_index < len(self.running) and token_budget > 0:
request = self.running[req_index]
num_new_tokens = (
request.num_tokens_with_spec
+ request.num_output_placeholders
- request.num_computed_tokens
)
num_new_tokens = min(num_new_tokens, token_budget)
# KV 블록 할당 시도
new_blocks = self.kv_cache_manager.allocate_slots(
request, num_new_tokens, ...
)
if new_blocks is None:
# 메모리 부족 → preemption
preempted_req = self.running.pop()
self._preempt_request(preempted_req, scheduled_timestamp)
RUNNING 요청을 먼저 처리하는 이유는 이미 KV 캐시가 할당되어 있어 preemption 비용을 최소화할 수 있기 때문이다.
2단계: WAITING 요청 스케줄링
# Next, schedule the WAITING requests.
while (self.waiting or self.skipped_waiting) and token_budget > 0:
if len(self.running) == self.max_num_running_reqs:
break
request = request_queue.peek_request()
# 캐시 히트 탐색
new_computed_blocks, num_new_local_computed_tokens = (
self.kv_cache_manager.get_computed_blocks(request)
)
num_new_tokens = request.num_tokens - num_computed_tokens
num_new_tokens = min(num_new_tokens, token_budget)
남은 토큰 예산 내에서 새 요청을 최대한 채워 넣는다. 이것이 continuous batching의 핵심 — 완료된 요청의 자리를 즉시 새 요청이 차지하는 것이다.
토큰 예산 관리
token_budget = self.max_num_scheduled_tokens
# 요청 스케줄링할 때마다 차감
token_budget -= num_new_tokens
token_budget은 한 스텝에서 처리할 수 있는 총 토큰 수이다. GPU 메모리와 연산 자원을 고려한 상한선으로, 이 예산이 소진될 때까지 요청을 채워 넣는다.
왜 이 설계인가
-
GPU 활용률 극대화: Static batching에서는 가장 긴 시퀀스가 끝날 때까지 다른 슬롯이 낭비된다. Continuous batching은 매 iteration마다 빈 슬롯을 새 요청으로 채운다.
-
Prefill과 Decode의 통합: vLLM v1 스케줄러는 prefill/decode 구분 없이 모든 요청을
num_computed_tokens로 통합 관리한다. 이 덕분에 chunked prefill, speculative decoding 등 다양한 최적화를 자연스럽게 지원한다. -
Preemption으로 과할당 방지: KV 캐시가 부족하면 우선순위가 낮은 요청을 preempt해서 메모리를 확보한다. OS의 프로세스 스왑과 동일한 원리이다.
-
토큰 예산 기반 공정 스케줄링: 하나의 긴 prefill이 전체 예산을 독점하지 않도록
token_budget으로 제어한다.
Continuous batching은 LLM 서빙의 처리량을 static batching 대비 최대 수십 배까지 향상시키는 핵심 기법이다. vLLM의 스케줄러는 이를 단순하면서도 확장 가능한 구조로 구현하고 있다.
관련 포스트
vLLM 의 다른글
- 이전글 [vLLM] PagedAttention: OS 페이징 기법으로 KV 캐시를 관리하는 방법
- 현재글 : [vLLM] Continuous Batching: 요청이 끝나는 즉시 새 요청을 채우는 동적 배칭
- 다음글 [vLLM] Chunked Prefill: 긴 프롬프트를 청크 단위로 분할 처리하는 기법
댓글