2025-10-26 12:03 北京
大量示例,大量代码,大量架构图。
选自Aleksa Gordic博客机器之心编译
编辑:冷猫

💡 **vLLM核心引擎架构**:vLLM的推理引擎包含vLLM配置、输入处理器、引擎核心客户端、输出处理器以及核心引擎。核心引擎进一步细分为模型执行器、结构化输出管理器和调度器。调度器内部则管理着策略设置、等待队列、运行队列以及至关重要的KV缓存管理器(分页注意力),后者通过free_block_queue维护可用KV缓存块池,实现高效的内存分配。
🚀 **分页注意力与连续批处理**:vLLM采用分页注意力机制来管理KV缓存,将缓存块作为索引结构,将token映射到计算得到的KV缓存块,从而高效利用显存。结合连续批处理技术,vLLM能够将多个序列展平成一个“超级序列”进行并行处理,并在不使用右填充的情况下实现高效的吞吐量提升。
✨ **高级推理功能**:文章详细介绍了vLLM支持的多项高级功能,包括:分块预填充(Chunked prefill)优化长提示词处理;前缀缓存(Prefix caching)通过复用已计算的前缀KV缓存来加速多请求的预填充;引导式解码(Guided decoding)利用有限状态机(FSM)约束采样过程,确保输出符合语法规则;推测解码(Speculative decoding)引入草稿模型(如n-gram、Eagle、Medusa)在保持统计一致性的前提下加速生成过程;以及解耦的预填充/解码(Disaggregated P/D)通过分离执行预填充和解码任务,精细化控制延迟。
📈 **扩展性与性能衡量**:vLLM的架构设计支持从单GPU到多GPU、多节点的扩展,能够处理从小型模型到万亿参数的超大模型。文章还探讨了Web端部署、服务化以及通过延迟(TTFT, ITL, e2e, TPOT)和吞吐量等指标进行推理系统性能的测量,并介绍了GPU性能屋顶线模型(roofline model)等分析工具。
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 花了好些时间才达到对代码库的这种理解程度,充分低估了这篇文章的工作量,甚至这些内容很容易就能写成一本书。
博客标题:Inside vLLM: Anatomy of a High-Throughput LLM Inference System
文中涵盖了:
推理引擎流程基础:包括输入 / 输出请求处理、调度(scheduling)、分页注意力(paged attention)、连续批处理(continuous batching)。
「高级」功能:分块预填充(chunked prefill)、前缀缓存(prefix caching)、引导式解码(guided decoding,基于语法约束的有限状态机 FSM)、推测解码(speculative decoding)、解耦的 P/D(prefill/decoding)。
扩展能力:从可以在单 GPU 上托管的小型模型,到参数量超过万亿的超大模型(通过张量并行 TP、流水线并行 PP、序列并行 SP 实现),最终扩展为多 GPU、多节点的部署方案。
Web 端部署与服务:从离线部署,到多个 API 服务器的在线服务;再到负载均衡(load balancing)、数据并行(DP)协调器,以及多引擎(multiple engines)部署架构。
推理系统性能测量:包括延迟(latency,涵盖首 token 时间 TTFT、迭代延迟 ITL、端到端 e2e、吞吐时间 TPOT)、吞吐量(throughput),以及 GPU 性能屋顶线模型(roofline model)。
这篇博客包含了大量的实例以及作者手绘的架构示意图和可视化图像,希望能够对读者们理解推理引擎提供一些有价值的帮助。
以下是博客的详细内容:
在这篇文章中,我会逐步介绍一个现代高吞吐量大语言模型(LLM, Large Language Model)推理系统的核心组件和高级特性。具体来说,我将详细拆解 vLLM 的工作原理。
这篇文章是系列中的第一篇。写作方式采用「倒金字塔结构」:先从宏观层面入手,再逐步深入细节。这样你可以在不被繁琐技术细节淹没的情况下,先建立起对整个系统的清晰整体认知。
LLM 引擎与引擎核心
LLM 引擎是 vLLM 的核心构建模块。单独使用时,它已经能够实现高吞吐量的推理 —— 但仅限于离线场景。此时,你还无法将其直接通过 Web 提供给用户。
接下来,我们将使用下面的 离线推理代码片段(改写自 basic.py)作为示例进行讲解。
from vllm import LLM, SamplingParamsprompts ="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
这个配置的特点是:
离线:没有 Web 或分布式系统的支撑。
同步:所有执行都发生在一个单一的阻塞进程中。
单 GPU:没有数据并行、模型并行、流水线并行或专家并行(DP/TP/PP/EP = 1)。
使用标准 Transformer:如果要支持像 Jamba 这样的混合模型,就需要一个更复杂的 混合 KV-Cache 内存分配器。
从这里开始,我们会逐步扩展,构建一个在线、异步、多 GPU、多节点的推理系统 —— 但仍然基于标准 Transformer。
在这个示例中,我们主要做两件事:
1. 实例化一个引擎(instantiate an engine)。
2. 调用它的 generate 方法,从给定的提示词(prompt)中进行采样。
接下来,让我们从 构造函数(constructor) 的分析开始。
LLM 引擎构造函数
引擎的主要组成部分包括:
vLLM 配置(vLLM config):包含模型、缓存、并行等所有可调参数。
处理器(processor):将原始输入转化为 EngineCoreRequests,过程包括校验、分词(tokenization)和预处理。
引擎核心客户端(engine core client):在我们的示例中使用的是 InprocClient,它基本上等同于 EngineCore;后面我们会逐步扩展到 DPLBAsyncMPClient,它能支持大规模推理服务。
输出处理器(output processor):将原始 EngineCoreOutputs 转化为用户最终可见的 RequestOutput。
引擎核心(engine core) 本身由多个子组件构成:
模型执行器(Model Executor):负责驱动模型的前向计算。当前我们使用的是 UniProcExecutor,它只在单 GPU 上运行一个工作进程(Worker)。后面我们会逐步扩展到 MultiProcExecutor,以支持多 GPU。
结构化输出管理器(Structured Output Manager):用于 引导式解码(guided decoding),后面章节会详细讲解。
调度器(Scheduler):决定哪些请求进入下一步的引擎执行。它内部进一步包含:
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)
分配一个 CUDA 设备(例如 "cuda:0")给 Worker,并检查模型的数据类型(dtype)是否受支持(例如 bf16)。
根据设定的 gpu_memory_utilization(例如 0.8 → 使用 80% 的显存),验证是否有足够的显存可用。
设置分布式参数(数据并行 DP、张量并行 TP、流水线并行 PP、专家并行 EP 等)。
实例化一个 model_runner(包含采样器、KV 缓存、以及前向计算所需的 buffer,如 input_ids、positions 等)。
实例化一个 InputBatch 对象(包含 CPU 侧的前向计算 buffer、用于 KV 缓存索引的块表 block tables、采样元数据等)。
2. 加载模型(Load model)
实例化模型结构。
加载模型权重。
调用 model.eval ()(PyTorch 推理模式)。
(可选)对模型调用 torch.compile ()。
3. 初始化 KV 缓存(Initialize KV cache)
获取每一层的 KV 缓存规格。历史上通常是 FullAttentionSpec(同质 Transformer),但在混合模型(如滑动窗口、Transformer/SSM 混合模型 Jamba)中会更复杂(参考 Jenga [5])。
运行一次虚拟 / 性能分析前向计算,并截取显存快照,用于计算可容纳的 KV 缓存块数量。
分配、重塑并将 KV 缓存张量绑定到注意力层。
准备注意力元数据(例如指定后端为 FlashAttention),在前向计算时由内核调用。
如果没有传入 --enforce-eager,则会对每个预热(warmup)批大小运行一次虚拟推理,并捕获 CUDA 图(CUDA graphs)。CUDA 图会记录整个 GPU 工作流程形成一个有向无环图(DAG)。在后续的前向计算中,系统会直接复用这些「预编译」的图,避免重复的内核启动开销,从而降低延迟。
这里我省略了一些底层细节,但以上就是需要重点掌握的核心部分。后续章节会多次引用这些概念。
既然引擎已经初始化完成,接下来我们就进入 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。此时请求被加入到调度器的 等待队列:
若为 FCFS(先来先服务) 策略,则采用追加(append);
若为 优先级调度,则采用堆插入(heap-push)。
到这里,引擎已经接收请求,执行流程可以开始。
在同步引擎示例中,这些初始提示是唯一会被处理的请求 —— 中途无法再注入新的请求。
而异步引擎则支持这种操作(即 连续批处理):每一步之后,都会同时考虑新请求与旧请求。
由于前向传播(forward pass)会将批次展平成一个单一序列,并且自定义内核可以高效处理,所以即便是同步引擎,连续批处理 也是在底层得到支持的。
之后,只要仍有请求需要处理,引擎就会反复调用 step () 函数。
每个 step 包含三个阶段:
1. 调度(Schedule):选择本步骤要执行的请求(可能是解码 decode,或 / 和分块预填充 chunked prefill)。
2. 前向传播(Forward pass):运行模型并采样新 token。
3. 后处理(Postprocess):
将采样得到的 token IDs 附加到对应的请求上;
进行去分词(detokenize);
检查停止条件(stop conditions)。
如果请求完成,执行清理操作(例如将其 KV 缓存块归还到 free_block_queue),并提前返回输出。
停止条件(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. 更新状态
从 input_batch 中剪枝已完成的请求。
更新与前向传播相关的元数据(例如:每个请求对应的 KV-cache 块数量,用于索引到分块的 KV-cache 内存)。
2. 准备输入
将输入缓冲区从 CPU 拷贝到 GPU。
计算位置索引。
构建 slot_mapping(后续示例会讲)。
构造注意力元数据(attention metadata)。
3. 执行前向传播
使用自定义 paged attention 内核运行模型。
所有序列会被 展平并拼接成一个长的 「超级序列」 (super sequence)。
通过位置索引和注意力 mask 确保每个序列 只关注自己的 token,这样就能在 不使用右填充 (right-padding) 的情况下实现 连续批处理 (continuous batching)。
4. 收集最后一个 token 的状态
提取每个序列在其 最后位置 的隐藏状态 (hidden states)。
计算 logits。
5. 采样 (sampling)
根据采样配置(greedy、temperature、top-p、top-k 等)从 logits 中采样出下一个 token。
前向传播的两种执行模式
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, SamplingParamslong_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. 计算哈希:
对每个完整的块计算一个哈希值(可使用内置哈希,或者更慢但碰撞率更低的 SHA-256)。
这个哈希由以下部分组合而成:前一个块的哈希值、当前块的 token 以及可选元数据。可选元数据包括:多模态哈希(MM hash)、LoRA ID、缓存盐值(cache salt,注入到第一个块的哈希中,用来确保只有带相同 cache salt 的请求才能复用该块)。
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:
这个函数会把新的 BlockHash 条目 与分配到的 KV 块(KV blocks)关联起来。
同时,它会将这些映射记录在 cached_block_hash_to_block 中。
随后,在前向传播(forward pass)阶段,系统会在分页 KV 缓存(paged KV cache) 中填充对应于刚分配的 KV 块的 K/V 值。
经过多次引擎步骤之后,系统可能会分配更多的 KV 缓存块,但这对我们当前的例子没有影响,因为在 long_prefix 之后,提示词立即发生了分支(diverge),不再复用之前的前缀块。
在第二次调用 generate 时,如果使用相同的前缀(prefix):
步骤 1 到 3 会重复执行。
但是此时,find_longest_cache_hit 会在 n 个块 中找到全部匹配(通过线性搜索)。
引擎可以直接复用这些 KV 块,无需重新计算前缀部分的 token。
如果原始请求仍然存在,这些 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 才能被采样。
这个机制非常强大:
可以强制遵循 正规文法(Chomsky Type-3,例如任意正则表达式模式)。
也可以支持 上下文无关文法(Context-Free Grammar, Type-2,覆盖大多数编程语言)。
为了让概念更直观,我们从最简单的例子开始,基于之前的代码示例进行说明:
from vllm import LLM, SamplingParamsfrom vllm.sampling_params import GuidedDecodingParamsprompts = ["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):
如果异步编译完成,状态切换为 WAITING,并将 request_id 添加到 structured_output_request_ids;
如果尚未完成,则将请求放入 skipped_waiting_requests,在下一次引擎步骤重试。
5. 调度循环结束后(仍在调度阶段):如果存在 FSM 请求,StructuredOutputManager 会调用后端准备或更新 _grammar_bitmask。
6. 前向传播(forward pass)生成 logits 后:
xgr_torch_compile 的函数会将 _grammar_bitmask 扩展到词表大小(使用 32 位整数时,扩展比例为 32 倍)。
不允许的 token 的 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)来加速这一过程:
草稿模型负责快速生成 k 个候选 token。
但我们并不最终从小模型中采样 token —— 它只是用来猜测可能的续写。
大模型仍然决定哪些 token 是有效的。
具体步骤:
1. Draft(草稿):在当前上下文下运行小模型,提出 k 个 token 候选。
2. Verify(验证):使用大模型在 上下文 + k 个草稿 token 上运行一次。这会生成这 k 个位置的概率分布,再加一个额外位置(总共 k+1 个候选)。
3. Accept/Reject(接受 / 拒绝):从左到右检查 k 个草稿 token:
如果大模型给该 token 的概率 ≥ 草稿模型概率,则接受;
否则以概率 p_large (token) /p_draft (token) 接受;
遇到第一个拒绝就停止,或者接受全部 k 个草稿 token。如果全部 k 个 token 都被接受,还可以「免费」采样第 k+1 个 token(因为我们已经计算过大模型分布)。如果有拒绝,则在该位置重新生成一个再平衡的概率分布(p_large - p_draft,最小值限制为 0,归一化到和为 1),并从中采样最后一个 token。
为什么可行?
虽然我们使用小模型来提出候选 token,但接受/拒绝规则保证了序列的期望分布与逐 token 从大模型采样完全一致。
也就是说,推测解码在统计上等价于标准的自回归解码。但它潜在更快,因为一次大模型前向传播可以生成 最多 k+1 个 token。
注意,可以参考 gpt-fast 获取简单实现,原始论文提供了数学细节和等价性证明。vLLM V1 不支持 LLM 草稿模型方法,而是实现了更快但精度略低的候选方案:n-gram、EAGLE、Medusa。
各方法简述:
1. n-gram:
取最后 prompt_lookup_max 个 token,在序列中寻找先前匹配;
若找到,则提出该匹配后面的 k 个 token;
否则缩小窗口并重复,直到 prompt_lookup_min。
当前实现返回第一个匹配后的 k 个 token,可以考虑引入近期偏置(recency bias),反向搜索更自然(即寻找最后一次匹配)。
2. Eagle:
对大模型进行「模型手术」 —— 保留 embeddings 和 LM head,将 transformer 堆栈替换为轻量 MLP;
微调该 MLP 作为廉价草稿模型。
3. Medusa:
在大模型的 embeddings(LM head 前)上训练辅助线性头(linear heads),并行预测接下来的 k 个 token;
使用这些线性头比单独运行小模型更高效地提出 token。
下面是如何在 vLLM 中使用 n-gram 方法启用推测解码的示例:
from vllm import LLM, SamplingParamsprompts = ["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 osimport timefrom multiprocessing import Event, Processimport multiprocessing as mpfrom vllm import LLM, SamplingParamsfrom vllm.config import KVTransferConfigprompts = ["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 loopoutputs = 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 的显存中:
第一个选项是使用 张量并行(Tensor Parallelism, TP),将模型分片到同一节点的多张 GPU 上(例如 TP=8)。
如果模型仍然无法容纳,则下一步是在跨节点使用 流水线并行(Pipeline Parallelism, PP)。
说明:
节点内带宽(intranode bandwidth)显著高于节点间带宽(internode bandwidth),这也是为什么通常更偏好 张量并行(TP) 而非流水线并行(PP)。(同时,PP 通信的数据量通常比 TP 少。)
我们不讨论 Expert Parallelism(EP),因为这里关注的是标准 Transformer 而非 MoE 模型,也不讨论序列并行(Sequence Parallelism),TP 和 PP 在实际中是最常用的。
在这个阶段,我们需要多个 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 被抽象掉:
UniProcExecutor 情况:execute_model 直接调用 worker 的 execute_model。
MultiProcExecutor 情况:execute_model 间接通过 rpc_broadcast_mq 调用每个 worker 的 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
在另一台节点上运行同样的命令,做如下调整:
去掉 --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 中的工作原理:
在 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):
输入线程(Input thread) — 阻塞在输入套接字上,直到 API 服务器路由一个请求过来;收到请求后,它会解码负载,通过 input_queue.put_nowait (...) 将工作项入队,然后回到套接字阻塞状态。
主线程(Main thread) — 当 input_queue.get (...) 被唤醒时,将请求传入引擎;MultiProcExecutor 执行前向推理,并将结果入队到 output_queue。
输出线程(Output thread) — 当 output_queue.get (...) 被唤醒时,将结果返回给 API 服务器,然后恢复阻塞。
附加机制:
DP 波计数器(DP wave counter) — 系统跟踪「波次」;当所有引擎空闲时,它们进入静止状态,当新工作到达时计数器递增(有助于协调与指标统计)。
控制消息(Control messages) — API 服务器不仅可以发送推理请求,还可发送中止请求或其他控制 RPC。
锁步虚拟步骤(Dummy steps for lockstep) — 如果某个 DP 副本有工作,所有副本都会执行前向步骤;没有请求的副本执行虚拟步骤以参与必要的同步点(避免阻塞活跃副本)。
锁步说明(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 服务器)与后端(引擎核心)之间进行中介:
定期向前端的 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 应用,并挂载诸如 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. 在该引擎中:
Input 线程 — 解除阻塞,从输入套接字解码数据,并将工作项放入主线程的 input_queue。
Main 线程 — 在 input_queue 上解除阻塞,将请求添加到引擎中,并反复调用 engine_core.step (),将中间结果加入 output_queue,直到满足停止条件。
提醒:step () 会调用调度器(scheduler)、模型执行器(model executor,可为 MultiProcExecutor)等,我们之前已经讲过了。
Output 线程 — 在 output_queue 上解除阻塞,将结果通过输出套接字发送回去。
7. 这些结果触发 AsyncLLM 的输出 asyncio 任务(process_outputs_socket 和 output_handler),将生成的 token 传回 FastAPI 的 create_completion 路由。
8. FastAPI 附加元数据(完成原因、logprobs、使用信息等),通过 Uvicorn 返回 JSONResponse 到你的终端。
就这样,你的文本生成结果返回了 —— 整个复杂的分布式系统都被一个简单的 curl 命令隐藏了起来!😄
附加说明:
添加更多 API 服务器时,负载均衡在 OS / 套接字层处理。应用层感知不到复杂性,仍然是一条请求 - 响应流程。
使用 Ray 作为 DP 后端时,可以暴露 /scale_elastic_ep URL 接口,实现引擎副本数量的自动上下扩缩。
基准测试与自动调优 — 延迟 vs 吞吐量
到目前为止,我们一直在分析「气体粒子」—— 请求在引擎 / 系统内部的流动方式。现在,我们将视角拉远,看看整个系统,并思考:如何衡量推理系统的性能?
在最高层面上,有两个互相制约的指标:
1. 延迟(Latency) — 从请求提交到返回 token 的时间。
2. 吞吐量(Throughput) — 系统每秒能够生成或处理的 token/请求数量。
延迟对交互式应用最为重要,因为用户在等待响应。
吞吐量对离线工作负载更为关键,例如用于训练前 / 训练后的合成数据生成、数据清洗 / 处理,以及任何离线批量推理任务。
在解释为什么延迟和吞吐量存在竞争关系之前,我们先定义一些常见的推理性能指标:
下面是一个简化模型,用来解释延迟(Latency)和吞吐量(Throughput)之间的竞争关系。
假设:主导因素是权重(weight)I/O,而非 KV 缓存 I/O,也就是说我们处理的是短序列。
当观察批大小 B 对单次 decode 步骤的影响时,这种权衡就很明显了:
当 B↓趋近 1 时,每个 token 的间隔延迟(ITL, inter-token latency)下降:每步处理的工作量更少,token 之间不会互相「竞争」。
当 B ↑趋近无穷时,ITL 上升,因为每步要做更多 FLOPs,但吞吐量提高(直到达到峰值性能),原因是权重 I/O 被更多 token 分摊。
这里可以用 roofline 模型来帮助理解:
在饱和批大小 B_sat 以下,步骤时间主要受 HBM 带宽限制(权重逐层流入片上内存),因此步骤延迟几乎保持不变 —— 计算 1 个 token 和 10 个 token 所需时间相近。
超过 B_sat 后,kernel 开始受计算能力限制,步骤时间大约随 B 增长,每增加一个 token 都会增加 ITL。
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 — 使用短输入(默认 32 tokens)并生成 128 个输出 token,使用小批量(默认 8)。运行多次迭代,并报告整个 batch 的端到端延迟。
throughput — 一次性提交固定的 prompt 集(默认 1000 个 ShareGPT 样本,等价于 QPS=∞ 模式),并报告输入 / 输出 / 总 token 数以及每秒请求数。
serve — 启动 vLLM 服务器,模拟真实工作负载:请求间隔时间从 Poisson 或 Gamma 分布采样。它会在时间窗口内发送请求,测量前面讨论的所有指标,并可选择在服务器端设置最大并发量(例如通过 semaphore 限制为 64 个并发请求)。
下面是如何运行 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 还包含一些本文未详述的专门处理,例如:
自定义硬件后端:TPU、AWS Neuron(Trainium/Inferentia)等
架构 / 技术:MLA、MoE、编码器 - 解码器(例如 Whisper)、池化 / 嵌入模型、EPLB、m-RoPE、LoRA、ALiBi、无注意力变体、滑动窗口注意力、多模态大语言模型,以及状态空间模型(例如 Mamba/Mamba-2、Jamba)
TP/PP/SP
混合 KV-cache 逻辑(Jenga)、更复杂的采样方法如 beam sampling 等
实验性功能:异步调度
好处是,这些大部分与上文描述的主流程是正交的 —— 你几乎可以把它们当作「插件」来使用(当然实际上仍有一些耦合)。
我非常喜欢理解系统。话虽如此,在这个高度上讲解的细节肯定有所损失。接下来的文章里,我会聚焦具体子系统,深入探讨细节。
参考文献:
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
AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。
鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑