[译] 深入vLLM:高吞吐LLM推理系统剖析

262 阅读44分钟

原文链接:Inside vLLM: Anatomy of a High-Throughput LLM Inference System 注意本文中的📝笔记,是原文中原作者的笔记。

本文将从分页注意力机制(paged attention)、连续批处理(continuous batching)、前缀缓存(prefix caching)、投机解码(Specdec),到如何构建大规模多GPU、多节点动态服务逐一展开介绍。

2025年8月29日

在这篇文章中,我将逐步介绍现代高吞吐的LLM推理系统的所有核心系统组件以及高级功能。准确地说,我将详细分析 vLLM [1] 的工作原理。

本文是系列文章的第一篇。它将从宏观入手,然后逐步深入细节(采用倒金字塔式的方法),以便你能够对整个系统形成准确且高层次的思维模型,而不会陷入细节的泥淖之中。

本系列后续的文章将深入探讨具体子系统。

本文分为五个部分:

  1. LLM引擎(LLM Engine)及引擎核心(Engine Core):vLLM基础知识(调度、分页注意力、连续批处理等)
  2. 高级功能:分块预填充、前缀缓存、引导式和投机解码、P/D分离
  3. 扩展:从单GPU到多GPU执行
  4. 服务层:分布式/并发 Web 脚手架
  5. 基准测试和自动调优:测量延迟和吞吐量

📝笔记

  • 本文基于的代码版本: commit 42172ad(2025 年 8 月 9 日)。
  • 目标受众:所有对最先进(SOTA)的 LLM 引擎的工作原理感到好奇的人,以及有兴趣为 vLLM、SGLang 等做出贡献的人。
  • 我将重点介绍V1 引擎。我也研究过 V0 版本(现已弃用),这对于了解项目的演变过程很有帮助,而且许多概念仍然沿用至今。
  • 关于LLM引擎/引擎核心的第一部分可能有点晦涩难懂/枯燥,但博客的其余部分有很多示例和图示。:)

LLM引擎(LLM Engine)及引擎核心(Engine Core)

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)
  • 使用标准 transformer [2](支持 Jamba 等混合模型需要更复杂的混合 KV 缓存内存分配器)

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

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

  1. 实例化了一个LLM类型的引擎对象
  2. 调用它的generate方法来对给定的prompts进行采样

下面我们开始分析LLM类的初始化方法。

LLM类的初始化方法

引擎(LLM类)的主要组件有:

  • vLLM配置(vLLM config):包含用于配置模型、缓存、并行度等的所有参数。
  • 输入处理器(processor):通过验证(validation)、分词(tokenization)和其他处理(processing)将原始输入转换成EngineCoreRequests类型。
  • 引擎核心客户端(engine core client):在我的示例中,我使用的是InprocClient类型,它基本等价于EngineCore;我们将逐步构建出DPLBAsyncMPClient类型以实现能支持大规模请求的服务。
  • 输出处理器(output processor):将EngineCoreOutputs的原始数据转换为RequestOutput,即用户最终看到的内容。

📝笔记:

由于 V0版本的引擎已被弃用,类名和细节可能会有所变化。我将重点放在核心概念上,而不是具体的函数签名上。所以我会省略一些细节,但不会全部省略。

引擎核心(Engine Core)由几个子组件组成:

  • 模型执行器(Model Executor):负责模型的前向传播,我们目前使用的UniProcExecutor是在单个GPU上运行的单个Worker进程。我们将逐步构建出支持多个GPU的MultiProcExecutor类型。

  • 结构化输出管理器(Structured Output Manager):用于引导解码(guided decoding)——我稍后会介绍。

  • 调度器(Scheduler):决定哪些请求进入下一个引擎步骤——它还包含:

    1. 策略设置(policy setting)——可以是先到先得(FCFS),也可以基于优先级(优先级更高的请求优先处理)
    2. 等待(waiting)队列和运行(running)队列
    3. KV Cache管理器(KV cache Manager)——这是分页注意力机制(paged attention)的核心[3]

KV Cache管理器维护着一个free_block_queue,可用KV Cache块(block)的池子(通常有数十万个块,具体取决于显存大小和块大小)。在分页注意力机制下,这些块用作索引结构,将token映射到其对应的KV Cache块中。

LLM引擎

标准 Transformer 层(非 MLA [4])的块大小计算如下:
2(key/value)* block_size(默认值=16)* num_kv_headshead_sizedtype_num_bytes(例如,bf16 为 2)

在模型执行器(Model Executor)构建过程中,Worker会创建一个对象并执行三个关键流程。(之后,借助MultiProcExecutor,这些相同的流程会在不同 GPU 的每个工作进程上独立运行。)

  1. 初始化设备:

    • 为工作进程分配一个 CUDA 设备(例如“cuda:0”),并检查模型数据类型是否受支持(例如 bf16)。
    • gpu_memory_utilization根据请求的显存大小(例如 0.8 → 总显存的 80%),验证是否有足够的显存可用。
    • 设置分布式设置(DP / TP / PP / EP 等)
    • 实例化一个model_runner(包含采样器、KV Cache和前向传递缓冲区,例如input_idspositions等等)
    • 实例化一个InputBatch对象(保存 CPU 端前向传播缓冲区、用于 KV Cache索引的块表、采样元数据等)
  2. 加载模型:

    • 实例化模型架构
    • 加载模型权重
    • 调用 model.eval()(PyTorch 的推理模式)
    • 可选:对模型调用 torch.compile()
  3. 初始化 KV Cache:

    • 获取每层 KV Cache的规格。过去一直是FullAttentionSpec(Transformer的标准结构),但随着混合模型(滑动窗口、Transformer/SSM,如 Jamba)的出现,情况变得更加复杂(参见 Jenga [5])。
    • 运行一次虚拟/性能分析(dummy/profiling)的前向传播,并获取 GPU 显存快照,以计算可用显存可以容纳多少个 KV Cache块。
    • 分配(allocate)、重塑(reshape)和绑定(bind) KV Cache张量到注意力层。
    • 准备注意力元数据(例如,将后端设置为FlashAttention),供CUDA内核在转发过程中使用。
    • 除非设置了--enforce-eager,否则对于每个预热批次大小,都会执行一次虚拟运行并捕获 CUDA graphs。CUDA graphs会将整个 GPU 工作序列记录到一个 DAG 中。在后续的前向传播过程中,我们会启动/重放预先生成的 CUDA graphs,从而减少CUDA内核启动开销,进而降低延迟。

我在这里省略了很多底层细节——但接下来我会介绍这些核心部分,因为在以下章节中将频繁提到它们。

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

生成函数(Generate function)

第一步是请求验证并将其输入到引擎。对于每个prompt,我们将:

  1. 创建唯一的请求 ID 并记录其到达时间。
  2. 调用输入预处理器(input preprocessor),对prompt进行分词,并返回一个包含promptprompt_token_idstype(文本、token、embed等类型)的词典。
  3. 将上述信息打包成EngineCoreRequest类型的请求,并添加优先级、采样参数和其他元数据。
  4. 将请求传递给引擎核心(engine core),引擎核心会将其包装在一个Request对象中,并将其状态设置为WAITING。然后,该请求会被添加到调度器(Scheduler)的waiting队列中(如果是先到先得FCFS的策略,则追加到末尾;如果是基于优先级排序的策略,则将请求压入堆) 。

此时引擎已接收完所有数据,可以开始执行。在同步引擎示例中,我们只会处理这些初始请求——没有机制可以在运行过程中注入新的请求。相比之下,异步引擎支持这种机制(也称为连续批处理(continuous batching) [6]):在每个步骤之后,都会同时考虑新旧请求。

由于前向传播将批次打平成一个序列,并且自定义内核可以高效地处理它,因此即使在同步引擎中,连续批处理也得到了基本支持。

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

  1. 调度(Schedule):选择在此步骤中要运行的请求(解码和/或(分块)预填充)。
  2. 前向传播(Forward pass):运行模型并采样token。
  3. 后处理(Postprocess):将采样到的token ID 追加到每个Request中,进行分词还原(detokenize),并检查停止条件。如果请求已完成,则进行清理(例如,将其KV cache块返回给free_block_queue),并提前返回输出。

📝停止条件如下:

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

引擎循环

在流式传输模式下,我们会发送生成的中间token,但目前我们暂且忽略这一点。

接下来,我们将更详细地探讨调度器。

调度器(Scheduler)

推理引擎主要处理两种类型的任务:

  1. 预填充(Prefill) 请求:对所有prompt token进行前向遍历。这通常是计算密集的(阈值取决于硬件和prompt长度)。最后,我们从最终token位置的概率分布中采样一个token。
  2. 解码(Decode) 请求:仅对最新的token进行前向传播。所有先前的KV向量都已缓存。由于计算一个token仍然需要加载所有 LLM 权重和 KV Cache,因此这些请求是内存(显存)密集的

基准测试章节,我们将分析GPU性能的屋顶线模型。这将更详细地介绍预填充/解码性能背后的原理。

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

调度器会优先处理解码请求,也就是已经在running队列中的请求。对于每个解码请求,它会:

  1. 计算要生成的token的数量(由于推测解码[speculative decoding]和异步调度的存在,每次生成的token数量并不总是1,稍后会详细介绍)。
  2. 调用 KV Cache管理器的allocate_slots函数(详情后面会介绍)。
  3. 更新token预算,即减掉步骤 1 中的token数量。

然后,它会处理waiting队列中的预填充请求,具体来说:

  1. 检索已计算块的数量(如果禁用前缀缓存[Prefix Cache],则返回 0——我稍后会介绍)。
  2. 调用 KV Cache管理器的allocate_slots函数。
  3. 将请求从waiting队列取出并移至running队列,并将其状态设置为RUNNING
  4. 更新token总配额。

现在我们来看看allocate_slots的功能:

  1. 计算区块数量:确定需要分配多少(n)个新的KV Cache块。默认情况下,每个块存储 16 个token。例如,如果预填充请求包含 17 个新token,则需要ceil(17/16) = 2个块。
  2. 检查可用性:如果管理器的块池中没有足够的块,则提前退出。根据请求是解码请求还是预填充请求,引擎可能会尝试通过驱逐低优先级请求(调用kv_cache_manager.free函数将KV块归还给块池)来重新计算抢占(V0 版本支持交换抢占),或者可能会跳过调度并继续执行。
  3. 分配块:通过KV Cache管理器的协调器,从块池中获取前n个块,(前面提到过free_block_queue是双向链表)。存储到词典req_to_blocks中,该词典将request_id映射到其对应的KV Cache块列表。

KV Cahce块列表

我们终于到了前向传播了!

前向传播

我们调用模型执行器(model executor)的execute_model方法,它委托给WorkerWorker又委托给模型运行器(model runner)。

以下是主要步骤:

  1. 更新状态: 从input_batch中清理已完成的请求;更新其他前向传播相关元数据(例如,每个请求的 KV Cache块,将用于索引到分页 KV Cache内存中)。
  2. 准备输入:将缓冲区从 CPU 复制到 GPU;计算位置;构建slot_mapping(更多内容请参见示例);构建注意力元数据。
  3. 前向传播:使用自定义分页注意力内核运行模型。所有序列都被打平并连接成一个非常长的“超级序列”。位置索引和注意力掩码确保每个序列只关注自身的token,从而确保连续批处理无需右侧填充(right-padding)。
  4. 收集最后一个token状态:提取每个序列最终位置的隐藏状态并计算logits。
  5. 采样: 根据采样配置(贪心算法、温度、top-p、top-k 等)从计算出的 logits 中采样token。

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

  1. 急切模式(Eager mode): 运行 PyTorch 标准的前向传播。
  2. 捕获模式(Captured mode): 当不强制执行急切模式时,执行/重放预先捕获的 CUDA Grpah(记住,我们在初始化 KV Cache过程中引擎构造期间捕获了CUDA Graph)。

以下是一个具体的例子,应该能清楚地说明连续批处理(continuous batching)和分页注意力(paged attention)的概念:

前向传播 - 连续批处理和分页注意力

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

基本的引擎流程介绍以后,我们现在可以研究高级功能了。

我们已经讨论过抢占、分页注意力以及连续批处理。

接下来,我们将深入探讨:

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

分块预填充(Chunked prefill)

分块预填充是一种处理长prompt的技术,它将prefill步骤拆分成更小的块。如果没有这项技术,可能会出现一个非常长的请求占用引擎的一个步骤(step),导致其他prefill请求无法运行。导致其他请求的延迟增长。

例如,假设每个块包含n(=8) 个token,token用小写字母表示,字母之间用“-”分隔。一个较长的promptP可能类似于x-y-z,其中z是一个不完整的块(例如,包含 2 个token)。对 P执行完整的prefill操作需要>=3个引擎步骤(如果某个步骤未被调度执行,则可能出现 > ),并且我们只在最后一个分块预填充步骤中采样一个新token。

以下是图示:

分块预填充 - 第 1 部分

实现起来很简单:限制每一步生成的新token数量。如果请求的数量超过限制long_prefill_token_threshold,则将其重置为该值。底层索引逻辑(前面已描述)会处理其余部分。

在 vLLM V1 中,你可以通过将long_prefill_token_threshold的值设置为正整数来启用分块预填充。(从技术上来说,即使不设置,如果prompt长度超过token预算,我们也会截断prompt并运行分块预填充。)

前缀缓存(Prefix Caching)

为了解释前缀缓存的工作原理,我对原始示例代码稍作修改:

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——因此得名前缀(Prefix

关键在于long_prefix:它被定义为任何长度超过 KV Cache块大小(默认为 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 Cache的分页内存中),然后即可重复使用,因此只需要处理新的prompt的token。就可以加快prefill阶段的速度(但对decode阶段没有帮助)。

那么在vLLM中是如何实现的呢?

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

  1. 该函数将数据分割long_prefix + prompts[0]成 16个token一组的块。
  2. 对于每个完整的块,它都会计算一个哈希值(使用python内置hash算法或SHA-256算法,SHA-256 速度较慢但冲突较少)。该哈希值的原文是组合了前一个数据块的哈希值、当前数据块的token以及可选的元数据。

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

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

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

接下来,引擎会调用函数find_longest_cache_hit检查这些哈希值是否已存在于cached_block_hash_to_block中。第一次请求时,将不会找到任何匹配项。

前缀缓存逻辑 - 第一部分

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

之后,前向传播会将 KV 填充到 KV Cache 的分页内存中,对应于我们上面分配的 KV Cache块。

经过一系列引擎步骤后,它会分配更多的 KV Cache块,但这对于我们的示例来说无关紧要,因为在long_prefix之后前缀立即发生了变化。

前缀缓存逻辑 - 第 2 部分

使用相同前缀的请求第二次调用generate时,步骤 1-3 重复执行,但这次find_longest_cache_hit会找到匹配的n个块(通过线性查找)。引擎可以直接复用这些KV块。

前缀缓存逻辑 - 第 3 部分

如果原始请求仍然有效,这些数据块的引用计数会递增(例如递增到 2)。在本例中,第一个请求已经完成,因此这些数据块被释放回块池,其引用计数也重置为 0。由于我们能够通过cached_block_hash_to_block函数从块池中检索到它们,因此我们知道它们是有效的(KV Cache管理器的逻辑就是这样设置的),所以我们只需调用free_block_queue再次将它们从块池中移除即可。

📝进阶笔记:

只有当队列free_block_queue(从左侧弹出)出现重新分配时KV Cache块才会失效,此时我们发现该块在cached_block_hash_to_block中仍然具有关联的哈希值。此时,我们会清除该块的哈希值并从cached_block_hash_to_block中删除其条目,以确保它不能通过前缀缓存被复用(指的是不能被旧前缀复用)。

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

如果你理解了这个例子,那么你也就理解了分页注意力机制的工作原理。

前缀缓存是默认启用的。可以使用:enable_prefix_caching = False去禁用它。

引导解码(Guided Decoding,FSM)

引导解码是这样一种技术:每个解码步骤中的logits都受到基于语法的有限状态机(FSM)的约束。这确保了只能对语法允许的token进行采样。

这是一个强大的设置:你可以从正则语法(乔姆斯基3型文法[Chomsky type-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()

在我给出的简单示例中(假设采用字符级分词):在预填充阶段,有限状态机对logits做掩码,只允许使用“P”或“N”。如果采样到字母“P”,则有限状态机跳转到“正”分支;下一步只允许使用字母“o”,依此类推。

有限状态机

在vLLM中的工作原理如下:

  1. 在LLM引擎构造过程中,创建了一个StructuredOutputManager类型的对象;它可以访问分词器(tokenizer)并维护一个张量_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 倍扩展比,因为我们使用的32位整数),并将不允许的 logits 掩码设置为 -∞。
  7. 在采样到下一个token后,请求的有限状态机通过调用函数accept_tokens进行前进。从视觉上看,我们在有限状态机图中移动到下一个状态。

步骤 6 需要进一步说明。

如果vocab_size = 32,则_grammar_bitmask是一个整数。那么它的二进制表示编码了哪些标记是允许的(“1”),哪些标记是不允许的(“0”)。例如,“101…001”会展开成一个长度为 32 的数组[1, 0, 1, …, 0, 0, 1];位置值为 0 的标记的 logits 值会被设置为 -∞。对于更大的词汇表,会使用多个 32 位字,并根据需要进行展开/连接。后端(例如xgrammar)负责使用当前的有限状态机的状态生成这些位的模式。

📝笔记:

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

这里有一个更简单的例子,vocab_size = 8,整数为 8 位(献给喜欢我这种可视化方式的朋友们):

有限状态机

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

推测解码(Speculative Decoding)

在自回归生成过程中,每个新token都需要对大型语言模型进行一次前向传播。这非常耗费资源——每一步都需要重新加载并应用模型的所有权重,仅仅为了计算一个token!(假设批次大小(batch size)为 1,通常情况下是这样的B

推测性解码[8]通过引入一个较小的草稿语言模型来加速这一过程。该草稿模型可以低成本地提出k个词元。但我们最终并不想从这个小模型中采样——它只是用来猜测候选的后续词形。最终决定哪些词形有效的仍然是大型模型。

步骤如下:

  1. 草稿(Draft): 在当前环境下运行小型模型并提出k个token。

  2. 验证(Verify): 对上下文和k个草稿token运行一次大型模型。这将生成k+1个位置的概率(以便我们获得k+1候选位置)。

  3. 接受/拒绝(Accept/reject): 从左到右依次浏览k个草稿token:

    • 如果大型模型预测的草稿token的概率≥草稿模型预测的概率,则接受这个token。
    • 否则,以p_large(token)/p_draft(token)的概率接受它。
    • 如果第一次出现token拒绝,或者已经接受所有k个草稿token,就会停止。
      • 如果所有k草稿代币都被接受,则从大模型中“免费”抽取额外的第(k+1)个token(我们已经计算了该分布)。
      • 如果遭到拒绝,则在该位置创建一个新的重新平衡分布(p_large - p_draft,最小值设为 0,归一化为总和为 1),并从中抽取最后一个标记。

其原理在于: 尽管我们使用小模型来提出候选词,但接受/拒绝的规则保证了在预期情况下,序列的分布与我们逐个从大模型中采样词元时完全相同。这意味着推测性解码在统计学上等价于标准自回归解码——但速度可能快得多,因为一次大型模型迭代最多可以产生k+1个token。

📝笔记:

我建议参考gpt-fast来实现一个简单的模型,也可以参考原始论文去了解数学细节,并了解它和从完整模型中采样等效是如何证明的。

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

一句话概括如下:

  1. n-gram: 取最后prompt_lookup_max个词元;在序列中查找先前的匹配项;如果找到,则提出紧随该匹配项之后的k个token;否则,减小窗口大小重试,直到窗口大小为prompt_lookup_min

目前的实现方式是在首次匹配后才返回k个token。引入一个近因效应并反转搜索方向(即返回上次匹配结果)是否更自然呢?****

  1. Eagle: 对大型语言模型进行“模型手术”——保留embedding和LM head,用一个轻量的多层感知机(MLP)替换transformer堆栈;以此为基础进行微调来作为一个低成本的草稿模型。
  2. Medusa: 在大型模型之上(LM head之前的embedding)训练辅助线性头,以并行预测接下来的k个token;使用这些head比运行单独的小型语言模型可以更高效地提出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中是如何实现的?

设置(在engine构造过程中):

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

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

  1. 使用大模型运行常规预填充步骤。
  2. 经过前向传播和标准采样后,调用propose_draft_token_ids(k)从草稿模型中采样k个草稿token。
  3. request.spec_token_ids中存储这些信息(更新请求的元数据)。
  4. 在下一个引擎步骤中,当请求在运行队列(running queue)中时,将len(request.spec_token_ids)加到“新token”的计数中,以便allocate_slots为前向传播保留足够的 KV 块。
  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,直到满足停止条件为止。

理解这一点的最佳方法是启动调试器并单步执行代码库,但希望本节内容能让你初步了解其原理。此外,还有:

起草阶段

验证阶段和拒绝采样阶段

P/D分离(Disaggregated P/D)

我前面已经提到过P/D(预填充/解码)分离背后的动机。

预填充和解码的性能特点截然不同(前者受限于计算能力,后者受限于内存带宽),因此将它们的执行分开是一种合理的设计。这样可以更精确地控制耗时——包括TFTT(首token耗时)和ITL(token间耗时)——更多内容将在基准测试部分介绍。

在实际应用中,我们运行N个vLLM 预填充实例和M个vLLM 解码实例,并根据实时请求情况来自动扩缩容这两类实例。预填充worker进程将KV写入专用的KV cache服务;解码worker进程则从该服务中读取KV。这样就将长时间、突发性的预填充操作与稳定、对延迟敏感的解码操作隔离开来。

那么在vLLM 中是如何实现的?

为了清晰起见,下面的示例依赖于SharedStorageConnector,这是一个调试版本的连接器实现,只是用来说明该机制。

Connector 是 vLLM 用于处理实例间KV交换的抽象层。Connector 接口目前尚不稳定,近期计划进行一些改进,这些改进将涉及接口修改,其中一些修改可能会造成破坏性影响。

我们启动 2 个 vLLM 实例(GPU 0 用于预填充,GPU 1 用于解码),然后在它们之间传输 KV cache:


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”。
    • 在调度器构造函数中,角色为“调度器”。
  2. 缓存查找: 当调度器处理waiting队列中的预填充请求(在本地前缀缓存检查之后)时,它会调用连接器的get_num_new_matched_tokens函数。此函数会检查KV Cache服务器中是否存在外部缓存的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上传到外部服务器。对于解码操作,此函数不执行任何操作。

以下是一个直观的示例:

P/D分离

📝补充说明:

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

扩展

从UniprocExecutor到MultiProcExecutor

核心技术讲完以后,我们现在可以讨论如何纵向扩展(scale up)了。

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

第一种方法是使用张量并行(tensor parallelism)技术将模型分片到同一节点上的多个GPU上(例如,使用TP=8)。如果模型仍然不够,下一步是使用跨节点的流水线并行(pipeline parallelism)技术。

📝笔记:

  • 节点内带宽远高于节点间带宽,因此张量并行(TP)通常优于流水线并行(PP)。(此外,PP 传输的数据量也比 TP 少。)
  • 我在这里不涉及专家并行(EP),因为我们专注于标准transformer模型架构而不是MoE,也不涉及序列并行(sequence parallelism),因为TP和PP是实践中最常用的。

现阶段,我们需要多个GPU进程(worker进程)和一个编排层来协调它们。而这正是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 pipe)和一个写入管道(writer pipe)。

  4. 新进程运行WorkerProc.worker_main,该进程实例化一个worker进程(经历与之前UniprocExecutor相同的“初始化设备”、“加载模型”等过程)。

  5. 每个工人确定自己是driver(TP 组中的rank为0)还是普通worker。每个worker设置两个队列:

    • rpc_broadcast_mq(与父进程共享)用于接收任务。
    • worker_response_mq用于返回响应。
  6. 初始化期间,每个子进程通过管道向父进程发送自己的worker_response_mq句柄。父进程收到所有句柄后,便解除阻塞——至此协调完成。

  7. worker进程随后进入忙循环(busy loop),阻塞在rpc_broadcast_mq.dequeue函数上。当有工作项到达时,它们会执行该工作项(就像在UniprocExecutor中一样,但现在是针对TP/PP中的特定分区的工作)。结果通过worker_response_mq.enqueue发送回父进程。

  8. 运行过程中,当请求到达时,MultiProcExecutor会将该请求入队到rpc_broadcast_mq(非阻塞式),以便所有子进程感知。然后,它会等待指定的输出进程worker_response_mq.dequeue收集最终结果。

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

  • UniProcExecutor中:直接在worker上调用execute_mode`。
  • MultiProcExecutor中:通过rpc_broadcast_mq在每个worker上间接调用 execute_model。

至此,我们可以使用相同的引擎接口将模型运行在尽可能多的资源上了。

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

服务层

vLLM的分布式系统服务

搭建服务化的基础设施的方法有很多,但具体起见,这里举一个例子:假设我们有两个H100节点,并且想在它们上运行四个vLLM引擎。

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

服务器配置包含 2 个 8xH100 节点

在第一个节点上,使用以下参数以无头(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

将这个命令稍作调整后,在另一个节点上运行:

  • 不加--headless
  • 修改DP起始等级(start 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启动两个进程(每个--data-parallel-size-local)每个进程运行一个函数EngineCoreProc.run_engine_core。这些函数各自创建一个DPEngineCoreProc(引擎核心),然后进入其忙循环。

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

  1. 创建一个input_queueoutput_queuequeue.Queue类型)。
  2. 使用ZMQ socket(异步消息库)与另一节点上的前端使用DEALER执行初始握手,并接收协调地址信息。
  3. 初始化DP组(例如使用NCCL后端)。
  4. 使用MultiProcExecutor(如前所述,在4个GPU上设置TP=4)去初始化EngineCore
  5. 创建一个ready_eventthreading.Event类型)。
  6. 启动一个输入守护线程(threading.Thread)去运行process_input_sockets(…, ready_event)。类似地,也会启动一个输出线程。
  7. 仍在主线程中,ready_event等待所有4个进程(跨越2个节点)中的所有输入线程完成协调握手,最终执行ready_event.set()
  8. 一旦解除阻塞,便向前端发送“ready”消息,其中包含元数据(例如,在分页 KV Cache内存可用的块数量num_gpu_blocks)。
  9. 主线程、输入线程和输出线程随后进入各自的忙循环。

简而言之:最终会生成4个子进程(每个DP副本一个),每个子进程运行一个主线程、一个输入线程和一个输出线程。它们与 DP 协调器和前端完成协调握手后,每个进程的所有三个线程都会运行稳定的忙循环。

具有 4 个 DPEngineCoreProc 的分布式系统

当前稳定状态:

  • 输入线程:阻塞在输入套接字上,直到收到从API服务器路由过来的请求;收到请求后,它会对请求反序列化,然后调用input_queue.put_nowait(...)将工作项加入队列,然后返回给阻塞的那个套接字。
  • 主线程input_queue.get(...)被唤醒,将请求传递给引擎;MultiProcExecutor运行前向传播并将结果入队给output_queue
  • 输出线程output_queue.get(...)被唤醒,将结果发送回API服务器,然后恢复阻塞状态。

其他机制:

  • DP波计数器(DP wave counter):该系统跟踪“波”;当所有引擎都空闲时,它会进入静默状态,当有新工作到达时,计数器会递增(对协调/指标监控很有用)。
  • 控制消息(Control messages):API服务器不仅仅可以发送推理请求(还有,中止请求、功能/控制的RPC)。
  • 锁步虚拟步骤(Dummy steps for lockstep):如果任一DP副本在运行,则所有副本执行接下来的步骤;没有请求的副本执行虚拟步骤以参与系统所需的同步点(避免阻塞活动副本)。

关于同步机制的澄清:实际上,这仅适用于专家层构成EP或TP组而注意力层仍为DP的MoE模型。目前总是使用DP——这是因为“内置”的非MoE DP用途有限,因为你可以运行多个独立的vLLM并在它们之间进行常规的负载均衡。

接下来是第二部分,API服务器节点上会发生什么呢?

在 API 服务器节点上

我们实例化一个AsyncLLM对象(LLM类的asyncio包装器)。在内部,这将创建一个DPLBAsyncMPClient(数据并行、负载均衡、异步、多进程客户端)。

在父类MPClient中,该launch_core_engines函数运行并:

  1. 创建用于启动握手的 ZMQ 地址(如在无头节点上所见)。
  2. 生成一个DPCoordinator进程。
  3. 创建CoreEngineProcManager(与无头节点上相同)。

AsyncMPClientMPClient的子类)内部,我们:

  1. 创建一个outputs_queueasyncio.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_socketoutput_handler)进程处理来自底层引擎的输出消息。
  • 一个任务(run_engine_stats_update_task)进程负责与DP协调器保持通信:发送波触发信号、轮询负载均衡状态以及处理动态扩展请求。

最后,主服务器进程创建一个FastAPI应用,并挂载诸如 OpenAIServingCompletionOpenAIServingChat之类的端点,这些端点会暴露/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服务器上,命中OpenAIServingCompletioncreate_completion函数。

  2. 该函数异步地对prompt进行分词处理,并准备元数据(请求 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_socket)的数据,并将工作项放置到input_queue,这是为了为了主线程感知。

    • 主线程: 解除input_queue的阻塞,将请求添加到引擎,并反复调用engine_core.step(),将中间结果入队到output_queue中,直到满足停止条件。

    提醒:step()调用调度器、模型执行器(这里是MultiProcExecutor!)等等。我们已经见过这种情况了!

    • 输出线程:解除output_queue阻塞并将结果通过输出套接字(output_socket)发送回去。
  7. 这些结果会触发AsyncLLM的用来输出的asyncio任务(process_outputs_socketoutput_handler),这些任务会将token传回 FastAPI的create_completion路由。

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

就这样,你的completion请求回来了——原来整个分布式机制都隐藏在一个简单的curl命令背后!:) 真是太有趣了!!!

📝补充说明:

  • 添加更多 API 服务器时,负载均衡由操作系统/套接字层处理。从应用程序的角度来看,不会发生任何重大变化——复杂性被隐藏起来了。
  • 使用 Ray 作为 DP 后端,您可以暴露一个URL端点(/scale_elastic_ep),从而实现引擎副本数量的自动扩缩容。

基准测试和自动调优

延迟与吞吐量

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

在最高层面上,存在两套相互竞争的衡量标准:

  1. 延迟:从提交请求到返回token所需的时间
  2. 吞吐量:系统每秒可以生成或处理的token数量除以请求数量。

对于交互式应用程序而言,因为用户需要等待响应,因此延迟最为重要。

对于离线任务(例如训练前/训练后运行的合成数据生成、数据清理/处理以及一般的任何类型的离线批量推理作业)而言,吞吐量很重要。

在解释延迟和吞吐量为何相互竞争之前,让我们先定义一些常见的推理指标:

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

TFT、ITL、端到端延迟

以下是一个简化的模型,解释了这两个指标之间的竞争关系。

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

在观察批处理大小B如何影响单个解码步骤时,权衡(tradeoff)就变得清晰起来。当批处理大小B ↓趋近于 1 时,ITL 下降:每一步的工作量减少,并且该token不会与其他token“竞争”。当批处理大小B ↑趋近于无穷大时,ITL 上升,因为每一步执行的 FLOP 操作更多——但吞吐量会提高(直到达到性能峰值),因为权重 I/O 被分摊到更多token上。

屋顶线模型(roofline model)有助于理解这一点:在饱和批次B_sat以下,每一步的时间主要取决于 HBM 带宽(将权重逐层流式传输到片上内存),因此每一步的延迟几乎保持不变——计算1个token和计算10 个token所需的时间相近。超过饱和批次B_sat后,内核成为计算瓶颈,步进时间大致随批次增加B;每个额外的令牌都会增加 ITL。

屋顶线模型

📝笔记:

为了更严谨地处理这个问题,我们必须考虑内核自动调优:随着批处理大小B的增加,运行时可能会切换到更适合该形状的高效内核,从而改变最终的性能P_kernel。每一步的延迟为t = FLOPs_step / P_kernel,其中FLOPs_step是该步的计算量。可以看出,随着P_kernel达到P_peak,每步的计算量增加会直接导致延迟增加。

如何在 vLLM 中进行基准测试

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

以下是脚本的功能:

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

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

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

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

此外,还有一个自动调优脚本,用于驱动服务器基准测试,以找到满足目标SLO的参数设置(例如,“在保持 p99 e2e < 500 ms 时的最大化吞吐量”),并返回建议的配置。

结语

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

vLLM 还包含一些我本文省略的其他特殊能力,例如:

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

好处在于,其中大多数与上面描述的主要流程正交——你几乎可以把它们当作“插件”来对待(当然,在实践中还是有一些耦合的)。

我喜欢研究各种系统。不过话说回来,在如此宏观的角度下,细节确实受到了影响。因此在接下来的文章中,我会聚焦于特定的子系统,并深入探讨其中的细节。

参考资料

  1. vLLM
  2. "Attention Is All You Need"
  3. "Efficient Memory Management for Large Language Model Serving with PagedAttention"
  4. "DeepSeek-V2: A Strong, Economical, and Efficient Mixture-of-Experts Language Model"
  5. "Jenga: Effective Memory Management for Serving LLM with Heterogeneity"
  6. "Orca: A Distributed Serving System for Transformer-Based Generative Models"
  7. "XGrammar: Flexible and Efficient Structured Generation Engine for Large Language Models"
  8. "Accelerating Large Language Model Decoding with Speculative Sampling"
  9. "EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty"
  10. "Medusa: Simple LLM Inference Acceleration Framework with Multiple Decoding Heads"
  11. LMCache