Inside vLLM: Anatomy of a High-Throughput LLM Inference System

发表时间: 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 层的块大小计算公式为:

$$ \text{Block Size} = 2 \times \text{block\_size} \times \text{num\_kv\_heads} \times \text{head\_size} \times \text{dtype\_num\_bytes} $$


在此基础上,调度器支持连续批处理,在每个引擎步后动态评估新旧请求,将所有序列展平为超长序列交由自定义算子执行。针对长提示词阻塞问题,引入分块预填充(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、多节点动态分布式服务的大规模扩展过程。文章的核心贡献在于系统性地拆解并详述了以下关键技术创新及其工程实现:

  1. 核心引擎机制:包括分页注意力(Paged Attention)、连续批处理(Continuous Batching)以及细粒度调度的实现。
  2. 高级推理特性:详细论述了分块预填充(Chunked Prefill)、前缀缓存(Prefix Caching)、基于有限状态机(FSM)的引导解码(Guided Decoding)、投机解码(Speculative Decoding)以及计算/内存分离的 P/D(预填充/解码)架构。
  3. 分布式扩展与服务:展示了系统如何通过张量并行(TP)、流水线并行(PP)和数据并行(DP)从单进程(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 函数对给定的提示词进行采样。后续的所有高级特性与分布式架构均建立在此核心逻辑之上。

三、 方法细节

3.1 LLM 引擎与引擎核心构造

LLM 引擎的构造函数包含以下主要组件:

  • vLLM 配置:包含模型、缓存、并行化等所有配置参数。
  • 处理器(Processor):负责通过验证、分词和处理,将原始输入转化为 EngineCoreRequests。
  • 引擎核心客户端(Engine Core Client):在单进程示例中使用 InprocClient(等同于 EngineCore),在分布式扩展中将演变为 DPLBAsyncMPClient。
  • 输出处理器(Output Processor):将原始的 EngineCoreOutputs 转换为用户可见的 RequestOutput。

Engine Core 本身由多个子组件构成:

  • 模型执行器(Model Executor):驱动模型的前向传播。基础版本为 UniProcExecutor(单 GPU 单 Worker 进程),后续扩展为 MultiProcExecutor(支持多 GPU)。
  • 结构化输出管理器(Structured Output Manager):用于引导解码。
  • 调度器(Scheduler):决定哪些请求进入下一个引擎步骤。它包含:
    1. 策略设置:支持FCFS(先到先得)或优先级调度。
    2. waiting(等待)和 running(运行)队列。
    3. KV 缓存管理器:这是分页注意力(Paged Attention)的核心。

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)。

图1:本文描述的核心组件及其关系
图1:本文描述的核心组件及其关系

在模型执行器构造期间,系统会创建一个 Worker 对象,并执行三个关键步骤(在多 GPU 环境下,这些步骤将在不同 GPU 的各个 Worker 进程中独立运行):

  1. 初始化设备(Init device):

    • 为 Worker 分配 CUDA 设备(如 "cuda:0")并检查模型数据类型(如 bf16)是否受支持。
    • 根据请求的 gpu_memory_utilization(如 0.8 即 80% VRAM)验证可用显存。
    • 设置分布式配置(DP / TP / PP / EP)。
    • 实例化 model_runner(包含采样器、KV 缓存及前向传播缓冲区如 input_ids、positions 等)。
    • 实例化 InputBatch 对象(保存 CPU 端前向传播缓冲区、KV 缓存索引的块表、采样元数据等)。
  2. 加载模型(Load model):

    • 实例化模型架构并加载权重。
    • 调用 model.eval() 进入推理模式,并可选择调用 torch.compile()。
  3. 初始化 KV 缓存(Initialize KV cache):

    • 获取每层 KV 缓存规格(标准 Transformer 为 FullAttentionSpec)。
    • 运行虚拟/分析前向传播,捕获 GPU 内存快照,计算可用 VRAM 能容纳多少 KV 缓存块。
    • 分配、重塑并将 KV 缓存张量绑定到注意力层。
    • 准备注意力元数据(如设置后端为 FlashAttention)。
    • 除非指定 --enforce-eager,否则针对每个预热批次大小执行虚拟运行并捕获 CUDA Graphs。CUDA Graphs 将 GPU 工作序列记录为 DAG,在前向传播期间回放这些图可消除内核启动开销,从而降低延迟。

3.2 Generate 函数与执行循环

请求进入引擎的过程如下:

  1. 为每个 Prompt 创建唯一请求 ID 并记录到达时间。
  2. 调用输入预处理器,对 Prompt 进行分词,返回包含 prompt、prompt_token_ids 和 type 的字典。
  3. 将信息打包为 EngineCoreRequest,附加优先级和采样参数。
  4. 将请求传递给引擎核心,包装为 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 包含三个阶段:

  1. 调度(Schedule):选择要在本步骤中运行的请求(解码和/或分块预填充)。
  2. 前向传播(Forward pass):运行模型并采样 Token。
  3. 后处理(Postprocess):将采样的 Token ID 追加到每个 Request,进行反分词,并检查停止条件(达到长度限制、采样到 EOS ID、匹配 stop_token_ids 或出现停止字符串)。若请求完成,则清理资源(将 KV 块归还至 free_block_queue)并提前返回输出。
图2:引擎执行循环
图2:引擎执行循环

3.3 调度器工作流

推理引擎处理两种主要负载:

  1. 预填充(Prefill)请求:对所有 Prompt Token 进行前向传播。属于计算密集型,最终在最后一个 Token 位置的概率分布中采样出一个 Token。
  2. 解码(Decode)请求:仅对最新 Token 进行前向传播(历史 KV 已缓存)。属于内存带宽密集型。

V1 调度器能够在同一步骤中混合处理这两种请求。调度器优先处理已在 running 队列中的解码请求,对每个请求执行:

  1. 计算要生成的新 Token 数量。
  2. 调用 KV 缓存管理器的 allocate_slots 函数。
  3. 通过减去步骤 1 中的 Token 数量来更新 Token 预算。

接着,调度器处理 waiting 队列中的预填充请求:

  1. 检索已计算的块数(若禁用前缀缓存则返回 0)。
  2. 调用 allocate_slots。
  3. 将请求从等待队列弹出并移至运行队列,状态设为 RUNNING。
  4. 更新 Token 预算。

allocate_slots 执行逻辑:

  1. 计算块数:确定需要分配的新 KV 缓存块数 $n$。默认每块存 16 个 Token,如 17 个新 Token 需 $ceil(17/16) = 2$ 块。
  2. 检查可用性:若块池余量不足,则提前退出。引擎可能尝试通过驱逐低优先级请求(调用 kv_cache_manager.free)来进行重计算抢占,或跳过调度继续执行。
  3. 分配块:通过协调器从 free_block_queue 获取前 $n$ 个块,并存储到 req_to_blocks(映射 request_id 到 KV 缓存块列表的字典)。
图3:KV 缓存块列表映射
图3:KV 缓存块列表映射

3.4 运行前向传播

调用模型执行器的 execute_model,委托给 Worker 及 model runner。主要步骤包括:

  1. 更新状态:从 input_batch 中修剪已完成的请求;更新前向传播元数据(如用于索引分页 KV 缓存的请求块信息)。
  2. 准备输入:将缓冲区从 CPU 拷贝至 GPU;计算位置;构建 slot_mapping;构造注意力元数据。
  3. 前向传播:使用自定义分页注意力内核运行模型。所有序列被展平并拼接成一个长的"超级序列"。位置索引和注意力掩码确保每个序列仅关注自身的 Token,这使得系统能够在无需右侧填充(right-padding)的情况下实现连续批处理。
  4. 收集尾 Token 状态:提取每个序列最终位置的隐藏状态并计算 Logits。
  5. 采样:根据采样配置(贪心、温度、top-p 等)从 Logits 中采样 Token。

前向传播支持 Eager 模式(标准 PyTorch)和 Captured 模式(回放预先捕获的 CUDA Graph)。

图4:前向传播中的连续批处理与分页注意力
图4:前向传播中的连续批处理与分页注意力

3.5 高级特性:分块预填充(Chunked Prefill)

为防止超长 Prompt 的预填充步骤独占引擎导致其他请求延迟增加,系统引入了分块预填充。假设块大小 $n=8$,长提示词 $P$ 包含多个块,系统将其拆分在多个引擎步骤中执行,仅在最后一个块完成时采样新 Token。
实现机制:将每步允许的新 Token 数量限制为 long_prefill_token_threshold。如果请求数量超过该阈值,则截断至阈值大小,底层索引逻辑会自动处理后续拼接。

图5:分块预填充示意图
图5:分块预填充示意图

3.6 高级特性:前缀缓存(Prefix Caching)

前缀缓存旨在避免重复计算多个 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()

工作原理:

  1. 在首次 generate 调用时,调度阶段调用 hash_request_tokens,将 long_prefix + prompts[0] 拆分为 16-Token 块。
  2. 对每个完整块,结合前一块的哈希、当前 Token 及可选元数据(MM哈希、LoRA ID、缓存盐)计算哈希值(内置哈希或 SHA-256)。结果存储为 BlockHash 对象列表,保存在 req_to_block_hashes[request_id] 中。
  3. 调用 find_longest_cache_hit 在 cached_block_hash_to_block 中查找。首次请求无命中。
图6:前缀缓存 - 哈希函数计算
图6:前缀缓存 - 哈希函数计算
  1. 分配块后,前向传播将 KV 填入分页内存,协调器记录哈希与物理块的映射关系。
图7:前缀缓存 - 将 KV 填入分页内存
图7:前缀缓存 - 将 KV 填入分页内存
  1. 第二次调用时,find_longest_cache_hit 会找到匹配的哈希块。系统直接复用这些 KV 块,将它们的引用计数加 1。如果原请求已结束,块被归还至池中,只要它们还在 cached_block_hash_to_block 中且未被重新分配覆盖,即可直接从 free_block_queue 中移除并复用。
图8:前缀缓存 - 复用 KV 缓存
图8:前缀缓存 - 复用 KV 缓存

3.7 高级特性:引导解码(Guided Decoding, FSM)

引导解码通过基于语法的有限状态机(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 中的工作流:

  1. 构造引擎时创建 StructuredOutputManager,维护 _grammar_bitmask 张量。
  2. 请求加入时状态设为 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】) 。
  3. 异步编译语法。编译完成后,请求状态转为 WAITING。
  4. 调度后,StructuredOutputManager 要求后端准备 _grammar_bitmask。
  5. 前向传播产生 Logits 后,将位掩码扩展至词表大小(使用 32 位整数,扩展率为 32x),将不允许的 Logits 设为 $-\infty$。
  6. 采样后,通过 accept_tokens 推进 FSM 状态。

图9:玩具示例 FSM
图10:词表大小为 8 的位掩码玩具示例

3.8 高级特性:投机解码(Speculative Decoding)

投机解码【[8], Accelerating Large Language Model Decoding with Speculative Sampling + 2023 + https://arxiv.org/abs/2302.01318】引入小型草稿模型 (Draft Model)廉价地提出 $k$ 个候选 Token,再由大模型进行一次前向传播验证。
验证规则:

  • 若大模型概率 $\ge$ 草稿模型概率,则接受。
  • 否则以概率 $\frac{p\_large(token)}{p\_draft(token)}$ 接受。
  • 遇到首次拒绝即停止,若全部接受,则"免费"从大模型采样第 $k+1$ 个 Token。若被拒绝,则从重新平衡的分布($p\_large - p\_draft$)中采样。

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()

工作流:

  1. 初始化时创建 drafter 和 rejection_sampler。
  2. 大模型常规预填充后,调用 propose_draft_token_ids(k) 从草稿模型采样 $k$ 个 Token,存入 request.spec_token_ids。
  3. 下一引擎步骤中,allocate_slots 会为这 $k$ 个新 Token 预留 KV 块。
  4. 将 spec_token_ids 复制到 input_batch.token_ids_cpu。
  5. 调用 _calc_spec_decode_metadata 准备元数据,大模型对草稿 Token 进行前向传播。
  6. 使用 rejection_sampler 从左到右进行接受/拒绝判断,产生 output_token_ids。重复此过程直至满足停止条件。

图11:投机解码流程1
图12:投机解码流程2

3.9 高级特性:计算与内存分离的 P/D 架构

为隔离长且突发的预填充(计算密集)和稳定且对延迟敏感的解码(内存带宽密集),系统运行 $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()

工作流:

  1. 实例化:在 Worker 的 init device 过程中创建角色为 "worker" 的 Connector;在调度器构造函数中创建角色为 "scheduler" 的 Connector。
  2. 缓存查找:调度器处理预填充请求时,调用 get_num_new_matched_tokens 检查外部 KV 缓存服务器。解码可能命中缓存,结果加入本地计数。
  3. 状态更新与元数据:调用 update_state_after_alloc,随后调用 build_connector_meta。预填充标记 is_store=True,解码标记 is_store=False。
  4. 上下文管理器:前向传播前进入 KV-connector 上下文。进入时调用 start_load_kv(解码端加载外部 KV 注入分页内存);退出时调用 wait_for_save(预填充端阻塞等待 KV 上传完成)。
图13:分离的 P/D 架构
图13:分离的 P/D 架构

3.10 从单进程扩展至多进程(MultiProcExecutor)

当模型超出单卡显存时,系统通过张量并行(TP,优先使用因节点内带宽高于节点间)或流水线并行(PP)进行扩展。MultiProcExecutor 提供了多 GPU 进程的编排层。

图14:TP=8 设置下的 MultiProcExecutor(驱动 Worker 排名为 0)
图14:TP=8 设置下的 MultiProcExecutor(驱动 Worker 排名为 0)

实现逻辑:

  1. MultiProcExecutor 初始化一个基于共享内存的 rpc_broadcast_mq 消息队列。
  2. 循环遍历 world_size(如 $TP=8$ 则为 8),通过 WorkerProc.make_worker_process 生成守护进程。
  3. 父进程为每个 Worker 创建读写管道。
  4. 子进程运行 WorkerProc.worker_main 实例化 Worker,执行初始化设备、加载模型等流程。
  5. 每个 Worker 建立 rpc_broadcast_mq(接收工作)和 worker_response_mq(发送响应)。
  6. 子进程将 worker_response_mq 句柄通过管道发送给父进程完成握手。
  7. Worker 进入忙等待循环,阻塞在 rpc_broadcast_mq.dequeue。收到工作后执行划分好的 TP/PP 任务,结果通过 worker_response_mq.enqueue 返回。
  8. 引擎层面,通过调用 execute_model 自动将任务分发到各个 Worker,对上层完全透明。

3.11 分布式系统服务架构

以 2 个 8xH100 节点(1 个无头节点,1 个 API 服务器节点)、设置 $TP=4$ 和 $DP=4$ 为例。

图15:包含 2 个 8xH100 节点的服务器配置(1 个无头节点,1 个 API 服务器)
图15:包含 2 个 8xH100 节点的服务器配置(1 个无头节点,1 个 API 服务器)

无头节点(Headless Node):
运行带有 --headless 参数的 vllm serve 命令。CoreEngineProcManager 启动 2 个进程(对应本地 DP 数量),每个运行 EngineCoreProc.run_engine_core。

  1. 创建 input_queue 和 output_queue。
  2. 使用 DEALER ZMQ 套接字与另一节点的 Frontend 进行初始握手。
  3. 初始化 DP 组(如使用 NCCL)。
  4. 使用 MultiProcExecutor 初始化 EngineCore。
  5. 启动 Input、Output 守护线程和 Main 线程。
  6. 同步 ready_event 后,向 Frontend 发送就绪消息(包含可用 KV 块数等)。
  7. 进入稳态忙等待:Input 线程从套接字接收路由请求放入 input_queue;Main 线程取出请求执行 step() 并将结果放入 output_queue;Output 线程将结果发回 API 服务器。系统维护 DP 波次计数器(Wave counter),并在空闲时执行虚拟步骤(Dummy steps)以保持同步。
图16:包含 4 个 DP 副本运行 4 个 DPEngineCoreProc 的分布式系统
图16:包含 4 个 DP 副本运行 4 个 DPEngineCoreProc 的分布式系统

API 服务器节点(API Server Node):
实例化 AsyncLLM,内部创建 DPLBAsyncMPClient(数据并行、负载均衡、异步多进程客户端)。

  1. 创建 ZMQ 地址并生成 DPCoordinator 进程。
  2. 创建 outputs_queue。
  3. 运行 asyncio 任务 process_outputs_socket 从底层引擎接收数据并写入队列,output_handler 读取队列供 create_completion 使用。
  4. run_engine_stats_update_task 与 DP 协调器通信,处理负载均衡信息(如等待/运行请求数)并支持弹性伸缩。
  5. 收到 curl 请求时,FastAPI 路由调用 AsyncLLM.generate,进而调用 get_core_engine_for_request 进行负载均衡(选择分数最低的引擎,$score = len(waiting) \times 4 + len(running)$)。请求发送至选中引擎的 input_socket,处理完毕后通过 output_socket 返回,最终通过 Uvicorn 返回 JSONResponse。

四、 实验环境

  • 硬件配置:实验基于 Hyperstack 提供的 H100 GPU。分布式测试示例使用了 2 台 8xH100 服务器节点(节点间通过网络通信,配置了 Master IP 和 RPC 端口)。
  • 模型配置:测试示例主要使用 TinyLlama/TinyLlama-1.1B-Chat-v1.0 标准 Transformer 模型。
  • 软件与依赖:基于 vLLM V1 引擎(禁用或启用 multiprocessing 环境变量)、PyTorch(使用 model.eval() 和 torch.compile)、CUDA Graphs、FlashAttention 内核。分布式通信依赖 NCCL 和 ZMQ(异步消息传递库)。服务端依赖 FastAPI 和 Uvicorn。引导解码依赖 xgrammar,KV 缓存传输测试涉及 LMCache【[11], LMCache + https://github.com/LMCache/LMCache】 。

五、 实验结果与性能评估

系统性能评估主要围绕两个相互竞争的指标:延迟(Latency)和吞吐量(Throughput)。

  • TTFT(Time to first token):提交请求到收到首个 Token 的时间。
  • ITL(Inter-token latency):连续两个 Token 之间的生成时间。
  • TPOT(Time per output token):请求中所有输出 Token 的平均 ITL。
  • E2E Latency(端到端延迟):处理请求的总时间(TTFT + 所有 ITL 之和)。
  • Throughput / Goodput:每秒处理的总 Token 数或请求数 / 满足 SLO(服务级别目标)的吞吐量。
图17:TTFT、ITL 与端到端延迟示意图
图17:TTFT、ITL 与端到端延迟示意图

Roofline 性能模型与批处理权衡:
当批次大小 $B$ 趋近 1 时,ITL 最低,但吞吐量差。随着 $B$ 增大,ITL 上升(每步执行更多 FLOPs),但因权重 I/O 被摊销,吞吐量提升。
根据 Roofline 模型:在饱和批次大小 $B_{sat}$ 以下,步进时间受 HBM 带宽主导(将权重流式传输至片上内存),因此步进延迟几乎平坦;超过 $B_{sat}$ 后,内核变为计算密集型,步进时间随 $B$ 线性增长。步进延迟公式为 $t = \frac{FLOPs_{step}}{P_{kernel}}$(其中 $P_{kernel}$ 为内核性能)。

图18:Roofline 性能模型
图18:Roofline 性能模型

vLLM 基准测试工具:
vLLM 提供了 vllm bench CLI 工具:

  • latency:使用短输入(默认 32 Tokens)和小批次(默认 8)采样 128 个输出 Tokens,报告端到端延迟。
  • throughput:一次性提交固定提示词集(如 1000 个 ShareGPT 样本),在 $QPS=\infty$ 模式下报告每秒 Token 数。
  • serve:启动服务器并从泊松分布中采样请求到达时间,模拟真实负载,并可强制限制服务器端最大并发数。
    此外,系统提供自动调优脚本,通过驱动 serve 基准测试来寻找满足特定 SLO(如"最大化吞吐量同时保持 p99 端到端延迟 < 500 ms")的最佳配置。

六、 结论

本文全面解析了 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 对本文预发布版本的审阅与反馈。

参考文献