vLLM은 어떻게 그렇게 많은 요청을 한꺼번에 처리하는가: PagedAttention과 연속 배칭 thumbnail
분석2026년 7월 2일AI 인프라

vLLM은 어떻게 그렇게 많은 요청을 한꺼번에 처리하는가: PagedAttention과 연속 배칭

vLLM의 처리량은 여기서 v0.24.0rc2(v1 엔진)를 기준으로 읽은, 서로 맞물린 두 아이디어에서 나온다. PagedAttention은 각 요청의 KV 캐시를 공유 풀의 고정 16토큰 블록으로 페이징해 메모리를 비연속적이고 빽빽하게 채우며 동일한 접두사끼리 공유할 수 있게 한다. 연속 배칭은 매 순전파 단계마다 토큰 예산을 기준으로 배치를 다시 구성하고, 프리필과 디코드를 섞으며, 블록이 떨어지면 선점한다.

ArcYou Editorial. 방법론 섹션에 고정된 저장소 상태를 기준으로 읽었습니다.

결정 요약

이 소스 리드가 분명하게 하는 것

실제로 무엇인가
동시 요청 처리량을 위해 KV 캐시와 배칭을 다시 설계한 추론 서버
잘 맞는 경우
여러 사용자의 요청을 계속 섞어 처리해야 하는 GPU 서버
피할 경우
개발자 머신 한 대에서 작은 모델을 돌리는 것이 목적일 때
핵심 메커니즘
PagedAttention, 공유 블록 풀, continuous batching

읽은 기준: vllm-v0.24.0rc2 @ v0.24.0rc2

Ollama가 llama.cpp 위에 놓인 단일 사용자용 편의 계층이라면, vLLM은 로컬 추론 스펙트럼의 반대쪽 끝에 있다. 많은 동시 요청으로 GPU를 계속 포화 상태로 유지하도록 만든 처리량 엔진이다. vLLM의 명성은 PagedAttention과 연속 배칭이라는 두 단어에 기대고 있고, 나는 이를 v0.24.0rc2, 커밋 6d37570(새로 작성된 “v1” 엔진)에서 읽었다. 알고 보니 하나는 메모리에 관한 아이디어이고 다른 하나는 스케줄링에 관한 아이디어이며, 둘은 서로가 있어야만 작동한다. 아래의 모든 주장은 해당 코드 줄을 가리킨다.

두 아이디어가 함께 해결하는 문제

트랜스포머의 KV 캐시는 토큰 하나씩 커지고, 요청이 얼마나 오래 실행될지는 알 수 없다. 순진한 구현은 GPU를 두 번 낭비한다. 요청마다 큰 연속 KV 버퍼를 예약하고(그래서 짧은 요청은 메모리를 놀리고 긴 요청은 메모리를 파편화한다), 정적으로 배치한다(N개 요청을 모아 모두 끝날 때까지 실행한 뒤 다음 N개를 시작한다. 따라서 전체 배치가 가장 느린 요청을 기다리고, 요청이 제각기 다른 시점에 끝나면서 GPU가 유휴 상태가 된다). vLLM은 둘 다 공략한다.

PagedAttention: KV 캐시를 가상 메모리처럼 페이징한다

요청마다 연속 버퍼 하나를 두는 대신, vLLM은 GPU의 KV 메모리를 고정 크기 블록으로 나눈다. 기본값은 블록당 16토큰이다(cache.py:46). 풀 크기는 가중치를 올리고 남은 GPU 메모리를 기준으로 시작할 때 한 번만 정한다. num_blocks = available_memory // page_size // num_layers(kv_cache_utils.py:987)이며, 여기서 “사용 가능” 메모리는 카드 메모리에서 모델을 뺀 뒤 gpu_memory_utilization이 정하는 몫이다. 기본값은 0.92다(cache.py:67).

그러면 각 요청은 연속 범위가 아니라 블록 인덱스의 목록을 가진다. 새 토큰을 위한 공간이 필요할 때 allocate_slots는 빈 큐에서 ceil(new_tokens / 16)개 블록을 가져온다(kv_cache_manager.py:244). 풀이 이를 감당할 수 없으면 None을 반환한다(kv_cache_manager.py:416). 이는 뒤에서 다시 살펴볼 신호다. 어텐션 커널은 연속성을 전혀 가정하지 않는다. 요청별 블록 테이블을 전달받아(flash_attn.py:582) 논리적 토큰 위치를 물리적 블록 id에 매핑하고, 그 블록이 어디에 있든 KV를 모은다. 이것이 비결의 전부다. PagedAttention의 “paged”는 말 그대로 KV 캐시에 OS 방식 페이징을 적용한다는 뜻이고, 파편화를 없앤다.

그 위에 얹는 이점은 접두사 공유다. 가득 찬 모든 블록은 이전 블록의 해시와 현재 블록의 토큰 id를 잇는 방식으로 해시된다. hash((parent_block_hash, curr_block_token_ids, extra_keys))(kv_cache_utils.py:603)이므로 해시는 블록 하나가 아니라 전체 접두사를 포괄한다. 같은 시스템 프롬프트로 시작하는 두 요청은 같은 해시를 만든다. 따라서 두 번째 요청은 자체 블록을 할당하는 대신 기존 물리 블록을 touch하기만 한다(참조 횟수 +1, 복사 없음)(block_pool.py:597). 블록은 참조 횟수가 영이 되어야 빈 풀로 돌아가고, 캐시되어 있지만 비어 있는 블록은 빈 큐의 뒤쪽에 머물러 가장 나중에 축출된다(LRU).

vLLM은 블록 테이블을 통해 논리적 토큰 범위를 비연속적인 물리 GPU KV 캐시 블록에 매핑한다

PagedAttention은 각 요청에 블록 테이블을 준다. 논리적 토큰 위치는 비연속적인 물리 KV 블록을 가리킬 수 있고, 공유 접두사는 같은 블록을 재사용할 수 있다.

연속 배칭: 매 단계마다 배치를 다시 구성한다

스케줄러의 설계 주석은 이를 노골적으로 말한다. “스케줄러에는 ’디코딩 단계’도 ’프리필 단계’도 없다”(scheduler.py:391). 모든 요청은 그저 (num_computed_tokens, num_tokens_with_spec) 쌍이고, 스케줄러는 매 단계 토큰 예산max_num_batched_tokens(scheduler.py:408)를 나눠 주면서 각 요청의 계산 완료 개수가 전체 개수를 따라잡게 하려 한다. 완전히 새로운 2,000토큰 프롬프트(“프리필”)와 500번째 토큰을 생성 중인 요청(“디코드”)은 같은 종류의 대상이다. 둘 다 그저 예산에서 토큰을 원한다.

그래서 한 번의 순전파는 프롬프트와 생성을 자유롭게 섞는다. 스케줄러는 이미 실행 중인 요청을 채워 넣고(scheduler.py:432), 그런 다음 예산이 바닥날 때까지 대기 중인 요청을 받아들인다(scheduler.py:629). 긴 프롬프트는 청크 프리필을 통해 여러 단계에 나눌 수 있다(scheduler.py:468). 배치를 매 단계 처음부터 다시 구성하므로, 요청 하나가 끝나는 순간 그 자리를 대기 중인 요청이 재사용한다. GPU는 배치가 비워질 때까지 기다리느라 유휴 상태가 되지 않는다. 이것이 “연속”(진행 중) 배칭이고, 처리량을 증폭하는 요인이다.

블록이 바닥나면 어떻게 되는가

두 아이디어는 실패 상황에서 만난다. 스케줄링 도중 allocate_slotsNone을 반환하면 실행 중인 요청을 키울 빈 메모리가 없다는 뜻이다. 그래서 vLLM은 하나를 선점한다. 기본 FCFS 정책에서는 가장 최근에 실행된 요청인 self.running.pop()을 축출하고(scheduler.py:562), 우선순위 스케줄링에서는 우선순위가 가장 낮고 가장 늦게 도착한 요청을 축출한다(scheduler.py:538). 희생된 요청의 블록은 해제되고, num_computed_tokens0으로 초기화되며(scheduler.py:1120), 나중에 재계산되도록 대기 큐의 맨 앞으로 돌아간다.

여기서 알아둘 것이 두 가지 있다. 첫째, v1의 선점은 재계산만 사용한다. 이전의 CPU 스왑 경로는 사라졌고, 선점된 요청은 KV를 버리고 다시 생성한다. 둘째, 접두사 캐싱은 그 부담을 줄인다. 해제할 때 요청의 꼬리 블록부터 축출하므로 프롬프트 접두사는 풀에 남아 있는 경우가 많다. 따라서 다시 스케줄링할 때 get_computed_blocks(kv_cache_manager.py:202)가 아직 캐시된 접두사를 돌려주고 재계산을 건너뛸 수 있다. 메모리를 빽빽하게 채우는 바로 그 페이징이 재계산 비용도 낮춰 준다.

이는 어디에 자리하는가?

내가 그리는 로컬 추론의 축은 편의성 대 처리량이고, 이는 더 깊은 구분을 따른다. 엔진 위의 관리자목적에 맞춰 만든 엔진이다.

Ollama는 관리자다. llama.cpp를 별도 프로세스로 실행하며, 내가 Ollama 티어다운에서 확인했듯 GPU 계층 분할조차 업스트림 서버에 위임한다. 친숙한 UX로 모델 하나를 실행하는 개발자 한 명에게 최적화되어 있다. vLLM은 그 자체가 엔진이다. 자체 PagedAttention CUDA 커널, 자체 페이징 KV 할당기, 자체 연속 배칭 스케줄러를 갖추고 있고, 위의 모든 설계 선택은 많은 동시 요청을 높은 처리량으로 제공하기 위해 존재한다. 어느 쪽도 “더 낫지” 않다. 서로 다른 공간을 위해 만들어졌다. Ollama는 노트북에서 모델 하나를 쉽게 쓸 수 있게 한다. vLLM은 사용자 천 명이 몰릴 때 A100을 바쁘게 유지한다.

언제 이것을 선택하게 될까?

여러 동시 요청을 서빙하고 처리량과 GPU 사용률이 중요하며, 공유 접두사(큰 공통 시스템 프롬프트)가 접두사 캐싱으로 공짜로 거둘 수 있는데도 놓치고 있는 이익이라면 vLLM을 선택하면 된다. 트레이드오프의 형태는 알고 들어가야 한다. 강력한 GPU를 원하고 캐시를 위해 약 92%를 미리 예약한다. 메모리 압박 아래에서는 스왑하는 대신 선점하고 재계산하므로 극단적인 초과 할당은 낭비되는 재계산으로 나타난다. 그리고 brew install로 끝나는 도구가 아니라 운영 구성 요소다. 자기 컴퓨터에서 로컬 모델과 대화하고 싶을 뿐이라면 이곳이 아니라 Ollama의 영역이다.

방법론과 범위

나는 **vLLM v0.24.0rc2, 커밋 6d37570**을 읽었고, v1 엔진의 vllm/v1/core/sched/scheduler.py, vllm/v1/core/kv_cache_manager.py, vllm/v1/core/block_pool.py, vllm/v1/core/kv_cache_utils.py, vllm/config/cache.py에 집중했다. 세 가지 핵심 주장, 즉 접두사 공유를 지원하는 고정 블록 페이징 KV 캐시, 프리필/디코드 단계가 없는 단계별 토큰 예산 연속 배칭, 블록 소진 시 선점 후 재계산은 이 커밋에서 각각 적대적 주장 검증을 거쳤고 확인되었다. 코드 줄 번호는 이 커밋에서 정확하다.

범위의 주의점을 명확히 밝힌다.

  • 이는 v1 엔진이다. 이전 v0 아키텍처에는 CPU 스왑 선점 경로가 있었다. v1은 재계산만 사용하므로 이 주장에는 버전이 중요하다.
  • 블록 테이블을 통한 실제 KV 수집은 내가 읽지 않은 바이너리 의존성인 컴파일된 FlashAttention CUDA 커널에서 실행된다. 내가 검증한 것은 커널 내부가 아니라 vLLM이 요청별 블록 테이블을 커널에 전달한다는 점이다.
  • 나는 스케줄러와 KV 캐시 하위 시스템을 읽었고, 분산 경로(텐서/파이프라인 병렬화, 선점과 별도로 존재하는 KV 커넥터 오프로딩 기능)는 읽지 않았다.

방법론

이 글은 아래의 고정된 저장소 상태를 기준으로 읽었습니다. 디렉터리 이름은 편의용 키이고, 실제 고정 지점은 commit SHA입니다.

저장소 키버전커밋원본
vllm-v0.24.0rc2v0.24.0rc26d37570a1c6dgithub.com/vllm-project/vllm

관련 분석

결정 축은 운영 계층이다. 바이너리 엔진을 직접 통제하느냐, 모델 매니저를 쓰느냐, 서버 처리량을 위해 엔진을 갈아 끼우느냐로 갈린다.