vLLM 推理内核深度解析
第1章 架构总览:从一条请求读懂整个 vLLM
读懂一座城市的交通,光看线路图不够——得跟着一班车从起点坐到终点。这一章就跟着一条请求,走完 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 做三件事:
- 参数校验:用 Pydantic schema 校验
model、temperature、top_p、messages结构等。校验不通过直接返回 400。 - Chat Template 渲染 + Tokenize:messages 数组通过模型自带的
chat_template(Jinja2 格式,来自 tokenizer 的元数据)渲染成单一 prompt 字符串,再用 tokenizer 分词成prompt_token_ids。 - 构造 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,由它依次:
_update_states:差量更新本地InputBatch——新请求添加槽位、老请求追加 token_prepare_inputs:构造 GPU 输入张量(input_ids、positions、block_tables、slot_mapping、cu_seqlens)- 前向:
- 若开了 CUDA Graph 且 token 数不超过最大 capture size → pad 到不小于它的最小 capture size → replay
- 否则 eager 模式
sampler:从 logits 采样出新 token- 返回
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 分发:
- detokenize:token_id → 字符片段(fast tokenizer 走 HuggingFace tokenizers 的
DecodeStream增量解码,见vllm/v1/engine/detokenizer.py),结果放进该请求的RequestOutputCollector - 对应的 FastAPI handler 在
async for循环里取到RequestOutput,做 SSE 封装:data: {"choices": [{"delta": {"content": "Page"}}]}\n\n - 通过 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 # 版本号
读源码的起点:
- 主循环看
v1/engine/core.py::EngineCoreProc.run_busy_loop - 调度看
v1/core/sched/scheduler.py::Scheduler.schedule - 执行看
v1/worker/gpu_model_runner.py::GPUModelRunner.execute_model - 采样看
v1/sample/sampler.py::Sampler.forward - 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% | 运行时统计 |
三点非显然的观察——
- Worker 比 Engine 大 18%——直觉会认为"engine 是主循环、应该最重"——实际是 GPUModelRunner 把一次
execute_model的所有细节都塞在 worker 里:InputBatch reshape、CUDA Graph 选择、sampling 调度、attention meta 构造、KV write 路径——全是 worker 的责任;engine 只是分派器 - attention 3081 行、比 sample 大近一倍——V1 的 backend(FlashAttention 2/3、FlashInfer、Triton、Pallas、MLA)每条路径各自维护一份 metadata 构造和 KV cache 形状——多后端兼容的代价;其中
mla/一个子目录就占了这 3081 行的四成(第 4 章 §4.9.1 有逐文件实测) - 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。中间会经过:
serving_chat.py的协议适配(chat template 渲染)AsyncLLM的generate()(vllm/v1/engine/async_llm.py)- ZMQ client →
EngineCoreProc(vllm/v1/engine/core_client.py+core.py) Scheduler.schedule()(vllm/v1/core/sched/scheduler.py)- Executor 的
execute_model(vllm/v1/executor/multiproc_executor.py或ray_distributed_executor.py) - 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 章介绍了一堆还没讲细节的东西、会不会记不住"——这正是本章的设计目的:不求你现在就懂所有细节、只求你脑子里有一张"粗糙但完整"的地图。读后续每一章时,可以这样用这张地图:
- 先在本章的架构图上找到这一章深入的那个框
- 把该章展开的内部细节和这个框对上号
- 读完把学到的内容填回本章的全景图
这样读完 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