vLLM 推理内核深度解析

第1章 架构总览:从一条请求读懂整个 vLLM

作者 杨艺韬 · 13,813 字 · 发布于 · 更新于

读懂一座城市的交通,光看线路图不够——得跟着一班车从起点坐到终点。这一章就跟着一条请求,走完 API Server、EngineCore、Worker 三个进程。

本章要点

  • 以"一次 HTTP 请求的完整旅程"为线索,建立对 vLLM V1 架构的直觉
  • 理解 V1 的三进程拓扑(API Server / EngineCore / Worker):为什么这样拆分、为什么每一对之间的协议不同
  • 掌握五大子系统的职责边界:入口层、引擎核心、执行层、模型层、内核层
  • 读懂从 SchedulerOutput 到 ModelRunnerOutput 的四类 DTO 数据契约
  • 对照 V0 → V1 七个结构性变革:多进程化、统一调度、有状态 Worker、零开销前缀缓存、分段 CUDA Graph、异步去分词、Executor 抽象
  • 认识 vLLM 源码目录的组织逻辑:V1 vs V0 代码在哪里、读源码从哪里开始
  • 拿到一张"章节地图":后续 17 章每一章讲什么、映射到源码哪个位置

1.1 一个推理请求的完整旅程

让我们从最具体的场景出发。

一个用户通过 HTTP POST 向你的 vLLM 服务发送:

POST /v1/chat/completions
{
  "model": "Llama-3-70B-Instruct",
  "messages": [
    {"role": "system", "content": "你是一个专业的 AI 助手。"},
    {"role": "user", "content": "请用一句话解释什么是 PagedAttention。"}
  ],
  "stream": true,
  "temperature": 0.7
}

从这个请求到达你的服务器,到用户的浏览器里逐字蹦出完整回复——中间发生了什么?

我们把旅程切成六个阶段,一步步看:

graph LR
    A["① HTTP 到达<br/>FastAPI"] --> B["② API Server<br/>tokenize + 请求构造"]
    B --> C["③ EngineCore<br/>排队 + 状态机"]
    C --> D["④ Scheduler<br/>调度决策 + KV 分配"]
    D --> E["⑤ Worker<br/>GPU 前向 + 采样"]
    E --> F["⑥ API Server<br/>detokenize + SSE 输出"]
    style A fill:#2563eb,color:#fff,stroke:none
    style B fill:#7c3aed,color:#fff,stroke:none
    style C fill:#db2777,color:#fff,stroke:none
    style D fill:#f59e0b,color:#333,stroke:none
    style E fill:#10b981,color:#333,stroke:none
    style F fill:#6366f1,color:#fff,stroke:none

这六个阶段横跨三个独立进程,用两套 IPC 协议连接。我们逐个展开。

1.1.1 阶段 ① ②:API Server 接收请求

请求首先抵达 API Server 进程(vllm/entrypoints/openai/api_server.py)。这是一个基于 FastAPI 的 HTTP 服务,对外暴露 OpenAI 兼容的接口。

API Server 做三件事:

  1. 参数校验:用 Pydantic schema 校验 model、temperature、top_p、messages 结构等。校验不通过直接返回 400。
  2. Chat Template 渲染 + Tokenize:messages 数组通过模型自带的 chat_template(Jinja2 格式,来自 tokenizer 的元数据)渲染成单一 prompt 字符串,再用 tokenizer 分词成 prompt_token_ids。
  3. 构造 EngineCoreRequest:把 token IDs、sampling params、请求元数据打包成一个最小化的跨进程 DTO。

关键问题:分词为什么放在 API Server 而不是 Worker 或 EngineCore?

答案在 V0 的痛:V0 自 v0.6.2 起在线服务默认经 MQLLMEngine 把引擎放进独立进程,HTTP 层和引擎已经分开;但 V0 的 LLMEngine 仍在同一个引擎进程、同一条循环里做输入预处理(多模态处理、尚未分词的文本 prompt 的 tokenize,见 vllm/engine/llm_engine.py 的 input_preprocessor.preprocess)、detokenize 和输出处理,这些 CPU 活与调度互相挤占。V1 把 tokenize、多模态预处理、detokenize 都放在前端进程(AsyncLLM 里的 Processor / OutputProcessor),EngineCore 进程只管调度和执行,CPU 密集的文本处理和 GPU 调度真正并行。

除此之外还有收益:

  • Engine Core 里的请求对象只含 token IDs 和已处理好的多模态输入,不带原始文本

1.1.2 阶段 ③:EngineCore 接收请求

API Server 把 EngineCoreRequest 通过 ZMQ ROUTER → DEALER socket 发给独立的 EngineCore 进程(回程输出走另一条 PUSH → PULL socket)(vllm/v1/engine/core.py)。

EngineCore 内部是一个单线程同步主循环 run_busy_loop——ZMQ 收发被拆到两个后台线程里,主循环只碰 Python queue.Queue:

# vllm/v1/engine/core.py:405::EngineCoreProc
def run_busy_loop(self):
    # Loop until process is sent a SIGINT or SIGTERM
    while True:
        # 1) Poll the input queue until there is work to do.
        self._process_input_queue()
        # 2) Step the engine core and return the outputs.
        self._process_engine_step()

"没请求时阻塞等待"这件事发生在 _process_input_queue() 内部——它在 self.input_queue.get() 上阻塞,直到有第一条消息进来。

第 2 章会详细讲这个循环的每一拍。此时新请求进入WAITING 状态,等待调度器选中。

1.1.3 阶段 ④:Scheduler 决定这一拍做什么

step() 的第一步是调用 Scheduler(vllm/v1/core/sched/scheduler.py)。Scheduler 的输出是一个 SchedulerOutput 数据类,核心是一个 req_id → token 数的字典(示意):

SchedulerOutput(
    num_scheduled_tokens={
        "req-A": 128,    # 新请求,prefill 前 128 token
        "req-B": 1,      # 老请求,decode 1 token
        "req-C": 64,     # 老请求,chunked prefill 的后续块
        "req-D": 1,      # 老请求 decode
    },
    scheduled_new_reqs=[...],      # 新进 RUNNING 的请求元数据
    scheduled_cached_reqs=[...],   # 继续 RUNNING 的请求
    finished_req_ids={...},        # 本拍完成的请求
    ...
)

这个看似轻描淡写的字典背后,是调度器做了数十个小决策:

  • 还剩多少空闲 KV 块?需要抢占谁来腾空间?
  • Chunked prefill 切多大块合适?
  • 前缀缓存命中了多少 block?命中的 block 不计入 token_budget
  • 有没有 LoRA 限制?max_loras=4 的情况下是否要放弃某些 LoRA 请求?
  • Spec decode 这一拍要产多少个 token?

调度逻辑的精妙之处在第 3 章(Scheduler)和第 10、11、12 章(前缀缓存、Chunked Prefill、Spec Decode)详细展开。

1.1.4 阶段 ⑤:Worker 执行 GPU 计算

Scheduler 产出 SchedulerOutput 后,EngineCore 通过 Executor 抽象(第 6 章)把它广播给所有 Worker。单机多卡场景下用 MultiprocExecutor,走共享内存 MessageQueue 广播(pickle 序列化后写入共享内存环形缓冲区,不是对象零拷贝)。

每个 Worker(vllm/v1/worker/gpu_worker.py;多卡 MultiprocExecutor 下每张 GPU 一个进程,单卡 UniProcExecutor 下 Worker 就在 EngineCore 进程里)收到后,Worker.execute_model 转交给 GPUModelRunner.execute_model,由它依次:

  1. _update_states:差量更新本地 InputBatch——新请求添加槽位、老请求追加 token
  2. _prepare_inputs:构造 GPU 输入张量(input_ids、positions、block_tables、slot_mapping、cu_seqlens)
  3. 前向:
    • 若开了 CUDA Graph 且 token 数不超过最大 capture size → pad 到不小于它的最小 capture size → replay
    • 否则 eager 模式
  4. sampler:从 logits 采样出新 token
  5. 返回 ModelRunnerOutput(含 sampled_token_ids、logprobs、spec_token_ids)

这是整个系统里唯一真正吃 GPU 算力的环节。第 6 章(Worker/Executor)、第 8 章(ModelRunner/CUDA Graph)、第 9 章(Sampling)详讲。

1.1.5 阶段 ⑥:API Server 去分词 + 流式输出

Worker 结果通过 Executor 汇总回 EngineCore,再通过 ZMQ output socket 发回 API Server。

API Server 进程里,AsyncLLM 的后台输出任务(_run_output_handler)拉取 EngineCoreOutputs,交给 OutputProcessor 按 request_id 分发:

  1. detokenize:token_id → 字符片段(fast tokenizer 走 HuggingFace tokenizers 的 DecodeStream 增量解码,见 vllm/v1/engine/detokenizer.py),结果放进该请求的 RequestOutputCollector
  2. 对应的 FastAPI handler 在 async for 循环里取到 RequestOutput,做 SSE 封装:data: {"choices": [{"delta": {"content": "Page"}}]}\n\n
  3. 通过 HTTP 长连接 push 给客户端

用户浏览器里就看到一个 token 一个 token 蹦出来。

1.1.6 这六个阶段的时间构成

六个阶段的成本量级差得非常远,按"一次性 / 每 step / 每 token"分类记住即可:

一次性(整个请求只付一次)
  阶段 ①②  HTTP 解析 + chat template 渲染 + tokenize     ← CPU,随 prompt 长度增长
  阶段 ③    ZMQ 投递 EngineCoreRequest                   ← 一条小消息

每 step 付一次(decode 阶段有多少个输出 token 就付多少次)
  阶段 ④    Scheduler.schedule()                         ← 纯 Python,CPU
  阶段 ⑤    GPU forward + sample                         ← 唯一吃 GPU 算力的环节

每 token 付一次
  阶段 ⑥    detokenize + SSE 封装                        ← CPU,在另一个进程里

V1 架构追求的目标可以一句话概括:让阶段 ⑤ 之外的所有成本,要么摊薄成一次性、要么挪到别的进程里和 GPU 并行。至于"非 GPU 开销究竟占几个百分点",那取决于模型大小、并发数、prompt 长度分布和硬件——本专栏不给一个放之四海的数字,要在你自己的负载上测,可以从第 17 章 §17.8 的压测工具起步。

1.2 三进程拓扑:为什么要这样拆?

上一节你应该已经注意到一个反复出现的关键词:进程分离。这是 V1 架构最关键的设计决策。

1.2.1 V0 引擎进程的痛

V0 并不是"单进程":自 v0.6.2 起,vllm serve 默认经 MQLLMEngine(vllm/engine/multiprocessing/)把 V0 的 LLMEngine 放进独立进程,前端 API 进程只做 HTTP 和协议层(离线 LLM 类则直接在本进程里跑 V0 LLMEngine)。问题出在引擎进程内部:Scheduler、KV 管理、向 Worker 发指令,和输入预处理、detokenize、输出处理挤在同一个 Python 进程的同一条循环里;多卡时 rank 0 的 driver worker 也住在这个进程里。CPython 的 GIL 让这个进程在任何时刻只有一个线程能运行 Python 代码。后果:

  • Tokenize 一个尚未分词的长文本 prompt → GIL 被占 → Scheduler 无法做下一拍决策
  • Detokenize 新 token(虽然快但频繁)→ 主循环被打断(V0 的 async output processing 只能把输出处理和 GPU 前向重叠,仍占引擎进程的 GIL)
  • 图像预处理(VLM 场景)→ 调度被推迟

CPU 和 GPU 无法并行——GPU 在等 CPU 干完活才能开始下一拍,CPU 开销越重,GPU 空转比例越高。

1.2.2 V1 的三进程分工

graph TB
    subgraph "API Server 进程 (CPU 密集)"
        Web[HTTP Handler<br/>FastAPI]
        Tok[Tokenizer<br/>Rust impl]
        Detok[Detokenizer]
        SSE[SSE 流式输出]
    end
    subgraph "EngineCore 进程 (CPU 密集,主循环)"
        Sched[Scheduler]
        KVMgr[KVCacheManager]
        Loop[run_busy_loop]
    end
    subgraph "Worker 进程 × N (GPU 密集)"
        W1[Worker 0 / GPU 0]
        W2[Worker 1 / GPU 1]
        WN[Worker N / GPU N]
    end
    Web <-->|ZMQ ROUTER/DEALER + PUSH/PULL| Loop
    Loop <-->|共享内存 MQ| W1
    Loop <-->|共享内存 MQ| W2
    Loop <-->|共享内存 MQ| WN
    style Web fill:#7c3aed,color:#fff,stroke:none
    style Tok fill:#7c3aed,color:#fff,stroke:none
    style Sched fill:#db2777,color:#fff,stroke:none
    style W1 fill:#10b981,color:#333,stroke:none
    style W2 fill:#10b981,color:#333,stroke:none

每个进程一个独立 Python interpreter + 独立 GIL。三个进程物理并行:

  • API Server 做 tokenize / detokenize 时,EngineCore 可以做 schedule、Worker 可以跑 GPU forward
  • EngineCore 与 Worker 之间默认是同步的:EngineCore 发出 SchedulerOutput 后等本拍结果回来再调度下一拍;只有 PP 下的 Ray executor 才让"下一拍调度"和"上一拍前向"重叠(见 §1.11.2)
  • 三者通过 ZMQ / 共享内存通信

前端进程里持有 EngineCore 连接的是 EngineCoreClient(vllm/v1/engine/core_client.py),V1 LLMEngine 经 make_client() 按两个布尔选实现,AsyncLLM 则不经过它、在构造函数里直接二选一(vllm/v1/engine/async_llm.py:114):vllm serve 用的 AsyncLLM 拿到 AsyncMPClient(data_parallel_size > 1 时是 DPAsyncMPClient,每个 DP rank 一个 EngineCore 进程);离线 LLM 类背后的 V1 LLMEngine(vllm/v1/engine/llm_engine.py)默认拿到 SyncMPClient,只有设 VLLM_ENABLE_V1_MULTIPROCESSING=0 时才用 InprocClient,让 EngineCore 与前端同进程。V0 对应的是 vllm/engine/ 下的 LLMEngine / AsyncLLMEngine 与 MQLLMEngineClient。第 2 章 §2.6 细讲这几种 Client。

1.2.3 为什么用两套 IPC 协议

V1 在 API Server ↔ EngineCore 之间用 ZMQ,在 EngineCore ↔ Worker 之间用共享内存 MessageQueue。两套协议各司其职:

协议 位置 消息频率 消息大小 延迟要求 选型理由
ZMQ ROUTER/DEALER + PUSH/PULL API Server ↔ EngineCore 输入 per request;输出每拍一条批量 EngineCoreOutputs 小(token IDs + params / 新 token) 与 EngineCore 在不同进程,可与调度、前向重叠 msgpack(MsgpackEncoder)编码;ROUTER identity 还能按 DP rank 精确投递
Shared Memory MQ EngineCore ↔ Worker 高(per step) 每拍的 SchedulerOutput(新请求全量、老请求只发差量) 在关键路径上,必须远小于一拍 pickle 序列化后写入共享内存环形缓冲区,本地多读者直接读同一段内存,避开 socket 的内核态拷贝;超过 max_chunk_bytes(默认 10 MB)或远端读者才走 ZMQ

差别的本质在是否在关键路径上:ZMQ 那条链路的输入每个请求只走一次,输出虽然每拍一条,但由 EngineCore 的后台线程发送、由前端另一个进程消费,能与调度和 GPU 前向重叠;共享内存那条链路每一拍都卡在"调度 → 前向"之间,开销直接乘以 step 数,必须压到远小于一拍 GPU forward 的量级。具体数值取决于消息大小和硬件,需要在目标机器上实测。

1.3 V0 → V1 的七个结构性变革

V1 不是 V0 的 patch,而是引擎层的整体重写(模型实现、UniProcExecutor / Ray executor 基类等仍复用 V0 代码,见第 6 章 §6.9.4)。总结下来有七个结构性变革:

graph TB
    V0[V0 旧架构] -.变革 1.-> MP[多进程化]
    V0 -.变革 2.-> UT[统一 Token 调度]
    V0 -.变革 3.-> SW[有状态 Worker]
    V0 -.变革 4.-> PC[零开销前缀缓存]
    V0 -.变革 5.-> PCG[分段 CUDA Graph]
    V0 -.变革 6.-> AD[异步 detokenize]
    V0 -.变革 7.-> EA[Executor 抽象]
    MP --> V1[V1 新架构]
    UT --> V1
    SW --> V1
    PC --> V1
    PCG --> V1
    AD --> V1
    EA --> V1
    style V0 fill:#dc2626,color:#fff,stroke:none
    style V1 fill:#10b981,color:#333,stroke:none
# 变革 V0 V1 相关章节
1 多进程化 调度与前后处理挤在引擎进程里、共享一个 GIL API Server / EngineCore 分进程并行(多卡时 Worker 另起进程) 第 2 章
2 统一 Token 调度 Prefill / Decode 分离代码路径 {req_id: num_tokens} 统一 第 3 章
3 有状态 Worker 无状态,每 step 广播全量(仅 Ray SPMD 下可选 send_delta_data 发增量,默认关) 有状态,发 diff 第 6 章
4 零开销前缀缓存 opt-in,开了要付可观的吞吐代价 命中率为 0 时吞吐损耗 < 1%(V1 Alpha 官方博文口径),默认开 第 10 章
5 分段 CUDA Graph 整图 capture(默认档位 [1,2,4,8,...,max_num_seqs]) Piecewise:按 attention 算子切图,attention 走 eager、其余段进 graph(默认档位 [1,2,4]+[8,16,...,512]) 第 8 章
6 异步 detokenize 在引擎进程里做(async output processing 只能与 GPU 前向重叠,仍占引擎进程的 GIL) API Server 进程异步做 第 2、17 章
7 Executor 抽象层 已有 ExecutorBase 与 collective_rpc,但 mp / Ray 路径默认由与引擎同进程的 driver worker(rank 0)执行、再广播给其余 Worker V1 Executor 契约(KV cache 规格、max_concurrent_batches 等);多卡时 Worker 全部在引擎进程之外(MultiprocExecutor 独立进程、Ray 强制 SPMD),EngineCore 只经 collective_rpc 下发 第 6 章

官方 V1 Alpha 发布博文给出的口径是:相对 V0(未开 multi-step scheduling),V1 吞吐最高 1.7×;文本模型(Llama 3.1 8B / 3.3 70B on ShareGPT)在高 QPS 下延迟一致更低,VLM(Qwen2-VL on VisionArena)的相对提升比纯文本更大。博文同时点明:kernel 几乎没变,收益来自架构改进带来的 CPU 开销下降。

不是一个算法改进一个 kernel 替换加出来的——是全栈重构的结果。

1.4 五大子系统:代码库骨架

从全局视角看,vLLM 的代码库可以映射为五个子系统。理解它们的边界和交互,就掌握了整个系统骨架。

graph TB
    USER[👤 用户]
    USER --> ENT[① 入口层<br/>Entrypoints]
    ENT --> ENG[② 引擎核心<br/>Engine Core]
    ENG --> EXE[③ 执行层<br/>Executor + Worker]
    EXE --> MOD[④ 模型层<br/>Model Executor]
    MOD --> KER[⑤ 内核层<br/>CUDA + Triton Kernels]
    KER --> GPU[🔥 GPU]
    style ENT fill:#7c3aed,color:#fff,stroke:none
    style ENG fill:#db2777,color:#fff,stroke:none
    style EXE fill:#f59e0b,color:#333,stroke:none
    style MOD fill:#2563eb,color:#fff,stroke:none
    style KER fill:#10b981,color:#333,stroke:none

1.4.1 入口层 (Entrypoints)

vllm/entrypoints/ 是 vLLM 对外的窗口:

  • OpenAI API Server(vllm/entrypoints/openai/api_server.py)——最常用的入口,提供 /v1/chat/completions 等
  • LLM 类(vllm/entrypoints/llm.py)——离线批处理,llm = LLM(model="...") 直接跑
  • CLI 工具(entrypoints/cli/)——vllm serve 命令

核心原则:薄。入口层只做协议适配(OpenAI ↔ 内部 DTO),不含任何推理逻辑。

1.4.2 引擎核心 (Engine Core)

vllm/v1/engine/ + vllm/v1/core/ 是大脑:

  • EngineCore(vllm/v1/engine/core.py)——主循环,系统唯一的全局状态持有者
  • Scheduler(vllm/v1/core/sched/scheduler.py)——每一拍做什么的决策
  • KVCacheManager(vllm/v1/core/kv_cache_manager.py)——KV 块的分配、释放、共享
  • BlockPool(vllm/v1/core/block_pool.py)——物理块的 malloc 实现

哲学:集中决策,分布执行。Scheduler 看全局、做决策;Worker 只执行。

1.4.3 执行层 (Executor & Worker)

vllm/v1/executor/ + vllm/v1/worker/ 是肌肉:

  • Executor 接口(vllm/v1/executor/abstract.py)——屏蔽部署拓扑的抽象层
  • UniProc / Multiproc / Ray / ExternalLauncher 四种 Executor 实现(Executor.get_class() 按 distributed_executor_backend 分派)
  • GPU Worker(vllm/v1/worker/gpu_worker.py)——单张 GPU 的控制者
  • GPUModelRunner(vllm/v1/worker/gpu_model_runner.py)——每 step 前向计算的协调者

哲学:同一套 collective_rpc 接口覆盖单卡 / 多卡 / 多机。

1.4.4 模型层 (Model Executor)

vllm/model_executor/ 是具体模型的实现:

  • 模型家族(model_executor/models/)——Llama / Qwen / Mistral / DeepSeek / Gemma 等 134 个源文件;registry.py 六张分类表合计 163 条,合并成 _VLLM_MODELS 后是 146 个唯一 architecture key(第 7 章 §7.9 有逐表实测)
  • 层库(model_executor/layers/)——Attention、MLP、LayerNorm、Embedding 等基础组件
  • 量化(model_executor/layers/quantization/)——FP8 / GPTQ / AWQ / BnB 等 20+ 种
  • 权重加载器(model_executor/model_loader/)——从 HF / 本地 / S3 加载

哲学:可插拔。新模型 = 实现接口 + PR 提交,不动核心引擎。

1.4.5 内核层 (Kernels)

性能的最后一公里:

  • PagedAttention kernels(vllm/attention/ops/ + vllm_flash_attn/)
  • Triton kernels(vllm/attention/ops/ 下的 triton_* 文件、vllm/v1/sample/rejection_sampler.py、各种 fused 操作)
  • Punica LoRA kernels(vllm/lora/ops/triton_ops/,由 vllm/lora/punica_wrapper/ 封装调用)
  • 量化 kernels(Marlin / Machete / CUTLASS FP8)
  • 集合通信 kernels(NCCL wrapper vllm/distributed/device_communicators/)

CUDA/C++ 部分以 PyTorch 自定义算子(如 torch.ops._C_cache_ops.reshape_and_cache_flash)注册给 Python 调用,Triton 部分直接写在 Python 里、运行时 JIT 编译。

1.5 数据契约:四类跨边界 DTO

理解 vLLM 架构还有一个线索——跨进程/跨层边界的四类 DTO:

graph LR
    U[👤 用户请求] --> R1[ChatCompletionRequest 等<br/>API Server 内部 Pydantic]
    R1 --> R2[EngineCoreRequest<br/>跨进程最小化]
    R2 --> R3[Request<br/>EngineCore 内部有状态]
    R3 --> SO[SchedulerOutput<br/>Scheduler → Worker]
    SO --> MO[ModelRunnerOutput<br/>Worker → Scheduler]
    MO --> ECO[EngineCoreOutput<br/>跨进程回程]
    ECO --> U
    style R2 fill:#7c3aed,color:#fff,stroke:none
    style SO fill:#f59e0b,color:#333,stroke:none
    style MO fill:#f59e0b,color:#333,stroke:none
    style ECO fill:#7c3aed,color:#fff,stroke:none
DTO 边界 字段
EngineCoreRequest API Server → EngineCore 最小化:request_id、token IDs、多模态输入/哈希/占位、sampling params、eos_token_id、arrival_time、lora_request、current_wave
Request EngineCore 内部 含状态:num_computed_tokens、status、spec_token_ids(KV 块不在 Request 上,由 KVCacheManager.req_to_blocks 按 req_id 索引)
SchedulerOutput EngineCore → Worker 每 step 指令:num_scheduled_tokens dict、scheduled_new_reqs / scheduled_cached_reqs、finished_req_ids、num_common_prefix_blocks、结构化输出 bitmask
ModelRunnerOutput Worker → EngineCore 执行结果:sampled_token_ids、logprobs、spec_token_ids
EngineCoreOutput EngineCore → API Server 用户结果:new_token_ids、new_logprobs、finish_reason、stop_reason、events(多条打包进每拍一个 EngineCoreOutputs)

数据契约最小化(第 18 章的设计哲学)——每个 DTO 只带下游真正需要的字段。这让每一层都能独立演化:换 API Server 不动 EngineCore、换 Executor 不动 Scheduler。

1.5.1 msgspec.Struct 的四个参数:为什么不用 dataclass

打开 vllm/v1/engine/__init__.py:41 看 EngineCoreRequest 的真实定义:

class EngineCoreRequest(
    msgspec.Struct,
    array_like=True,       # ← ①
    omit_defaults=True,    # ← ②
    gc=False,              # ← ③
):
    request_id: str
    prompt_token_ids: list[int]
    mm_inputs: Optional[...]
    # ...

不是 Python dataclass、不是 Pydantic BaseModel——用 msgspec.Struct。这个类型本身加上三个关键字参数,构成了 vLLM 跨进程通信性能的底子。

① msgspec.Struct 本身(vs dataclass / Pydantic):

msgspec 是专门做序列化性能的库——其 JSON / MessagePack 编解码走 C 扩展实现。vLLM 用 ZMQ 在 API Server 和 EngineCore 之间传递 EngineCoreRequest(每个请求一次)和 EngineCoreOutputs(每一拍一次),后者的频率直接压在 step 频率上——这个选型影响的是 P99 延迟的稳定性,而不是峰值吞吐。

dataclass 没有内置序列化、Pydantic 的验证开销大(每次构造都做类型检查)。msgspec.Struct 介于两者之间:构造时不验证(类型注解只在用带类型的解码器解码时才检查)、序列化极快。vLLM 选它是因为 request 构造后很快就序列化传输、验证逻辑放在更上层(API Server 入口)完成。

② array_like=True:

默认 msgspec 序列化成 JSON 对象 {"request_id": "abc", "prompt_token_ids": [...], ...}——每个字段带 key 名。array_like=True 切换成按位置的数组:["abc", [...], ...]——省掉所有 key 字符串。

对 EngineCoreRequest 这种有 10 个字段的结构、JSON object 形式的 payload 要为每个字段名多带一份 key 字符串;array 形式只有值本身。代价是编码/解码两端必须严格按相同字段顺序——一端加字段另一端没同步就数据错位。前端和 EngineCore 来自同一份 vLLM 安装、共用同一个类定义,这个代价才可控。

③ omit_defaults=True:

字段值等于默认值时不序列化。current_wave: int = 0 这种字段、绝大多数请求都是 0——不发省字节。接收端发现字段缺失时用 Struct 定义里的默认值填。这是一个 "只传变化量" 的经典优化。

④ gc=False:

最精妙的参数——告诉 Python GC "别追踪这个对象的引用环"。Python 默认对每个 container 对象(list/dict/class instance)加 GC tracking——为了检测循环引用。但 EngineCoreRequest 是叶子 DTO:它持有 token 列表、SamplingParams、多模态输入等数据,这些对象不会回指请求本身,不构成循环引用(msgspec 文档明确:只有确定不会参与引用环时才能设 gc=False,否则环永远不会被回收)。

GC tracking 的代价:每个对象 +16 字节的 PyGC_Head、每次分配加入 gen-0 链表、gc.collect() 遍历时被扫到。对按请求频率分配的 EngineCoreRequest、按 step 频率分配的 EngineCoreOutputs 这类 DTO,这个 overhead 会以 GC 停顿的形式落到延迟尾部。gc=False 让 msgspec 生成的 struct 不进 GC 链表——少一次 list append/remove、少一次 scan、少 16 字节头部。

四个参数合起来让 EngineCoreRequest 的序列化成本接近"裸的二进制"——没有不必要的 key 字符串、没有默认值、没有 GC 开销。这是 API Server 与 EngineCore 之间每拍 IPC 能保持低开销的底层原因。

1.5.2 EngineCoreEvent 的 time.monotonic 明示限制

对照 EngineCoreEvent(line 74-89):

class EngineCoreEvent(msgspec.Struct):
    """A timestamped engine core event associated with a request.

    The timestamp is a monotonic timestamps and is used for by the engine
    frontend to calculate intervals between engine core events. These
    timestamps should not be compared with timestamps from other processes."""
    type: EngineCoreEventType
    timestamp: float

docstring 明确警告:"timestamps should not be compared with timestamps from other processes"——因为 Python 只保证 time.monotonic() 两次读数的差值有意义,参考点未定义;Linux 上同一台机器的进程虽然共享同一个单调时钟,但换一台机器(比如多节点部署)就完全不可比,把它当成可跨进程对齐的时间戳是在依赖平台细节。

正确做法:只用同一来源的 monotonic 时间算 interval、要对齐绝对时间就用 time.time()(墙钟)。vllm/v1/metrics/stats.py 正是这样分工的:queued_ts、scheduled_ts 等都来自 EngineCore 的 monotonic 时间戳,只在它们之间相减算排队、prefill、decode 时长;端到端延迟则用前端记录的墙钟 arrival_time 计算——文档明示、约束显式。这种"文档化的约束"比隐含的"大家别这么用"约定强得多——新贡献者读到就不会犯错。

1.5.3 current_wave 的 DP 竞态注释

EngineCoreRequest 的 current_wave: int = 0 字段(line 64)有个容易忽略的详细注释:

# Used in DP case to indicate which wave of requests this is expected to
# belong to, to cover a race condition where the request is sent before
# a wave finished notification is received.

场景:数据并行(DP)模式下,各 rank 的 EngineCore 以"wave"为单位同步运行——有请求时所有 rank 一起跑(没活的 rank 跑 dummy batch 陪跑),经 all-reduce 确认全局都没有未完成请求后,这一 wave 结束、各 rank 暂停,rank 0 向前端发 wave_complete(vllm/v1/engine/core.py 的 DPEngineCoreProc.run_busy_loop)。前端 DPAsyncMPClient 给每个新请求打上自己认为的 current_wave。竞态在于:前端还没收到"wave N 结束"就发出了标着 N 的新请求,而引擎已经进入 N+1 并暂停。

引擎的处理(DPEngineCoreProc.add_request)是:请求的 wave 比自己新就直接跟上;比自己旧且引擎处于暂停状态,就向前端回一条 start_wave,由前端通知其他 rank 一起开始下一 wave,请求本身照常加入调度。这样就消除了"请求到了、其他 rank 却没醒"的分布式竞态。

字段值 0 默认:非 DP 场景前端从不设置它,始终是默认值 0——omit_defaults=True 让这类请求不需要传这个字段、不额外增加带宽开销。msgspec 三个参数在这里和业务字段设计精确配合——不是一个装饰性选择、是性能和正确性缠在一起的工程系统。

1.6 目录结构快速导航

vllm/
├── v1/                         # V1 引擎(默认,本专栏主线)
│   ├── engine/                 # EngineCore, EngineCoreProc, Client
│   ├── core/                   # Scheduler, KVCacheManager, BlockPool
│   ├── executor/               # UniProc / Multiproc / Ray executor
│   ├── worker/                 # Worker, GPUModelRunner, InputBatch
│   ├── attention/backends/     # FlashAttention / FlashInfer / Triton / Pallas / MLA
│   ├── sample/                 # Sampler, RejectionSampler, penalties, topk/topp
│   ├── spec_decode/            # 投机解码(V1 只实现 ngram 与 EAGLE/EAGLE3,且需 VLLM_USE_V1=1)
│   ├── structured_output/      # guided decoding (XGrammar / Guidance)
│   ├── metrics/                # Prometheus 指标
│   └── kv_cache_interface.py   # KV cache 接口定义
│
├── engine/                     # V0 遗留(本专栏不涉及)
├── core/                       # V0 遗留
│
├── entrypoints/                # 入口层
│   ├── openai/                 # OpenAI 兼容 API
│   │   ├── api_server.py       # FastAPI app
│   │   ├── serving_chat.py     # Chat 协议
│   │   ├── serving_completion.py
│   │   ├── serving_embedding.py
│   │   └── protocol.py         # OpenAI Pydantic models
│   ├── cli/                    # vllm serve CLI
│   └── llm.py                  # 离线推理入口
│
├── model_executor/             # 模型层
│   ├── models/                 # 134 .py 文件、registry.py 注册 146 个唯一架构
│   ├── layers/                 # attention, mlp, norm, etc.
│   │   └── quantization/       # FP8 / GPTQ / AWQ / ...
│   └── model_loader/           # safetensors / HF / S3 加载
│
├── distributed/                # 分布式
│   ├── parallel_state.py       # TP / PP / EP / DP 进程组
│   ├── communication_op.py     # all_reduce / all_gather 等
│   ├── device_communicators/   # NCCL / shm_broadcast / XLA / HPU
│   └── kv_transfer/            # KV 跨机传输(Disagg Serving)
│
├── multimodal/                 # 多模态:图、视频、音频
├── lora/                       # LoRA 适配器 + punica kernels
├── attention/                  # 共享 attention 层(含 V0 遗留 ops)
├── compilation/                # torch.compile / piecewise CUDA graph
├── platforms/                  # CUDA / ROCm / TPU / CPU 平台适配
├── config.py                   # 全部配置类(SchedulerConfig, ModelConfig, VllmConfig ...)
├── sampling_params.py          # SamplingParams 定义
├── inputs/                     # 输入预处理
├── outputs.py                  # 输出结构
└── version.py                  # 版本号

读源码的起点:

  1. 主循环看 v1/engine/core.py::EngineCoreProc.run_busy_loop
  2. 调度看 v1/core/sched/scheduler.py::Scheduler.schedule
  3. 执行看 v1/worker/gpu_model_runner.py::GPUModelRunner.execute_model
  4. 采样看 v1/sample/sampler.py::Sampler.forward
  5. Attention 看 vllm/v1/attention/backends/flash_attn.py

从这 5 个文件出发,可以到达系统的任何角落。

1.6.1 vllm/v1/ 21013 行的十个子目录分布

把 v1 整个目录按子模块按行数统计一次——最大的不是 engine,是 worker——

子目录 行 份额 内容
worker/ 4851 23% GPUModelRunner(含 CUDA Graph、InputBatch 维护)——单个最重的子系统
engine/ 4121 20% EngineCore + EngineCoreProc + Client + IPC
attention/ 3081 15% FlashAttention / FlashInfer / Triton / Pallas / MLA backend
core/ 2840 14% Scheduler + KVCacheManager + BlockPool
sample/ 1627 8% Sampler + RejectionSampler + ops/(penalties、bad_words、topk/topp)
structured_output/ 981 5% XGrammar / Guidance 两个后端的适配
metrics/ 736 4% Prometheus + stats_logger
spec_decode/ 692 3% ngram proposer + EAGLE/EAGLE3 proposer
executor/ 653 3% MultiProc / Ray(UniProc 复用 vllm/executor/uniproc_executor.py)
stats/ 453 2% 运行时统计

三点非显然的观察——

  1. Worker 比 Engine 大 18%——直觉会认为"engine 是主循环、应该最重"——实际是 GPUModelRunner 把一次 execute_model 的所有细节都塞在 worker 里:InputBatch reshape、CUDA Graph 选择、sampling 调度、attention meta 构造、KV write 路径——全是 worker 的责任;engine 只是分派器
  2. attention 3081 行、比 sample 大近一倍——V1 的 backend(FlashAttention 2/3、FlashInfer、Triton、Pallas、MLA)每条路径各自维护一份 metadata 构造和 KV cache 形状——多后端兼容的代价;其中 mla/ 一个子目录就占了这 3081 行的四成(第 4 章 §4.9.1 有逐文件实测)
  3. executor/ 仅 653 行——跨进程执行架构本质上很薄——因为重活(worker 创建、RPC、NCCL 初始化)都委托给 Ray 或 multiprocessing——这条规律在 §14.8.4 kv_transfer 和 §14.6.5 device_communicators 也成立:vLLM 的设计纪律是"重通信语义用成熟库、自己只维护接口契约"

1.7 顶层模块账本:vllm/ 根目录的 29 个子系统

上节讲完 v1/ 内部十个子目录的行数分布,这一节把视野拉回到 vllm/ 根——整个 vLLM 仓库的子系统拓扑。在 v0.8.5 这个 tag 上,ls -d vllm/*/ 数出来正好 29 个顶层子目录,外加若干核心 .py 文件(config.py、sampling_params.py、outputs.py、sequence.py、envs.py、tracing.py 等)。按"谁依赖谁"的方向整理一遍、能看清 V1 在整个工程里的定位。

1.7.1 按职能分成的六个圈层

graph TB
    subgraph "圈层 ① 对外协议"
        ENT[entrypoints/<br/>OpenAI API · CLI · LLM 类]
        SP[sampling_params.py<br/>outputs.py]
    end
    subgraph "圈层 ② 引擎(新旧并存)"
        V1D[v1/<br/>21013 行 · 默认引擎]
        V0D[engine/<br/>V0 遗留主循环]
        V0C[core/<br/>V0 遗留 scheduler]
        V0W[worker/<br/>V0 遗留 worker]
        V0S[spec_decode/<br/>V0 投机解码]
    end
    subgraph "圈层 ③ 执行与通信"
        DIST[distributed/<br/>TP/PP/EP/DP + KV 传输]
        EXEC[executor/<br/>uniproc / ray 基类]
    end
    subgraph "圈层 ④ 模型与权重"
        ME[model_executor/<br/>134 files · 146 唯一架构]
        MM[multimodal/<br/>图/视频/音频]
        LORA[lora/<br/>punica + 加载]
        TRANS[transformers_utils/<br/>HF config/tokenizer 桥接]
        INP[inputs/<br/>输入预处理]
        AS[assets/<br/>测试用图/音/视频素材]
        PA["prompt_adapter/ + adapter_commons/"]
    end
    subgraph "圈层 ⑤ 性能底座"
        ATT[attention/<br/>共享 ops + V0 backend]
        FA[vllm_flash_attn/<br/>FA2/FA3 wrapper]
        COMP[compilation/<br/>torch.compile · piecewise graph]
        TU[triton_utils/]
        DA[device_allocator/<br/>cumem allocator]
        PLAT[platforms/<br/>CUDA/ROCm/TPU/CPU 适配]
    end
    subgraph "圈层 ⑥ 观测与扩展"
        PROF[profiler/]
        LOG[logging_utils/]
        USAGE[usage/]
        PLUG[plugins/<br/>OOT 模型注册]
        REASON[reasoning/<br/>推理内容解析]
        BM["benchmarks/ + third_party/"]
    end
    ENT --> V1D
    V1D --> DIST
    V1D --> ME
    V1D --> EXEC
    ME --> ATT
    ME --> FA
    ME --> LORA
    V1D --> COMP
    V1D --> MM
    V1D --> PLAT
    style V1D fill:#10b981,color:#333,stroke:none
    style V0D fill:#dc2626,color:#fff,stroke:none
    style V0C fill:#dc2626,color:#fff,stroke:none
    style V0W fill:#dc2626,color:#fff,stroke:none
    style V0S fill:#dc2626,color:#fff,stroke:none
    style ENT fill:#7c3aed,color:#fff,stroke:none

1.7.2 圈层与章节对应表

圈层 主要子目录 主责章节 辅助章节
① 对外协议 entrypoints/、sampling_params.py、outputs.py ch17 API Server ch09 Sampling
② 引擎(V1) v1/engine、v1/core ch02 EngineCore、ch03 Scheduler、ch05 KVCacheManager ch04 PagedAttention
② 引擎(V0 遗留) engine/、core/、worker/、spec_decode/ 本专栏不覆盖 ch18 设计哲学(演化脉络)
③ 执行与通信 v1/executor、v1/worker、distributed/、executor/ ch06 Worker & Executor、ch14 TP/PP/EP/DP ch17 生产部署
④ 模型与权重 model_executor/、multimodal/、lora/、transformers_utils/、inputs/、prompt_adapter/、adapter_commons/、assets/ ch07 模型加载、ch15 多模态、ch16 LoRA ch08 ModelRunner
⑤ 性能底座 attention/、vllm_flash_attn/、compilation/、triton_utils/、device_allocator/、platforms/ ch04 PagedAttention、ch08 CUDA Graph、ch13 量化 ch18 哲学(分层复用)
⑥ 观测与扩展 profiler/、tracing.py(v0.8.5 设 --otlp-traces-endpoint 会回退 V0)、logging_utils/、usage/、plugins/、reasoning/、benchmarks/、third_party/ ch17 生产部署 ch18 可扩展性

这张表是阅读本专栏时最常回翻的一张——当你定位到源码某个文件、忘记它属于哪一章时、循"文件 → 子目录 → 圈层 → 章节"的四跳路径就能回到正确的位置。

1.7.3 "V1 与 V0 并存"的工程含义

读者往往第一眼就注意到一个"反常"的现象:v1/ 是默认引擎、但 engine/ 和 core/ 两个 V0 遗留目录并没有被删除。这不是疏忽、而是 vLLM 社区的演化纪律。

V0 代码仍被保留的三个原因:

  • 不少路径还没迁完——v0.8.5 的 VLLM_USE_V1 默认为 "1",但未显式设置时由 EngineArgs._is_v1_supported_oracle()(vllm/engine/arg_utils.py)逐项判断,命中就回退到 V0:非 generate 任务(embedding 等)、Mamba / encoder-decoder 模型、计算能力 < 8.0、fp32、GGUF、非 fcfs 的 --scheduling-policy、--scheduler-delay-factor、显式指定的非 V1 attention backend(如 xFormers)、ngram / EAGLE / EAGLE3 之外的投机解码方法等;ngram、EAGLE 这类"实验性"特性和非 CUDA 平台默认也回退,只有显式设 VLLM_USE_V1=1 才走 V1(此时不支持的特性直接报错)。直接删除 V0 会让这些路径一夜崩掉。
  • 回归对照——当 V1 调度器出现诡异性能回退时、社区维护者可以在相同硬件上切回 V0 对照复现,这是工程 debugging 的"备胎"。
  • 教学与考古价值——V0 相对"朴素"的引擎架构对新 contributor 更好读,对照 V1 能把"为什么这样重构"讲清楚。第 18 章 §18.9 专门回顾这次重写。

本专栏的态度:本专栏 18 章里,除第 18 章的个别对照段落外,其余都以 V1 为主线。当你翻源码看到 vllm/engine/llm_engine.py(V0)和 vllm/v1/engine/core.py(V1)名字相近、不要混淆——V1 路径永远在 vllm/v1/ 下。一个实用判定:import 语句里出现 vllm.v1.xxx 就是 V1 代码,其他路径大概率是 V0 或跨版本共用。

1.8 架构全景大图:从 LLMEngine 到 Kernels 的一次性视图

前面几节把系统切成"进程/阶段/子系统/DTO"四个维度各讲一次——每次都强调某一面、但缺一张"一次性把所有东西串到同一张图上"的全景图。这一节补上这张图、并用它作为本章最重要的"离场帽子"。

graph TB
    USER([👤 外部调用者<br/>HTTP / SDK])
    subgraph "进程 P1 · API Server / 前端(CPU)"
        API[AsyncLLM(serve)/ V1 LLMEngine(LLM 类)<br/>+ EngineCoreClient]
        CLI_TOK[tokenizer / detokenizer]
        CLI_SSE[SSE / 长连接]
    end
    subgraph "进程 P2 · EngineCore(CPU 主循环)"
        LLME[EngineCoreProc<br/>run_busy_loop]
        SCHED[Scheduler<br/>调度决策]
        KVM[KVCacheManager<br/>块分配]
        BP[BlockPool<br/>物理块]
        SG[Request<br/>状态机 + 迭代状态]
        EXEC[Executor<br/>抽象层]
    end
    subgraph "进程 P3..PN · Worker × N(GPU)"
        W[Worker<br/>单卡总控]
        MR[GPUModelRunner<br/>InputBatch · CUDA Graph]
        MOD[Model<br/>Llama / Qwen / ...]
        ATT[Attention Backend<br/>FlashAttention / FlashInfer]
        SAMP[Sampler]
        KVT[KV Cache Tensors<br/>on GPU HBM]
    end
    subgraph "底层 · Kernels(C++/Triton)"
        PAK[PagedAttention Kernel]
        QUANT[FP8 / GPTQ / Marlin Kernel]
        NCCL[NCCL / shm_broadcast]
    end
    USER -->|HTTP POST| API
    API --> CLI_TOK
    API -->|ZMQ ROUTER→DEALER<br/>EngineCoreRequest| LLME
    LLME --> SG
    SG --> SCHED
    SCHED --> KVM
    KVM --> BP
    SCHED -->|SchedulerOutput| EXEC
    EXEC -->|共享内存 MQ<br/>广播 diff| W
    W --> MR
    MR -->|input_ids/block_tables| MOD
    MOD --> ATT
    ATT --> PAK
    MOD --> QUANT
    ATT -.读写.-> KVT
    MR --> SAMP
    SAMP -->|ModelRunnerOutput| W
    W -->|shm MQ 汇总| EXEC
    EXEC -->|回程| LLME
    LLME -->|EngineCoreOutput<br/>ZMQ PUSH| API
    API --> CLI_SSE
    CLI_SSE -->|SSE chunks| USER
    W <-->|AllReduce / AllGather| NCCL
    style API fill:#7c3aed,color:#fff,stroke:none
    style LLME fill:#db2777,color:#fff,stroke:none
    style SCHED fill:#f59e0b,color:#333,stroke:none
    style KVM fill:#f59e0b,color:#333,stroke:none
    style W fill:#10b981,color:#333,stroke:none
    style MR fill:#10b981,color:#333,stroke:none
    style PAK fill:#2563eb,color:#fff,stroke:none
    style NCCL fill:#2563eb,color:#fff,stroke:none

这张图上有几条容易被忽视的边、值得单独强调:

① Attention 到 KV Cache Tensors 是双向虚线——同一个 attention 层在一次 forward 里既写又读:先把本 step 新算出的 K/V 按 slot_mapping 写进 paged KV cache(torch.ops._C_cache_ops.reshape_and_cache_flash),再按 block_table 把包含刚写进去那部分的完整历史 KV 读回来算 attention。写和读是同一层里紧挨着的两个 op,不是"先读后写",也不是同一个 kernel——第 4 章 §4.5 会把这两步的源码位置逐行贴出来。

② Executor 既下行(广播 SchedulerOutput)、也上行(汇总 ModelRunnerOutput)——这是 collective_rpc 接口的本质:一次 RPC 既是广播、也是 gather;Executor 抽象层把"请求所有 worker 执行某函数、收集所有返回值"封装成一个调用。第 6 章会贴 abstract.py 的主要接口签名。

③ NCCL 出现在 Worker 之间而不是 Worker 与 EngineCore 之间——这是 TP/PP/EP 的通信物理定位:所有跨 GPU 集合通信都发生在 Worker 进程群内部、EngineCore 永远不参与 NCCL。这解释了为什么 vllm/distributed/parallel_state.py 的进程组初始化发生在 Worker 启动阶段(第 14 章)。

④ Model 到 FP8/GPTQ Kernel 没有经过 Attention——权重量化作用在 Linear 层(QKV projection、O projection、MLP up/down/gate,以及 MoE 专家)上,走的是量化 GEMM kernel;Attention 的 KV 读写属于另一条线——KV cache 量化(如 FP8 KV)由 attention backend 自己处理,第 13 章 §13.7 单独讲。

把这张图钉在读源码时的显示器角落——看到任何一个模块都能回到它在全景里的位置。

1.9 读源码的三个"切入姿势"

理解了架构、下一步是真正打开 IDE 读 vLLM 源码。不同目的下的切入姿势不同——走错路会花几小时读无关的代码。给出三个最常用的入口:

1.9.1 姿势 A:追"一条请求"(推荐第一次读源码的人)

从 vllm/entrypoints/openai/api_server.py 的 /v1/chat/completions handler 入手、用 IDE 的 Go to Definition 一路跟进去、直到进入 vllm/v1/worker/gpu_model_runner.py::execute_model。中间会经过:

  1. serving_chat.py 的协议适配(chat template 渲染)
  2. AsyncLLM 的 generate()(vllm/v1/engine/async_llm.py)
  3. ZMQ client → EngineCoreProc(vllm/v1/engine/core_client.py + core.py)
  4. Scheduler.schedule()(vllm/v1/core/sched/scheduler.py)
  5. Executor 的 execute_model(vllm/v1/executor/multiproc_executor.py 或 ray_distributed_executor.py)
  6. Worker 的 execute_model + GPUModelRunner

追完一遍后你就知道"每一层在哪个文件"——这比读任何综述性文档都有效。

1.9.2 姿势 B:追"一个特性"(想加 feature 或 fix bug 的人)

从 tests/v1/ 目录里找对应特性的测试用例、以测试为锚点反向定位源码。例如:

  • 想懂前缀缓存 → 读 tests/v1/core/test_prefix_caching.py → 追到 vllm/v1/core/kv_cache_manager.py
  • 想懂 Chunked Prefill → 读 tests/v1/core/test_scheduler.py 的相关测试 → 追到 vllm/v1/core/sched/scheduler.py 的 schedule()(V1 没有独立的 _schedule_prefills,prefill 与 decode 在同一个方法里共用一份 token budget)
  • 想懂 Spec Decode → 读 tests/v1/spec_decode/test_ngram.py → 追到 v1/spec_decode/ngram_proposer.py

测试代码用最少的 setup 触发目标代码路径、是天然的"最小复现案例"。配合 pytest 的 --trace 可以直接单步调试。

1.9.3 姿势 C:追"一次 benchmark"(做性能调优的人)

从 benchmarks/benchmark_serving.py 或 benchmarks/benchmark_throughput.py 入手、用 PyTorch Profiler / NVIDIA Nsight 录一段 trace、再回源码定位。这个姿势回答"时间到底花在哪"——光靠读代码猜不到真实瓶颈。第 17 章 §17.8 介绍压测工具;Profiler / Nsight 的具体用法本专栏不展开。

1.9.4 三种姿势的共同底层:查找入口

无论哪种姿势、最关键的能力是快速定位入口文件。记住这五个"根入口":

入口 路径 用途
HTTP 根 vllm/entrypoints/openai/api_server.py 所有 OpenAI 协议请求起点
离线根 vllm/entrypoints/llm.py::LLM.generate llm.generate(prompts) 起点
主循环根 vllm/v1/engine/core.py::EngineCoreProc.run_busy_loop 整个系统的心跳
调度根 vllm/v1/core/sched/scheduler.py::Scheduler.schedule 每 step 决策的起点
前向根 vllm/v1/worker/gpu_model_runner.py::GPUModelRunner.execute_model GPU 计算的起点

把这五个路径抄写下来贴在显示器边——比任何"vLLM 入门 PPT"都实用。

1.10 章节地图:本专栏每一章对应什么

用这张表作为本专栏的导航:

graph LR
    subgraph "第一篇 · 全景"
        C1["ch01 架构总览<br/>当前章节,本专栏地图"]
    end
    subgraph "第二篇 · 引擎核心"
        C2["ch02 EngineCore"]
        C3["ch03 Scheduler"]
        C4["ch04 PagedAttention"]
        C5["ch05 KVCacheManager"]
    end
    subgraph "第三篇 · 执行层"
        C6["ch06 Worker & Executor"]
        C7["ch07 模型加载"]
        C8["ch08 ModelRunner & CUDA Graph"]
        C9["ch09 Sampling"]
    end
    subgraph "第四篇 · 性能优化"
        C10["ch10 前缀缓存"]
        C11["ch11 Chunked Prefill"]
        C12["ch12 投机解码"]
        C13["ch13 量化"]
    end
    subgraph "第五篇 · 分布式与工程"
        C14["ch14 TP/PP/EP/DP"]
        C15["ch15 多模态"]
        C16["ch16 LoRA"]
        C17["ch17 API Server"]
        C18["ch18 设计哲学"]
    end
    C1 --> C2 --> C3 --> C4 --> C5
    C5 --> C6 --> C7 --> C8 --> C9
    C9 --> C10 --> C11 --> C12 --> C13
    C13 --> C14 --> C15 --> C16 --> C17 --> C18
    style C1 fill:#2563eb,color:#fff,stroke:none
    style C18 fill:#10b981,color:#333,stroke:none

1.10.1 按问题驱动阅读

如果你不想从头通读一遍,这里是常见问题的章节映射:

你的问题 主读 辅读
服务 p99 TTFT 不稳定 ch03 Scheduler, ch11 Chunked Prefill ch10 前缀缓存
KV OOM 频繁 ch04 PagedAttention, ch05 KVCacheManager ch10 前缀缓存
想让 decode 更快 ch12 投机解码, ch13 量化 ch08 CUDA Graph
想加新模型 ch07 模型加载, ch08 ModelRunner 模型家族源码 model_executor/models/
想上多机 ch06 Executor, ch14 TP/PP/EP/DP ch17 生产部署
想 host 多个微调 ch16 LoRA ch13 量化
接图片/视频输入 ch15 多模态 ch07 模型加载
为什么 V1 吞吐比 V0 最高快 1.7× ch02、ch03、ch06、ch08 交叉 ch18 哲学
部署到 K8s ch17 API Server & 生产部署 ch06 Executor

1.10.2 按角色阅读

  • AI 基础设施工程师:ch01 → ch02 → ch03 → ch06 → ch14 → ch17
  • 推理算法研究者:ch01 → ch04 → ch10 → ch11 → ch12 → ch13
  • 系统架构师:ch01 → ch02 → ch06 → ch14 → ch18(强推荐,架构原则全在这)
  • 好奇的开发者:ch01 → ch04 → ch18,三章看完能在饭桌上聊 vLLM

1.11 组件协作序列图:一拍里到底发生了什么

给"一拍(step)"画一张精细的 sequence diagram,粒度从 LLMEngine 进入主循环一直到 Worker 把 ModelRunnerOutput 送回来。这张图比前面的 block diagram 多一个维度:时间轴——谁在等谁、谁和谁可以并行。

sequenceDiagram
    autonumber
    participant API as API Server<br/>AsyncLLM
    participant CORE as EngineCoreProc<br/>run_busy_loop
    participant SCHED as Scheduler
    participant KVM as KVCacheManager
    participant BP as BlockPool
    participant RS as Request + State
    participant EXEC as Executor
    participant W as Worker × N
    participant MR as GPUModelRunner
    participant M as Model + Attention
    participant S as Sampler
    API ->>+ CORE: EngineCoreRequest<br/>(ZMQ PUSH)
    CORE ->> RS: 构造 Request 对象<br/>status=WAITING
    CORE ->>+ SCHED: schedule()
    SCHED ->> RS: 枚举 waiting / running 队列
    SCHED ->>+ KVM: allocate_slots(req, n_tokens)
    KVM ->>+ BP: 查空闲块数,够则 get_new_blocks(n)
    BP -->>- KVM: 空闲块数 / 新块列表
    KVM -->>- SCHED: 新块 / None(空闲块不够)
    alt 返回 None
        SCHED ->> KVM: free(running 队尾请求)
        KVM ->> BP: free_blocks(被抢占请求的块)
    end
    SCHED -->>- CORE: SchedulerOutput<br/>(num_scheduled_tokens)
    CORE ->>+ EXEC: execute_model(SchedulerOutput)
    EXEC ->>+ W: 共享内存广播 diff
    W ->>+ MR: execute_model(SchedulerOutput)
    MR ->> MR: _update_states<br/>(差量更新 InputBatch)
    MR ->> MR: _prepare_inputs<br/>构造 input_ids / block_tables / slot_mapping
    MR ->>+ M: forward(CUDA Graph replay 或 eager)
    M ->> M: Attention (读/写 KV cache)
    M -->>- MR: hidden_states / logits
    MR ->>+ S: sampler.forward(logits)
    S -->>- MR: sampled_token_ids
    MR -->>- W: ModelRunnerOutput
    W -->>- EXEC: 汇总
    EXEC -->>- CORE: ModelRunnerOutput
    CORE ->> SCHED: update_from_output<br/>(追加新 token、检查停止)
    CORE ->>- API: EngineCoreOutput<br/>(ZMQ PUSH)
    API ->> API: detokenize + SSE 下发

1.11.1 五个值得品一遍的细节

细节 ①:allocate_slots 可能返回"失败"——第 5 步。不是每一拍都能 alloc 成功;空闲块不够时 allocate_slots 返回 None,调度器走抢占分支(第 9 步):self.running.pop() 把 running 队尾的 Request 踢出去、kv_cache_manager.free() 把它的 KV 块归还 BlockPool。被抢占的 Request 的 status 变成 PREEMPTED、num_computed_tokens 被清零并插回 waiting 队首;开着前缀缓存且这些块还没被驱逐时,下次调度能靠前缀缓存把已算过的完整块找回来。这是第 3 章讲调度公平性时的核心机制。

细节 ②:_update_states 是"差量"不是"全量"——第 15 步。V1 的 Worker 是有状态的、自己维护 InputBatch(所有 RUNNING 请求的 token IDs、positions、block tables);每一拍只发"变化量"(新增请求、追加 token、完成请求)。V0 每 step 要广播全部 RUNNING 请求的完整状态——广播量随并发数线性增长,并发越高越吃亏。这是七大变革里 "有状态 Worker" 的具体落地。

细节 ③:CUDA Graph replay 或 eager——第 17 步。是否走 graph 取决于本拍的 token 数是否不超过最大 capture size:不超过就 pad 到不小于它的最小档位再 replay,超过就走 eager(V1 默认档位是 [1,2,4] + [8,16,...,512],再按 max_num_batched_tokens 截断,见 vllm/config.py::_set_cudagraph_sizes)。V1 的 piecewise CUDA Graph 按 attention 算子把整图切开:attention 段走 eager(KV 长度可变),其余段各自 capture。第 8 章会把切分规则完整列出来。

细节 ④:Attention (读/写 KV cache)——第 18 步。这一步在源码里是两个紧挨着的 op:先 reshape_and_cache_flash(key, value, key_cache, value_cache, slot_mapping, ...) 把本 step 的 K/V 写进 paged 内存,再 flash_attn_varlen_func(..., block_table=block_table) 读回全部历史 KV 算注意力(vllm/v1/attention/backends/flash_attn.py)。这就是前面 1.8 全景图里那条"双向虚线"的真实含义——不要脑补成"一个 kernel 内部顺手写了"。

细节 ⑤:Scheduler.update_from_output 的副作用——第 25 步。这一步不只是"把新 token 追加到 Request"——它还会经 check_stop() 判断 Request 是否完成(EOS 或 stop_token_ids、达到 max_tokens 或 max_model_len;stop 字符串要在前端 detokenize 之后才能判断,由前端检测后再 abort)。完成的 Request 当场经 _free_request() 把 KV 块归还 BlockPool,从 running 列表移除,请求 ID 记进 finished_req_ids,随下一拍的 SchedulerOutput 通知 Worker 清理缓存状态。开启统计(log_stats)时,返回的 EngineCoreOutputs 里还带着 SchedulerStats(running / waiting 数、KV 使用率、前缀缓存命中等),前端据此更新 Prometheus 指标(第 17 章)。

1.11.2 哪些步骤可以并行?

这张图是一拍的串行因果链、但几拍之间有流水线并行可能:

  • 拍 N 的 CPU 调度 ∥ 拍 N-1 的 GPU 前向:默认不并行(EngineCore 单线程)。只有 Executor.max_concurrent_batches > 1 时主循环才切到 step_with_batch_queue()(vllm/v1/engine/core.py:209)、把多个 batch 先塞进流水线再回收最早的结果;v0.8.5 里满足这个条件的只有 pipeline_parallel_size > 1 的 Ray executor,和 enable_chunked_prefill 无关——第 2 章 §2.8 详细讨论。
  • API Server 的 detokenize ∥ EngineCore 的下一拍:天然并行。detokenize 发生在独立进程、不影响 EngineCore。这是 V1 架构相比 V0 最直观的吞吐收益来源之一。
  • Worker 的 NCCL 通信 ∥ 计算:默认 TP 路径里,All-Reduce 在 RowParallelLinear 之后按顺序调用,不做专门的通信-计算重叠;v0.8.5 里显式的重叠开关只有 Ray Compiled Graph 下实验性的 VLLM_USE_RAY_COMPILED_DAG_OVERLAP_COMM(默认关,第 14 章 §14.9.2)。

记住这个并行清单——性能调优的许多决策(要不要开 chunked prefill、TP 度设多大、API Server 实例数)都围绕这三组并行可能性展开。

1.12 18 章与架构的精确映射

前面 1.10 节给了按问题和按角色的阅读路径、这一节补上最细粒度的一张映射表——本专栏每一章深入的是架构图的哪一个具体组件。适合"想研究某一组件时精确定位章节"的场景。

章节 架构组件 源码主路径 本章要解决的核心问题
ch01(当前) 全景 vllm/ 根 读一次,知道"哪一章讲哪部分"
ch02 EngineCore EngineCore + IPC vllm/v1/engine/core.py vllm/v1/engine/async_llm.py vllm/v1/engine/core_client.py run_busy_loop 每一拍做什么、ZMQ 怎么收发、几种 EngineCoreClient 怎么选
ch03 Scheduler Scheduler vllm/v1/core/sched/scheduler.py 为什么一个字典能替代 V0 的两条代码路径、抢占/连续 batching/chunked 怎么做决策
ch04 PagedAttention Attention Backend + KV Cache Tensors vllm/v1/attention/backends/flash_attn.py vllm_flash_attn/ vllm/attention/ 分页为什么能把"预留 + 对齐 + 外部碎片"三类浪费一起消掉、block_table 与 slot_mapping 的物理布局
ch05 KVCacheManager KVCacheManager + BlockPool vllm/v1/core/kv_cache_manager.py vllm/v1/core/block_pool.py allocate/free/preempt 的数据结构(FreeKVCacheBlockQueue、ref_cnt、cached_block_hash_to_block)
ch06 Worker & Executor Executor + Worker vllm/v1/executor/abstract.py vllm/v1/executor/multiproc_executor.py vllm/v1/worker/gpu_worker.py collective_rpc 统一接口、三种 Executor 各自适合什么场景
ch07 模型加载 Model Loader model_executor/model_loader/ vllm/model_executor/models/registry.py HF / S3 / safetensors 怎么加载、qkv_proj 怎么融合
ch08 ModelRunner & CUDA Graph GPUModelRunner + compilation/ vllm/v1/worker/gpu_model_runner.py compilation/ InputBatch 怎么维护、CUDA Graph 为什么切在 attention 处分段 capture 而不是整图录制
ch09 Sampling Sampler vllm/v1/sample/sampler.py v1/sample/ops/ Sampler.forward 的处理流水线、贪心与随机采样怎么在一个 batch 里混合
ch10 前缀缓存 KVCacheManager.get_computed_blocks + BlockPool.cached_block_hash_to_block vllm/v1/core/kv_cache_manager.py vllm/v1/core/block_pool.py 零开销怎么做到(官方口径:命中率为 0 时吞吐损耗 <1%)、hash 碰撞怎么处理
ch11 Chunked Prefill Scheduler.schedule() 的 waiting 段 vllm/v1/core/sched/scheduler.py 怎么切、切多大、和 decode 共存的 token budget 模型
ch12 投机解码 v1/spec_decode/ v1/spec_decode/ v0.8.5 的 V1 只落地了 ngram 与 EAGLE/EAGLE3 两条路径(需 VLLM_USE_V1=1,否则回退 V0),它们在调度上有什么差异
ch13 量化 model_executor/layers/quantization/ model_executor/layers/quantization/ FP8 / GPTQ / AWQ / Marlin 的 kernel 选择矩阵
ch14 TP/PP/EP/DP distributed/ + Worker 进程组 vllm/distributed/parallel_state.py vllm/distributed/communication_op.py 并行策略的通信模式、何时混合使用
ch15 多模态 multimodal/ + model_executor/models/ VLM 家族 multimodal/ vllm/model_executor/models/qwen2_vl.py 等 图像预处理何时发生、vision encoder 的 placement
ch16 LoRA lora/ + Punica kernels lora/ lora/punica_wrapper/ 多 LoRA 共存的 batching、Punica 的 kernel 融合思路
ch17 API Server entrypoints/ + 可观测 entrypoints/openai/ v1/metrics/ 生产环境的 p99 调优、SSE 长连接稳定性
ch18 设计哲学 全栈回顾 —— 把前 17 章的设计决策提炼成 12 条可迁移的工程原则

怎么用这张表:把本章学到的架构图放左侧、这张表放右侧——随时可以定位"我想深入的那个框对应哪一章、源码在哪个文件",维持"大地图 + 当前位置"的认知框架。

1.12.1 三条"贯穿各章的主线"

除了每章独立的映射、还有三条跨章节的主线、理解它们能帮你把分散的知识点串成脉络。

主线 A · "数据流":Request 从构造到销毁一共跨越 ch02/ch03/ch05/ch06/ch08/ch09 六章。每一章讲这个对象在它那一层的新字段和状态变化——读完就能完整理解"一个请求在系统里的生命周期"。

主线 B · "内存":KV 内存管理散落在 ch04(物理布局)、ch05(块池数据结构)、ch10(前缀共享)、ch11(chunked 对 KV 压力的缓解)、ch14(跨机 KV 传输)五章。这条线是理解 vLLM 性能天花板的关键——GPU 显存永远是最稀缺的资源、整个专栏一半的性能优化都绕着它转。

主线 C · "并行":并行不是只有 ch14 讲——ch02(进程并行)、ch06(Executor 抽象)、ch08(CUDA Graph 内部并行)、ch14(TP/PP/EP/DP)、ch17(副本并行)五章各自讨论不同粒度的并行。读懂这条线你就能回答"在任何时刻、vLLM 里有多少个层次的并行在同时发生"这个看似简单实则刁钻的问题。

读后续章节时,自己把每一章归到其中一条主线上——比孤立地读单章收获大得多。

1.12.2 "先看一遍全景再深入"的阅读节奏

很多读者担心"第 1 章介绍了一堆还没讲细节的东西、会不会记不住"——这正是本章的设计目的:不求你现在就懂所有细节、只求你脑子里有一张"粗糙但完整"的地图。读后续每一章时,可以这样用这张地图:

  1. 先在本章的架构图上找到这一章深入的那个框
  2. 把该章展开的内部细节和这个框对上号
  3. 读完把学到的内容填回本章的全景图

这样读完 18 章、你不仅知道每个组件怎么实现、还知道它在整个系统里处于什么位置、和相邻组件怎么协作。这就是本专栏追求的"体系化"——不是"讲完所有细节"、是"让读者自己能把细节拼回整体"。

1.13 与同类推理框架的架构对比

vLLM 不是唯一的 LLM 推理框架——TGI(Hugging Face Text Generation Inference)、TensorRT-LLM(NVIDIA)、SGLang、LMDeploy 都在同一竞技场上。读懂 vLLM V1 的架构、回头对照这些同类、能理解vLLM 为什么在开源生态里被广泛采用——不是某一个点强、是整体架构选型的一致性。

维度 vLLM V1 TGI TensorRT-LLM SGLang
进程模型 API / Engine / Worker 三进程(单卡时 Worker 在 EngineCore 进程内) Router(Rust)+ Shard(Python)双进程 不拆 API/引擎/Worker,进程拓扑随并行配置在启动时固定 TokenizerManager(主进程,含 HTTP)+ Scheduler 子进程 + DetokenizerManager 子进程,ZMQ 通信
KV 管理 PagedAttention + 块池 Paged(继承 vLLM 思想)+ 定长 slot Paged KV Cache(C++ 实现) RadixAttention(Radix 树前缀共享)
调度 统一 token-level 调度、chunked 默认开 continuous batching + chunked prefill Inflight batching(近似 continuous) Radix 树驱动调度
投机解码 V1 已落地 ngram + EAGLE/EAGLE3(v0.8.5 需显式 VLLM_USE_V1=1,否则回退 V0);medusa / mlp_speculator / draft model 在 v0.8.5 仍走 V0 仅 medusa 和 n-gram Medusa / EAGLE / draft EAGLE-2 / ngram
可扩展性 Ray / Multiproc / UniProc / ExternalLauncher 四种 Executor 单机多卡为主、多机依赖外部编排 静态并行配置、启动时固定 类似 vLLM,Python 主导
语言栈 Python 为主 + CUDA kernels Rust(router)+ Python(model) C++ 为主、Python 仅前端 Python 为主
生态包容性 HF 模型覆盖广,未原生实现的可走 Transformers 回退 聚焦少量 HF 模型、有深度优化 需要导出 + 引擎构建(门槛高) HF 模型 + 自带前端语言(SGLang 本身即 Structured Generation Language)

三个非显然的观察:

  • "把 CPU 活拆到独立进程"不是 vLLM 独有、但 vLLM 把"调度 + 块池 + 统一 token 调度"组合得很完整——SGLang 同样把 tokenize、调度、detokenize 拆进不同进程,TGI 用 Rust router + Python shard 两层,各自在调度粒度和 KV 管理上有不同倾向;vLLM 的取舍偏向在"通用性 × 性能 × 生态"三个维度上兼顾。
  • TensorRT-LLM 走的是"静态最优"路线——构建引擎时把并行度、最大 batch size、kernel 选择固定下来、换配置要重建引擎;跑对了场景极快、但失去了 vLLM 的动态调度弹性。
  • SGLang 的 RadixAttention 和 vLLM 的前缀缓存是"同一个思想的两种实现"——都是把 KV 按 token 前缀去重;vLLM 用块级 hash 表、SGLang 用 trie。不是谁对谁错,是数据结构的不同选择——vLLM 的选择换来更简单的块分配代码、SGLang 的选择换来更自然的多分支共享(tree-of-thought 推理)。

读完本章、对 vLLM 的架构选择有了"为什么是这样"的答案——不是因为这些选择唯一正确、而是因为它们在 "通用开源框架" 这个定位下是一组自洽的取舍。

1.14 本章小结

本章通过"跟一条请求走完全程"的方式,建立了 vLLM V1 架构的全景认知:

  • 六阶段旅程:HTTP → tokenize → 排队 → 调度 → GPU 前向 → detokenize → SSE 输出
  • 三进程拓扑:API Server(CPU 密集)+ EngineCore(CPU 密集)+ Worker(GPU 密集),物理并行、GIL 隔离
  • 两套 IPC 协议:ZMQ(API↔EngineCore,输入按请求、输出每拍一批,不在调度关键路径上)+ 共享内存 MQ(EngineCore↔Worker,每拍都在关键路径上,pickle 后写入共享内存环形缓冲区)
  • 七个 V0→V1 变革:多进程化、统一调度、有状态 Worker、零开销前缀缓存、分段 CUDA Graph、异步 detokenize、Executor 抽象
  • 五大子系统:Entrypoints / Engine Core / Executor+Worker / Model Executor / Kernels
  • 四类跨边界 DTO:EngineCoreRequest(前端→引擎)/ SchedulerOutput(引擎→Worker)/ ModelRunnerOutput(Worker→引擎)/ EngineCoreOutput(引擎→前端);Request 是 EngineCore 进程内部的有状态对象,不跨边界
  • 源码目录导航:V1 代码在 v1/ 下,从 vllm/v1/engine/core.py 起步
  • 章节地图:18 章按"全景 → 引擎核心 → 执行层 → 性能优化 → 分布式与工程(含第 18 章设计哲学)"五篇组织
  • 物理骨架:v1/ 21013 行、worker 23% 是最大子系统、executor 3% 是最薄(重活委托给 Ray / multiprocessing)
  • 六圈层拓扑:从"对外协议 → 引擎 → 执行与通信 → 模型与权重 → 性能底座 → 观测与扩展",v0.8.5 的 29 个顶层子目录全部对号入座
  • 全景大图:AsyncLLM → EngineCore → Scheduler → KVCacheManager → Executor → Worker → ModelRunner → Attention ↔ KV Cache → Sampler 一张图串全栈
  • 一拍 sequence:27 个步骤粒度的组件协作图,标出"差量更新 InputBatch"、"CUDA Graph replay vs eager"、"先按 slot_mapping 写 KV、再按 block_table 读 KV" 等五个非显然细节
  • 18 章精确映射:每章对应全景图的某个具体组件、主路径源码、要解决的核心问题——阅读时随时定位

1.15 下一步

第 2 章会钻进 EngineCoreProc.run_busy_loop——把本章反复提到的"一拍"拆成十几个微观动作、贴 vllm/v1/engine/core.py 的每一段关键代码,并讲清楚 ZMQ 这套 IPC 在源码层面到底长什么样(共享内存 MQ 留到第 6 章)。如果你读本章时对"三进程/两协议/差量更新"三个概念产生了"直觉上懂但想看代码"的冲动——第 2 章就是为这种冲动准备的。

翻开前、建议再看一眼 1.8 的全景大图——第 2 章的每一段代码、都会对应这张图里的一个位置。读完第 2 章、整张全景图的"中央大脑"部分(API Server + EngineCore 连接线)会从"概念"变成"代码"。


延伸阅读

  • vLLM V1 Alpha 发布博文:https://blog.vllm.ai/2025/01/27/v1-alpha-release.html
  • V1 设计 RFC:GitHub Issue #8779
  • PagedAttention 原论文:Kwon et al., "Efficient Memory Management for Large Language Model Serving with PagedAttention", SOSP 2023 (arXiv:2309.06180)
  • FlashAttention 系列:Dao et al., FlashAttention (NeurIPS 2022) / FlashAttention-2 (ICLR 2024) / FlashAttention-3 (NeurIPS 2024)

源码起点

  • 主循环:vllm/v1/engine/core.py
  • Scheduler:vllm/v1/core/sched/scheduler.py
  • Worker:vllm/v1/worker/gpu_worker.py
  • ModelRunner:vllm/v1/worker/gpu_model_runner.py
  • API Server:vllm/entrypoints/openai/api_server.py