说明

原文发布于 Aleksa Gordic 的个人网站

从 PagedAttention、连续批处理、前缀缓存、推测性解码等,到多 GPU、多节点的大规模动态服务

在这篇文章中,我将逐步介绍构成一个现代高吞吐量 LLM 推理系统的所有核心系统组件和高级功能。我将特别剖析 vLLM [1] 的工作原理。

这篇文章是一个系列的第一篇。它从宏观入手,然后层层递进(遵循倒金字塔方法),以便您能形成一个准确的系统高层心智模型,而不会陷入细节的汪洋大海。

后续文章将深入探讨特定的子系统。

本文分为五个部分

  1. LLM 引擎与引擎核心:vLLM 的基础(调度、PagedAttention、连续批处理等)
  2. 高级功能:分块预填充、前缀缓存、引导式与推测性解码、P/D 分离
  3. 向上扩展:从单 GPU 到多 GPU 执行
  4. 服务层:分布式/并发 Web 服务框架
  5. 基准测试与自动调优:衡量延迟与吞吐量

说明

  • 本文分析基于 commit 42172ad (2025年8月9日)。
  • 目标读者:任何对最先进的 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” # 我们正在单进程中运行

这个配置是

  • 离线(无 Web/分布式系统框架)
  • 同步(所有执行都在一个阻塞式单进程中进行)
  • 单 GPU(无数据/模型/流水线/专家并行;DP/TP/PP/EP = 1)
  • 使用标准的 Transformer [2](支持像 Jamba 这样的混合模型需要更复杂的混合 KV 缓存内存分配器)

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

在这个例子中我们做两件事:

  1. 实例化一个引擎
  2. 调用它的 generate 方法,根据给定的提示进行采样

让我们从分析构造函数开始。

LLM 引擎构造函数

引擎的主要组件包括

  • vLLM 配置(包含所有用于配置模型、缓存、并行度等的参数)
  • 处理器(通过验证、分词和处理将原始输入转换为 EngineCoreRequests
  • 引擎核心客户端(在我们的运行示例中,我们使用 InprocClient,它基本等同于 EngineCore;我们将逐步升级到 DPLBAsyncMPClient,以支持大规模服务)
  • 输出处理器(将原始的 EngineCoreOutputs 转换为用户看到的 RequestOutput

说明

随着 V0 引擎被弃用,类名和细节可能会发生变化。我将强调核心思想而非确切的函数签名。我会抽象掉部分但非全部细节。

引擎核心本身由几个子组件构成

  • 模型执行器(驱动模型的前向传播,目前我们使用的是 UniProcExecutor,它在单个 GPU 上有一个 Worker 进程)。我们将逐步升级到支持多 GPU 的 MultiProcExecutor
  • 结构化输出管理器(用于引导式解码——我们稍后会讲到)
  • 调度器(决定哪些请求进入下一个引擎步骤)——它进一步包含
    1. 策略设置——可以是 FCFS(先到先服务)或优先级(高优先级请求先服务)
    2. waiting(等待)和 running(运行中)队列
    3. KV 缓存管理器——PagedAttention 的核心 [3]

KV 缓存管理器维护一个 free_block_queue——一个可用的 KV 缓存块池(通常有数十万个,取决于 VRAM 大小和块大小)。在 PagedAttention 期间,这些块作为索引结构,将 token 映射到其计算出的 KV 缓存块。


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

说明

对于一个标准的 Transformer 层(非 MLA [4]),块大小计算如下:2 (key/value) * block_size (默认=16) * num_kv_heads * head_size * dtype_num_bytes (例如 bf16 为 2)

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

  1. 初始化设备
    • 为 worker 分配一个 CUDA 设备(例如 "cuda:0")并检查模型数据类型是否受支持(例如 bf16)
    • 根据请求的 gpu_memory_utilization(例如 0.8 → 80% 的总 VRAM),验证是否有足够的 VRAM 可用
    • 设置分布式设置(DP / TP / PP / EP 等)
    • 实例化一个 model_runner(持有采样器、KV 缓存和前向传播缓冲区,如 input_idspositions 等)
    • 实例化一个 InputBatch 对象(持有 CPU 端的前向传播缓冲区、用于 KV 缓存索引的块表、采样元数据等)
  2. 加载模型
    • 实例化模型架构
    • 加载模型权重
    • 调用 model.eval() (PyTorch 的推理模式)
    • 可选:对模型调用 torch.compile()
  3. 初始化 KV 缓存
    • 获取每层的 KV 缓存规范。历史上这总是 FullAttentionSpec(同构 Transformer),但随着混合模型(如滑动窗口、类 Jamba 的 Transformer/SSM)的出现,情况变得更加复杂(参见 Jenga [5]
    • 运行一次虚拟/性能分析前向传播,并获取 GPU 内存快照,以计算可用 VRAM 中可以容纳多少个 KV 缓存块
    • 分配、重塑并将 KV 缓存张量绑定到注意力层
    • 准备注意力元数据(例如,将后端设置为 FlashAttention),这些元数据将在前向传播期间被 CUDA 核使用
    • 除非提供了 --enforce-eager,否则会对每个预热批次大小进行一次虚拟运行并捕获 CUDA 图。CUDA 图将整个 GPU 工作序列记录成一个有向无环图 (DAG)。之后在前向传播期间,我们启动/重放预先烘焙好的图,从而减少核函数启动开销,进而提高延迟。

我在这里抽象掉了许多底层细节——但这些是我现在要介绍的核心部分,因为我将在接下来的章节中反复引用它们。

现在引擎已经初始化完毕,让我们继续看 generate 函数。

Generate 函数

第一步是验证请求并将其送入引擎。对于每个提示:

  1. 创建一个唯一的请求 ID 并记录其到达时间
  2. 调用一个输入预处理器,它会对提示进行分词并返回一个包含 promptprompt_token_idstype(文本、token、嵌入等)的字典
  3. 将这些信息打包成一个 EngineCoreRequest,并添加优先级、采样参数和其他元数据
  4. 将该请求传递给引擎核心,引擎核心会将其包装在一个 Request 对象中,并将其状态设置为 WAITING。然后此请求被添加到调度器的 waiting 队列中(如果是 FCFS 策略则追加,如果是优先级策略则使用堆推送)

此时,引擎已经接收了请求,可以开始执行。在同步引擎的例子中,这些初始提示是我们唯一要处理的——没有机制可以在运行中注入新的请求。相比之下,异步引擎支持这种操作(即连续批处理 [6]):在每个步骤之后,新旧请求都会被一并考虑。

说明

由于前向传播将批次展平为单个序列,并且自定义核函数能高效处理它,因此即使在同步引擎中,连续批处理也从根本上得到了支持。

接下来,只要有请求需要处理,引擎就会重复调用其 step() 函数。每个步骤有三个阶段

  1. 调度:选择在这一步中运行哪些请求(解码,和/或(分块)预填充)
  2. 前向传播:运行模型并采样 token
  3. 后处理:将采样的 token ID 附加到每个 Request 对象,进行反分词,并检查停止条件。如果一个请求完成,进行清理(例如,将其 KV 缓存块返回到 free_block_queue)并提前返回输出

说明

停止条件包括

  • 请求超出了其长度限制(max_model_length 或其自身的 max_tokens
  • 采样的 token 是 EOS ID(除非启用了 ignore_eos -> 这对于基准测试中强制生成特定数量的输出 token 很有用)
  • 采样的 token 与采样参数中指定的任何 stop_token_ids 匹配
  • 输出中出现停止字符串——我们将输出截断到第一个停止字符串出现的位置,并在引擎中中止该请求(注意 stop_token_ids 会出现在输出中,但停止字符串不会)。


图 2:引擎循环

说明

在流式模式下,我们会随着 token 的生成而发送中间结果,但我们暂时忽略这一点。

接下来,我们将更详细地研究调度。

调度器

推理引擎处理两种主要类型的工作负载

  1. 预填充 (Prefill) 请求——对所有提示 token 进行一次前向传播。这些通常是计算密集型的(阈值取决于硬件和提示长度)。最后,我们从最后一个 token 位置的概率分布中采样一个 token。
  2. 解码 (Decode) 请求——只对最近的一个 token 进行一次前向传播。所有早期的 KV 向量都已缓存。这些是内存带宽密集型的,因为我们仍然需要加载所有 LLM 权重(和 KV 缓存)才能计算一个 token。

说明

基准测试部分,我们将分析所谓的 GPU 性能屋顶线模型 (roofline model)。那部分会更详细地探讨预填充/解码的性能特征。

V1 调度器可以在同一步骤中混合处理这两种类型的请求,这得益于更智能的设计选择。相比之下,V0 引擎一次只能处理预填充或解码。

调度器优先处理解码请求——即那些已经在 running 队列中的请求。对于每个这样的请求,它会

  1. 计算要生成的新 token 数量(由于推测性解码和异步调度,并不总是 1——稍后详述)。
  2. 调用 KV 缓存管理器的 allocate_slots 函数(详见下文)。
  3. 从步骤 1 中减去 token 数量,更新 token 预算。

之后,它处理来自 waiting 队列的预填充请求,它会

  1. 获取已计算的块数(如果前缀缓存被禁用,则返回 0——我们稍后会介绍)。
  2. 调用 KV 缓存管理器的 allocate_slots 函数。
  3. 将请求从等待队列中弹出并移至运行队列,将其状态设置为 RUNNING
  4. 更新 token 预算。

现在让我们看看 allocate_slots 做了什么,它

  1. 计算块数——确定必须分配多少个新的 KV 缓存块 (n)。每个块默认存储 16 个 token。例如,如果一个预填充请求有 17 个新 token,我们需要 ceil(17/16) = 2 个块。
  2. 检查可用性——如果管理器池中没有足够的块,则提前退出。根据是解码还是预填充请求,引擎可能会尝试重计算抢占(V0 中支持交换抢占),通过驱逐低优先级请求(调用 kv_cache_manager.free 将 KV 块返回到块池),或者它可能会跳过调度并继续执行。
  3. 分配块——通过 KV 缓存管理器的协调器,从块池(前面提到的 free_block_queue 双向链表)中获取前 n 个块。存储到 req_to_blocks,这是一个将每个 request_id 映射到其 KV 缓存块列表的字典。


图 3:KV 缓存块列表

我们终于准备好进行前向传播了!

运行前向传播

我们调用模型执行器的 execute_model,它委托给 Worker,后者再委托给模型运行器。

以下是主要步骤

  1. 更新状态——从 input_batch 中移除已完成的请求;更新与前向传播相关的杂项元数据(例如,每个请求的 KV 缓存块,将用于索引分页的 KV 缓存内存)。
  2. 准备输入——将缓冲区从 CPU 复制到 GPU;计算位置;构建 slot_mapping(详见示例);构造注意力元数据。
  3. 前向传播——使用自定义的分页注意力(paged attention)核函数运行模型。所有序列被展平并连接成一个长的“超级序列”。位置索引和注意力掩码确保每个序列只关注自己的 token,这使得连续批处理无需右填充即可实现。
  4. 收集最后一个 token 的状态——提取每个序列最后一个位置的隐藏状态并计算 logits。
  5. 采样——根据采样配置(贪心、温度、top-p、top-k 等)从计算出的 logits 中采样 token。

前向传播步骤本身有两种执行模式

  1. Eager 模式——当启用 eager 执行时,运行标准的 PyTorch 前向传播。
  2. “捕获”模式——当未强制使用 eager 时,执行/重放预先捕获的 CUDA 图(记住我们在引擎构建的初始化 KV 缓存步骤中捕获了这些)。

下面是一个具体的例子,应该能清楚地说明连续批处理和 PagedAttention


图 4:前向传播:连续批处理和 PagedAttention

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

在了解了基本的引擎流程之后,我们现在可以看看高级功能了。

我们已经讨论了抢占、PagedAttention 和连续批处理。

接下来,我们将深入探讨

  1. 分块预填充 (Chunked prefill)
  2. 前缀缓存 (Prefix caching)
  3. 引导式解码(通过语法约束的有限状态机)
  4. 推测性解码 (Speculative decoding)
  5. 预填充/解码分离 (Disaggregated P/D)

分块预填充 (Chunked prefill)

分块预填充是一种处理长提示的技术,它将其预填充步骤分割成更小的块。如果没有这个技术,一个非常长的请求可能会独占一个引擎步骤,阻止其他预填充请求运行。这会推迟所有其他请求并增加它们的延迟。

例如,假设每个块包含 n (=8) 个 token,用小写字母和“-”分隔。一个长提示 P 可能看起来像 x-y-z,其中 z 是一个不完整的块(例如 2 个 token)。执行 P 的完整预填充将需要 ≥ 3 个引擎步骤(如果它在某个步骤中未被调度执行,则可能 > 3),并且只有在最后一个分块预填充步骤中我们才会采样一个新的 token。

下面是同一个例子的直观展示


图 5:分块预填充

实现很简单:限制每步的新 token 数量。如果请求的数量超过 long_prefill_token_threshold,就将其重置为该值。底层的索引逻辑(如前所述)会处理剩下的部分。

在 vLLM V1 中,您可以通过将 long_prefill_token_threshold 设置为一个正整数来启用分块预填充。(技术上,无论如何都可能发生,如果提示长度超过了 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()

前缀缓存避免了重新计算多个提示在开头共享的 token——因此称为前缀 (prefix)

关键部分是 long_prefix:它被定义为任何比 KV 缓存块更长的前缀(默认为 16 个 token)。为了简化我们的例子,我们假设 long_prefix 的长度恰好是 n x block_size(其中 n ≥ 1)。

说明

也就是说,它与块边界完美对齐——否则我们必须重新计算 long_prefix_len % block_size 个 token,因为我们无法缓存不完整的块。

如果没有前缀缓存,每次我们处理一个带有相同 long_prefix 的新请求时,我们都会重新计算所有 n x block_size 个 token。

有了前缀缓存,这些 token 只计算一次(它们的 KV 存储在分页的 KV 缓存内存中),然后被重用,所以只需要处理新的提示 token。这加快了预填充请求(尽管对解码没有帮助)。

这在 vLLM 中是如何工作的?

在第一次 generate 调用期间,在调度阶段,在 kv_cache_manager.get_computed_blocks 内部,引擎会调用 hash_request_tokens

  1. 此函数将 long_prefix + prompts[0] 分割成 16 个 token 的块。
  2. 对于每个完整的块,它计算一个哈希值(使用内置哈希或 SHA-256,后者较慢但冲突更少)。哈希值结合了前一个块的哈希、当前 token 和可选的元数据。

说明

可选元数据包括:多模态哈希、LoRA ID、缓存盐(注入到第一个块的哈希中,确保只有带有此缓存盐的请求才能重用块)。

  1. 每个结果都存储为一个 BlockHash 对象,包含哈希值及其 token ID。我们返回一个块哈希列表。

该列表存储在 self.req_to_block_hashes[request_id] 中。

接下来,引擎调用 find_longest_cache_hit 来检查这些哈希中是否有任何一个已经存在于 cached_block_hash_to_block 中。在第一个请求时,没有找到任何命中。


图 6:前缀缓存 - 哈希函数

然后我们调用 allocate_slots,它会调用 coordinator.cache_blocks,这个函数将新的 BlockHash 条目与分配的 KV 块关联起来,并记录在 cached_block_hash_to_block 中。

之后,前向传播将在分页的 KV 缓存内存中填充与我们上面分配的 KV 缓存块相对应的 KV 值。

说明

经过多个引擎步骤后,它会分配更多的 KV 缓存块,但这对于我们的例子来说不重要,因为前缀在 long_prefix 之后立即就分叉了。


图 7:前缀缓存 - 在分页内存中填充 KV

在第二次使用相同前缀调用 generate 时,步骤 1-3 会重复,但现在 find_longest_cache_hit 会找到所有 n 个块的匹配项(通过线性搜索)。引擎可以直接重用那些 KV 块。


图 8:前缀缓存 - 重用 KV

如果原始请求仍然存活,这些块的引用计数会增加(例如,增加到 2)。在这个例子中,第一个请求已经完成,所以这些块被释放回池中,它们的引用计数被重置为 0。因为我们能从 cached_block_hash_to_block 中检索到它们,我们知道它们是有效的(KV 缓存管理器的逻辑就是这样设置的),所以我们只是再次将它们从 free_block_queue 中移除。

高级备注

KV 缓存块只有在它们即将从 free_block_queue(从左侧弹出)中被重新分配,并且我们发现该块仍然有关联的哈希值并存在于 cached_block_hash_to_block 中时,才会失效。在那一刻,我们清除该块的哈希值并从 cached_block_hash_to_block 中移除其条目,确保它不能通过前缀缓存被重用(至少不能用于那个旧的前缀)。

这就是前缀缓存的要点:不要重新计算你已经见过的​​前缀——只需重用它们的 KV 缓存!

如果你理解了这个例子,那么你也理解了 PagedAttention 是如何工作的。

前缀缓存默认启用。要禁用它:enable_prefix_caching = False

引导式解码 (FSM)

引导式解码是一种技术,在每个解码步骤中,logits 会受到基于语法的有限状态机的约束。这确保了只有语法允许的 token 才能被采样。

这是一个强大的设置:你可以强制执行从正则语法(乔姆斯基 3 型,例如任意正则表达式模式)一直到上下文无关语法(2 型,涵盖大多数编程语言)的任何规则。

为了让这个概念不那么抽象,让我们从最简单的例子开始,在我们之前的代码基础上构建

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

在我给出的这个简单例子中(假设是字符级分词):在预填充阶段,FSM 会屏蔽 logits,使得只有“P”或“N”是可行的。如果采样到“P”,FSM 会进入“Positive”分支;下一步只允许“o”,以此类推。


图 9:简单的 FSM 示例

这在 vLLM 中是如何工作的

  1. 在 LLM 引擎构建时,会创建一个 StructuredOutputManager;它可以访问分词器并维护一个 _grammar_bitmask 张量。
  2. 当添加请求时,其状态被设置为 WAITING_FOR_FSM,并且 grammar_init 会选择后端编译器(例如,xgrammar [7];注意后端是第三方代码)。
  3. 该请求的语法是异步编译的。
  4. 在调度期间,如果异步编译已完成,状态将切换为 WAITING,并且 request_id 会被添加到 structured_output_request_ids;否则,它会被放入 skipped_waiting_requests 中,以便在下一个引擎步骤重试。
  5. 在调度循环之后(仍在调度内部),如果有 FSM 请求,StructuredOutputManager 会要求后端准备/更新 _grammar_bitmask
  6. 在前向传播产生 logits 后,xgr_torch_compile 的函数会将位掩码扩展到词汇表大小(由于我们使用 32 位整数,扩展比为 32x),并将不允许的 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 的 logits 会被设置为 –∞。对于更大的词汇表,会使用多个 32 位字并相应地扩展/连接。后端(例如 xgrammar)负责使用当前的 FSM 状态生成这些位模式。

说明

这里的大部分复杂性都隐藏在像 xgrammar 这样的第三方库中。

这里有一个更简单的例子,词汇表大小为 8,使用 8 位整数(给那些喜欢我图示的朋友们)


图 10:简单示例

你可以在 vLLM 中通过传入所需的 guided_decoding 配置来启用此功能。

推测性解码

在自回归生成中,每个新 token 都需要大型语言模型(LM)进行一次前向传播。这非常昂贵——每一步都要重新加载并应用所有模型权重,只为计算一个 token!(假设批次大小为 1,一般情况下是 B

推测性解码 [8] 通过引入一个更小的草稿 LM 来加速这一过程。草稿模型廉价地提出 k 个 token。但我们最终不希望从较小的模型中采样——它只是用来猜测候选的续写。大型模型仍然决定什么是有效的。

以下是步骤

  1. 起草:在当前上下文上运行小型模型并提出 k 个 token
  2. 验证:在上下文 + k 个草稿 token 上运行一次大型模型。这将为这 k 个位置加上一个额外位置生成概率(因此我们得到 k+1 个候选)
  3. 接受/拒绝:从左到右遍历 k 个草稿 token
  • 如果大型模型对草稿 token 的概率 ≥ 草稿模型的概率,则接受它
  • 否则,以概率 p_large(token)/p_draft(token) 接受它
  • 在第一次拒绝时停止,或者接受所有 k 个草稿 token
    • 如果所有 k 个草稿 token 都被接受,则同时从大型模型中“免费”采样额外的第 (k+1) 个 token(我们已经计算了那个分布)
    • 如果发生了拒绝,在该位置创建一个新的重新平衡的分布(p_large - p_draft,最小值限制在 0,归一化使总和为 1),并从中采样最后一个 token

为什么这行得通:虽然我们使用小型模型来提出候选,但接受/拒绝规则保证了在期望上,序列的分布与我们从大型模型中逐个 token 采样完全相同。这意味着推测性解码在统计上等同于标准的自回归解码——但可能快得多,因为一次大型模型的前向传播最多可以产生 k+1 个 token。

说明

我建议查看 gpt-fast 以了解一个简单的实现,以及原始论文以了解数学细节和与从完整模型采样等价的证明。

vLLM V1 不支持 LLM 草稿模型方法,而是实现了更快但准确性较低的提议方案:n-gram、EAGLE [9] 和 Medusa [10]

每种方法的一句话总结

  • n-gram:取最后 prompt_lookup_max 个 token;在序列中寻找之前的匹配项;如果找到,提出该匹配项后面的 k 个 token;否则减少窗口大小并重试,直到 prompt_lookup_min

说明

当前的实现是在找到第一个匹配项后返回 k 个 token。引入一个近时偏好并反转搜索方向似乎更自然?(即,最后的匹配项)

  • Eagle:对大型 LM 进行“模型手术”——保留嵌入层和 LM 头,用一个轻量级的 MLP 替换 Transformer 栈;将其微调成一个廉价的草稿模型

  • Medusa:在大型模型(LM 头之前的嵌入层)之上训练辅助的线性头,以并行预测接下来的 k 个 token;使用这些头来比运行一个单独的小型 LM 更有效地提出 token

以下是如何在 vLLM 中使用 ngram 作为草稿方法来调用推测性解码

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 中是如何工作的?

设置(在引擎构建期间)

  1. 初始化设备:创建一个 drafter(草稿模型,例如 NgramProposer)和一个 rejection_sampler(其部分是用 Triton 编写的)。
  2. 加载模型:加载草稿模型权重(对于 n-gram 是无操作)。

之后在 generate 函数中(假设我们收到一个全新的请求)

  1. 使用大型模型运行常规的预填充步骤。
  2. 在前向传播和标准采样之后,调用 propose_draft_token_ids(k) 从草稿模型中采样 k 个草稿 token。
  3. 将这些存储在 request.spec_token_ids 中(更新请求元数据)。
  4. 在下一个引擎步骤中,当请求在运行队列中时,将 len(request.spec_token_ids) 添加到“新 token”计数中,以便 allocate_slots 为前向传播保留足够的 KV 块。
  5. spec_token_ids 复制到 input_batch.token_ids_cpu 中,以形成(上下文 + 草稿)token。
  6. 通过 _calc_spec_decode_metadata 计算元数据(这会从 input_batch.token_ids_cpu 复制 token,准备 logits 等),然后在草稿 token 上运行一次大型模型的前向传播。
  7. 不从 logits 中进行常规采样,而是使用 rejection_sampler 从左到右地接受/拒绝并生成 output_token_ids
  8. 重复步骤 2-7,直到满足停止条件。

要内化这个过程,最好的方法是启动调试器并单步调试代码库,但希望这部分能让你对它有个初步的了解。这张图也是如此


图 11:推测性解码

预填充/解码分离 (Disaggregated P/D)

我之前已经暗示过预填充/解码 (P/D) 分离背后的动机。

预填充和解码具有非常不同的性能特征(计算密集型 vs. 内存带宽密集型),因此将它们的执行分离开来是一种明智的设计。它能更精确地控制延迟——包括 TFTT(首个 token 时间)和 ITL(token 间延迟)——更多相关内容将在基准测试部分讨论。

在实践中,我们运行 N 个 vLLM 预填充实例和 M 个 vLLM 解码实例,并根据实时请求组合对它们进行自动伸缩。预填充工作节点将 KV 写入专用的 KV 缓存服务;解码工作节点则从中读取。这将长时间、突发性的预填充与稳定、对延迟敏感的解码隔离开来。

这在 vLLM 中是如何工作的?

为清晰起见,下面的例子依赖于 SharedStorageConnector,这是一个用于说明机制的调试连接器实现。

说明

连接器 (Connector) 是 vLLM 用于处理实例之间 KV 交换的抽象。连接器接口尚不稳定,计划在近期进行一些改进,这会涉及一些变化,其中一些可能是破坏性的。

我们启动 2 个 vLLM 实例(GPU 0 用于预填充,GPU 1 用于解码),然后在它们之间传输 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 [11],这是最快的生产就绪连接器(使用 NVIDIA 的 NIXL 作为后端),但它仍处于前沿阶段,我遇到了一些 bug。由于其大部分复杂性存在于一个外部仓库中,SharedStorageConnector 是一个更好的解释选择。

以下是 vLLM 中的步骤

  1. 实例化 — 在引擎构建期间,连接器在两个地方被创建
    • 在 worker 的初始化设备过程中(在初始化 worker 分布式环境函数下),角色为“worker”。
    • 在调度器构造函数中,角色为“scheduler”。
  2. 缓存查找 — 当调度器处理来自 waiting 队列的预填充请求时(在本地前缀缓存检查之后),它会调用连接器的 get_num_new_matched_tokens。这会在 KV 缓存服务器中检查外部缓存的 token。预填充在这里总是看到 0;解码可能会有缓存命中。在调用 allocate_slots 之前,结果会加到本地计数中。
  3. 状态更新 — 然后调度器调用 connector.update_state_after_alloc,它记录了有缓存的请求(对预填充是无操作)。
  4. 构建元数据对象 — 在调度结束时,调度器调用 meta = connector.build_connector_meta
    • 预填充会添加所有 is_store=True 的请求(用于上传 KV)。
    • 解码会添加所有 is_store=False 的请求(用于获取 KV)。
  5. 上下文管理器 — 在前向传播之前,引擎进入一个 KV 连接器上下文管理器
    • 进入时:调用 kv_connector.start_load_kv。对于解码,这会从外部服务器加载 KV 并将其注入到分页内存中。对于预填充,这是无操作。
    • 退出时:调用 kv_connector.wait_for_save。对于预填充,这会阻塞直到 KV 上传到外部服务器。对于解码,这是无操作。

下面是一个可视化的例子


图 12:分离的 P/D

补充说明

  • 对于 SharedStorageConnector,“外部服务器”只是一个本地文件系统。
  • 根据配置,KV 传输也可以逐层进行(在每个注意力层之前/之后)。
  • 解码只在请求的第一步加载一次外部 KV;之后它会在本地计算/存储。

从 UniprocExecutor 到 MultiProcExecutor

在掌握了核心技术之后,我们现在可以讨论如何向上扩展。

假设你的模型权重不再能装入单个 GPU 的 VRAM 中。

第一个选项是使用张量并行(例如,TP=8)将模型分片到同一节点上的多个 GPU 上。如果模型仍然放不下,下一步是在节点之间使用流水线并行。

备注

  • 节点内带宽远高于节点间带宽,这就是为什么张量并行(TP)通常优于流水线并行(PP)。(同样真实的是,PP 传输的数据量比 TP 少。)
  • 我没有涵盖专家并行(EP),因为我们专注于标准 Transformer 而非 MoE,也没有涵盖序列并行,因为 TP 和 PP 在实践中是最常用的。

在这个阶段,我们需要多个 GPU 进程(worker)和一个协调层来协调它们。这正是 MultiProcExecutor 提供的功能。


图 13:在 TP=8 设置下的 MultiProcExecutor(驱动 worker 为 rank 0)

这在 vLLM 中是如何工作的

  1. MultiProcExecutor 初始化一个 rpc_broadcast_mq 消息队列(底层使用共享内存实现)。
  2. 构造函数会遍历 world_size(例如 TP=8 ⇒ world_size=8),并通过 WorkerProc.make_worker_process 为每个 rank 派生一个守护进程。
  3. 对于每个 worker,父进程首先创建一个读写管道。
  4. 新进程运行 WorkerProc.worker_main,它会实例化一个 worker(经历与 UniprocExecutor 中相同的“初始化设备”、“加载模型”等步骤)。
  5. 每个 worker 确定自己是驱动程序(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 会将其(非阻塞地)入队到所有子 worker 的 rpc_broadcast_mq 中。然后它在指定的输出 rank 的 worker_response_mq.dequeue 上等待,以收集最终结果。

从引擎的角度来看,没有任何改变——所有这些多进程的复杂性都通过调用模型执行器的 execute_model 被抽象掉了。

  • UniProcExecutor 的情况下:execute_model 直接导致调用 worker 上的 execute_model
  • MultiProcExecutor 的情况下:execute_model 间接通过 rpc_broadcast_mq 导致调用每个 worker 上的 execute_model

此时,我们可以使用相同的引擎接口运行资源允许的任意大小的模型。

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

分布式系统服务 vLLM

建立服务基础设施有很多种方法,但为了具体起见,这里有一个例子:假设我们有两台 H100 节点,并希望在它们上面运行四个 vLLM 引擎。

如果模型需要 TP=4,我们可以这样配置节点。


图 14:使用 2 个 8xH100 节点的服务器配置(1 个无头节点,1 个 API 服务器)

在第一个节点上,以无头模式(无 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

然后在另一个节点上运行相同的命令,但做一些调整

  • 没有 --headless
  • 修改 DP 起始 rank
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 中是如何工作的?

在无头服务器节点上

在无头节点上,一个 CoreEngineProcManager 会启动 2 个进程(根据 --data-parallel-size-local),每个进程运行 EngineCoreProc.run_engine_core。这些函数中的每一个都会创建一个 DPEngineCoreProc(引擎核心),然后进入其忙碌循环。

DPEngineCoreProc 会初始化其父类 EngineCoreProcEngineCore 的子类),它会:

  1. 创建一个 input_queueoutput_queue (queue.Queue)。
  2. 使用一个 DEALER ZMQ 套接字(异步消息库)与另一节点上的前端进行初始握手,并接收协调地址信息。
  3. 初始化 DP 组(例如使用 NCCL 后端)。
  4. 使用 MultiProcExecutor 初始化 EngineCore(如前所述,在 4 个 GPU 上 TP=4)。
  5. 创建一个 ready_event (threading.Event)。
  6. 启动一个输入守护线程 (threading.Thread),运行 process_input_sockets(…, ready_event)。类似地,启动一个输出线程。
  7. 仍在主线程中,等待 ready_event,直到跨越 2 个节点的全部 4 个进程的所有输入线程都完成了协调握手,最终执行 ready_event.set()
  8. 一旦解除阻塞,就向前端发送一条“就绪”消息,并附带元数据(例如,分页 KV 缓存内存中可用的 num_gpu_blocks)。
  9. 然后,主线程、输入线程和输出线程各自进入它们的忙碌循环。

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


图 15:具有 4 个 DP 副本的分布式系统,运行 4 个 DPEngineCoreProc

当前稳态:

  • 输入线程 — 阻塞在输入套接字上,直到一个请求从 API 服务器路由过来;收到后,它解码有效载荷,通过 input_queue.put_nowait(...) 将一个工作项入队,然后返回到套接字上继续阻塞。
  • 主线程 — 在 input_queue.get(...) 上被唤醒,将请求喂给引擎;MultiProcExecutor 运行前向传播并将结果入队到 output_queue
  • 输出线程 — 在 output_queue.get(...) 上被唤醒,将结果发送回 API 服务器,然后继续阻塞。

附加机制:

  • DP 波次计数器 — 系统跟踪“波次”;当所有引擎变为空闲时,它们会静默下来,当新工作到达时,计数器会递增(这对于协调/指标很有用)。
  • 控制消息 — API 服务器可以发送的不仅仅是推理请求(例如,中止和实用程序/控制 RPC)。
  • 用于同步的虚拟步骤 — 如果任何一个 DP 副本有工作,所有副本都会执行一个前向步骤;没有请求的副本会执行一个虚拟步骤,以参与所需的同步点(避免阻塞活动的副本)。

说明

同步说明:这实际上只在 MoE 模型中需要,其中专家层形成一个 EP 或 TP 组,而注意力层仍然是 DP。目前它总是与 DP 一起完成——这只是因为“内置”的非 MoE DP 用途有限,因为你可以只运行多个独立的 vLLM,并以常规方式在它们之间进行负载均衡。

现在是第二部分,API 服务器节点上发生了什么?

在 API 服务器节点上

我们实例化一个 AsyncLLM 对象(LLM 引擎的一个 asyncio 包装器)。在内部,这会创建一个 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. 随后,来自 AsyncLLM 的另一个 asyncio 任务 output_handler 从这个队列中读取,并最终将信息发送给 create_completion 函数。

DPAsyncMPClient 内部,我们创建一个 asyncio 任务 run_engine_stats_update_task,它与 DP 协调器通信。

DP 协调器在前端(API 服务器)和后端(引擎核心)之间进行协调。它

  • 定期向前端的 run_engine_stats_update_task 发送负载均衡信息(队列大小、等待/运行中的请求)。
  • 通过动态改变引擎数量来处理来自前端的 SCALE_ELASTIC_EP 命令(仅适用于 Ray 后端)。
  • 向后端发送 START_DP_WAVE 事件(当由前端触发时)并报告波次状态更新。

总而言之,前端 (AsyncLLM) 运行多个 asyncio 任务(记住:是并发,不是并行)

  • 一类任务通过 generate 路径处理输入请求(每个新的客户端请求都会产生一个新的 asyncio 任务)。
  • 两个任务 (process_outputs_socket, output_handler) 处理来自底层引擎的输出消息。
  • 一个任务 (run_engine_stats_update_task) 维护与 DP 协调器的通信:发送波次触发器、轮询负载均衡状态以及处理动态扩缩容请求。

最后,主服务器进程创建一个 FastAPI 应用并挂载诸如 OpenAIServingCompletionOpenAIServingChat 之类的端点,这些端点暴露了 /completion/chat/completion 等接口。然后,这个堆栈通过 Uvicorn 提供服务。

所以,综合起来,这就是完整的请求生命周期!

你从终端发送

curl -X POST https://: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 服务器上 OpenAIServingCompletioncreate_completion 路由。
  2. 该函数异步地对提示进行分词,并准备元数据(请求 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. 在那个引擎上
    • 输入线程 — 解除阻塞,从输入套接字解码数据,并将一个工作项放入主线程的 input_queue 中。
    • 主线程 — 在 input_queue 上解除阻塞,将请求添加到引擎中,并重复调用 engine_core.step(),将中间结果排入 output_queue,直到满足停止条件。

说明

提醒:step() 会调用调度器、模型执行器(后者又可以是 MultiProcExecutor!),等等。我们已经见过这个了!

  • 输出线程 — 在 output_queue 上解除阻塞,并通过输出套接字将结果发送回去。
  1. 这些结果触发了 AsyncLLM 的输出 asyncio 任务(process_outputs_socketoutput_handler),这些任务将 token 传回 FastAPI 的 create_completion 路由。
  2. FastAPI 附加元数据(完成原因、logprobs、使用信息等),并通过 Uvicorn 向您的终端返回一个 JSONResponse

就这样,你的补全结果回来了——整个分布式机器的复杂性隐藏在一个简单的 curl 命令背后!:) 太有趣了!!!

补充说明

  • 当添加更多 API 服务器时,负载均衡在操作系统/套接字层面处理。从应用程序的角度来看,没有显著变化——复杂性被隐藏了。
  • 使用 Ray 作为 DP 后端,你可以暴露一个 URL 端点 (/scale_elastic_ep),它能自动增减引擎副本的数量。

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

到目前为止,我们一直在分析“气体粒子”——请求在引擎/系统中流动的内部机制。现在是时候放大视角,审视整个系统,并提出问题:我们如何衡量一个推理系统的性能?

在最高层面,有两个相互竞争的指标

  1. 延迟 — 从提交请求到返回 token 的时间
  2. 吞吐量 — 系统每秒可以生成/处理的 token/请求数量

延迟对于交互式应用最为重要,因为用户在等待响应。

吞吐量在离线工作负载中很重要,如为预/后训练生成合成数据、数据清洗/处理,以及通常任何类型的离线批量推理作业。

在解释为什么延迟和吞吐量相互竞争之前,让我们定义几个常见的推理指标

指标 定义
TTFT
(首个 token 时间)
从提交请求到接收到第一个输出 token 的时间
ITL
(token 间延迟)
两个连续 token 之间的时间(例如,从 token i-1 到 token i)
TPOT
(每个输出 token 的时间)
一个请求中所有输出 token 的平均 ITL
延迟 / E2E
(端到端延迟)
处理一个请求的总时间,即 TTFT + 所有 ITL 的总和,或者等同于提交请求和接收到最后一个输出 token 之间的时间
吞吐量 每秒处理的总 token 数(输入、输出或两者兼有),或者每秒请求数
有效吞吐量 (Goodput) 满足服务水平目标 (SLO) 的吞吐量,例如最大 TTFT、TPOT 或端到端延迟。例如,只计算满足这些 SLO 的请求所产生的 token


图 16:ttft、itl、e2e 延迟

这里有一个简化模型,解释了这两个指标的竞争性质。

假设

权重 I/O 而非 KV 缓存 I/O 占主导;即我们处理的是短序列。

当观察批次大小 B 如何影响单个解码步骤时,这种权衡变得清晰。当 B ↓ 趋向 1 时,ITL 下降:每步的工作量更少,token 不会与其他 token“竞争”。当 B ↑ 趋向无穷大时,ITL 上升,因为我们每步执行更多的 FLOPs——但吞吐量提高(直到达到性能峰值),因为权重 I/O 被分摊到更多 token 上。

屋顶线模型 (roofline model) 有助于理解这一点:在饱和批次大小 B_sat 以下,步骤时间由 HBM 带宽主导(逐层将权重流式传输到片上内存),所以步骤延迟几乎是平的——计算 1 个或 10 个 token 可能花费相似的时间。超过 B_sat,核函数变为计算密集型,步骤时间大致随 B 增长;每个额外的 token 都会增加 ITL。


图 17:屋顶线性能模型

说明

为了更严谨地处理,我们必须考虑核函数的自动调优:随着 B 的增长,运行时可能会切换到对该形状更高效的核函数,从而改变达到的性能 P_kernel。步骤延迟是 t = FLOPs_step / P_kernel,其中 FLOPs_step 是该步骤的工作量。你可以看到,当 P_kernel 达到 P_peak 时,每一步更多的计算将直接导致延迟增加。

如何在 vLLM 中进行基准测试

vLLM 提供了一个 vllm bench {serve,latency,throughput} 命令行工具,它包装了 vllm / benchmarks / {server,latency,throughput}.py。

这些脚本的作用如下

  • latency — 使用短输入(默认 32 个 token)并以小批量(默认 8)采样 128 个输出 token。它运行多次迭代并报告批处理的端到端延迟。
  • throughput — 一次性提交一组固定的提示(默认:1000 个 ShareGPT 样本)(即 QPS=Inf 模式),并报告整个运行过程中的输入/输出/总 token 数和每秒请求数。
  • serve — 启动一个 vLLM 服务器,并通过从泊松(或更一般的,伽马)分布中采样请求的到达间隔时间来模拟真实世界的工作负载。它在一个时间窗口内发送请求,测量我们讨论过的所有指标,并且可以选择性地强制执行服务器端最大并发数(通过信号量,例如限制服务器为 64 个并发请求)。

下面是一个如何运行延迟脚本的例子

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

说明

CI 中使用的基准测试配置位于 .buildkite/nightly-benchmarks/tests 下。

还有一个自动调优脚本,它驱动服务基准测试来寻找满足目标 SLOs 的参数设置(例如,“在保持 p99 端到端延迟 < 500 毫秒的同时最大化吞吐量”),并返回一个建议的配置。

结语

我们从基本的引擎核心 (UniprocExecutor) 开始,添加了像推测性解码和前缀缓存这样的高级功能,扩展到 MultiProcExecutor (TP/PP > 1),最后横向扩展,将所有内容包装在异步引擎和分布式服务堆栈中——最后讨论了如何衡量系统性能。

vLLM 还包含一些我跳过的专门处理。例如:

  • 多样化的硬件后端:TPU、AWS Neuron (Trainium/Inferentia) 等。
  • 架构/技术MLAMoE、编码器-解码器(如 Whisper)、池化/嵌入模型、EPLBm-RoPELoRAALiBi、无注意力变体、滑动窗口注意力、多模态 LM 和状态空间模型(如 Mamba/Mamba-2, Jamba)
  • TP/PP/SP
  • 混合 KV 缓存逻辑 (Jenga)、更复杂的采样方法如束搜索 (beam sampling) 等
  • 实验性功能:异步调度

好在这些功能大多与上述主流程是正交的——你几乎可以把它们当作“插件”(当然,实践中存在一些耦合)。

我热爱理解系统。话虽如此,在这个高度的视角下,分辨率肯定有所损失。在接下来的文章中,我将聚焦于特定的子系统,深入探讨其中的细节。

联系我

如果你在文章中发现任何错误,请私信我——欢迎在 XLinkedIn 上给我发消息,或通过匿名反馈联系。

致谢

非常感谢 Hyperstack 在过去一年中为我的实验提供 H100!

感谢 Nick Hill(vLLM 核心贡献者, RedHat)、Kaichao You(vLLM 核心贡献者)、Mark Saroufim (PyTorch)、Kyle Krannen (NVIDIA, Dynamo) 和 Ashish Vaswani 阅读了这篇博客的预发布版本并提供了反馈!

参考文献

  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