| 编辑推荐: |
在这篇文章中,会逐步介绍构成现代高吞吐 LLM 推理系统的所有核心系统组件和高级特性;会详细拆解 vLLM [1] 的工作原理, 希望对你的学习有帮助。
本文来自于NLP轻松谈,由火龙果软件Alice编辑推荐。 |
|
本文分为五个部分:
- LLM Engine 与 Engine Core:vLLM 的基础机制(调度、Paged Attention、Continuous Batching 等)
- 高级特性:Chunked Prefill、Prefix Caching、Guided Decoding、Speculative Decoding、Prefill/Decode 解耦
- 纵向扩展:从单 GPU 到多 GPU 执行
- Serving 层:分布式 / 并发 Web 服务框架
- Benchmark 与自动调优:如何衡量延迟与吞吐
**📝说明** - 本文分析基于 [commit 42172ad](https://github.com/vllm-project/vllm/tree/42172ad)(2025 年 8 月 9 日)。 - 目标读者:对最先进 LLM 推理引擎如何工作感兴趣的人,以及有兴趣参与 vLLM、SGLang 等项目贡献的人。 - 本文将聚焦 [V1 Engine](https://docs.vllm.ai/en/latest/usage/v1_guide.html)。我也研究过 V0([现已弃用]
(https://github.com/vllm-project/vllm/issues/18571)),这对于理解项目的演进非常有价值,而且许多概念在 V1 中依然沿用。 - 第一部分 LLM Engine / Engine Core 可能会稍显密集、枯燥,但后面的文章有大量示例和图示。:)
|
LLM Engine 与 Engine Core
LLM Engine 是 vLLM 最基础的构建模块。单独使用它,就已经能够实现高吞吐推理——但仅限于离线场景。此时你还无法通过 Web 把它作为服务提供给用户。
我们会使用下面这段离线推理代码作为贯穿全文的示例(basic.py)。
from vllm import LLM, SamplingParams
prompts = [ "Hello, my name is", "The president of the United States is", ]
sampling_params = SamplingParams(temperature=0.8, top_p=0.95)
def main(): llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0")
outputs = llm.generate(prompts, sampling_params)
if __name__ == "__main__": main()
|
📝环境变量:
- VLLM_USE_V1="1" # 使用 V1 Engine
- VLLM_ENABLE_V1_MULTIPROCESSING="0" # 单进程运行
这套配置具有以下特点:
- 离线(没有 Web / 分布式系统服务框架)
- 同步(所有执行都发生在一个阻塞进程中)
- 单 GPU(没有 Data / Model / Pipeline / Expert Parallelism;DP/TP/PP/EP = 1)
- 使用标准 Transformer (如果要支持 Jamba 这类混合模型,则需要更复杂的 Hybrid KV Cache Memory Allocator)
从这里开始,我们会逐步构建到一个在线、异步、多 GPU、多节点的推理系统——但仍然先以标准 Transformer 为例。
在这个示例中,我们做了两件事:
- 实例化一个 Engine
- 对给定的 Prompt 调用 generate 进行采样
先从构造函数开始分析。
LLM Engine 构造函数
Engine 的主要组件包括:
- vLLM Config(包含模型、Cache、并行策略等所有配置项)
- Processor(通过校验、Tokenization 和预处理,将原始输入转换为 EngineCoreRequests )
- Engine Core Client(在当前示例中使用 InprocClient ,它基本等同于 EngineCore ;后面我们会逐步演进到可支持大规模 Serving 的 DPLBAsyncMPClient )
- Output Processor(将原始 EngineCoreOutputs 转换成用户最终看到的 RequestOutput )
📝说明:
随着 V0 Engine 被弃用,类名和具体实现细节可能发生变化。本文会重点强调核心思想,而不是精确的函数签名。抽象掉一部分细节,但不会全部略去。
Engine Core 本身又由多个子组件组成:
- Model Executor(负责驱动模型执行 Forward Pass;当前我们使用的是 UniProcExecutor ,其中只有一个 Worker 进程运行在单张 GPU 上。后面会演进到支持多 GPU 的 MultiProcExecutor )
- Structured Output Manager(用于 Guided Decoding,后面会讲)
- Scheduler(决定下一次 Engine Step 中运行哪些请求),它进一步包含:
- Policy 设置——可以是 FCFS (First Come First Served,先来先服务)或 Priority (高优先级请求先服务)
- waiting 与 running 队列
- KV Cache Manager——Paged Attention 的核心
KV Cache Manager 会维护一个 free_block_queue ——也就是可用 KV Cache Block 的池。根据 VRAM 容量和 Block Size,这个池通常可能包含数十万甚至更多 Block。在 Paged Attention 中,这些 Block 构成索引结构,把 Token 映射到其已经计算好的 KV Cache Block。
对于标准 Transformer Layer(非 MLA (https://www.aleksagordic.com/blog/vllm#ref-4)),一个 Block 的大小计算如下: 2(Key/Value)* `block_size`(默认=16)* `num_kv_heads` * `head_size` * `dtype_num_bytes`(例如 bf16 为 2)
|
在 Model Executor 构造过程中,会创建一个 Worker 对象,并执行三个关键过程。(后面使用 MultiProcExecutor 时,同样的过程会在不同 GPU 对应的每个 Worker 进程中独立执行。)
- 初始化设备:
- 给 Worker 分配 CUDA Device(例如 cuda:0 ),并检查模型 dtype 是否受支持(例如 bf16)
- 根据请求的 gpu_memory_utilization (例如 0.8 → 总 VRAM 的 80%)检查是否有足够显存
- 配置分布式设置(DP / TP / PP / EP 等)
- 实例化 model_runner (持有 Sampler、KV Cache,以及 input_ids 、 positions 等 Forward Pass Buffer)
- 实例化 InputBatch 对象(持有 CPU 侧 Forward Pass Buffer、用于 KV Cache 索引的 Block Table、Sampling Metadata 等)
- 加载模型:
- 实例化模型架构
- 加载模型权重
- 调用 model.eval() (PyTorch 推理模式)
- 可选:对模型调用 torch.compile()
- 初始化 KV Cache:
- 获取每一层的 KV Cache Spec。历史上这里一直是 FullAttentionSpec (同构 Transformer),但随着混合模型出现(Sliding Window、Transformer/SSM 混合模型如 Jamba),情况变得更复杂(参见 Jeng(https://www.aleksagordic.com/blog/vllm #ref -5))
- 运行 Dummy / Profiling Forward Pass,并获取 GPU Memory Snapshot,用来计算可用 VRAM 中能放多少个 KV Cache Block
- 分配、Reshape KV Cache Tensor,并绑定到 Attention Layer
- 准备 Attention Metadata(例如把 Backend 设置为 FlashAttention),供后续 Forward Pass 中的 Kernel 使用
- 除非指定 --enforce-eager ,否则会针对每个 Warmup Batch Size 执行一次 Dummy Run 并 Capture CUDA Graph。CUDA Graph 会把整条 GPU 工作序列记录成一个 DAG。之后 Forward Pass 中可以直接 Launch / Replay 预先录制好的 Graph,从而减少 Kernel Launch 开销并降低延迟。
这里我省略了大量底层细节,但这些是接下来反复会用到的核心组件,因此先在这里介绍。
现在 Engine 已经初始化完成,我们继续看 generate 函数。
Generate 函数
第一步,是校验请求并把请求送入 Engine。对于每个 Prompt,我们会:
- 创建唯一 Request ID,并记录到达时间
- 调用 Input Preprocessor 对 Prompt 进行 Tokenize,返回包含 prompt 、 prompt_token_ids 以及 type (text、tokens、embeds 等)的 Dictionary
- 将这些信息打包到 EngineCoreRequest 中,同时加入 Priority、Sampling Params 和其他 Metadata
- 把 Request 传入 Engine Core,由其包装成 Request 对象,并将状态设置为 WAITING 。随后,该请求会进入 Scheduler 的 waiting 队列(如果是 FCFS 就 append;如果是 Priority 则 heap-push)
此时请求已经送入 Engine,执行可以开始。在同步 Engine 的示例中,最开始送入的这些 Prompt 就是本轮要处理的全部请求——运行过程中没有机制动态插入新请求。相比之下,异步 Engine 支持这一点,也就是 Continuous Batching (https://www.aleksagordic.com/blog/vllm #ref -6):每一步结束后,新请求和旧请求都会重新参与调度。
由于 Forward Pass 会把 Batch 展平为一条连续序列,再由自定义 Kernel 高效处理,因此即使在同步 Engine 中,底层也天然支持 Continuous Batching。
接下来,只要还有请求需要处理,Engine 就会不断调用 step() 。每个 Step 包含三个阶段:
- Schedule:选择本 Step 中运行哪些请求(Decode 和/或 Chunked Prefill)
- Forward Pass:执行模型并采样 Token
- Postprocess:把采样得到的 Token ID 追加到各个 Request ,Detokenize,并检查停止条件。如果某个请求完成,则进行清理(例如把它的 KV Cache Block 归还给 free_block_queue ),并尽早返回输出
📝停止条件包括:
- 请求超过长度限制( max_model_length 或请求自身的 max_tokens )
- 采样 Token 是 EOS ID(除非启用 ignore_eos → 在 Benchmark 中很有用,可以强制生成固定数量的输出 Token)
- 采样 Token 命中 Sampling Parameters 中指定的任意 stop_token_ids
- 输出中出现 Stop String——输出会截断到第一个 Stop String 之前,并在 Engine 中 Abort 该请求(注意: stop_token_ids 会出现在输出中,但 Stop String 本身不会出现在最终输出中)
在 Streaming 模式下,我们会随着 Token 生成逐步发送中间结果,不过这里先忽略。
|
Scheduler
推理引擎主要需要处理两类工作负载:
- Prefill 请求——对整个 Prompt 的所有 Token 执行 Forward Pass。它们通常是 计算受限(Compute-Bound) 的(具体阈值与硬件和 Prompt Length 有关)。Prefill 结束时,我们会从最后一个 Token 位置对应的概率分布中采样一个 Token。
- Decode 请求——只对最近的一个 Token 执行 Forward Pass。此前所有 KV Vector 都已经缓存。它们通常是 内存带宽受限(Memory-Bandwidth-Bound) 的,因为即使只计算一个 Token,我们依旧需要加载全部 LLM 权重(以及 KV Cache)。
在 [Benchmark 章节](https://www.aleksagordic.com/blog/vllm#cpt5) 中,
我们会分析所谓 GPU 性能 Roofline Model,更深入解释 Prefill / Decode 各自的性能特征。
|
得益于更聪明的设计,V1 Scheduler 可以在同一个 Step 中混合处理这两种请求。相比之下,V0 Engine 每一步只能处理 Prefill 或 Decode 其中一种。
Scheduler 会优先处理 Decode 请求,也就是已经位于 running 队列中的请求。对于每个此类请求,它会:
- 计算本 Step 需要生成的新 Token 数量(由于 Speculative Decoding 和 Async Scheduling,数量不一定总是 1,后面会进一步解释)
- 调用 KV Cache Manager 的 allocate_slots 函数(后面详讲)
- 从 Token Budget 中减去第 1 步的 Token 数量
之后,它会处理 waiting 队列中的 Prefill 请求:
- 获取已经计算好的 Block 数量(如果禁用 Prefix Caching,则返回 0——后面会讲)
- 调用 KV Cache Manager 的 allocate_slots 函数
- 从 waiting 中 Pop 该请求,并移入 running ,同时将状态设置为 RUNNING
- 更新 Token Budget
下面看 allocate_slots 具体做什么:
- 计算 Block 数量 ——确定需要新分配多少个 KV Cache Block( n )。默认情况下,每个 Block 存储 16 个 Token。例如,如果一个 Prefill Request 新增 17 个 Token,则需要 ceil(17/16) = 2 个 Block。
- 检查可用性 ——如果 Manager 的 Block Pool 中没有足够 Block,则提前返回。根据请求是 Decode 还是 Prefill,Engine 可能尝试 Recompute Preemption(V0 支持 Swap Preemption):通过驱逐低优先级请求,调用 kv_cache_manager.free 把这些请求占用的 KV Block 归还给 Block Pool;也可能跳过此次调度并继续执行。
- 分配 Block ——通过 KV Cache Manager 的 Coordinator,从 Block Pool 中取出前 n 个 Block(也就是前文提到的 free_block_queue 双向链表),并写入 req_to_blocks ,这个 Dictionary 会将每个 request_id 映射到对应的 KV Cache Block List。
执行 Forward Pass
我们调用 Model Executor 的 execute_model ,它把执行委托给 Worker ,而 Worker 再委托给 Model Runner。
主要步骤如下:
- 更新状态 ——从 input_batch 中移除已经结束的请求;更新与 Forward Pass 相关的其他 Metadata(例如每个 Request 对应的 KV Cache Block,稍后用于索引 Paged KV Cache Memory)
- 准备输入 ——将 Buffer 从 CPU→GPU;计算 Position;构造 slot_mapping (示例中会继续讲);构建 Attention Metadata
- Forward Pass ——使用自定义 Paged Attention Kernel 执行模型。所有 Sequence 会被 Flatten 并拼接成一条长“Super Sequence”。Position Index 和 Attention Mask 会确保每个 Sequence 只关注自己的 Token,因此无需 Right Padding 就可以支持 Continuous Batching。
- 收集最后 Token 的状态 ——提取每个 Sequence 最后一个位置的 Hidden State,并计算 Logits
- 采样 ——根据 Sampling Config(Greedy、Temperature、Top-p、Top-k 等)从计算得到的 Logits 中采样 Token
Forward Pass 本身有两种执行模式:
- Eager Mode ——启用 Eager Execution 时,执行标准 PyTorch Forward Pass
- Captured Mode ——如果没有强制 Eager,则执行 / Replay 预先 Capture 的 CUDA Graph(还记得我们在 Engine 构造阶段初始化 KV Cache 时 Capture 过这些 Graph)
下面给出一个具体示例,帮助理解 Continuous Batching 和 Paged Attention:
Forward pass: continuous batching and paged attention
高级特性——扩展核心 Engine 逻辑
基础 Engine Flow 已经建立,接下来可以看高级特性。
前面我们已经讨论了 Preemption、Paged Attention 和 Continuous Batching。
接下来会深入:
- Chunked Prefill
- Prefix Caching
- Guided Decoding(通过受 Grammar 约束的有限状态机)
- Speculative Decoding
- Disaggregated P/D(Prefill / Decode)
Chunked Prefill
Chunked Prefill 是一种处理长 Prompt 的技术:把一次完整的 Prefill 拆成多个较小 Chunk。否则,一个非常长的请求可能会独占整个 Engine Step,使其他 Prefill Request 无法执行,最终推迟所有其他请求并增加它们的延迟。
例如,假设每个 Chunk 包含 n (=8)个 Token,并用小写字母加 - 表示。一个长 Prompt P 可以表示成 x-y-z ,其中 z 是一个不完整 Chunk(例如只有 2 个 Token)。对 P 执行完整 Prefill 至少需要 3 个 Engine Step(甚至可能更多,因为某个 Step 中它可能没有被调度),而且只有在最后一个 Chunked Prefill Step 中,我们才会采样一个新 Token。
下面用图示表示同一个例子:
实现方式很简单:限制每个 Step 中新处理的 Token 数。如果请求的 Token 数超过 long_prefill_token_threshold ,就把它截断到该阈值。底层索引逻辑(前面已经介绍)会自动处理剩余部分。
在 vLLM V1 中,可以通过把 long_prefill_token_threshold 设置为正整数来启用 Chunked Prefill。(严格来说,即使没有显式开启,如果 Prompt Length 超过 Token Budget,也会被截断并以 Chunked Prefill 的方式执行。)
Prefix Caching
为了说明 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()
|
Prefix Caching 的作用,是避免重复计算多个 Prompt 在开头共享的 Token——因此叫做 Prefix 。
关键部分是 long_prefix :这里我们把它定义为长度超过一个 KV Cache Block(默认 16 Token)的任意 Prefix。为了简化示例,假设 long_prefix 的长度刚好等于 n x block_size (其中 n ≥ 1 )。
也就是说,它刚好与 Block Boundary 对齐——否则,由于无法缓存不完整 Block,就必须重新计算 long_prefix_len % block_size 个 Token。
如果没有 Prefix Caching,每当处理一个拥有相同 long_prefix 的新请求时,都需要重新计算全部 n x block_size 个 Token。
有了 Prefix Caching,这些 Token 只需要计算一次(对应 KV 会保存到 Paged KV Cache Memory 中),之后即可复用,因此只需要处理新 Prompt 的 Token。它能够加速 Prefill Request(但对 Decode 没帮助)。
它在 vLLM 中是如何实现的?
第一次调用 generate 时,在 Scheduling 阶段、 kv_cache_manager.get_computed_blocks 内部,Engine 会调用 hash_request_tokens :
- 该函数把 long_prefix + prompts[0] 按每 16 个 Token 切成 Chunk。
- 对每个完整 Chunk 计算 Hash(可以使用内置 Hash,也可以用 SHA-256;SHA-256 更慢,但 Collision 更少)。Hash 会组合前一个 Block 的 Hash、当前 Token,以及可选 Metadata。
- 可选 Metadata 包括:MM Hash、LoRA ID、Cache Salt(注入第一个 Block 的 Hash,确保只有使用相同 Cache Salt 的请求才能复用 Block)。
- 每个结果会保存成一个 BlockHash 对象,其中同时包含 Hash 与 Token ID。最终返回 Block Hash List。
这个 List 会被存入 self.req_to_block_hashes[request_id] 。
接着,Engine 调用 find_longest_cache_hit ,检查这些 Hash 是否已经存在于 cached_block_hash_to_block 。第一次请求时,不会有任何命中。
然后调用 allocate_slots ,它会进一步调用 coordinator.cache_blocks ,把新的 BlockHash Entry 与已经分配的 KV Block 关联起来,并记录到 cached_block_hash_to_block 中。
随后,Forward Pass 会把对应 KV 写入前面分配好的 Paged KV Cache Memory。
在后续多个 Engine Step 中,系统还会继续分配更多 KV Cache Block,但对于这个例子并不重要,因为在 long_prefix 之后两个 Prompt 已经分叉。
第二次调用 generate 且使用相同 Prefix 时,步骤 1–3 会再次执行,但此时 find_longest_cache_hit 会通过线性搜索命中全部 n 个 Block。Engine 可以直接复用这些 KV Block。
如果原请求仍然存活,这些 Block 的 Reference Count 会增加(例如变成 2)。在当前示例中,第一个 Request 已经结束,因此 Block 已被归还到 Pool,其 Reference Count 重新变为 0。因为我们能够从 cached_block_hash_to_block 中找到它们,就知道这些 Block 仍然有效(KV Cache Manager 的逻辑保证了这一点),所以只需要再次把它们从 free_block_queue 中移除即可。
📝高级说明:
KV Cache Block 只有在即将从 free_block_queue 中重新分配时才会失效(该 Queue 从左侧 Pop)。如果此时发现该 Block 仍然关联着某个 Hash,并且仍存在于 cached_block_hash_to_block ,系统会清除它的 Hash,并删除 cached_block_hash_to_block 中对应的 Entry,确保它不能继续通过 Prefix Caching 复用于旧 Prefix。
这就是 Prefix Caching 的核心:已经见过的 Prefix 不要重复计算——直接复用它们的 KV Cache!
如果你理解了这个例子,你其实也就理解了 Paged Attention 的工作方式。
Prefix Caching 默认启用。如需关闭: enable_prefix_caching = False 。
Guided Decoding(FSM)
Guided Decoding 是这样一种技术:在每一步 Decode 中,根据 Grammar 驱动的有限状态机约束 Logits,从而确保最终只能采样 Grammar 所允许的 Token。
它非常强大:既可以约束正则文法(Chomsky Type-3,例如任意 Regex Pattern),也可以约束到上下文无关文法(Type-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()
|
在这个玩具示例中(假设使用 Character-Level Tokenization):Prefill 时,FSM 会 Mask Logits,使得只有 P 或 N 可选。如果采样到 P ,FSM 就进入 Positive 分支;下一步只有 o 合法,以此类推。
Toy example FSM
在 vLLM 中的工作流程如下:
- 构造 LLM Engine 时,会创建 StructuredOutputManager ;它能够访问 Tokenizer,并维护 _grammar_bitmask Tensor。
- 添加请求时,请求状态会设置为 WAITING_FOR_FSM , grammar_init 会选择 Backend Compiler(例如 xgrammar (https://www.aleksagordic.com/blog/vllm #ref -7);注意这些 Backend 是第三方代码)。
- 该请求对应的 Grammar 会异步编译。
- Scheduling 时,如果异步编译已经完成,状态切换为 WAITING ,并把 request_id 加入 structured_output_request_ids ;否则会放入 skipped_waiting_requests ,下一次 Engine Step 再重试。
- Scheduling Loop 结束后(仍在 Scheduling 阶段),如果存在 FSM Request, StructuredOutputManager 会让 Backend 准备 / 更新 _grammar_bitmask 。
- Forward Pass 产生 Logits 后, xgr_torch_compile 的函数会把 Bitmask 扩展到 Vocab Size(由于使用 32-bit Integer,扩展比例是 32x),然后把不允许的 Logit Mask 成 –∞。
- 采样下一个 Token 之后,通过 accept_tokens 推进 Request 的 FSM 状态。从图上看,就是移动到 FSM 的下一个 State。
第 6 步值得进一步解释。
假设 vocab_size = 32 ,那么 _grammar_bitmask 只需要一个 Integer,它的二进制表示编码了哪些 Token 允许( 1 )、哪些不允许( 0 )。例如 101…001 会展开成长度为 32 的 Array [1, 0, 1, …, 0, 0, 1] ;值为 0 的位置,其 Logit 会被设置成 –∞。对于更大的 Vocabulary,则使用多个 32-bit Word,并展开 / 拼接。Backend(例如 xgrammar )负责根据当前 FSM State 生成这些 Bit Pattern。
📝说明:
这里的大部分复杂度实际上都隐藏在 xgrammar 这类第三方 Library 中。
下面是一个更简单的示例,假设 vocab_size = 8 ,并使用 8-bit Integer(适合喜欢图示的读者):
Toy example
在 vLLM 中,只需传入期望的 guided_decoding Config 即可启用。
Speculative Decoding
在自回归生成中,每生成一个新 Token,都需要对大型 LM 执行一次 Forward Pass。这非常昂贵——每一步都要重新加载并应用全部 Model Weight,只为了计算一个 Token!(这里假设 Batch Size == 1;一般情况是 B 。)
Speculative Decoding(https://www.aleksagordic.com/blog/vllm #ref -8) 通过引入一个更小的 Draft LM 加速这个过程。Draft Model 会低成本地提出 k 个候选 Token。不过我们最终并不希望从小模型采样——它只是用来猜测候选 Continuation。最终什么是有效的,仍然由大模型决定。
步骤如下:
- Draft: 在当前 Context 上运行小模型,并提出 k 个 Token
- Verify: 在 context + k 个 draft token 上对大模型只运行一次。这会得到这 k 个位置以及额外一个位置的概率分布(因此一共有 k+1 个 Candidate)
- Accept / Reject: 从左到右遍历这 k 个 Draft Token:
- 如果全部 k 个都接受,则还可以“免费”从大模型已经计算好的额外第 (k+1) 个位置的分布中再采样一个 Token
- 如果发生 Reject,则在该位置构造新的 Rebalanced Distribution( p_large - p_draft ,最小截断为 0,再 Normalize 到和为 1),并从中采样最后一个 Token
- 如果大模型对该 Draft Token 的概率 ≥ Draft Model 的概率,则接受它
- 否则,以 p_large(token)/p_draft(token) 的概率接受
- 第一次 Reject 时停止,或者接受全部 k 个 Draft Token
为什么它有效: 虽然用小模型提出候选,但上述 Accept / Reject 规则能够保证,从期望分布上看,最终 Sequence 与完全逐 Token 从大模型采样的结果严格一致。因此,Speculative Decoding 在统计意义上等价于标准自回归 Decode——但可能快得多,因为一次大模型 Forward Pass 最多可以得到 k+1 个 Token。
📝说明:
推荐查看 gpt-fast 的简单实现,以及原始论文中的数学细节和等价性证明。
vLLM V1 不支持通过独立 LLM Draft Model 的方式实现 Speculative Decoding;相反,它实现了更快但准确性略低的 Proposal 方法:n-gram、EAGLE (https://www.aleksagordic.com/blog/vllm #ref -9) 和 Medusa(https://www.aleksagordic.com/blog/vllm #ref -10)。
分别用一句话解释:
- n-gram: 取最后 prompt_lookup_max 个 Token;在此前 Sequence 中寻找匹配;如果找到,就提出该匹配之后的 k 个 Token;否则缩小 Window 并重试,直到 prompt_lookup_min
- 当前实现会返回 第一次 匹配之后的 k 个 Token。我个人感觉增加 Recency Bias、反向搜索会更自然?(也就是找最后一次匹配)
- Eagle: 对大型 LM 做“Model Surgery”——保留 Embedding 和 LM Head,用轻量 MLP 替换 Transformer Stack;再 Fine-Tune 它作为廉价 Draft
- Medusa: 在大模型的 Embedding(进入 LM Head 之前)之上训练额外的 Linear Head,并行预测后续 k 个 Token;相比单独运行小型 Draft LM,这些 Head 可以更高效地提出 Candidate
下面展示如何在 vLLM 中使用 ngram 作为 Draft Method 调用 Speculative Decoding:
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 构造时):
- Init Device:创建一个 drafter (Draft Model,例如 NgramProposer )和 rejection_sampler (其中部分实现使用 Triton 编写)。
- Load Model:加载 Draft Model Weight(对于 n-gram 是 No-op)。
之后进入 generate 函数 (假设收到一个全新的 Request):
- 先用大模型执行常规 Prefill Step。
- Forward Pass 和标准 Sampling 完成后,调用 propose_draft_token_ids(k) ,从 Draft Model 采样 k 个 Draft Token。
- 把它们保存到 request.spec_token_ids (更新 Request Metadata)。
- 下一个 Engine Step 中,当 Request 已经位于 Running Queue 时,将 len(request.spec_token_ids) 加到 “new tokens” 数量中,使 allocate_slots 为 Forward Pass 预留足够 KV Block。
- 把 spec_token_ids Copy 到 input_batch.token_ids_cpu ,形成 (context + draft) Token。
- 通过 _calc_spec_decode_metadata 计算 Metadata(包括从 input_batch.token_ids_cpu 复制 Token、准备 Logits 等),然后让大模型对这些 Draft Token 执行 Forward Pass。
- 不再使用普通 Sampling,而是用 rejection_sampler 从左到右 Accept / Reject,并生成 output_token_ids 。
- 重复步骤 2–7,直到触发 Stop Condition。
最好的理解方式,还是打开 Debugger 一步步走代码;不过希望这一节已经能让你对它的实现有直观认识。这张图也有帮助:
Disaggregated P/D
前面我已经提到过 Prefill / Decode 解耦(Disaggregated P/D)的动机。
Prefill 和 Decode 的性能特征差异很大(Compute-Bound vs. Memory-Bandwidth-Bound),因此把两者拆开执行是很合理的设计。这样可以更精细地控制延迟,包括 TTFT (Time-to-First-Token)和 ITL (Inter-Token Latency)——后面的 Benchmark 章节会继续讲。
实践中,我们会运行 N 个 vLLM Prefill Instance 和 M 个 vLLM Decode Instance,并根据实时请求结构分别自动扩缩容。Prefill Worker 把 KV 写入专用 KV Cache Service;Decode Worker 从中读取。这能够把长而突发的 Prefill 与稳定且对延迟敏感的 Decode 隔离开。
这在 vLLM 中是怎么实现的?
为了方便说明,下面的示例使用 SharedStorageConnector ,它是一个用于演示机制的 Debug Connector 实现。
Connector 是 vLLM 用来处理不同 Instance 之间 KV 交换的抽象层。Connector Interface 目前尚未完全稳定,近期还有一些改进计划,可能会带来 API 变化,甚至 Breaking Change。
我们启动 2 个 vLLM Instance(GPU 0 用于 Prefill,GPU 1 用于 Decode),然后在它们之间传输 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() # 通知 Decode Instance:KV Cache 已准备好
# 保持 Prefill Node 持续运行,以防 Decode Node 尚未结束; # 否则脚本可能提前退出,导致 Decode 不完整。 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() # 阻塞等待 Prefill Instance 的 KV Cache
# 内部会先 Fetch KV Cache,然后再开始 Decode 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 (https://www.aleksagordic.com/blog/vllm #ref -11),它是目前最快的生产就绪 Connector(底层使用 NVIDIA NIXL),但仍然处在非常前沿的状态,我遇到过一些 Bug。由于它的大部分复杂逻辑都位于外部 Repository 中,因此为了讲解机制, SharedStorageConnector 更合适。
vLLM 中的步骤如下:
- 实例化(Instantiation) ——Engine 构造时会在两个地方创建 Connector:
- Worker 的 Init Device 流程中(在初始化 Worker Distributed Environment 的函数下),Role 为 worker
- Scheduler 构造函数内部,Role 为 scheduler
- Cache Lookup ——Scheduler 处理 waiting 队列中的 Prefill Request 时(完成本地 Prefix Cache 检查之后),会调用 Connector 的 get_num_new_matched_tokens 。它会检查 KV Cache Server 中是否存在外部缓存 Token。Prefill 在这里始终得到 0;Decode 则可能 Cache Hit。结果会加入本地命中数量,然后再调用 allocate_slots 。
- 状态更新(State Update) ——Scheduler 接着调用 connector.update_state_after_alloc ,记录发生 Cache Hit 的 Request(对 Prefill 是 No-op)。
- 构建 Metadata(Meta Build) ——Scheduling 结束时,Scheduler 调用 meta = connector.build_connector_meta :
- Prefill 加入所有 is_store=True 的 Request(用于上传 KV)
- Decode 加入 is_store=False 的 Request(用于获取 KV)
- Context Manager ——Forward Pass 之前,Engine 进入 KV Connector Context Manager:
- Enter 时:调用 kv_connector.start_load_kv 。对于 Decode,它会从外部 Server 加载 KV,并注入 Paged Memory;对于 Prefill,是 No-op。
- Exit 时:调用 kv_connector.wait_for_save 。对于 Prefill,它会一直阻塞到 KV 上传到外部 Server;对于 Decode,是 No-op。
下面是一个图示示例:
Disaggregated P/D
📝补充说明:
- 对 SharedStorageConnector 而言,“External Server”其实只是本地文件系统。
- 根据配置不同,KV Transfer 也可以逐 Layer 执行(在每个 Attention Layer 前后传输)。
- Decode 只会在请求的第一步从外部加载一次 KV;之后全部在本地计算 / 存储。
从 UniProcExecutor 到 MultiProcExecutor
核心技术已经讲完,现在可以讨论如何扩展到更大规模。
假设模型权重已经无法放入单张 GPU 的 VRAM。
第一个选择,是使用 Tensor Parallelism 把模型切分到同一节点的多张 GPU 上(例如 TP=8 )。如果依然放不下,下一步就是跨节点使用 Pipeline Parallelism。
📝说明:
- 节点内带宽显著高于节点间带宽,因此通常优先选择 Tensor Parallelism(TP),而不是 Pipeline Parallelism(PP)。(当然,PP 的通信数据量通常比 TP 少。)
- 这里不会讨论 Expert Parallelism(EP),因为我们聚焦标准 Transformer 而不是 MoE;也不会展开 Sequence Parallelism,因为 TP 与 PP 是实践中最常见的两种方式。
到了这个阶段,我们需要多个 GPU Process(Worker)以及一个 Orchestration Layer 来协调它们。这正是 MultiProcExecutor 提供的能力。
TP=8 场景下的 MultiProcExecutor(Driver Worker 为 Rank 0)
它在 vLLM 中如何工作:
- MultiProcExecutor 初始化一个 rpc_broadcast_mq Message Queue(底层通过 Shared Memory 实现)。
- Constructor 遍历 world_size (例如 TP=8 ⇒ world_size=8 ),通过 WorkerProc.make_worker_process 为每个 Rank 启动一个 Daemon Process。
- 对于每个 Worker,Parent Process 首先创建 Reader Pipe 和 Writer Pipe。
- 新 Process 执行 WorkerProc.worker_main ,实例化一个 Worker(经历与 UniProcExecutor 相同的 “init device”“load model” 等流程)。
- 每个 Worker 判断自己是否为 Driver(TP Group 中 Rank 0)或普通 Worker。每个 Worker 会设置两个 Queue:
- rpc_broadcast_mq (与 Parent 共享)用于接收工作
- worker_response_mq 用于返回响应
- 初始化过程中,每个 Child Process 会通过 Pipe 把自己的 worker_response_mq Handle 发给 Parent。全部收到后,Parent 解除阻塞——协调初始化完成。
- Worker 进入 Busy Loop,并阻塞在 rpc_broadcast_mq.dequeue 。当收到 Work Item 时执行它(逻辑与 UniProcExecutor 相同,只是现在执行的是 TP / PP 切分后的工作),结果通过 worker_response_mq.enqueue 返回。
- 运行时,收到 Request 后, MultiProcExecutor 会把它非阻塞地 Enqueue 到 rpc_broadcast_mq ,广播给所有 Child Worker。然后等待指定 Output Rank 的 worker_response_mq.dequeue ,收集最终结果。
从 Engine 视角看,什么都没变——所有多进程复杂性都被抽象在 Model Executor 的 execute_model 调用之后。
- UniProcExecutor 场景: execute_model 会直接调用单个 Worker 上的 execute_model
- MultiProcExecutor 场景: execute_model 会通过 rpc_broadcast_mq 间接触发每个 Worker 上的 execute_model
此时,只要资源允许,我们就能够通过同一个 Engine Interface 运行任意大的模型。
下一步是 Scale Out:启用 Data Parallelism( DP > 1 ),把模型复制到多个 Node;增加轻量级 DP Coordination Layer;在 Replica 之间加入 Load Balancing;并在最前面放置一个或多个 API Server 来接收流量。
vLLM 分布式 Serving 系统
Serving Infrastructure 有很多搭建方式。为了保持具体,这里举一个例子:假设我们有两台 H100 Node,希望在上面运行 4 个 vLLM Engine。
如果模型需要 TP=4 ,可以这样配置 Node。
使用 2 台 8×H100 Node 的服务器配置(1 个 Headless,1 个 API Server)
在第一台 Node 上,以 Headless Mode(没有 API Server)启动 Engine,参数如下:
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
|
在另一台 Node 上运行相同命令,但做少量修改:
- 不加 --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
|
📝说明:
这里假设网络已经正确配置,所有 Node 都能够访问指定的 IP 和 Port。
VLLM 内部是如何工作的?
在 Headless Server Node 上
Headless Node 上, CoreEngineProcManager 会启动 2 个 Process(由 --data-parallel-size-local 决定),每个 Process 执行 EngineCoreProc.run_engine_core 。每个函数会创建一个 DPEngineCoreProc (也就是 Engine Core),然后进入自己的 Busy Loop。
DPEngineCoreProc 会初始化其 Parent EngineCoreProc (它继承自 EngineCore ),具体包括:
- 创建 input_queue 和 output_queue ( queue.Queue )。
- 使用 DEALER ZMQ Socket(异步消息库)与另一台 Node 上的 Frontend 进行第一次 Handshake,并接收 Coordination Address 信息。
- 初始化 DP Group(例如使用 NCCL Backend)。
- 使用 MultiProcExecutor 初始化 EngineCore (前面提到的 TP=4 ,运行在 4 张 GPU 上)。
- 创建 ready_event ( threading.Event )。
- 启动 Input Daemon Thread( threading.Thread ),执行 process_input_sockets(…, ready_event) ;同样启动 Output Thread。
- Main Thread 继续等待 ready_event ,直到跨 2 个 Node、全部 4 个 Process 的 Input Thread 都完成 Coordination Handshake,并最终执行 ready_event.set() 。
- 解除阻塞后,向 Frontend 发送一个 “ready” Message,其中带上 Metadata(例如 Paged KV Cache Memory 中可用的 num_gpu_blocks )。
- Main、Input、Output 三个 Thread 分别进入各自的 Busy Loop。
最终会有 4 个 Child Process(每个 DP Replica 一个),每个 Process 运行 Main / Input / Output 三个 Thread。它们先与 DP Coordinator 和 Frontend 完成 Handshake,然后三个 Thread 都进入稳定状态的 Busy Loop。
由 4 个 DPEngineCoreProc 组成的 4 个 DP Replica 分布式系统
当前稳定运行状态:
- Input Thread ——阻塞等待 Input Socket 上由 API Server 路由过来的 Request;收到后 Decode Payload,通过 input_queue.put_nowait(...) 入队,然后继续阻塞等待 Socket。
- Main Thread ——阻塞在 input_queue.get(...) ;收到任务后把 Request Feed 给 Engine; MultiProcExecutor 执行 Forward Pass,并把结果 Enqueue 到 output_queue 。
- Output Thread ——阻塞在 output_queue.get(...) ;收到结果后发回 API Server,然后继续等待。
其他机制:
- DP Wave Counter ——系统会跟踪 “Wave”;当全部 Engine 变为空闲后会进入 Quiesce 状态;当新 Work 到达时 Counter 增加(便于 Coordination / Metrics)。
- Control Message ——API Server 不仅可以发送 Inference Request,还可以发送 Abort、Utility / Control RPC 等消息。
- Lockstep 的 Dummy Step ——只要任意 DP Replica 有工作,所有 Replica 都会执行一次 Forward Step;没有 Request 的 Replica 会执行 Dummy Step,以参与必需的同步点,避免 Active Replica 被阻塞。
关于 Lockstep 的澄清:严格来说,这只在 MoE Model 中是必需的——当 Expert Layer 组成 EP 或 TP Group,而 Attention Layer 仍然是 DP 时。目前 DP 模式下总是这样做,主要因为“内置”非 MoE DP 的使用场景并不多;对于普通模型,你完全可以运行多个独立 vLLM,再用常规方式做 Load Balancing。
现在看第二部分:API Server Node 上发生了什么?
在 API Server Node 上
我们会实例化一个 AsyncLLM 对象(LLM Engine 的 asyncio Wrapper)。内部会创建 DPLBAsyncMPClient (Data-Parallel、Load-Balancing、Asynchronous、Multiprocessing Client)。
在 MPClient 的 Parent Class 中, launch_core_engines 函数会执行:
- 创建用于 Startup Handshake 的 ZMQ Address(前面 Headless Node 已经见过)。
- 启动一个 DPCoordinator Process。
- 创建 CoreEngineProcManager (与 Headless Node 上相同)。
在 AsyncMPClient ( MPClient 的 Child Class)中,我们:
- 创建 outputs_queue ( asyncio.Queue )。
- 创建 asyncio Task process_outputs_socket ,它通过 Output Socket 与全部 4 个 DPEngineCoreProc 的 Output Thread 通信,并把数据写入 outputs_queue 。
- 随后, AsyncLLM 中另一个 asyncio Task output_handler 会读取这个 Queue,并最终把信息发送给 create_completion 函数。
在 DPAsyncMPClient 中,还会创建 asyncio Task run_engine_stats_update_task ,负责与 DP Coordinator 通信。
DP Coordinator 位于 Frontend(API Server)与 Backend(Engine Core)之间。它会:
- 定期把 Load Balancing 信息(Queue Size、Waiting / Running Request 数量)发送给 Frontend 的 run_engine_stats_update_task
- 处理来自 Frontend 的 SCALE_ELASTIC_EP Command,动态调整 Engine 数量(仅适用于 Ray Backend)
- 向 Backend 发送 START_DP_WAVE Event(由 Frontend 触发),并把 Wave State 更新反馈回来
总结一下,Frontend( AsyncLLM )会运行多个 asyncio Task(记住:它们是 Concurrent,而不是 Parallel):
- 一类 Task 通过 generate 路径处理输入 Request(每个新的 Client Request 都会创建一个新的 asyncio Task)
- 两个 Task( process_outputs_socket 、 output_handler )负责处理底层 Engine 的 Output Message
- 一个 Task( run_engine_stats_update_task )维护与 DP Coordinator 的通信:发送 Wave Trigger、轮询 LB State、处理 Dynamic Scaling Request
最后,Main Server Process 创建 FastAPI App,并挂载 OpenAIServingCompletion 、 OpenAIServingChat 等 Endpoint,对外暴露 /completion 、 /chat/completion 等接口。整套 Stack 最终通过 Uvicorn 提供服务。
把所有东西组合起来,下面就是完整 Request Lifecycle!
你在终端发送:
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 }'
|
接下来发生的事情:
- Request 到达 API Server 上 OpenAIServingCompletion 的 create_completion Route。
- 该函数异步 Tokenize Prompt,并准备 Metadata(Request ID、Sampling Params、Timestamp 等)。
- 然后调用 AsyncLLM.generate ,其流程与同步 Engine 类似,最终调用 DPAsyncMPClient.add_request_async 。
- 接着调用 get_core_engine_for_request ,根据 DP Coordinator 的 State 在不同 Engine 之间做 Load Balancing,选择 Score 最低 / Load 最小的 Engine: score = len(waiting) * 4 + len(running) 。
- ADD Request 被发送到选中 Engine 的 input_socket 。
- 在该 Engine 内部:
- Input Thread——解除阻塞,从 Input Socket Decode Data,并把 Work Item 放入 input_queue ,供 Main Thread 使用。
- Main Thread——从 input_queue 解除阻塞,把 Request 加入 Engine,并不断调用 engine_core.step() ,持续将中间结果 Enqueue 到 output_queue ,直到触发 Stop Condition。
- 提醒一下: step() 会调用 Scheduler、Model Executor(它本身又可能是 MultiProcExecutor !)等,我们前面都已经讲过。
- Output Thread——从 output_queue 解除阻塞,并通过 Output Socket 把结果发回。
- 这些结果会触发 AsyncLLM 的 Output asyncio Task( process_outputs_socket 和 output_handler ),把 Token 一路传回 FastAPI 的 create_completion Route。
- FastAPI 添加 Metadata(Finish Reason、Logprobs、Usage Info 等),然后通过 Uvicorn 把 JSONResponse 返回到你的终端!
就这样,你的 Completion 返回了——而一整套复杂的分布式机制,全都被隐藏在一个简单的 curl 命令后面!
📝补充说明:
- 增加更多 API Server 时,Load Balancing 会在 OS / Socket 层完成。从 Application 视角看,几乎没有明显变化——复杂度被底层隐藏了。
- 如果使用 Ray 作为 DP Backend,可以暴露一个 URL Endpoint( /scale_elastic_ep ),用于自动增减 Engine Replica 数量。
Benchmark 与自动调优——延迟 vs 吞吐
到目前为止,我们一直在分析“气体粒子”——也就是 Request 在 Engine / System 内部如何流动。现在该拉远视角,从整体系统层面思考:我们应该怎样衡量一个推理系统的性能?
最高层面,有两个彼此竞争的指标:
- Latency ——从 Request 提交到 Token 返回所花费的时间
- Throughput ——系统每秒能够生成 / 处理的 Token 或 Request 数量
对于交互式应用, Latency 最重要,因为用户正在实时等待响应。
对于离线 Workload,例如用于 Pre / Post-Training 的 Synthetic Data Generation、Data Cleaning / Processing,以及各种 Offline Batch Inference Job, Throughput 更重要。
在解释为什么 Latency 与 Throughput 会相互竞争之前,先定义几个常见推理指标:
| 指标定义 | |
| TTFT
(Time to First Token) |
从提交 Request 到收到第一个输出 Token 的时间 |
| ITL
(Inter-Token Latency) |
两个连续输出 Token 之间的时间(例如从 Token i-1 到 Token i) |
| TPOT
(Time Per Output Token) |
单个 Request 中所有输出 Token 的平均 ITL |
| Latency / E2E
(End-to-End Latency) |
处理一个 Request 的总时间,即 TTFT + 所有 ITL 之和;等价地说,就是从提交 Request 到收到最后一个输出 Token 的时间 |
| Throughput |
每秒处理的总 Token 数(Input、Output 或两者),也可以用每秒 Request 数表示 |
| Goodput |
满足 Service-Level Objective(SLO)的 Throughput,例如要求最大 TTFT、TPOT 或 E2E Latency。举例来说,只统计满足这些 SLO 的 Request 所产生的 Token |
ttft, itl, e2e latency
下面用一个简化模型解释这两个指标为何存在竞争关系。
假设:Weight I/O 占主导,而 KV Cache I/O 不占主导;也就是我们处理的是短 Sequence。
观察 Batch Size B 如何影响一次 Decode Step,就能看到 Trade-off。随着 B ↓ 接近 1,ITL 会下降:每个 Step 需要完成的工作更少,而且 Token 不需要与其他 Token “竞争”。随着 B ↑ 趋近无穷,ITL 会升高,因为每一步需要做更多 FLOPs——但 Throughput 会提高(直到达到 Peak Performance),因为 Weight I/O 能够在更多 Token 之间摊销。
Roofline Model 有助于理解这一点:在低于饱和 Batch B_sat 时,Step Time 主要由 HBM Bandwidth 决定(逐 Layer 把权重 Streaming 到 On-Chip Memory),因此 Step Latency 基本保持不变——一次计算 1 个 Token 与 10 个 Token,可能花费近似相同的时间。超过 B_sat 后,Kernel 转为 Compute-Bound,Step Time 会大致随着 B 增长;每新增一个 Token 都会进一步推高 ITL。
roofline perf model
📝说明:
更严格地分析时,还必须考虑 Kernel Auto-Tuning:随着 B 增大,Runtime 可能会针对新的 Shape 切换到更高效的 Kernel,使实际性能 P_kernel 发生变化。Step Latency 为 t = FLOPs_step / P_kernel ,其中 FLOPs_step 是该 Step 的计算量。可以看出,当 P_kernel 达到 P_peak 后,每一步增加更多计算,就会直接导致 Latency 上升。
如何在 vLLM 中做 Benchmark
vLLM 提供 vllm bench {serve,latency,throughput} CLI ,封装了 vllm / benchmarks / {server,latency,throughput}.py 。
这些 Script 分别做什么:
- latency ——使用短输入(默认 32 Token),并以较小 Batch(默认 8)采样 128 个输出 Token。它会运行多次 Iteration,并报告整个 Batch 的 E2E Latency。
- throughput ——一次性提交固定 Prompt Set(默认:1000 个 ShareGPT Sample,也就是 QPS=Inf 模式),报告整个运行期间的 Input / Output / Total Token,以及每秒 Request 数。
- serve ——启动 vLLM Server,并通过从 Poisson Distribution(更一般地说是 Gamma Distribution)采样 Request Inter-Arrival Time,模拟现实 Workload。它在一个时间窗口内发送请求、测量本文讨论过的所有指标,还可以选择限制 Server Side Max Concurrency(通过 Semaphore,例如限制最多 64 个并发 Request)。
下面是运行 Latency Script 的示例:
vllm bench latency --model <model-name> --input-tokens 32 --output-tokens 128 --batch-size 8
|
CI 使用的 Benchmark Config 位于 .buildkite/nightly-benchmarks/tests 。
此外还有一个 Auto-Tune Script,它会驱动 Serve Benchmark,寻找满足目标 SLO 的参数设置(例如:“在保持 p99 e2e < 500 ms 的情况下最大化 Throughput”),并返回建议 Config。
后记
我们从最基础的 Engine Core( UniProcExecutor )开始,加入 Speculative Decoding、Prefix Caching 等高级特性;然后扩展到 MultiProcExecutor ( TP/PP > 1 );最后继续 Scale Out,把所有东西包装进 Async Engine 与 Distributed Serving Stack;最终又回到如何衡量整个系统的性能。
vLLM 还包含许多本文没有展开的特殊处理,例如:
- 多样化 Hardware Backend: TPU、AWS Neuron(Trainium / Inferentia)等
- 架构 / 技术: MLA 、 MoE 、Encoder-Decoder(例如 Whisper)、Pooling / Embedding Model、 EPLB 、 m-RoPE 、 LoRA 、 ALiBi 、Attention-Free Variant、Sliding-Window Attention、Multimodal LM,以及 State-Space Model(例如 Mamba / Mamba-2、Jamba)
- TP / PP / SP
- Hybrid KV Cache Logic (Jenga)、Beam Sampling 等更复杂的采样方法,以及更多内容
- 实验特性: Async Scheduling
好处在于,大多数能力与前面描述的 Main Flow 基本正交——你几乎可以把它们看成“Plugin”(当然实践中还是会存在一定耦合)。
我非常喜欢理解系统。不过在如此高的抽象层级下,细节分辨率必然会有所损失。
|