发表时间: 2025-09 · Blog post by Aleksa Gordic (blog.vllm.ai)
原文: https://blog.vllm.ai/blog/2025-09-05-anatomy-of-vllm
作者:Aleksa Gordic
一句话结论
本文全面解剖了高吞吐量大语言模型推理系统 vLLM 的底层架构,详细阐述了从单卡核心引擎到多节点分布式服务的设计机制,展示了其如何通过分页注意力、连续批处理及多种高级特性实现高效的大规模动态推理。
要解决什么问题
传统大语言模型推理面临多个具体的底层卡点。首先,自回归解码阶段是典型的内存带宽受限(memory-bandwidth-bound)过程,每次生成一个 token 都需要重新加载全部模型权重和历史 KV 缓存,导致计算资源闲置,批处理规模受限。其次,长提示词的预填充(Prefill)过程属于计算密集型,会长时间独占引擎,阻塞其他请求的执行,增加整体延迟;而具有相同前缀的请求会引发大量重复的 KV 缓存计算。此外,当预填充和解码任务混合在同一节点执行时,由于两者的性能特征截然不同,会相互干扰。在结构化输出场景下,传统的生成方式难以高效限制词表输出;而在硬件层面,单卡显存无法容纳超大模型,且离线推理引擎缺乏处理高并发网络请求和多节点协同的机制,限制了系统的横向扩展能力与并发吞吐量。
怎么做的
系统以 LLM Engine 为核心,通过多个关键部件协同绕开上述卡点。核心机制是基于 PagedAttention 的 KV 缓存管理器,它将显存划分为固定大小的块(默认每块 16 个 token),通过块表实现非连续存储。单个标准 Transformer 层的块大小计算公式为:
在此基础上,调度器支持连续批处理,在每个引擎步后动态评估新旧请求,将所有序列展平为超长序列交由自定义算子执行。针对长提示词阻塞问题,引入分块预填充(Chunked prefill),强制限制单步最大 token 数,将长序列切分为多步执行。针对重复计算,设计了前缀缓存(Prefix Caching),以 16 个 token 为块计算哈希值,若在缓存中命中,则直接复用已分配的 KV 块。为了加速解码,系统集成了投机解码(Speculative Decoding)。通过较小的草稿模型(如 n-gram、EAGLE 或 Medusa)一次性低成本生成 $k$ 个候选 token,再由大模型并行验证。其接受率遵循以下规则以保证分布一致性:若大模型概率 $p_{\text{large}} \ge p_{\text{draft}}$ 则接受;否则以 $\frac{p_{\text{large}}}{p_{\text{draft}}}$ 的概率接受。对于结构化输出,引入引导解码(Guided Decoding),利用有限状态机(FSM)在后台异步编译语法,并在前向传播后将不合法 token 的对数几率(logits)掩码为 $-\infty$。在架构扩展上,系统实现了预填充与解码分离(Disaggregated P/D),预填充节点计算后将 KV 缓存写入外部存储,解码节点读取复用。对于多卡多机,底层通过 MultiProcExecutor 建立共享内存消息队列处理张量并行(TP),上层通过 DPCoordinator 管理数据并行(DP)副本,利用波次计数器和虚拟步保持同步,并结合 API 服务器的异步任务实现动态负载均衡。
效果如何
本文并未提供与特定基线方法的量化对比实验,而是通过理论模型和内置基准测试工具阐述了系统的性能表现机制。实验场景以 H100 节点和 TinyLlama-1.1B-Chat-v1.0 模型为示例,使用 1000 条 ShareGPT 数据进行吞吐量测试。性能评估的核心在于揭示延迟与吞吐量之间的权衡关系,主要测量首字延迟(TTFT)、词间延迟(ITL)、单输出 token 耗时(TPOT)以及有效吞吐量(Goodput)。根据 Roofline 性能模型,在批处理大小 $B$ 达到饱和点 $B_{\text{sat}}$ 之前,单步耗时受限于高带宽内存(HBM)的权重读取,此时增加 $B$ 几乎不增加延迟,但能显著提升吞吐量;当 $B$ 超过 $B_{\text{sat}}$ 后,算子转为计算受限,单步耗时随 $B$ 线性增长,导致 ITL 等延迟指标恶化。系统内置了自动化调优脚本,能够在满足特定服务级别目标(例如要求 p99 端到端延迟小于 500 毫秒)的前提下,搜索最大化吞吐量的配置参数。作者也指出了当前实现的一些局限性,例如预填充与解码分离所依赖的 KV 缓存传输连接器接口尚未稳定,且投机解码目前不支持完整的 LLM 草稿模型方法,仅实现了 n-gram 等较快但准确率略低的提议方案。
本文深入剖析了现代高吞吐量大语言模型(LLM)推理系统的核心架构,以 vLLM(基于 V1 引擎,commit 42172ad)为具体研究对象,详细阐述了从单 GPU 离线推理到多 GPU、多节点动态分布式服务的大规模扩展过程。文章的核心贡献在于系统性地拆解并详述了以下关键技术创新及其工程实现:
UniProcExecutor)扩展至多进程(MultiProcExecutor),并构建带有负载均衡的 API 服务层。在引入复杂的分布式服务之前,vLLM 引擎本身即可在离线环境中实现高吞吐量推理。系统的设计起点是一个离线、同步、单 GPU(DP/TP/PP/EP = 1)且运行标准 Transformer 模型的环境。
以下是基础离线推理的代码范例,环境配置要求设置 VLLM_USE_V1="1"(启用 V1 引擎)和 VLLM_ENABLE_V1_MULTIPROCESSING="0"(单进程运行):
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()
在此配置下,系统主要执行两个操作:实例化 LLM 引擎,并调用 generate 函数对给定的提示词进行采样。后续的所有高级特性与分布式架构均建立在此核心逻辑之上。
LLM 引擎的构造函数包含以下主要组件:
EngineCoreRequests。InprocClient(等同于 EngineCore),在分布式扩展中将演变为 DPLBAsyncMPClient。EngineCoreOutputs 转换为用户可见的 RequestOutput。Engine Core 本身由多个子组件构成:
UniProcExecutor(单 GPU 单 Worker 进程),后续扩展为 MultiProcExecutor(支持多 GPU)。waiting(等待)和 running(运行)队列。KV 缓存管理器维护一个 free_block_queue(可用 KV 缓存块池,数量通常在十万级别)。在分页注意力中,这些块作为索引结构,将 Token 映射到其计算出的 KV 缓存块。标准 Transformer 层的块大小计算公式为:$2 \times block\_size \times num\_kv\_heads \times head\_size \times dtype\_num\_bytes$(其中 $2$ 代表 key 和 value,$block\_size$ 默认为 16)。
在模型执行器构造期间,系统会创建一个 Worker 对象,并执行三个关键步骤(在多 GPU 环境下,这些步骤将在不同 GPU 的各个 Worker 进程中独立运行):
初始化设备(Init device):
gpu_memory_utilization(如 0.8 即 80% VRAM)验证可用显存。model_runner(包含采样器、KV 缓存及前向传播缓冲区如 input_ids、positions 等)。InputBatch 对象(保存 CPU 端前向传播缓冲区、KV 缓存索引的块表、采样元数据等)。加载模型(Load model):
model.eval() 进入推理模式,并可选择调用 torch.compile()。初始化 KV 缓存(Initialize KV cache):
FullAttentionSpec)。--enforce-eager,否则针对每个预热批次大小执行虚拟运行并捕获 CUDA Graphs。CUDA Graphs 将 GPU 工作序列记录为 DAG,在前向传播期间回放这些图可消除内核启动开销,从而降低延迟。请求进入引擎的过程如下:
prompt、prompt_token_ids 和 type 的字典。EngineCoreRequest,附加优先级和采样参数。Request 对象并将其状态设置为 WAITING,随后加入调度器的 waiting 队列(FCFS 策略下追加,优先级策略下堆推入)。引擎支持连续批处理(Continuous batching)【[6], Orca: A Distributed Serving System for Transformer-Based Generative Models + 2022 + OSDI + https://www.usenix.org/conference/osdi22/presentation/yu】,在每步执行后都会同时考虑新旧请求。只要有请求存在,引擎就会循环调 用 step() 函数,每个 step 包含三个阶段:
Request,进行反分词,并检查停止条件(达到长度限制、采样到 EOS ID、匹配 stop_token_ids 或出现停止字符串)。若请求完成,则清理资源(将 KV 块归还至 free_block_queue)并提前返回输出。推理引擎处理两种主要负载:
V1 调度器能够在同一步骤中混合处理这两种请求。调度器优先处理已在 running 队列中的解码请求,对每个请求执行:
allocate_slots 函数。接着,调度器处理 waiting 队列中的预填充请求:
allocate_slots。RUNNING。allocate_slots 执行逻辑:
kv_cache_manager.free)来进行重计算抢占,或跳过调度继续执行。free_block_queue 获取前 $n$ 个块,并存储到 req_to_blocks(映射 request_id 到 KV 缓存块列表的字典)。调用模型执行器的 execute_model,委托给 Worker 及 model runner。主要步骤包括:
input_batch 中修剪已完成的请求;更新前向传播元数据(如用于索引分页 KV 缓存的请求块信息)。slot_mapping;构造注意力元数据。前向传播支持 Eager 模式(标准 PyTorch)和 Captured 模式(回放预先捕获的 CUDA Graph)。
为防止超长 Prompt 的预填充步骤独占引擎导致其他请求延迟增加,系统引入了分块预填充。假设块大小 $n=8$,长提示词 $P$ 包含多个块,系统将其拆分在多个引擎步骤中执行,仅在最后一个块完成时采样新 Token。
实现机制:将每步允许的新 Token 数量限制为 long_prefill_token_threshold。如果请求数量超过该阈值,则截断至阈值大小,底层索引逻辑会自动处理后续拼接。
前缀缓存旨在避免重复计算多个 Prompt 共享的初始 Token。以下为示例代码:
from vllm import LLM, SamplingParams
long_prefix = "<a piece of text that is encoded into more than block_size tokens>"
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(long_prefix + prompts[0], sampling_params)
outputs = llm.generate(long_prefix + prompts[1], sampling_params)
if __name__ == "__main__":
main()
工作原理:
generate 调用时,调度阶段调用 hash_request_tokens,将 long_prefix + prompts[0] 拆分为 16-Token 块。BlockHash 对象列表,保存在 req_to_block_hashes[request_id] 中。find_longest_cache_hit 在 cached_block_hash_to_block 中查找。首次请求无命中。find_longest_cache_hit 会找到匹配的哈希块。系统直接复用这些 KV 块,将它们的引用计数加 1。如果原请求已结束,块被归还至池中,只要它们还在 cached_block_hash_to_block 中且未被重新分配覆盖,即可直接从 free_block_queue 中移除并复用。引导解码通过基于语法的有限状态机(FSM)约束每步解码的 Logits,确保仅采样符合语法(如正则表达式、编程语言上下文无关语法)的 Token。
from vllm import LLM, SamplingParams
from vllm.sampling_params import GuidedDecodingParams
prompts = [
"This sucks",
"The weather is beautiful",
]
guided_decoding_params = GuidedDecodingParams(choice=["Positive", "Negative"])
sampling_params = SamplingParams(guided_decoding=guided_decoding_params)
def main():
llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0")
outputs = llm.generate(prompts, sampling_params)
if __name__ == "__main__":
main()
vLLM 中的工作流:
StructuredOutputManager,维护 _grammar_bitmask 张量。WAITING_FOR_FSM,grammar_init 选择后端编译器(如 xgrammar【[7], XGrammar: Flexible and Efficient Structured Generation Engine for Large Language Models + 2024 + https://arxiv.org/abs/2411.15100】) 。WAITING。StructuredOutputManager 要求后端准备 _grammar_bitmask。accept_tokens 推进 FSM 状态。
投机解码【[8], Accelerating Large Language Model Decoding with Speculative Sampling + 2023 + https://arxiv.org/abs/2302.01318】引入小型草稿模型 (Draft Model)廉价地提出 $k$ 个候选 Token,再由大模型进行一次前向传播验证。
验证规则:
vLLM V1 不使用独立的 LLM 草稿模型,而是实现了 n-gram、EAGLE【[9], EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty + 2024 + https://arxiv.org/abs/2401.15077】 和 Medusa【[10], Medusa: Simple LLM Inference Acceleration Framework with Multiple Decoding Heads + 2024 + https://arxiv.org/abs/2401.10774】等更快的提议方案 。
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)
speculative_config={
"method": "ngram",
"prompt_lookup_max": 5,
"prompt_lookup_min": 3,
"num_speculative_tokens": 3,
}
def main():
llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0", speculative_config=speculative_config)
outputs = llm.generate(prompts, sampling_params)
if __name__ == "__main__":
main()
工作流:
drafter 和 rejection_sampler。propose_draft_token_ids(k) 从草稿模型采样 $k$ 个 Token,存入 request.spec_token_ids。allocate_slots 会为这 $k$ 个新 Token 预留 KV 块。spec_token_ids 复制到 input_batch.token_ids_cpu。_calc_spec_decode_metadata 准备元数据,大模型对草稿 Token 进行前向传播。rejection_sampler 从左到右进行接受/拒绝判断,产生 output_token_ids。重复此过程直至满足停止条件。
为隔离长且突发的预填充(计算密集)和稳定且对延迟敏感的解码(内存带宽密集),系统运行 $N$ 个预填充实例和 $M$ 个解码实例。预填充 Worker 将 KV 写入专用缓存服务,解码 Worker 从中读取。
import os
import time
from multiprocessing import Event, Process
import multiprocessing as mp
from vllm import LLM, SamplingParams
from vllm.config import KVTransferConfig
prompts = [
"Hello, my name is",
"The president of the United States is",
]
def run_prefill(prefill_done):
os.environ["CUDA_VISIBLE_DEVICES"] = "0"
sampling_params = SamplingParams(temperature=0, top_p=0.95, max_tokens=1)
ktc=KVTransferConfig(
kv_connector="ExampleConnector",
kv_role="kv_both",
kv_connector_extra_config={"shared_storage_path": "local_storage"},
)
llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0", kv_transfer_config=ktc)
llm.generate(prompts, sampling_params)
prefill_done.set()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("Script stopped by user.")
def run_decode(prefill_done):
os.environ["CUDA_VISIBLE_DEVICES"] = "1"
sampling_params = SamplingParams(temperature=0, top_p=0.95)
ktc=KVTransferConfig(
kv_connector="ExampleConnector",
kv_role="kv_both",
kv_connector_extra_config={"shared_storage_path": "local_storage"},
)
llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0", kv_transfer_config=ktc)
prefill_done.wait()
outputs = llm.generate(prompts, sampling_params)
if __name__ == "__main__":
prefill_done = Event()
prefill_process = Process(target=run_prefill, args=(prefill_done,))
decode_process = Process(target=run_decode, args=(prefill_done,))
prefill_process.start()
decode_process.start()
decode_process.join()
prefill_process.terminate()
工作流:
init device 过程中创建角色为 "worker" 的 Connector;在调度器构造函数中创建角色为 "scheduler" 的 Connector。get_num_new_matched_tokens 检查外部 KV 缓存服务器。解码可能命中缓存,结果加入本地计数。update_state_after_alloc,随后调用 build_connector_meta。预填充标记 is_store=True,解码标记 is_store=False。start_load_kv(解码端加载外部 KV 注入分页内存);退出时调用 wait_for_save(预填充端阻塞等待 KV 上传完成)。当模型超出单卡显存时,系统通过张量并行(TP,优先使用因节点内带宽高于节点间)或流水线并行(PP)进行扩展。MultiProcExecutor 提供了多 GPU 进程的编排层。
实现逻辑:
MultiProcExecutor 初始化一个基于共享内存的 rpc_broadcast_mq 消息队列。world_size(如 $TP=8$ 则为 8),通过 WorkerProc.make_worker_process 生成守护进程。WorkerProc.worker_main 实例化 Worker,执行初始化设备、加载模型等流程。rpc_broadcast_mq(接收工作)和 worker_response_mq(发送响应)。worker_response_mq 句柄通过管道发送给父进程完成握手。rpc_broadcast_mq.dequeue。收到工作后执行划分好的 TP/PP 任务,结果通过 worker_response_mq.enqueue 返回。execute_model 自动将任务分发到各个 Worker,对上层完全透明。以 2 个 8xH100 节点(1 个无头节点,1 个 API 服务器节点)、设置 $TP=4$ 和 $DP=4$ 为例。
无头节点(Headless Node):
运行带有 --headless 参数的 vllm serve 命令。CoreEngineProcManager 启动 2 个进程(对应本地 DP 数量),每个运行 EngineCoreProc.run_engine_core。
input_queue 和 output_queue。MultiProcExecutor 初始化 EngineCore。ready_event 后,向 Frontend 发送就绪消息(包含可用 KV 块数等)。input_queue;Main 线程取出请求执行 step() 并将结果放入 output_queue;Output 线程将结果发回 API 服务器。系统维护 DP 波次计数器(Wave counter),并在空闲时执行虚拟步骤(Dummy steps)以保持同步。API 服务器节点(API Server Node):
实例化 AsyncLLM,内部创建 DPLBAsyncMPClient(数据并行、负载均衡、异步多进程客户端)。
DPCoordinator 进程。outputs_queue。process_outputs_socket 从底层引擎接收数据并写入队列,output_handler 读取队列供 create_completion 使用。run_engine_stats_update_task 与 DP 协调器通信,处理负载均衡信息(如等待/运行请求数)并支持弹性伸缩。curl 请求时,FastAPI 路由调用 AsyncLLM.generate,进而调用 get_core_engine_for_request 进行负载均衡(选择分数最低的引擎,$score = len(waiting) \times 4 + len(running)$)。请求发送至选中引擎的 input_socket,处理完毕后通过 output_socket 返回,最终通过 Uvicorn 返回 JSONResponse。TinyLlama/TinyLlama-1.1B-Chat-v1.0 标准 Transformer 模型。model.eval() 和 torch.compile)、CUDA Graphs、FlashAttention 内核。分布式通信依赖 NCCL 和 ZMQ(异步消息传递库)。服务端依赖 FastAPI 和 Uvicorn。引导解码依赖 xgrammar,KV 缓存传输测试涉及 LMCache【[11], LMCache + https://github.com/LMCache/LMCache】 。系统性能评估主要围绕两个相互竞争的指标:延迟(Latency)和吞吐量(Throughput)。
Roofline 性能模型与批处理权衡:
当批次大小 $B$ 趋近 1 时,ITL 最低,但吞吐量差。随着 $B$ 增大,ITL 上升(每步执行更多 FLOPs),但因权重 I/O 被摊销,吞吐量提升。
根据 Roofline 模型:在饱和批次大小 $B_{sat}$ 以下,步进时间受 HBM 带宽主导(将权重流式传输至片上内存),因此步进延迟几乎平坦;超过 $B_{sat}$ 后,内核变为计算密集型,步进时间随 $B$ 线性增长。步进延迟公式为 $t = \frac{FLOPs_{step}}{P_{kernel}}$(其中 $P_{kernel}$ 为内核性能)。
vLLM 基准测试工具:
vLLM 提供了 vllm bench CLI 工具:
latency:使用短输入(默认 32 Tokens)和小批次(默认 8)采样 128 个输出 Tokens,报告端到端延迟。throughput:一次性提交固定提示词集(如 1000 个 ShareGPT 样本),在 $QPS=\infty$ 模式下报告每秒 Token 数。serve:启动服务器并从泊松分布中采样请求到达时间,模拟真实负载,并可强制限制服务器端最大并发数。本文全面解析了 vLLM 引擎的内部机制。系统从基础的 UniprocExecutor 起步,通过高度模块化的设计,成功集成了投机解码、前缀缓存、计算/内存分离等高级特性,并最终扩展为支持张量并行、流水线并行和数据并行的复杂异步分布式服务栈(MultiProcExecutor)。这种架构设计不仅保证了极高的推理吞吐量,还使得对 TPU、MoE、多模态模型等多样化硬件和模型架构的支持能够以"插件"的形式无缝接入。未来的工作将进一步深入这些特定子系统的底层细节。
补充细节:未详细展开的系统特性
除了上述核心流程,vLLM 引擎还包含许多正交的"插件化"特性,包括但不限于:支持 TPU、AWS Neuron 等异构硬件后端;支持 MLA、MoE、编码器-解码器(如 Whisper)、池化/嵌入模型;支持 EPLB、m-RoPE、LoRA、ALiBi、滑动窗口注意力及状态空间模型(如 Mamba/Mamba-2, Jamba);以及复杂的混合 KV 缓存逻辑(Jenga【[5], Jenga: Effective Memory Management for Serving LLM with Heterogeneity + 2025 + https://arxiv.org/abs/2503.18292】)和束搜索 (Beam sampling)等实验性异步调度功能。
致谢
作者感谢 Hyperstack 在过去一年中为实验提供的 H100 GPU 支持。同时感谢 Nick Hill(RedHat 核心贡献者)、Kaichao You(核心贡献者)、Mark Saroufim(PyTorch)、Kyle Krannen(NVIDIA, Dynamo)以及 Ashish Vaswani 对本文预发布版本的审阅与反馈。
参考文献