机器之心 10月26日 23:19
vLLM 架构解析
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

vLLM 是一款高性能推理框架,针对大语言模型进行优化,提升吞吐量与响应速度。本文深入分析 vLLM 架构、代码和原理,涵盖输入输出处理、调度、分页注意力、连续批处理等基础流程,以及分块预填充、前缀缓存、引导式解码等高级功能。文章还探讨了 vLLM 在单 GPU 到多 GPU、多节点的扩展能力,以及 Web 端部署与服务方案。通过大量实例、架构图和可视化图像,帮助读者理解现代高吞吐量大语言模型推理系统的核心组件和高级特性。

🔹 vLLM 架构包含 LLM 引擎和引擎核心,其中 LLM 引擎支持离线高吞吐量推理,引擎核心由模型执行器、结构化输出管理器、调度器等子组件构成。调度器负责决定请求进入下一步执行,支持 FCFS 和优先级调度策略,并包含 KV 缓存管理器,用于分页注意力。

🔹 模型执行器负责驱动模型前向计算,包括初始化设备、加载模型权重、初始化 KV 缓存等关键步骤。它支持 Eager 模式和 Captured 模式,后者通过预捕获 CUDA Graph 提升效率。调度器通过 allocate_slots 函数管理 KV 缓存块的分配,确保请求获得所需资源。

🔹 vLLM 支持多种高级功能,如分块预填充,将长提示词拆分为小块执行,避免单次请求独占计算资源;前缀缓存,避免重复计算多个提示词共享的前缀部分,显著加速预填充请求;引导式解码,通过基于语法的有限状态机约束 logits,确保生成符合语法规则的序列。

🔹 推测解码通过引入草稿模型加速自回归生成过程,草稿模型快速生成候选 token,大模型验证并决定最终 token,统计上等价于标准自回归解码但更高效。vLLM 实现了 n-gram、EAGLE、Medusa 等方法,通过不同的策略提升生成速度。

🔹 分离式 Prefill/Decode 将预填充和解码分离执行,更精细地控制延迟,启动多个 prefill 和 decode 实例,并通过 KV 缓存服务进行数据交换,将长请求与延迟敏感请求隔离开来,提升系统稳定性和低延迟。

2025-10-26 12:03 北京

大量示例,大量代码,大量架构图。

选自Aleksa Gordic博客

机器之心编译

编辑:冷猫

在大模型应用快速发展的今天,如何让推理变得更快、更高效,已经成为研究和产业界共同关注的焦点。

vLLM 便是在这样的背景下诞生的一套高性能推理框架。它专门针对大语言模型的推理优化,在保持模型准确性的同时,大幅提升了吞吐量与响应速度。凭借对显存管理、并行调度和 KV 缓存等关键环节的创新,vLLM 已经成为业界广泛采用的开源推理引擎。

一篇超长的硬核博客文章:《Inside vLLM: Anatomy of a High-Throughput LLM Inference System》针对 vLLM 的架构、代码和原理进行了深入的分析,这可能是关于 LLM 推理引擎和 vLLM 工作原理的最深入解读。

本文作者是前 Google DeepMind 和 Microsoft 的研究工程师 Aleksa Gordć。

Aleksa 花了好些时间才达到对代码库的这种理解程度,充分低估了这篇文章的工作量,甚至这些内容很容易就能写成一本书。 

文中涵盖了:

这篇博客包含了大量的实例以及作者手绘的架构示意图和可视化图像,希望能够对读者们理解推理引擎提供一些有价值的帮助。

以下是博客的详细内容:

在这篇文章中,我会逐步介绍一个现代高吞吐量大语言模型(LLM, Large Language Model)推理系统的核心组件和高级特性。具体来说,我将详细拆解 vLLM 的工作原理。

这篇文章是系列中的第一篇。写作方式采用「倒金字塔结构」:先从宏观层面入手,再逐步深入细节。这样你可以在不被繁琐技术细节淹没的情况下,先建立起对整个系统的清晰整体认知。

LLM 引擎与引擎核心

LLM 引擎是 vLLM 的核心构建模块。单独使用时,它已经能够实现高吞吐量的推理 —— 但仅限于离线场景。此时,你还无法将其直接通过 Web 提供给用户。

接下来,我们将使用下面的 离线推理代码片段(改写自 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" # we're using engine V1VLLM_ENABLE_V1_MULTIPROCESSING="0" # we're running in a single process

这个配置的特点是:

从这里开始,我们会逐步扩展,构建一个在线、异步、多 GPU、多节点的推理系统 —— 但仍然基于标准 Transformer。

在这个示例中,我们主要做两件事:

1. 实例化一个引擎(instantiate an engine)。

2. 调用它的 generate 方法,从给定的提示词(prompt)中进行采样。

接下来,让我们从 构造函数(constructor) 的分析开始。

LLM 引擎构造函数

引擎的主要组成部分包括:

引擎核心(engine core) 本身由多个子组件构成:

1. 策略设置(policy setting):可以是 FCFS(先来先服务),也可以是 优先级调度(高优先级请求先执行)。

2. 等待队列与运行队列。

3. KV 缓存管理器(KV cache manager) —— 分页注意力(paged attention [3])的核心。

其中,KV 缓存管理器 会维护一个 free_block_queue —— 一个可用 KV 缓存块的池子(通常数量可达几十万甚至更多,具体取决于显存大小和块大小)。在分页注意力中,这些缓存块作为索引结构,负责将 token 映射到其计算得到的 KV 缓存块。

本节核心组件及其关系

标准 Transformer 层(非 MLA)的块大小(block size)计算公式如下:

2 * block_size (default=16) * num_kv_heads * head_size * dtype_num_bytes (2 for bf16)

在 模型执行器(Model Executor) 的构造过程中,会创建一个 Worker 对象,并执行三个关键步骤。(后续使用 MultiProcExecutor 时,这些步骤会在不同 GPU 上的每个工作进程中独立执行。)

1. 初始化设备(Init device)

2. 加载模型(Load model)

3. 初始化 KV 缓存(Initialize KV cache)

这里我省略了一些底层细节,但以上就是需要重点掌握的核心部分。后续章节会多次引用这些概念。

既然引擎已经初始化完成,接下来我们就进入 generate 函数 的解析。

Generate 函数

第一步:验证并将请求输入引擎

对于每个提示词(prompt),处理步骤如下:

1. 创建一个唯一的请求 ID,并记录其到达时间。

2. 调用输入预处理器(input preprocessor),对提示词进行分词(tokenization),并返回一个字典,内容包括:原始提示词、prompt_token_ids、以及输入类型(text、tokens、embeds 等)。

3. 将这些信息打包成一个 EngineCoreRequest,并添加优先级(priority)、采样参数(sampling params)以及其他元数据。

4. 将该请求传递到 引擎核心(engine core),它会把请求封装成一个 Request 对象,并将其状态设为 WAITING。此时请求被加入到调度器的 等待队列:

到这里,引擎已经接收请求,执行流程可以开始。

在同步引擎示例中,这些初始提示是唯一会被处理的请求 —— 中途无法再注入新的请求。

而异步引擎则支持这种操作(即 连续批处理):每一步之后,都会同时考虑新请求与旧请求。

由于前向传播(forward pass)会将批次展平成一个单一序列,并且自定义内核可以高效处理,所以即便是同步引擎,连续批处理 也是在底层得到支持的。

之后,只要仍有请求需要处理,引擎就会反复调用 step () 函数。

每个 step 包含三个阶段:

1. 调度(Schedule):选择本步骤要执行的请求(可能是解码 decode,或 / 和分块预填充 chunked prefill)。

2. 前向传播(Forward pass):运行模型并采样新 token。

3. 后处理(Postprocess):

停止条件(Stop conditions)

- 请求超出了长度限制(max_model_length 或该请求的 max_tokens)。

- 采样到的 token 是 EOS ID(除非启用了 ignore_eos —— 在基准测试时强制生成指定数量的输出 token 时很有用)。

- 采样到的 token 匹配了采样参数中指定的 stop_token_ids。

- 输出文本中出现了 stop string:此时我们会将输出截断到首次出现 stop string 的位置,并在引擎中终止该请求。注意:stop_token_ids 会出现在最终输出中,而 stop string 不会。

接下来,我们将更深入地分析 调度(scheduling) 机制。

调度机制

推理引擎主要需要处理两类工作负载:

1. Prefill 请求 —— 对所有提示(prompt)token 执行一次前向传播。这类请求通常是 计算受限(compute-bound) 的(具体阈值取决于硬件和提示长度)。在完成 prefill 之后,会从最后一个 token 的概率分布中 采样一个 token。

2. Decode 请求 —— 仅对最新生成的一个 token 执行前向传播。之前的 KV 向量已经被缓存好。这类请求则是 内存带宽受限(memory-bandwidth-bound) 的,因为即使只计算一个 token,也仍然需要加载全部 LLM 权重(以及 KV 缓存)。

在 V1 调度器 中,由于更智能的设计,可以在同一个 step 中混合处理 prefill 与 decode 请求。而 V0 引擎 在同一时刻只能处理 prefill 或 decode 二者之一。

调度器会优先处理 decode 请求(即那些已经在运行队列中的请求)。对于每个 decode 请求,它会:

1. 计算需要生成的新 token 数。

2. 调用 KV-cache 管理器的 allocate_slots 函数(下面详述)。

3. 更新 token 配额(token budget),减去第 1 步计算得到的 token 数量。

随后,调度器才会处理 prefill 请求(来自等待队列):

1. 获取计算的块数(如果前缀缓存 prefix caching 被禁用,则返回 0 —— 细节后续介绍)。

2. 调用 KV-cache 管理器的 allocate_slots 函数。

3. 将请求从 waiting 队列弹出,移入 running 队列,并将其状态设置为 RUNNING。

4. 更新 token 配额。

接下来,我们看看 allocate_slots 函数的作用:

1. 计算需要的块数 —— 确定需要分配多少新的 KV-cache 块 (n)。默认每个块可以存储 16 个 token。例如,一个 prefill 请求包含 17 个新 token,则需要分配 ceil (17/16) = 2 个块。

2. 检查可用性 —— 如果管理器的池中没有足够的空闲块,则提前退出。根据请求类型(decode 或 prefill),引擎可能尝试 重计算抢占(recompute preemption)(在 V0 中支持 swap preemption,即通过调用 kv_cache_manager.free 回收低优先级请求的 KV 块),或者跳过调度继续执行。

3. 分配块 —— 通过 KV-cache 管理器的协调器,从块池(即前面提到的 free_block_queue 双向链表)中取出前 n 个块。然后存储到 req_to_blocks 字典中,该字典映射 request_id → KV-cache 块列表。

KV cache 块的列表

前向传播流程

调用 model executor 的 execute_model,它会委托给 Worker,而 Worker 又会调用 model runner。主要步骤如下:

1. 更新状态

2. 准备输入

3. 执行前向传播

4. 收集最后一个 token 的状态

5. 采样 (sampling)

前向传播的两种执行模式

1. Eager 模式:当启用 eager execution 时,直接运行标准的 PyTorch 前向传播。

2. Captured 模式:当不强制 eager 时,执行/重放预先捕获的 CUDA Graph。这些图在引擎构造过程中(初始化 KV-cache 的时候)已经捕获好。

接下来会给一个具体的例子,用于解释 连续批处理 (continuous batching) 与 paged attention 的结合方式。

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

高级功能 —— 扩展核心引擎逻辑

在已经掌握基本的引擎流程之后,我们可以进一步看看一些高级功能。

我们之前已经讨论过 抢占(preemption)、分页注意力(paged attention)和连续批处理(continuous batching)。

接下来要深入介绍的功能包括:

1. 分块预填充(Chunked prefill)

2. 前缀缓存(Prefix caching)

3. 引导式解码(Guided decoding,基于语法约束的有限状态机 FSM)

4. 推测解码(Speculative decoding)

5. 解耦的预填充 / 解码(Disaggregated P/D,即 prefill/decoding 分离)

分块预填充

分块预填充是一种处理长提示词(prompt)的技术。它的核心思想是:把预填充步骤拆分为更小的块来执行。

如果没有这一步,我们可能会遇到一个非常长的请求,它会在一次引擎步骤中独占计算资源,导致其他预填充请求无法执行。这会推迟所有其他请求的执行,从而增加它们的延迟。

举个例子:假设我们让每个分块包含 n = 8 个 token,并用小写字母加 - 来表示分块。一个长提示词 P 可能表现为:x-y-z ,其中 z 是一个不完整的分块(例如只有 2 个 token)。

如果直接执行 P 的完整预填充,那么至少需要 3 个引擎步骤(甚至可能更多,如果它在某一步没有被调度执行)。而且,只有在最后一个分块的预填充步骤结束时,系统才会采样一个新的 token。

下面是这个例子的可视化示意:

实现方式其实很直接:限制每一步中新 token 的数量上限。

如果某个请求的新 token 数量超过了 long_prefill_token_threshold,系统就会把它重置为这个阈值。剩下的处理由之前介绍过的底层索引逻辑(indexing logic)自动完成。

在 vLLM V1 中,可以通过把 long_prefill_token_threshold 设置为一个正整数来启用分块预填充(chunked prefill)。(严格来说,即使没有主动设置,当提示词长度超过 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()

前缀缓存的核心思想是:避免重复计算多个提示词(prompt)在开头部分共享的 token —— 这就是「前缀」的由来。

关键点在于 long_prefix:它被定义为长度超过一个 KV-cache 块的前缀(默认一个块是 16 个 token)。

为了简化例子,我们假设 long_prefix 的长度刚好等于 n × block_size(其中 n ≥ 1)。

也就是说,前缀和块的边界完全对齐。否则,系统就需要重新计算 long_prefix_len % block_size 这些 token,因为我们无法缓存不完整的块。

如果没有前缀缓存,每次处理一个拥有相同 long_prefix 的新请求时,系统都会重复计算全部 n × block_size 个 token。

而启用前缀缓存后,这些 token 只需计算一次(它们的 KV 会被存储在 KV 缓存的分页内存里),之后就能直接复用。这样,系统只需要处理新的提示词 token,从而显著加快预填充请求(prefill request)的速度(不过对解码请求没有帮助)。

在 vLLM 中,它是如何工作的?

在第一次调用 generate 时,在调度阶段(scheduling stage),kv_cache_manager.get_computed_blocks 会调用 hash_request_tokens:

1. 分块:该函数会把 long_prefix + prompts [0] 拆分为若干个 16-token 的块。

2. 计算哈希:

3. 存储结果:每个结果会被封装成一个 BlockHash 对象,包含哈希值和对应的 token IDs。函数最终返回一个 BlockHash 列表。这个列表会被存储在 self.req_to_block_hashes [request_id] 中。

接着,引擎会调用 find_longest_cache_hit 来检查这些哈希是否已存在于 cached_block_hash_to_block 中。

在第一个请求中,通常不会命中任何缓存。

然后,我们调用 allocate_slots,它会进一步调用 coordinator.cache_blocks:

随后,在前向传播(forward pass)阶段,系统会在分页 KV 缓存(paged KV cache) 中填充对应于刚分配的 KV 块的 K/V 值。

经过多次引擎步骤之后,系统可能会分配更多的 KV 缓存块,但这对我们当前的例子没有影响,因为在 long_prefix 之后,提示词立即发生了分支(diverge),不再复用之前的前缀块。

在第二次调用 generate 时,如果使用相同的前缀(prefix):

如果原始请求仍然存在,这些 KV 块的 引用计数(reference count) 会增加(例如增加到 2)。

在这个例子中,第一个请求已经完成,因此这些块被释放回块池(pool),它们的引用计数也被重置为 0。

由于我们能够从 cached_block_hash_to_block 中检索到它们,这说明这些 KV 块仍然有效(KV-cache 管理器的逻辑就是这样设计的)。

因此,我们只需要再次将它们从 free_block_queue 中移除即可。

高级说明:

KV-cache 块只有在即将 从 free_block_queue 重新分配 时才会被标记为无效(free_block_queue 是从左侧 pop 块)。如果此时发现块仍然有相关的哈希值,并存在于 cached_block_hash_to_block 中,那么我们会:

清除该块的哈希值;从 cached_block_hash_to_block 中移除对应条目。

这样可以确保该块无法通过前缀缓存再次复用(至少不能用于旧前缀)。

总结一下前缀缓存(Prefix Caching) 的核心思想:

不要重复计算已经出现过的前缀 token —— 直接复用它们在 KV 缓存中的值即可!

如果你理解了这个例子,也就理解了分页注意力(paged attention)的工作原理。

引导式解码( FSM)

引导式解码是一种技术:在每一步解码(decoding step)时,logits 会受到基于语法的有限状态机(finite state machine, FSM)约束。

这确保了只有符合语法规则的 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()

在我给出的示例(假设使用字符级 tokenization)中:在 prefill 阶段,FSM 会对 logits 进行掩码(mask),确保只有 "P" 或 "N" 是可选的。如果采样到了 "P",FSM 就会切换到 「Positive」 分支;在下一步,FSM 只允许 "o" 被采样,以此类推。

FSM 示例

在 vLLM 中的实现方式:

1. 在 LLM 引擎构建阶段,会创建一个 StructuredOutputManager:它可以访问 tokenizer,并维护一个 _grammar_bitmask 张量(tensor)。

2. 当添加一个请求时:请求状态会被设置为 WAITING_FOR_FSM。grammar_init 会选择后端编译器(例如 xgrammar;注意,这些后端是第三方代码)。

3. 该请求的语法会 异步编译。

4. 在调度阶段(scheduling):

5. 调度循环结束后(仍在调度阶段):如果存在 FSM 请求,StructuredOutputManager 会调用后端准备或更新 _grammar_bitmask。

6. 前向传播(forward pass)生成 logits 后:

7. 采样下一个 token 后:请求的 FSM 会通过 accept_tokens 前进一步。在 FSM 图上,状态会相应向下移动到下一节点。

步骤 6 的进一步说明:

如果 vocab_size = 32,则 _grammar_bitmask 是一个整数。其二进制表示用来编码哪些 token 允许(1),哪些 禁止(0)。例如 "101…001" 会扩展成长度为 32 的数组 [1, 0, 1, …, 0, 0, 1],位置为 0 的 token logits 被设置为 –∞。对于更大的词表,会使用多个 32 位整数,并进行扩展和拼接。后端(例如 xgrammar)负责根据当前 FSM 状态生成这些位模式。

为了直观展示,这里给出一个更简单的示例:vocab_size = 8;使用 8 位整数,适合喜欢可视化表示的读者。

在 vLLM 中,可以通过传入所需的 guided_decoding 配置 来启用引导式解码(Guided Decoding)。

推测解码

在自回归生成(autoregressive generation)中,每生成一个新 token 都需要对大型语言模型(LLM)进行一次前向传播(forward pass)。

这非常耗时 —— 每一步都要重新加载并应用所有模型权重,仅仅为了生成一个 token!(假设 batch size = 1,一般情况是 B)

推测解码通过引入一个较小的草稿模型(draft LM)来加速这一过程:

具体步骤:

1. Draft(草稿):在当前上下文下运行小模型,提出 k 个 token 候选。

2. Verify(验证):使用大模型在 上下文 + k 个草稿 token 上运行一次。这会生成这 k 个位置的概率分布,再加一个额外位置(总共 k+1 个候选)。

3. Accept/Reject(接受 / 拒绝):从左到右检查 k 个草稿 token:

为什么可行?

虽然我们使用小模型来提出候选 token,但接受/拒绝规则保证了序列的期望分布与逐 token 从大模型采样完全一致。

也就是说,推测解码在统计上等价于标准的自回归解码。但它潜在更快,因为一次大模型前向传播可以生成 最多 k+1 个 token。

注意,可以参考 gpt-fast 获取简单实现,原始论文提供了数学细节和等价性证明。vLLM V1 不支持 LLM 草稿模型方法,而是实现了更快但精度略低的候选方案:n-gram、EAGLE、Medusa。

各方法简述:

1. n-gram:

2. Eagle:

3. Medusa:

下面是如何在 vLLM 中使用 n-gram 方法启用推测解码的示例:

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

在 vLLM 中是怎么实现的呢?

引擎构建阶段(Setup)

1. 初始化设备(Init device):创建 drafter(草稿模型,例如 NgramProposer) 和 rejection_sampler(拒绝采样器)。其中部分组件是用 Triton 实现的。

2. 加载模型(Load model):加载草稿模型的权重(对于 n-gram 方法,这一步是无操作)。

在 generate 函数中处理新请求(假设是全新请求)

1. 使用 大模型 执行常规的 prefill 步骤。

2. 前向传播(forward pass)和标准采样完成后,调用 propose_draft_token_ids (k) 从草稿模型采样 k 个 draft token。

3. 将这些 token 存入 request.spec_token_ids(更新请求元数据)。

4. 在下一次引擎步骤,当请求进入 运行队列(running queue) 时,将 len (request.spec_token_ids) 加入「新 token」计数,以便 allocate_slots 为前向传播保留足够的 KV 缓存块(KV blocks)。

5. 将 spec_token_ids 复制到 input_batch.token_ids_cpu,形成 上下文 + 草稿 token。

6. 通过 _calc_spec_decode_metadata 计算元数据,将 token 从 input_batch.token_ids_cpu 拷贝过来,准备 logits 等信息,然后对草稿 token 执行大模型前向传播。

7. 不是直接从 logits 采样,而是使用 rejection_sampler 从左到右进行接受 / 拒绝操作,生成最终的 output_token_ids。

8. 重复步骤 2–7,直到满足停止条件(stop condition)。

理解这一流程的最佳方式是启动调试器,逐步跟踪代码执行。不过这一节已经让你对 推测解码在 vLLM 中的执行流程有了基本了解。

分离式 Prefill/Decode

我之前已经提到过 分离式 Prefill/Decode 的设计动机。

Prefill 和 Decode 的性能特性非常不同:Prefill 主要受计算能力(compute-bound)限制;Decode 主要受内存带宽(memory-bandwidth-bound)限制。因此,将两者分离执行是一种合理的设计。这种设计可以更精细地控制延迟:TFTT(Time-To-First-Token,首个 token 时间);ITL(Inter-Token Latency,token 间延迟)。

实际上,我们会启动 N 个 vLLM prefill 实例 和 M 个 vLLM decode 实例,并根据实时请求负载自动伸缩。Prefill Worker 会将 KV 写入 专用 KV-cache 服务;Decode Worker 从该服务读取 KV。这样可以将 长且突发的 prefill 请求 与 延迟敏感的 decode 请求 隔离开来,保证系统稳定性和低延迟。

在 vLLM 中的实现方式是怎么样的?

为了说明原理,下列示例使用 SharedStorageConnector,这是一个用于调试的 Connector 实现,用来演示内部机制。

Connector 是 vLLM 用于处理实例间 KV 交换的抽象接口。目前该接口尚未稳定,短期内会有一些改进,其中一些可能会引入破坏性变更。

我们启动 2 个 vLLM 实例(GPU 0:用于 prefill,GPU 1:用于 decode),然后在这两个实例之间传输 KV 缓存。

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="SharedStorageConnector",
      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()  # notify decode instance that KV cache is ready
  # To keep the prefill node running in case the decode node is not done;
  # otherwise, the script might exit prematurely, causing incomplete decoding.
  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="SharedStorageConnector",
      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()  # block waiting for KV cache from prefill instance
  # Internally it'll first fetch KV cache before starting the decoding loop
  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()

注意:我也尝试过 LMCache,这是最快、可用于生产环境的 Connector(以 NVIDIA 的 NIXL 作为后端),但它仍处于前沿技术阶段,我在使用中遇到了一些 bug。由于其大部分复杂逻辑存在于外部仓库,SharedStorageConnector 更适合作为讲解示例。

在 vLLM 中的步骤如下:

1. 实例化(Instantiation) — 在引擎构建期间,Connector 会在两个地方创建:在 Worker 的 init device 过程中(位于 init worker distributed environment 函数下),角色为「worker」。在 Scheduler 构造函数中,角色为 「scheduler」。

2. 缓存查询(Cache lookup) — 当 Scheduler 处理来自等待队列的 prefill 请求(经过本地 prefix-cache 检查后),会调用 Connector 的 get_num_new_matched_tokens:该函数检查 KV-cache 服务器中是否有外部缓存的 token。Prefill 在这里始终返回 0;decode 可能会命中缓存。结果会在调用 allocate_slots 之前加入本地计数。

3. 状态更新(State update) — Scheduler 然后调用 connector.update_state_after_alloc,记录已经命中缓存的请求(prefill 为无操作)。

4. 元信息构建(Meta build) — 在调度结束时,Scheduler 调用 meta = connector.build_connector_meta:Prefill 会将 is_store=True 的请求加入,用于上传 KV。Decode 会将 is_store=False 的请求加入,用于获取 KV。

5. 上下文管理器(Context manager) — 在前向传播之前,引擎会进入 KV-Connector 上下文管理器:进入(enter):调用 kv_connector.start_load_kv。对于 decode,这会从外部服务器加载 KV 并注入到分页内存;对于 prefill,则无操作。退出(exit):调用 kv_connector.wait_for_save。对于 prefill,这会阻塞直到 KV 上传至外部服务器;对于 decode,则无操作。

下面是一个可视化示例:

附加说明:对于 SharedStorageConnector 来说,「外部服务器」只是本地文件系统。根据配置,KV 传输也可以按层进行(在每个注意力层前或后)。Decode 仅在请求的第一步加载外部 KV;之后的步骤在本地计算和存储。

拓展系统:从UniProcExecutor到MultiProcExecutor

在掌握核心技术后,我们可以讨论如何扩展系统。

假设你的模型权重已经无法完全放入单个 GPU 的显存中:

说明:

在这个阶段,我们需要多个 GPU 进程(Workers)以及一个协调层来管理它们,而这正是 MultiProcExecutor 提供的功能。

MultiProcExecutor 在 TP=8 配置下(其中 driver worker 的 rank 为 0)

在 vLLM 中的工作原理:

1. 初始化消息队列 — MultiProcExecutor 会初始化一个 rpc_broadcast_mq 消息队列(底层通过共享内存实现)。

2. 生成子进程 — 构造函数循环遍历 world_size(例如 TP=8 ⇒ world_size=8),通过 WorkerProc.make_worker_process 为每个 rank 启动一个守护进程。

3. 创建通信管道 — 对每个 worker,父进程首先创建 reader 和 writer 管道。

4. 运行子进程 — 新进程执行 WorkerProc.worker_main,在其中实例化 worker(经历与 UniprocExecutor 相同的 「init device」、「load model」等步骤)。

5. 确定角色并设置队列 — 每个 worker 判断自己是 driver(TP 组中的 rank 0)还是普通 worker。每个 worker 设置两条队列:

- rpc_broadcast_mq(与父进程共享)用于接收任务。

- worker_response_mq 用于发送结果回父进程。

6. 协调完成 — 初始化期间,每个子进程通过管道将 worker_response_mq 句柄发送给父进程。父进程收到所有句柄后解除阻塞,完成协调。

7. 执行任务循环 — Worker 进入忙等待循环,阻塞在 rpc_broadcast_mq.dequeue。当有任务到来时,执行任务(与 UniprocExecutor 类似,但现在是 TP/PP 特定的分区任务),结果通过 worker_response_mq.enqueue 发送回父进程。

8. 请求处理 — 运行时,当有请求到来,MultiProcExecutor 将其非阻塞地放入 rpc_broadcast_mq 给所有子 worker,然后等待指定输出 rank 的 worker_response_mq.dequeue 收集最终结果。

从引擎视角来看,接口没有变化 —— 所有的多进程复杂性都通过调用 model executor 的 execute_model 被抽象掉:

至此,我们可以使用相同的引擎接口运行尽可能大的模型,只受硬件资源限制。

下一步是横向扩展:启用数据并行(DP>1)在多个节点上复制模型,添加轻量级 DP 协调层,引入副本间的负载均衡,并在前端放置一个或多个 API 服务器来处理即将到来的流量。

分布式系统部署 vLLM

部署服务有很多方式,为了具体说明,这里给出一个示例:假设我们有两台 H100 芯片节点,并希望在它们上面运行四个 vLLM 引擎。如果模型需要 TP=4,我们可以将节点配置如下。

两台 8×H100 节点 配置:节点 1:Headless(无前端 API),节点 2:API 服务器(负责接收外部请求)

在第一台节点上,以 headless 模式(无 API 服务器)运行引擎,使用以下参数:

vllm serve <model-name>
  --tensor-parallel-size 4
  --data-parallel-size 4
  --data-parallel-size-local 2
  --data-parallel-start-rank 0
  --data-parallel-address <master-ip>
  --data-parallel-rpc-port 13345
  --headless

在另一台节点上运行同样的命令,做如下调整:

vllm serve <model-name>
  --tensor-parallel-size 4
  --data-parallel-size 4
  --data-parallel-size-local 2
  --data-parallel-start-rank 2
  --data-parallel-address <master-ip>
  --data-parallel-rpc-port 13345

注意:这假设网络已经配置好,所有节点都能访问指定的 IP 和端口。

vLLM 中的工作原理:

在 headless 服务器节点上,CoreEngineProcManager 会启动 2 个进程(根据 --data-parallel-size-local),每个进程运行 EngineCoreProc.run_engine_core。每个函数会创建一个 DPEngineCoreProc(引擎核心),然后进入其忙循环。

DPEngineCoreProc 会初始化其父类 EngineCoreProc(EngineCore 的子类),其主要步骤如下:

1. 创建 input_queue 和 output_queue(queue.Queue)。

2. 使用 DEALER ZMQ 套接字(异步消息库)与另一节点的前端进行初始握手,并接收协调地址信息。

3. 初始化 DP(数据并行)组(例如使用 NCCL 后端)。

4. 使用 MultiProcExecutor 初始化 EngineCore(如前所述,TP=4、4 个 GPU)。

5. 创建一个 ready_event(threading.Event)。

6. 启动一个输入守护线程(threading.Thread)运行 process_input_sockets (..., ready_event)。同样启动输出线程。

7. 主线程仍在等待 ready_event,直到跨 4 个进程(覆盖 2 个节点)的所有输入线程完成协调握手,然后执行 ready_event.set ()。

8. 一旦解除阻塞,向前端发送 "ready" 消息,并附带元数据(例如分页 KV 缓存中可用的 GPU 块数量)。

9. 主线程、输入线程和输出线程进入各自的忙循环。

总结:最终我们得到 4 个子进程(每个 DP 副本一个),每个进程运行主线程、输入线程和输出线程。它们完成与 DP 协调器和前端的协调握手,然后每个进程的三个线程进入稳定的忙循环状态。

分布式系统示例:4 个 DP 副本运行 4 个 DPEngineCoreProc

当前稳态(Steady State):

附加机制:

锁步说明(Lockstep clarification):事实上,锁步主要用于 MoE 模型,其中专家层(Expert layers)形成 EP 或 TP 组,而注意力层(Attention layers)仍为 DP。当前即使在标准 DP 下也总是执行锁步,这只是因为「内置」非 MoE 的 DP 使用场景有限;在普通场景下,你完全可以运行多个独立的 vLLM,并进行负载均衡。

接下来,我们来看 API 服务器节点会发生什么。

API 服务节点

我们实例化一个 AsyncLLM 对象(一个基于 asyncio 的大语言模型(LLM)引擎封装)。内部会创建一个 DPLBAsyncMPClient(数据并行、负载均衡、异步、多进程客户端)。

在 MPClient 的父类内部,launch_core_engines 函数执行以下操作:

1. 创建启动握手所需的 ZMQ 地址(类似于无头节点中的设置)。

2. 启动一个 DPCoordinator 进程。

3. 创建一个 CoreEngineProcManager(与无头节点相同)。

在 AsyncMPClient(MPClient 的子类)内部:

1. 创建一个 outputs_queue(asyncio.Queue)。

2. 创建一个 asyncio 任务 process_outputs_socket,通过输出套接字与 4 个 DPEngineCoreProc 的输出线程通信,并将结果写入 outputs_queue。

3. 之后,再创建一个 asyncio 任务 output_handler(在 AsyncLLM 中),从 outputs_queue 读取信息,并最终将其发送到 create_completion 函数。

在 DPAsyncMPClient 中,我们创建一个 asyncio 任务 run_engine_stats_update_task,用于与 DP 协调器通信。

DP 协调器在前端(API 服务器)与后端(引擎核心)之间进行中介:

总结前端(AsyncLLM)运行的 asyncio 任务:

最后,主服务器进程创建一个 FastAPI 应用,并挂载诸如 OpenAIServingCompletion 和 OpenAIServingChat 的端点,暴露 /completion、/chat/completion 等接口。整个堆栈通过 Uvicorn 对外提供服务。

把这些环节串起来,就是完整的请求生命周期:

你从终端发送请求:

curl -X POST http://localhost:8000/v1/completions -H "Content-Type: application/json" -d '{
  "model""TinyLlama/TinyLlama-1.1B-Chat-v1.0",
  "prompt""The capital of France is",
  "max_tokens"50,
  "temperature"0.7
}'

接下来会发生什么:

1. 请求到达 API 服务器上的 OpenAIServingCompletion 的 create_completion 路由。

2. 该函数异步对提示词进行分词(tokenize),并准备元数据(请求 ID、采样参数、时间戳等)。

3. 然后调用 AsyncLLM.generate,其流程与同步引擎相同,最终会触发 DPAsyncMPClient.add_request_async。

4. 接着调用 get_core_engine_for_request,根据 DP 协调器的状态在各个引擎间做负载均衡(选择分数最低 / 负载最小的引擎:score = len (waiting) * 4 + len (running))。

5. 将 ADD 请求发送到选定引擎的 input_socket。

6. 在该引擎中:

提醒:step () 会调用调度器(scheduler)、模型执行器(model executor,可为 MultiProcExecutor)等,我们之前已经讲过了。

7. 这些结果触发 AsyncLLM 的输出 asyncio 任务(process_outputs_socket 和 output_handler),将生成的 token 传回 FastAPI 的 create_completion 路由。

8. FastAPI 附加元数据(完成原因、logprobs、使用信息等),通过 Uvicorn 返回 JSONResponse 到你的终端。

就这样,你的文本生成结果返回了 —— 整个复杂的分布式系统都被一个简单的 curl 命令隐藏了起来!😄

附加说明:

基准测试与自动调优 — 延迟 vs 吞吐量

到目前为止,我们一直在分析「气体粒子」—— 请求在引擎 / 系统内部的流动方式。现在,我们将视角拉远,看看整个系统,并思考:如何衡量推理系统的性能?

在最高层面上,有两个互相制约的指标:

1. 延迟(Latency) — 从请求提交到返回 token 的时间。

2. 吞吐量(Throughput) — 系统每秒能够生成或处理的 token/请求数量。

在解释为什么延迟和吞吐量存在竞争关系之前,我们先定义一些常见的推理性能指标:

下面是一个简化模型,用来解释延迟(Latency)和吞吐量(Throughput)之间的竞争关系。

假设:主导因素是权重(weight)I/O,而非 KV 缓存 I/O,也就是说我们处理的是短序列。

当观察批大小 B 对单次 decode 步骤的影响时,这种权衡就很明显了:

这里可以用 roofline 模型来帮助理解:

Roofline 性能模型

注意:为了更严谨的分析,我们必须考虑 kernel 自动调优(kernel auto-tuning):随着批量大小 B 增大,运行时可能针对当前形状切换到更高效的 kernel,从而改变实际达到的性能 P_kernel。每步延迟为 t = FLOPs_step / P_kernel,其中 FLOPs_step 是该步的计算量。你可以看到,当 P_kernel 达到峰值 P_peak 时,每步增加的计算量会直接导致延迟上升。

如何在 vLLM 中做基准测试

vLLM 提供了一个 CLI: vllm bench {serve,latency,throughput} ,它封装了 vllm/benchmarks/{server,latency,throughput}.py 脚本。

这些脚本的作用如下:

下面是如何运行 latency 脚本的示例:

vllm bench latency
  --model <model-name>
  --input-tokens 32
  --output-tokens 128
  --batch-size 8
}'

CI 中使用的基准测试配置存放在 .buildkite/nightly-benchmarks/tests 目录下。

还有一个自动调优脚本,它会驱动 serve 基准测试以寻找满足目标 SLO 的参数设置(例如,「在保持 p99 端到端延迟 < 500 ms 的前提下最大化吞吐量」),并返回推荐配置。

尾声

我们从基础的引擎核心(UniprocExecutor)开始,添加了诸如投机解码(speculative decoding)和前缀缓存(prefix caching)等高级功能,随后扩展到 MultiProcExecutor(TP/PP > 1),最终实现分布式扩展,将所有内容封装在异步引擎和分布式服务栈中 —— 最后介绍了如何衡量系统性能。

vLLM 还包含一些本文未详述的专门处理,例如:

好处是,这些大部分与上文描述的主流程是正交的 —— 你几乎可以把它们当作「插件」来使用(当然实际上仍有一些耦合)。

我非常喜欢理解系统。话虽如此,在这个高度上讲解的细节肯定有所损失。接下来的文章里,我会聚焦具体子系统,深入探讨细节。

参考文献:

1. vLLM https://github.com/vllm-project/vllm

2. "Attention Is All You Need", https://arxiv.org/abs/1706.03762

3. "Efficient Memory Management for Large Language Model Serving with PagedAttention", https://arxiv.org/abs/2309.06180

4. "DeepSeek-V2: A Strong, Economical, and Efficient Mixture-of-Experts Language Model", https://arxiv.org/abs/2405.04434

5. "Jenga: Effective Memory Management for Serving LLM with Heterogeneity", https://arxiv.org/abs/2503.18292

6. "Orca: A Distributed Serving System for Transformer-Based Generative Models", https://www.usenix.org/conference/osdi22/presentation/yu

7. "XGrammar: Flexible and Efficient Structured Generation Engine for Large Language Models", https://arxiv.org/abs/2411.15100

8. "Accelerating Large Language Model Decoding with Speculative Sampling", https://arxiv.org/abs/2302.01318

9. "EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty", https://arxiv.org/abs/2401.15077

10. "Medusa: Simple LLM Inference Acceleration Framework with Multiple Decoding Heads", https://arxiv.org/abs/2401.10774

11. LMCache, https://github.com/LMCache/LMCache

© THE END 

转载请联系本公众号获得授权

投稿或寻求报道:liyazhou@jiqizhixin.com

阅读原文

跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

vLLM LLM Inference High-Throughput Inference Machine Learning Natural Language Processing
相关文章