您可以捐助,支持我们的公益事业。

1元 10元 50元





认证码:  验证码,看不清楚?请点击刷新验证码 必填



  求知 文章 文库 Lib 视频 iPerson 课程 认证 咨询 工具 讲座 Model Center 汽车系统工程   模型库  
会员   
   
知识图谱、本体论、RAG与大模型
8月22-23日 北京+线上
企业架构方法与实践
8月27-28日 深圳+线上
AI智能体开发技术实践
9月17-18日 厦门+线上
     
   
 订阅
一文拆透 vLLM:大模型高吞吐推理到底是怎么跑起来的?
 
 
  5   次浏览      1 次
 2026-8-19
 
编辑推荐:
在这篇文章中,会逐步介绍构成现代高吞吐 LLM 推理系统的所有核心系统组件和高级特性;会详细拆解 vLLM [1] 的工作原理, 希望对你的学习有帮助。
本文来自于NLP轻松谈,由火龙果软件Alice编辑推荐。

本文分为五个部分:

  1. LLM Engine 与 Engine Core:vLLM 的基础机制(调度、Paged Attention、Continuous Batching 等)
  2. 高级特性:Chunked Prefill、Prefix Caching、Guided Decoding、Speculative Decoding、Prefill/Decode 解耦
  3. 纵向扩展:从单 GPU 到多 GPU 执行
  4. Serving 层:分布式 / 并发 Web 服务框架
  5. 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 为例。

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

  1. 实例化一个 Engine
  2. 对给定的 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 中运行哪些请求),它进一步包含:
    1. Policy 设置——可以是  FCFS (First Come First Served,先来先服务)或  Priority (高优先级请求先服务)
    2. waiting  与  running  队列
    3. 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 进程中独立执行。)

  1. 初始化设备:
    • 给 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 等)
  2. 加载模型:
    • 实例化模型架构
    • 加载模型权重
    • 调用  model.eval() (PyTorch 推理模式)
    • 可选:对模型调用  torch.compile()
  3. 初始化 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,我们会:

  1. 创建唯一 Request ID,并记录到达时间
  2. 调用 Input Preprocessor 对 Prompt 进行 Tokenize,返回包含  prompt 、 prompt_token_ids  以及  type (text、tokens、embeds 等)的 Dictionary
  3. 将这些信息打包到  EngineCoreRequest  中,同时加入 Priority、Sampling Params 和其他 Metadata
  4. 把 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 包含三个阶段:

  1. Schedule:选择本 Step 中运行哪些请求(Decode 和/或 Chunked Prefill)
  2. Forward Pass:执行模型并采样 Token
  3. 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

推理引擎主要需要处理两类工作负载:

  1. Prefill  请求——对整个 Prompt 的所有 Token 执行 Forward Pass。它们通常是 计算受限(Compute-Bound) 的(具体阈值与硬件和 Prompt Length 有关)。Prefill 结束时,我们会从最后一个 Token 位置对应的概率分布中采样一个 Token。
  2. 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  队列中的请求。对于每个此类请求,它会:

  1. 计算本 Step 需要生成的新 Token 数量(由于 Speculative Decoding 和 Async Scheduling,数量不一定总是 1,后面会进一步解释)
  2. 调用 KV Cache Manager 的  allocate_slots  函数(后面详讲)
  3. 从 Token Budget 中减去第 1 步的 Token 数量

之后,它会处理  waiting  队列中的 Prefill 请求:

  1. 获取已经计算好的 Block 数量(如果禁用 Prefix Caching,则返回 0——后面会讲)
  2. 调用 KV Cache Manager 的  allocate_slots  函数
  3. 从  waiting  中 Pop 该请求,并移入  running ,同时将状态设置为  RUNNING
  4. 更新 Token Budget

下面看  allocate_slots  具体做什么:

  1. 计算 Block 数量 ——确定需要新分配多少个 KV Cache Block( n )。默认情况下,每个 Block 存储 16 个 Token。例如,如果一个 Prefill Request 新增 17 个 Token,则需要  ceil(17/16) = 2  个 Block。
  2. 检查可用性 ——如果 Manager 的 Block Pool 中没有足够 Block,则提前返回。根据请求是 Decode 还是 Prefill,Engine 可能尝试 Recompute Preemption(V0 支持 Swap Preemption):通过驱逐低优先级请求,调用  kv_cache_manager.free  把这些请求占用的 KV Block 归还给 Block Pool;也可能跳过此次调度并继续执行。
  3. 分配 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。

主要步骤如下:

  1. 更新状态 ——从  input_batch  中移除已经结束的请求;更新与 Forward Pass 相关的其他 Metadata(例如每个 Request 对应的 KV Cache Block,稍后用于索引 Paged KV Cache Memory)
  2. 准备输入 ——将 Buffer 从 CPU→GPU;计算 Position;构造  slot_mapping (示例中会继续讲);构建 Attention Metadata
  3. Forward Pass ——使用自定义 Paged Attention Kernel 执行模型。所有 Sequence 会被 Flatten 并拼接成一条长“Super Sequence”。Position Index 和 Attention Mask 会确保每个 Sequence 只关注自己的 Token,因此无需 Right Padding 就可以支持 Continuous Batching。
  4. 收集最后 Token 的状态 ——提取每个 Sequence 最后一个位置的 Hidden State,并计算 Logits
  5. 采样 ——根据 Sampling Config(Greedy、Temperature、Top-p、Top-k 等)从计算得到的 Logits 中采样 Token

Forward Pass 本身有两种执行模式:

  1. Eager Mode ——启用 Eager Execution 时,执行标准 PyTorch Forward Pass
  2. 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。

接下来会深入:

  1. Chunked Prefill
  2. Prefix Caching
  3. Guided Decoding(通过受 Grammar 约束的有限状态机)
  4. Speculative Decoding
  5. 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 :

  1. 该函数把  long_prefix + prompts[0]  按每 16 个 Token 切成 Chunk。
  2. 对每个完整 Chunk 计算 Hash(可以使用内置 Hash,也可以用 SHA-256;SHA-256 更慢,但 Collision 更少)。Hash 会组合前一个 Block 的 Hash、当前 Token,以及可选 Metadata。
  3. 可选 Metadata 包括:MM Hash、LoRA ID、Cache Salt(注入第一个 Block 的 Hash,确保只有使用相同 Cache Salt 的请求才能复用 Block)。
  4. 每个结果会保存成一个  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 中的工作流程如下:

  1. 构造 LLM Engine 时,会创建  StructuredOutputManager ;它能够访问 Tokenizer,并维护  _grammar_bitmask  Tensor。
  2. 添加请求时,请求状态会设置为  WAITING_FOR_FSM , grammar_init  会选择 Backend Compiler(例如  xgrammar  (https://www.aleksagordic.com/blog/vllm #ref -7);注意这些 Backend 是第三方代码)。
  3. 该请求对应的 Grammar 会异步编译。
  4. Scheduling 时,如果异步编译已经完成,状态切换为  WAITING ,并把  request_id  加入  structured_output_request_ids ;否则会放入  skipped_waiting_requests ,下一次 Engine Step 再重试。
  5. Scheduling Loop 结束后(仍在 Scheduling 阶段),如果存在 FSM Request, StructuredOutputManager  会让 Backend 准备 / 更新  _grammar_bitmask 。
  6. Forward Pass 产生 Logits 后, xgr_torch_compile  的函数会把 Bitmask 扩展到 Vocab Size(由于使用 32-bit Integer,扩展比例是 32x),然后把不允许的 Logit Mask 成 –∞。
  7. 采样下一个 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。最终什么是有效的,仍然由大模型决定。

步骤如下:

  1. Draft:  在当前 Context 上运行小模型,并提出  k  个 Token
  2. Verify:  在  context + k 个 draft token  上对大模型只运行一次。这会得到这  k  个位置以及额外一个位置的概率分布(因此一共有  k+1  个 Candidate)
  3. 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)。

分别用一句话解释:

  1. n-gram:  取最后  prompt_lookup_max  个 Token;在此前 Sequence 中寻找匹配;如果找到,就提出该匹配之后的  k  个 Token;否则缩小 Window 并重试,直到  prompt_lookup_min
  2. 当前实现会返回 第一次 匹配之后的  k  个 Token。我个人感觉增加 Recency Bias、反向搜索会更自然?(也就是找最后一次匹配)
  3. Eagle:  对大型 LM 做“Model Surgery”——保留 Embedding 和 LM Head,用轻量 MLP 替换 Transformer Stack;再 Fine-Tune 它作为廉价 Draft
  4. 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 构造时):

  1. Init Device:创建一个  drafter (Draft Model,例如  NgramProposer )和  rejection_sampler (其中部分实现使用 Triton 编写)。
  2. Load Model:加载 Draft Model Weight(对于 n-gram 是 No-op)。

之后进入  generate  函数 (假设收到一个全新的 Request):

  1. 先用大模型执行常规 Prefill Step。
  2. Forward Pass 和标准 Sampling 完成后,调用  propose_draft_token_ids(k) ,从 Draft Model 采样  k  个 Draft Token。
  3. 把它们保存到  request.spec_token_ids (更新 Request Metadata)。
  4. 下一个 Engine Step 中,当 Request 已经位于 Running Queue 时,将  len(request.spec_token_ids)  加到 “new tokens” 数量中,使  allocate_slots  为 Forward Pass 预留足够 KV Block。
  5. 把  spec_token_ids  Copy 到  input_batch.token_ids_cpu ,形成  (context + draft)  Token。
  6. 通过  _calc_spec_decode_metadata  计算 Metadata(包括从  input_batch.token_ids_cpu  复制 Token、准备 Logits 等),然后让大模型对这些 Draft Token 执行 Forward Pass。
  7. 不再使用普通 Sampling,而是用  rejection_sampler  从左到右 Accept / Reject,并生成  output_token_ids 。
  8. 重复步骤 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 中的步骤如下:

  1. 实例化(Instantiation) ——Engine 构造时会在两个地方创建 Connector:
    • Worker 的 Init Device 流程中(在初始化 Worker Distributed Environment 的函数下),Role 为  worker
    • Scheduler 构造函数内部,Role 为  scheduler
  2. 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 。
  3. 状态更新(State Update) ——Scheduler 接着调用  connector.update_state_after_alloc ,记录发生 Cache Hit 的 Request(对 Prefill 是 No-op)。
  4. 构建 Metadata(Meta Build) ——Scheduling 结束时,Scheduler 调用  meta = connector.build_connector_meta :
    • Prefill 加入所有  is_store=True  的 Request(用于上传 KV)
    • Decode 加入  is_store=False  的 Request(用于获取 KV)
  5. 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 中如何工作:

  1. MultiProcExecutor  初始化一个  rpc_broadcast_mq  Message Queue(底层通过 Shared Memory 实现)。
  2. Constructor 遍历  world_size (例如  TP=8 ⇒ world_size=8 ),通过  WorkerProc.make_worker_process  为每个 Rank 启动一个 Daemon Process。
  3. 对于每个 Worker,Parent Process 首先创建 Reader Pipe 和 Writer Pipe。
  4. 新 Process 执行  WorkerProc.worker_main ,实例化一个 Worker(经历与  UniProcExecutor  相同的 “init device”“load model” 等流程)。
  5. 每个 Worker 判断自己是否为 Driver(TP Group 中 Rank 0)或普通 Worker。每个 Worker 会设置两个 Queue:
    • rpc_broadcast_mq (与 Parent 共享)用于接收工作
    • worker_response_mq  用于返回响应
  6. 初始化过程中,每个 Child Process 会通过 Pipe 把自己的  worker_response_mq  Handle 发给 Parent。全部收到后,Parent 解除阻塞——协调初始化完成。
  7. Worker 进入 Busy Loop,并阻塞在  rpc_broadcast_mq.dequeue 。当收到 Work Item 时执行它(逻辑与  UniProcExecutor  相同,只是现在执行的是 TP / PP 切分后的工作),结果通过  worker_response_mq.enqueue  返回。
  8. 运行时,收到 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 ),具体包括:

  1. 创建  input_queue  和  output_queue ( queue.Queue )。
  2. 使用  DEALER  ZMQ Socket(异步消息库)与另一台 Node 上的 Frontend 进行第一次 Handshake,并接收 Coordination Address 信息。
  3. 初始化 DP Group(例如使用 NCCL Backend)。
  4. 使用  MultiProcExecutor  初始化  EngineCore (前面提到的  TP=4 ,运行在 4 张 GPU 上)。
  5. 创建  ready_event ( threading.Event )。
  6. 启动 Input Daemon Thread( threading.Thread ),执行  process_input_sockets(…, ready_event) ;同样启动 Output Thread。
  7. Main Thread 继续等待  ready_event ,直到跨 2 个 Node、全部 4 个 Process 的 Input Thread 都完成 Coordination Handshake,并最终执行  ready_event.set() 。
  8. 解除阻塞后,向 Frontend 发送一个 “ready” Message,其中带上 Metadata(例如 Paged KV Cache Memory 中可用的  num_gpu_blocks )。
  9. 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  函数会执行:

  1. 创建用于 Startup Handshake 的 ZMQ Address(前面 Headless Node 已经见过)。
  2. 启动一个  DPCoordinator  Process。
  3. 创建  CoreEngineProcManager (与 Headless Node 上相同)。

在  AsyncMPClient ( MPClient  的 Child Class)中,我们:

  1. 创建  outputs_queue ( asyncio.Queue )。
  2. 创建 asyncio Task  process_outputs_socket ,它通过 Output Socket 与全部 4 个  DPEngineCoreProc  的 Output Thread 通信,并把数据写入  outputs_queue 。
  3. 随后, 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
}'


接下来发生的事情:

  1. Request 到达 API Server 上  OpenAIServingCompletion  的  create_completion  Route。
  2. 该函数异步 Tokenize Prompt,并准备 Metadata(Request ID、Sampling Params、Timestamp 等)。
  3. 然后调用  AsyncLLM.generate ,其流程与同步 Engine 类似,最终调用  DPAsyncMPClient.add_request_async 。
  4. 接着调用  get_core_engine_for_request ,根据 DP Coordinator 的 State 在不同 Engine 之间做 Load Balancing,选择 Score 最低 / Load 最小的 Engine: score = len(waiting) * 4 + len(running) 。
  5. ADD  Request 被发送到选中 Engine 的  input_socket 。
  6. 在该 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 把结果发回。
  7. 这些结果会触发  AsyncLLM  的 Output asyncio Task( process_outputs_socket  和  output_handler ),把 Token 一路传回 FastAPI 的  create_completion  Route。
  8. 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 内部如何流动。现在该拉远视角,从整体系统层面思考:我们应该怎样衡量一个推理系统的性能?

最高层面,有两个彼此竞争的指标:

  1. Latency ——从 Request 提交到 Token 返回所花费的时间
  2. 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”(当然实践中还是会存在一定耦合)。

我非常喜欢理解系统。不过在如此高的抽象层级下,细节分辨率必然会有所损失。

   
5   次浏览       1 次
相关文章

基于图卷积网络的图深度学习
自动驾驶中的3D目标检测
工业机器人控制系统架构介绍
项目实战:如何构建知识图谱
 
相关文档

5G人工智能物联网的典型应用
深度学习在自动驾驶中的应用
图神经网络在交叉学科领域的应用研究
无人机系统原理
相关课程

人工智能、机器学习&TensorFlow
机器人软件开发技术
人工智能,机器学习和深度学习
图像处理算法方法与实践

最新活动计划
知识图谱.本体论.RAG大模型 8-22[在线]
企业架构方法与实践 8-27[深圳]
FDE(前沿部署工程师)实践指南 9-8[北京]
AI智能体开发技术实践 9-17[厦门]
UAF架构体系与实践 9-22[北京]
MBSE(基于模型的系统工程)9-29[北京]
 
 
最新文章
AIGC技术与应用全解析
详解知识图谱的构建全流程
大模型升级与设计之道
自动驾驶和辅助驾驶系统
ROS机器人操作系统底层原理
最新课程
人工智能,机器学习和深度学习
人工智能与机器学习应用实战
人工智能-图像处理和识别
人工智能、机器学习& TensorFlow+Keras框架实践
人工智能+Python+大数据
成功案例
某综合性科研机构 人工智能与机器学习
某银行 人工智能+Python+大数据
北京 人工智能、机器学习& TensorFlow
某领先数字地图提供商 Python数据分析
中国移动 人工智能、机器学习和深度学习