深入vLLM:高吞吐量LLM推理系统剖析(2025)

Hacker News Top 工具

摘要

深入探讨vLLM用于高吞吐量LLM推理的架构与组件,涵盖调度、分页注意力、连续批处理、高级特性、扩展、服务及基准测试。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/08/06 23:05

# vLLM 内部探秘:解剖一个高吞吐量 LLM 推理系统 - Aleksa Gordić 来源:https://www.aleksagordic.com/blog/vllm 在这篇文章中,我将逐步介绍构成现代高吞吐量 LLM 推理系统的所有核心系统组件和高级特性。具体来说,我会对 vLLM 的工作原理进行拆解[\[1\]](https://www.aleksagordic.com/blog/vllm#ref-1)。这篇文章是一个系列的第一篇。它从宏观入手,然后逐层加入细节(采用倒金字塔方式),让你在不被细枝末节淹没的情况下,形成一个准确的高层系统心智模型。后续文章将深入探讨各个子系统。 这篇文章分为五个部分: 1. [LLM 引擎与引擎核心](https://www.aleksagordic.com/blog/vllm#cpt1):vLLM 的基础(调度、分页注意力、连续批处理等) 2. [高级特性](https://www.aleksagordic.com/blog/vllm#cpt2):分块预填充、前缀缓存、引导式解码与推测解码、分离式 P/D 3. [横向扩展](https://www.aleksagordic.com/blog/vllm#cpt3):从单 GPU 到多 GPU 执行 4. [服务层](https://www.aleksagordic.com/blog/vllm#cpt4):分布式/并发 Web 脚手架 5. [基准测试与自动调优](https://www.aleksagordic.com/blog/vllm#cpt5):测量延迟与吞吐量 📝 说明 - 本文分析基于 [commit 42172ad](https://github.com/vllm-project/vllm/tree/42172ad)(2025 年 8 月 9 日)。 - 目标读者:任何对最先进 LLM 引擎如何工作感到好奇的人,以及有兴趣为 vLLM、SGLang 等项目做贡献的人。 - 我将重点介绍 [V1 引擎](https://docs.vllm.ai/en/latest/usage/v1_guide.html)。我也探究过 V0(现已[弃用](https://github.com/vllm-project/vllm/issues/18571)),它对于理解项目如何演进很有价值,而且许多概念仍然适用。 - 第一部分“LLM 引擎与引擎核心”可能略显冗长/枯燥——但博客其余部分有大量示例和可视化内容。:) ## LLM 引擎与引擎核心 LLM 引擎是 vLLM 的基本构建模块。仅凭它本身,就已经能够实现高吞吐量推理——但这仅限于离线场景。你还不能通过 Web 向客户提供服务。我们将使用下面的离线推理片段作为贯穿全文的示例(改编自 [basic.py](https://github.com/vllm-project/vllm/blob/main/examples/offline_inference/basic/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\]](https://www.aleksagordic.com/blog/vllm#ref-2)(支持像 Jamba 这样的混合模型需要更复杂的混合 KV 缓存内存分配器) 从这里开始,我们将逐步构建到一个在线、异步、多 GPU、多节点的推理系统——但仍然服务于标准 Transformer。 在这个示例中我们做两件事: 1. 实例化一个引擎 2. 调用 `generate` 来根据给定提示进行采样 让我们从构造函数开始分析。 ## LLM 引擎构造函数 引擎的主要组件包括: - **vLLM 配置**(包含用于配置模型、缓存、并行度等的所有旋钮) - **处理器**(通过验证、分词和处理,将原始输入转换为 `EngineCoreRequest`) - **引擎核心客户端**(在我们的示例中,使用的是 `InprocClient`,它基本上就等于 `EngineCore`;我们将逐步构建到 `DPLBAsyncMPClient`,它支持大规模服务) - **输出处理器**(将原始 `EngineCoreOutputs` 转换为用户看到的 `RequestOutput`) 📝 注意:随着 V0 引擎被弃用,类名和细节可能会发生变化。我会强调核心思想,而不是精确的函数签名。我会在某种程度上抽象掉这些细节,但不是全部。 引擎核心本身由几个子组件组成: - **模型执行器**(驱动模型的前向传播。我们目前处理的是 `UniProcExecutor`,它在单个 GPU 上有一个 `Worker` 进程。我们将逐步构建到支持多个 GPU 的 `MultiProcExecutor`) - **结构化输出管理器**(用于引导式解码——我们稍后会介绍) - **调度器**(决定哪些请求进入下一个引擎步骤)——它还包含: 1. 策略设置——可以是 **FCFS**(先到先服务)或 **priority**(高优先级请求优先被服务) 2. `waiting` 和 `running` 队列 3. **KV 缓存管理器**——分页注意力[\[3\]](https://www.aleksagordic.com/blog/vllm#ref-3)的核心 KV 缓存管理器维护着一个 `free_block_queue`——一个可用 KV 缓存块的池子(通常有几十万个,具体取决于 VRAM 大小和块大小)。在分页注意力过程中,这些块承担着索引结构的角色,将 token 映射到它们已计算的 KV 缓存块。 LLM 引擎构造函数 本节描述的核心组件及其关系 标准 Transformer 层(非 MLA[\[4\]](https://www.aleksagordic.com/blog/vllm#ref-4))的块大小计算如下: `2(键/值) * block_size(默认=16) * num_kv_heads * head_size * dtype_num_bytes(例如 bf16 为 2)` 在模型执行器构造期间,会创建一个 `Worker` 对象,并执行三个关键流程。(之后使用 `MultiProcExecutor` 时,这些相同流程会在不同 GPU 上的每个 worker 进程中独立运行。) 1. **初始化设备**: - 为 worker 分配一个 CUDA 设备(例如 `"cuda:0"`),并检查模型 dtype 是否受支持(例如 bf16) - 根据请求的 `gpu_memory_utilization`(例如 0.8 → 总 VRAM 的 80%)验证是否有足够的 VRAM - 设置分布式配置(DP / TP / PP / EP 等) - 实例化一个 `model_runner`(持有采样器、KV 缓存,以及诸如 `input_ids`、`positions` 等前向传播缓冲区) - 实例化一个 `InputBatch` 对象(持有 CPU 侧的前向传播缓冲区、用于 KV 缓存索引的块表、采样元数据等) 2. **加载模型**: - 实例化模型架构 - 加载模型权重 - 调用 `model.eval()`(PyTorch 的推理模式) - 可选:对模型调用 `torch.compile()` 3. **初始化 KV 缓存**: - 获取每层的 KV 缓存规格。历史上这总是 `FullAttentionSpec`(同构 Transformer),但随着混合模型(滑动窗口、类似 Jamba 的 Transformer/SSM)的出现,它变得更加复杂(参见 Jenga[\[5\]](https://www.aleksagordic.com/blog/vllm#ref-5)) - 执行一次虚拟/分析前向传播,并拍摄 GPU 内存快照,以计算可用 VRAM 中可以容纳多少个 KV 缓存块 - 分配、重塑并将 KV 缓存张量绑定到注意力层 - 准备注意力元数据(例如将后端设置为 FlashAttention),供前向传播期间的内核使用 - 除非提供了 `--enforce-eager`,否则对每个预热批次大小执行一次虚拟运行并捕获 CUDA 图。CUDA 图会将 GPU 工作的整个序列记录到 DAG 中。之后在前向传播中,我们启动/重放预先烘焙好的图,从而削减内核启动开销并改善延迟 我在这里抽象掉了许多底层细节——但这些是我现在要介绍的核心部分,因为后续章节中会反复引用它们。 现在我们已经初始化了引擎,接下来进入 `generate` 函数。 ## generate 函数 第一步是验证请求并将它们送入引擎。对于每个提示,我们会: 1. 创建唯一的请求 ID,并记录其到达时间 2. 调用输入预处理器,对提示进行分词,并返回一个包含 `prompt`、`prompt_token_ids` 和 `type`(text、tokens、embeds 等)的字典 3. 将此信息打包为 `EngineCoreRequest`,并添加优先级、采样参数和其他元数据 4. 将请求传入引擎核心,引擎核心将其包装为一个 `Request` 对象,并将其状态设置为 `WAITING`。然后该请求被添加到调度器的 `waiting` 队列中(FCFS 则追加,priority 则堆插入) 至此,引擎已经收到输入,可以开始执行了。 在同步引擎示例中,这些初始提示是我们唯一要处理的请求——没有在运行中途注入新请求的机制。相比之下,异步引擎支持这一点(也就是**连续批处理**[\[6\]](https://www.aleksagordic.com/blog/vllm#ref-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` 会出现在输出中,但停止字符串不会) 引擎循环 引擎循环 在流式模式下,我们会随着 token 的生成而发送中间结果,但这里先忽略这一点。 接下来,我们将更详细地考察调度器。 ## 调度器 推理引擎处理的工作负载主要有两种类型: 1. **预填充请求**——对所有提示 token 执行一次前向传播。这类请求通常是**计算受限**的(阈值取决于硬件和提示长度)。最后,我们从最后一个 token 位置的概率分布中采样一个 token。 2. **解码请求**——仅对最近一个 token 执行前向传播。所有更早的 KV 向量都已缓存。这类请求是**内存带宽受限**的,因为即使只计算一个 token,我们仍然需要加载所有 LLM 权重(以及 KV 缓存)。 在[基准测试部分](https://www.aleksagordic.com/blog/vllm#cpt5),我们将分析所谓的 GPU 性能 Roofline 模型,更详细地探讨预填充/解码性能特征背后的原因。 得益于更聪明的设计选择,V1 调度器可以在同一步骤中混合处理两种类型的请求。相比之下,V0 引擎一次只能处理预填充或解码中的一种。 调度器优先处理解码请求——也就是已经在 `running` 队列中的请求。对于每个这类请求,它会: 1. 计算要生成的新 token 数量(并不总是 1,因为推测解码和异步调度——稍后会详细讨论) 2. 调用 KV 缓存管理器的 `allocate_slots` 函数(详见下文) 3. 通过减去步骤 1 中的 token 数量来更新 token 预算 之后,它处理来自 `waiting` 队列的预填充请求: 1. 获取已计算块的数目(如果前缀缓存被禁用则返回 0——稍后会介绍) 2. 调用 KV 缓存管理器的 `allocate_slots` 函数 3. 将请求从 waiting 中弹出并移动到 running,将其状态设为 `RUNNING` 4. 更新 token 预算 现在我们来看 `allocate_slots` 做了什么: 1. **计算块数量**——确定需要分配多少个新的 KV 缓存块(`n`)。默认情况下每个块存储 16 个 token。例如,如果一个预填充请求有 17 个新 token,我们需要 `ceil(17/16) = 2` 个块。 2. **检查可用性**——如果管理器池中没有足够的块,则提前退出。根据是解码还是预填充请求,引擎可能会尝试通过驱逐低优先级请求(调用 `kv_cache_manager.free`,将 KV 块返还给块池)来进行重计算抢占(V0 支持交换抢占),或者可能跳过调度并继续执行。 3. **分配块**——通过 KV 缓存管理器的协调器,从块池(之前提到的 `free_block_queue`

相似文章

vLLM v0.28.0

Hacker News Top

vLLM v0.28.0 是用于快速高效 LLM 推理和服务的开源库的更新版本,具有 PagedAttention 等增强功能和广泛的硬件支持。