본문으로 건너뛰기

[vLLM] Continuous Batching: 요청이 끝나는 즉시 새 요청을 채우는 동적 배칭

들어가며

전통적인 LLM 서빙은 static batching을 사용한다. 배치 내 모든 요청이 끝날 때까지 기다린 후 다음 배치를 처리하는 방식이다. 짧은 응답은 이미 완료되었는데도 긴 응답이 끝날 때까지 GPU가 유휴 상태로 대기하는 문제가 있다. Continuous Batching(iteration-level scheduling)은 매 디코딩 스텝마다 완료된 요청을 빼고 새 요청을 넣어 GPU 활용률을 극대화한다.

핵심 구조/코드 분석

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_tokensnum_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 메모리와 연산 자원을 고려한 상한선으로, 이 예산이 소진될 때까지 요청을 채워 넣는다.

왜 이 설계인가

  1. GPU 활용률 극대화: Static batching에서는 가장 긴 시퀀스가 끝날 때까지 다른 슬롯이 낭비된다. Continuous batching은 매 iteration마다 빈 슬롯을 새 요청으로 채운다.

  2. Prefill과 Decode의 통합: vLLM v1 스케줄러는 prefill/decode 구분 없이 모든 요청을 num_computed_tokens로 통합 관리한다. 이 덕분에 chunked prefill, speculative decoding 등 다양한 최적화를 자연스럽게 지원한다.

  3. Preemption으로 과할당 방지: KV 캐시가 부족하면 우선순위가 낮은 요청을 preempt해서 메모리를 확보한다. OS의 프로세스 스왑과 동일한 원리이다.

  4. 토큰 예산 기반 공정 스케줄링: 하나의 긴 prefill이 전체 예산을 독점하지 않도록 token_budget으로 제어한다.

Continuous batching은 LLM 서빙의 처리량을 static batching 대비 최대 수십 배까지 향상시키는 핵심 기법이다. vLLM의 스케줄러는 이를 단순하면서도 확장 가능한 구조로 구현하고 있다.

댓글

관련 포스트

vLLM 의 다른글