메뉴
HN
Hacker News • 50일 전

vLLM 내부 구조: 고처리량 LLM 추론 시스템 분석 (2025)

IMP
9/10
핵심 요약

최신 고처리량 LLM 추론 엔진인 vLLM의 핵심 아키텍처와 작동 원리를 심층 분석한 글입니다. 스케줄링, 페이징 어텐션(paged attention) 등 엔진의 기본 구조부터 고급 기능까지 다루어 개발자가 시스템의 전체적인 동작 원리를 이해할 수 있게 돕습니다. AI 서버 운영 및 최적화에 필수적인 vLLM의 구조를 파악하는 데 매우 유용한 자료입니다.

번역된 본문

이 글에서는 현대의 고처리량 LLM 추론 시스템을 구성하는 모든 핵심 시스템 컴포넌트와 고급 기능들을 단계적으로 소개할 것입니다. 특히 vLLM [1]이 어떻게 작동하는지 상세히 분석해 보겠습니다. 이 글은 시리즈의 첫 번째 글입니다. 전체적인 개요를 먼저 설명한 뒤 점차 구체적인 디테일을 덧붙이는 방식(역피라미드 방식)으로 진행되므로, 사소한 디테일에 파묻히지 않고 전체 시스템에 대한 정확하고 높은 수준의 멘탈 모델을 구축할 수 있습니다. 다음 글들에서는 특정 하위 시스템들을 더 깊이 파고들 예정입니다.

이 글은 5개의 파트로 구성되어 있습니다:

  • LLM 엔진 및 엔진 코어: vLLM의 기본 요소 (스케줄링, 페이징 어텐션(paged attention), 연속 배칭(continuous batching) 등)
  • 고급 기능: 청크 단위 프리필(chunked prefill), 접두사 캐싱(prefix caching), 가이드 및 추측 디코딩(guided & speculative decoding), 분리된 P/D(disaggregated P/D)
  • 스케일 업: 단일 GPU에서 다중 GPU 실행으로의 확장
  • 서빙 계층: 분산/동시 웹 스캐폴딩
  • 벤치마크 및 자동 튜닝: 지연 시간 및 처리량 측정

📝 참고 사항 이 분석은 2025년 8월 9일 커밋 42172ad를 기준으로 작성되었습니다. 대상 독자: 최신 LLM 엔진의 작동 원리가 궁금한 사람, 그리고 vLLM, SGLang 등에 기여하는 데 관심이 있는 분들. 이 글에서는 V1 엔진에 초점을 맞출 것입니다. 프로젝트가 어떻게 발전해 왔는지 이해하는 데 V0(현재는 사용 중단됨)를 함께 살펴보았으며, 많은 개념이 여전히 유효합니다. 첫 번째 섹션인 'LLM 엔진 / 엔진 코어'는 다소 압도적이고 지루할 수 있지만, 나머지 블로그에는 풍부한 예시와 시각 자료가 준비되어 있습니다. :)

LLM 엔진 및 엔진 코어

LLM 엔진은 vLLM의 근본적인 빌딩 블록입니다. 이것만으로도 이미 고처리량 추론을 가능하게 하지만, 이는 오직 오프라인 환경에서만 해당됩니다. 아직 웹을 통해 고객에게 서비스할 수는 없습니다.

다음 오프라인 추론 코드 스니펫을 실행 예제로 사용하겠습니다(basic.py에서 변형됨).

from vllm import LLM , SamplingParams prompts = [ "Hello, my name is" , "The president of the United States is" , ] sampling_params = SamplingParams ( temperature = 0.8 , top_p = 0.95 ) def main ( ) : llm = LLM ( model = "TinyLlama/TinyLlama-1.1B-Chat-v1.0" ) outputs = llm . generate ( prompts , sampling_params ) if name == "main" : main ( )

📝 환경 변수: VLLM_USE_V1="1" # V1 엔진을 사용 중 VLLM_ENABLE_V1_MULTIPROCESSING="0" # 단일 프로세스로 실행 중

이 설정은 다음과 같습니다:

  • 오프라인 (웹/분산 시스템 스캐폴딩 없음)
  • 동기식 (모든 실행은 단일 차단 프로세스에서 발생)
  • 단일 GPU (데이터/모델/파이프라인/전문가 병렬화 없음; DP/TP/PP/EP = 1)
  • 표준 트랜스포머 사용 [2] (Jamba와 같은 하이브리드 모델을 지원하려면 더 복잡한 하이브리드 KV-캐시 메모리 할당자가 필요함)

이후 이 예제에서 시작하여 점차 온라인, 비동기, 다중 GPU, 다중 노드 추론 시스템으로 발전시켜 나갈 것입니다. (하지만 여전히 표준 트랜스포머를 서빙한다고 가정합니다)

이 예제에서는 두 가지 작업을 수행합니다:

  1. 엔진 인스턴스화
  2. 주어진 프롬프트에서 샘플링하기 위해 generate 호출

이제 생성자(constructor)를 분석해 보겠습니다.

LLM 엔진 생성자

엔진의 주요 컴포넌트는 다음과 같습니다:

  • vLLM config (모델, 캐시, 병렬화 등을 설정하는 모든 노브를 포함)
  • processor (검증, 토큰화 및 처리를 통해 원시 입력 -> EngineCoreRequests 로 변환)
  • engine core client (이 예제에서는 기본적으로 EngineCore 와 동일한 InprocClient 를 사용 중입니다. 점차 대규모 서빙을 허용하는 DPLBAsyncMPClient 로 구축해 나갈 것입니다)
  • output processor (원시 EngineCoreOutputs -> 사용자가 보는 RequestOutput 으로 변환)

📝 참고: V0 엔진이 사용 중단됨에 따라 클래스 이름과 세부 정보는 변경될 수 있습니다. 정확한 시그니처보다는 핵심 아이디어에 초점을 맞출 것입니다. 일부 세부 정보는 생략하겠지만 모두 그런 것은 아닙니다.

엔진 코어 자체는 여러 하위 컴포넌트로 구성되어 있습니다:

  • Model Executor (모델의 순방향 패스를 구동합니다. 현재 단일 GPU에 단일 Worker 프로세스를 갖는 UniProcExecutor 를 다루고 있습니다). 점차 다중 GPU를 지원하는 MultiProcExecutor 로 구축해 나갈 것입니다.
  • Structured Output Manager (가이드 디코딩(generated decoding)에 사용됨 - 이에 대해서는 나중에 다룰 것입니다)
원문 보기
원문 보기 (영어)
In this post, I'll gradually introduce all of the core system components and advanced features that make up a modern high-throughput LLM inference system. In particular I'll be doing a breakdown of how vLLM [1] works. This post is the first in a series. It starts broad and then layers in detail (following an inverse-pyramid approach) so you can form an accurate high-level mental model of the complete system without drowning in minutiae. Later posts will dive into specific subsystems. This post is structured into five parts: LLM engine & engine core : fundamentals of vLLM (scheduling, paged attention, continuous batching, etc.) Advanced features : chunked prefill, prefix caching, guided & speculative decoding, disaggregated P/D Scaling up : from single-GPU to multi-GPU execution Serving layer : distributed / concurrent web scaffolding Benchmarks and auto-tuning : measuring latency and throughput 📝 Notes Analysis is based on commit 42172ad (August 9th, 2025). Target audience: anyone curious about how state-of-the-art LLM engines work, as well as those interested in contributing to vLLM, SGLang, etc. I'll focus on the V1 engine . I also explored V0 ( now deprecated ), which was valuable for understanding how the project evolved, and many concepts still carry over. The first section on LLM Engine / Engine Core might be a bit overwhelming/dry - but the rest of the blog has plenty examples and visuals. :) LLM Engine & Engine Core The LLM engine is the fundamental building block of vLLM. On its own, it already enables high-throughput inference - but only in an offline setting. You can't serve it to customers over the web yet. We'll use the following offline inference snippet as our running example (adapted from basic.py ). from vllm import LLM , SamplingParams prompts = [ "Hello, my name is" , "The president of the United States is" , ] sampling_params = SamplingParams ( temperature = 0.8 , top_p = 0.95 ) def main ( ) : llm = LLM ( model = "TinyLlama/TinyLlama-1.1B-Chat-v1.0" ) outputs = llm . generate ( prompts , sampling_params ) if __name__ == "__main__" : main ( ) 📝 Environment vars: VLLM_USE_V1="1" # we're using engine V1 VLLM_ENABLE_V1_MULTIPROCESSING="0" # we're running in a single process This configuration is: offline (no web/distributed system scaffolding) synchronous (all execution happens in a single blocking process) single-GPU (no data/model/pipeline/expert parallelism; DP/TP/PP/EP = 1) using standard transformer [2] (supporting hybrid models like Jamba requires a more complex hybrid KV-cache memory allocator) From here, we'll gradually build up to an online, async, multi-GPU, multi-node inference system - but still serving a standard transformer. In this example we do two things, we: Instantiate an engine Call generate on it to sample from the given prompts Let's start analyzing the constructor. LLM Engine constructor The main components of the engine are: vLLM config (contains all of the knobs for configuring model, cache, parallelism, etc.) processor (turns raw inputs → EngineCoreRequests via validation, tokenization, and processing) engine core client (in our running example we're using InprocClient which is basically == EngineCore ; we'll gradually build up to DPLBAsyncMPClient which allows serving at scale) output processor (converts raw EngineCoreOutputs → RequestOutput that the user sees) 📝 Note: With the V0 engine being deprecated, class names and details may shift. I'll emphasize the core ideas rather than exact signatures. I'll abstract away some but not all of those details. Engine core itself is made up of several sub components: Model Executor (drives forward passes on the model, we're currently dealing with UniProcExecutor which has a single Worker process on a single GPU). We'll gradually build up to MultiProcExecutor which supports multiple GPUs Structured Output Manager (used for guided decoding - we'll cover this later) Scheduler (decides which requests go into the next engine step) - it further contains: policy setting - it can be either FCFS (first come first served) or priority (higher priority requests are served first) waiting and running queues KV cache manager - the heart of paged attention [3] The KV-cache manager maintains a free_block_queue - a pool of available KV-cache blocks (often on the order of hundreds of thousands, depending on VRAM size and block size). During paged attention, the blocks serve as the indexing structure that map tokens to their computed KV cache blocks. Core components described in this section and their relationships Block size for a standard transformer layer (non-MLA [4] ) is computed as follows: 2 (key/value) * block_size (default=16) * num_kv_heads * head_size * dtype_num_bytes (e.g. 2 for bf16) During model executor construction, a Worker object is created, and three key procedures are executed. (Later, with MultiProcExecutor , these same procedures run independently on each worker process across different GPUs.) Init device: Assign a CUDA device (e.g. "cuda:0") to the worker and check that the model dtype is supported (e.g. bf16) Verify enough VRAM is available, given the requested gpu_memory_utilization (e.g. 0.8 → 80% of total VRAM) Set up distributed settings (DP / TP / PP / EP, etc.) Instantiate a model_runner (holds the sampler, KV cache, and forward-pass buffers such as input_ids , positions , etc.) Instantiate an InputBatch object (holds CPU-side forward-pass buffers, block tables for KV-cache indexing, sampling metadata, etc.) Load model: Instantiate the model architecture Load the model weights Call model.eval() (PyTorch's inference mode) Optional: call torch.compile() on the model Initialize KV cache Get per-layer KV-cache spec. Historically this was always FullAttentionSpec (homogeneous transformer), but with hybrid models (sliding window, Transformer/SSM like Jamba) it became more complex (see Jenga [5] ) Run a dummy/profiling forward pass and take a GPU memory snapshot to compute how many KV cache blocks fit in available VRAM Allocate, reshape and bind KV cache tensors to attention layers Prepare attention metadata (e.g. set the backend to FlashAttention) later consumed by kernels during the fwd pass Unless --enforce-eager is provided, for each of warmup batch sizes do a dummy run and capture CUDA graphs. CUDA graphs record the whole sequence of GPU work into a DAG. Later during fwd pass we launch/replay pre-baked graphs and cut on kernel launch overhead and thus improve latency. I've abstracted away many low-level details here — but these are the core pieces I'll introduce now, since I'll reference them repeatedly in the following sections. Now that we have the engine initialized let's proceed to the generate function. Generate function The first step is to validate and feed requests into the engine. For each prompt we: Create a unique request ID and capture its arrival time Call an input preprocessor that tokenizes the prompt and returns a dictionary containing prompt , prompt_token_ids , and a type (text, tokens, embeds, etc.) Pack this info into an EngineCoreRequest , adding priority, sampling params, and other metadata Pass the request into the engine core, which wraps it in a Request object and sets its status to WAITING . This request is then added to the scheduler's waiting queue (append if FCFS, or heap-push if priority) At this point the engine has been fed and execution can begin. In the synchronous engine example, these initial prompts are the only ones we'll process — there's no mechanism to inject new requests mid-run. In contrast, the asynchronous engine supports this (aka continuous batching [6] ): after each step, both new and old requests are considered. Because the forward pass flattens the batch into a single sequence and custom kernels handle it efficiently, continuous batching is fundamentally supported even in the synchronous