《vLLM 内核探秘》完整目录
- 前言
- 第1章 架构总览(当前)
- 第2章 EngineCore:引擎的心脏
- 第3章 调度器:Token 的交通指挥
- 第4章 PagedAttention:虚拟内存的启示
- 第5章 KV Cache 管理:寸土寸金的显存
- 第6章 Worker 与 Executor:GPU 军团
- 第7章 模型加载与权重管理
- 第8章 前向计算与 CUDA Graph
- 第9章 采样与输出处理
- 第10章 前缀缓存:零开销的加速
- 第11章 分块预填充与混合批处理
- 第12章 投机解码:以小博大
- 第13章 量化引擎:精度与速度的平衡
- 第14章 张量并行与流水线并行
- 第15章 多模态推理
- 第16章 LoRA 适配器热切换
- 第17章 API 服务器与生产部署
- 第18章 设计模式与架构哲学
第1章 架构总览
"The best way to understand a city's traffic system is not to study the route map, but to ride a bus from terminal to terminal."
:::tip 本章要点
- 跟踪一个推理请求从 HTTP 到 Token 输出的完整旅程
- 理解 V1 引擎的多进程架构及其设计动因
- 掌握 vLLM 五大核心子系统的职责边界
- 认识 V0 到 V1 的架构跃迁:哪些被保留,哪些被重写,为什么
- 建立全书后续章节的认知地图 :::
1.1 一个请求的完整旅程
让我们从最具体的场景开始。
一个用户向你的 LLM 服务发送了一条消息:"请用一句话解释什么是 PagedAttention。" 这条消息经过网络,抵达你部署的 vLLM 服务。从这一刻起,到用户收到完整回复的那一刻,中间发生了什么?
这趟旅程可以分为六个阶段:
解析 · 分词"] B --> C["EngineCore
请求入队"] C --> D["Scheduler
调度决策"] D --> E["Worker
GPU 前向计算"] E --> F["API Server
去分词 · 流式输出"] style A fill:#3b82f6,color:#fff,stroke:none style B fill:#8b5cf6,color:#fff,stroke:none style C fill:#ec4899,color:#fff,stroke:none style D fill:#f59e0b,color:#fff,stroke:none style E fill:#10b981,color:#fff,stroke:none style F fill:#6366f1,color:#fff,stroke:none
阶段一:API Server 接收请求
请求首先抵达 API Server 进程(vllm/entrypoints/openai/api_server.py)。这是一个基于 FastAPI 的 HTTP 服务,提供 OpenAI 兼容的接口:/v1/chat/completions、/v1/completions 等。
API Server 做三件事:
- 参数校验------检查模型名、温度、top_p 等参数是否合法
- 分词(Tokenization)------将用户的文本转换为 Token ID 序列。这一步在 API Server 进程中完成,而不是在 GPU Worker 中------这是 V1 架构的一个关键决策,我们稍后会解释为什么
- 提交请求------通过 ZMQ Socket 将请求发送给 EngineCore 进程
注意这里的进程分离:API Server 和 EngineCore 运行在不同的进程中,通过 ZMQ IPC(进程间通信)连接。这与 V0 架构有本质区别------V0 中所有逻辑都在同一个进程内。
阶段二:EngineCore 接收并排队
EngineCore(vllm/v1/engine/core.py)是 vLLM V1 的心脏。它运行在一个独立的进程中,有自己的事件循环,不受 HTTP 请求处理的干扰。
收到请求后,EngineCore 将其封装为内部的 Request 对象,加入等待队列。此时请求还没有分配任何 GPU 资源------它在排队等候调度器的宠幸。
阶段三:调度器做出决策
这是最精妙的环节。
每一步推理开始前,调度器 (vllm/v1/core/sched/scheduler.py)都要回答一个问题:这一步该让哪些请求往前走?每个请求走多少个 Token?
调度器的输出极其简洁------一个字典:
python
# {request_id: num_tokens_to_process}
scheduled = {
"req-001": 128, # 新请求,预填充 128 个 Token
"req-002": 1, # 老请求,解码 1 个 Token
"req-003": 1, # 老请求,解码 1 个 Token
"req-004": 64, # 新请求的部分预填充(分块)
}
这个看似简单的字典背后,是调度器在显存预算、吞吐量和延迟之间做的精密权衡。它需要考虑:当前有多少空闲的 KV Cache 块?新请求的预填充会不会挤掉正在解码的老请求?分块预填充应该切多大?
我们会在第 3 章详细拆解这个决策过程。
阶段四:KV Cache 分配
调度器决策完成后,KV Cache 管理器 (vllm/v1/core/kv_cache_manager.py)登场。它需要为调度器选中的请求分配物理显存块。
这里就是 PagedAttention 发挥作用的地方。KV Cache 管理器维护一个 BlockPool------一个由固定大小显存块组成的资源池。每个块能存储 16 个 Token 的 Key-Value 对。分配时不需要连续空间,就像操作系统的内存分页一样,通过一张"块表"(类似页表)记录逻辑块到物理块的映射。
(空闲)"] end L1 -.-> P7 L2 -.-> P2 L3 -.-> P15 style L1 fill:#3b82f6,color:#fff,stroke:none style L2 fill:#3b82f6,color:#fff,stroke:none style L3 fill:#3b82f6,color:#fff,stroke:none style P7 fill:#10b981,color:#fff,stroke:none style P2 fill:#10b981,color:#fff,stroke:none style P15 fill:#10b981,color:#fff,stroke:none style Pfree fill:#94a3b8,color:#fff,stroke:none
这种设计消除了显存碎片------V0 时代,一个 2048 Token 的请求需要一整块连续显存,即使只生成了 100 个 Token 也要占着 2048 的坑位。PagedAttention 将浪费率从 60-80% 降到了 4% 以下。
阶段五:GPU 执行前向计算
一切准备就绪后,Executor 将调度结果发送给 Worker (vllm/v1/worker/gpu_worker.py)。Worker 是真正触碰 GPU 的角色------每张 GPU 卡对应一个 Worker 进程。
Worker 收到的不是完整的请求状态,而是一个差量更新(diff)。它本地缓存了所有活跃请求的状态,每一步只接收"哪些请求新增了、哪些变化了、哪些结束了"。这是 V1 的另一个关键优化------V0 每一步都要广播完整状态,通信开销随并发请求数线性增长。
Worker 调用 ModelRunner 执行模型的前向传播:
- 准备输入------从缓存的状态中组装 Token ID、位置编码、注意力掩码
- 执行注意力计算------调用 FlashAttention3 内核,利用块表访问分页的 KV Cache
- 计算 Logits------模型的最后一层输出每个词表位置的得分
- 采样------根据温度、top_p、top_k 等参数从 Logits 中采出下一个 Token
一个 Token 就这样诞生了。
阶段六:去分词与流式输出
新生成的 Token ID 被送回 API Server 进程。API Server 负责去分词------将 Token ID 转换回人类可读的文本片段。
如果用户请求了流式输出(stream=true),每个 Token 会立即通过 Server-Sent Events(SSE)推送给客户端。用户在浏览器中看到文字逐字蹦出来,就是这个过程的体现。
去分词之所以放在 API Server 而非 Worker 中,是 V1 架构的刻意选择。去分词是 CPU 密集型操作,放在 Worker 进程会与 GPU 计算争夺宝贵的执行时间。进程分离让 CPU 工作和 GPU 工作真正并行。
然后,旅程循环往复------EngineCore 启动下一步调度,Worker 生成下一个 Token,直到遇到终止条件(最大长度、EOS Token、停止词)。一个完整的回复就这样一个 Token 一个 Token 地拼出来了。
1.2 V1 多进程架构
上一节的旅程中,你可能已经注意到一个反复出现的关键词:进程分离。这是 V1 架构最重要的设计决策,值得我们停下来仔细审视。
为什么要多进程
V0 的架构是单进程的。LLMEngine 对象在一个 Python 进程中完成所有工作------接收 HTTP 请求、分词、调度、与 GPU Worker 通信、去分词、返回结果。这种设计简单直观,但有一个致命问题:Python 的 GIL(全局解释器锁)让 CPU 工作和 GPU 编排互相阻塞。
具体来说:当 API Server 在做分词(CPU 密集)时,调度器无法同时做下一步的调度决策;当去分词在处理输出时,Worker 的结果无法及时被消费。在高并发场景下,CPU 成了瓶颈,GPU 利用率反而下降。
V1 的解法是把 vLLM 拆分为三类进程:
GPU 0"] W1["Worker 1
GPU 1"] end HTTP --> |ZMQ IPC| SCHED SCHED --> |共享内存 MQ| W0 SCHED --> |共享内存 MQ| W1 W0 --> |共享内存 MQ| DETOK W1 --> |共享内存 MQ| DETOK style HTTP fill:#8b5cf6,color:#fff,stroke:none style TOK fill:#8b5cf6,color:#fff,stroke:none style DETOK fill:#8b5cf6,color:#fff,stroke:none style SCHED fill:#ec4899,color:#fff,stroke:none style KV fill:#ec4899,color:#fff,stroke:none style W0 fill:#10b981,color:#fff,stroke:none style W1 fill:#10b981,color:#fff,stroke:none
| 进程 | 职责 | CPU/GPU | 通信方式 |
|---|---|---|---|
| API Server | HTTP 处理、分词、去分词 | 纯 CPU | ZMQ IPC → EngineCore |
| EngineCore | 调度、KV Cache 管理 | 纯 CPU | 共享内存 MQ → Workers |
| Worker × N | 模型前向计算 | GPU | 共享内存 MQ → API Server |
每个进程有自己的 Python 解释器和 GIL,互不干扰。API Server 可以在 Worker 执行 GPU 计算时同时做分词和去分词;EngineCore 可以在 Worker 执行当前步时提前做下一步的调度决策。
进程间通信:ZMQ 与共享内存
进程分离带来了通信开销的问题。V1 选择了两种机制:
-
ZMQ IPC------用于 API Server 与 EngineCore 之间。ZMQ 是成熟的消息队列库,IPC 模式使用 Unix Domain Socket,延迟在微秒级。请求和响应的数据量不大(Token ID 序列),ZMQ 完全够用。
-
共享内存 MessageQueue ------用于 EngineCore 与 Worker 之间。调度结果需要高效广播给所有 Worker,共享内存避免了数据拷贝。
MultiprocExecutor(vllm/v1/executor/multiproc_executor.py)使用rpc_broadcast_mq向所有 Worker 广播调度指令,Worker 通过worker_response_mq返回结果。
为什么不全用 ZMQ 或全用共享内存?因为它们各有优势:ZMQ 提供了更好的抽象(发布-订阅、请求-回复模式),适合 API Server 这种面向外部的组件;共享内存则提供了最低的延迟和零拷贝语义,适合引擎内部的高频通信。
V0 到 V1:一张对比表
| 维度 | V0 | V1 | 为什么改 |
|---|---|---|---|
| 进程模型 | 单进程 | 多进程(API Server + EngineCore + Workers) | GIL 瓶颈,CPU/GPU 无法并行 |
| 调度模型 | 区分预填充/解码阶段 | 统一 Token 调度 | 简化逻辑,天然支持分块预填充 |
| Worker 状态 | 无状态(每步广播全量) | 有状态(只发差量) | 减少通信开销 |
| 前缀缓存 | 可选,有性能开销 | 默认启用,零开销 | 优化数据结构后开销可忽略 |
| CUDA 图 | 标准 CUDA 图 | 分段 CUDA 图(Piecewise) | 突破标准 CUDA 图的动态 shape 限制 |
| 请求抽象 | SequenceGroup | Request | 消除不必要的复杂性 |
| 去分词 | 在引擎循环内 | 在 API Server 进程异步执行 | CPU 工作不阻塞 GPU 编排 |
V1 发布后的基准测试显示,在不使用多步调度的情况下,吞吐量提升了 1.7 倍。对于视觉语言模型(VLM),提升更为显著,因为图像编码器的 CPU 预处理在 V0 中会严重阻塞 GPU。
1.3 五大子系统
从全局视角看,vLLM 的代码库可以映射为五个核心子系统。理解它们的职责边界和交互方式,就掌握了整个系统的骨架。
子系统一:入口层(Entrypoints)
vllm/entrypoints/ 目录是 vLLM 面向外部世界的窗口。它包含:
- OpenAI 兼容 API Server ------最常用的入口,支持
/v1/chat/completions、/v1/completions、/v1/embeddings等端点 - 离线推理接口 ------
LLM类(vllm/entrypoints/llm.py),用于批量推理场景,不启动 HTTP 服务 - gRPC Server------高性能 RPC 接口
- CLI 工具 ------
vllm serve命令
入口层的核心原则是薄------它只负责协议适配和序列化,不包含任何推理逻辑。所有的智慧都在 EngineCore 中。
子系统二:引擎核心(Engine Core)
vllm/v1/engine/ 和 vllm/v1/core/ 是 vLLM 的大脑。
- EngineCore (
vllm/v1/engine/core.py)------引擎的主循环。接收请求,驱动调度器,分发执行指令,收集结果。它是唯一了解系统全局状态的组件。 - Scheduler (
vllm/v1/core/sched/scheduler.py)------调度器。决定每一步哪些请求参与计算,每个请求处理多少 Token。 - KVCacheManager (
vllm/v1/core/kv_cache_manager.py)------KV Cache 块的分配、释放和共享。
引擎核心的设计哲学是集中决策,分布执行。调度器掌握全局信息(所有请求的状态、所有块的使用情况),做出集中的调度决策;Worker 只需要执行决策,不需要知道其他 Worker 在做什么。
子系统三:执行层(Executor & Worker)
vllm/v1/executor/ 和 vllm/v1/worker/ 是 vLLM 的肌肉。
- Executor 是引擎核心与 Worker 之间的代理层。它屏蔽了 Worker 的部署拓扑------EngineCore 不需要知道 Worker 是单卡还是多卡,是本地进程还是远程 Ray Actor。
- Worker 是真正操控 GPU 的角色。每个 Worker 负责一张 GPU 卡,执行模型的前向传播。
Executor 有三种实现,对应三种部署模式:
单卡"] EC --> MULTI["MultiprocExecutor
多卡(单机)"] EC --> RAY["RayExecutor
多卡(多机)"] UNI --> W1["Worker
GPU 0"] MULTI --> W2["Worker
GPU 0"] MULTI --> W3["Worker
GPU 1"] RAY --> W4["Worker
GPU 0
Node A"] RAY --> W5["Worker
GPU 0
Node B"] style EC fill:#ec4899,color:#fff,stroke:none style UNI fill:#10b981,color:#fff,stroke:none style MULTI fill:#10b981,color:#fff,stroke:none style RAY fill:#10b981,color:#fff,stroke:none
子系统四:模型层(Model Executor)
vllm/model_executor/ 包含了所有与具体模型相关的代码:
- 模型实现------Llama、Qwen、Mistral、GPT-NeoX 等数百种模型的适配
- 层实现------注意力层、MLP 层、嵌入层等基础组件
- 量化支持------FP8、GPTQ、AWQ 等量化方案的实现
- 模型加载------从 HuggingFace Hub 下载和加载模型权重
模型层的设计原则是可插拔。添加一个新模型只需要实现规定的接口,不需要修改引擎核心或调度器。这种解耦是 vLLM 能支持数百种模型的基础。
子系统五:内核层(Kernels)
vllm/kernels/ 和 vllm/vllm_flash_attn/ 包含了 vLLM 的性能秘密------用 CUDA 和 Triton 编写的定制内核。
最核心的是 PagedAttention 内核------它实现了在分页 KV Cache 上高效执行注意力计算。标准的 FlashAttention 假设 KV Cache 是连续的,PagedAttention 内核则能通过块表间接寻址,在非连续的内存块上完成同样的计算。
此外还有 FlashAttention3 的集成------vLLM V1 的主力注意力后端,支持预填充和解码在同一批次内混合执行。
1.4 源码目录导航
最后,让我们快速浏览 vllm/ 目录的组织结构,为后续的源码之旅做好准备:
bash
vllm/
├── v1/ # V1 引擎(当前默认)
│ ├── engine/ # EngineCore、客户端
│ ├── core/ # 调度器、KV Cache 管理
│ ├── executor/ # Executor 实现
│ ├── worker/ # GPU Worker
│ ├── attention/ # 注意力后端
│ ├── sample/ # 采样逻辑
│ └── spec_decode/ # 投机解码
│
├── entrypoints/ # API 服务器、CLI
│ ├── openai/ # OpenAI 兼容 API
│ └── llm.py # 离线推理接口
│
├── model_executor/ # 模型实现与加载
│ ├── models/ # 各模型适配
│ ├── layers/ # 基础层(注意力、量化等)
│ └── model_loader/ # 权重加载
│
├── distributed/ # 分布式通信
│ ├── parallel_state.py # 并行状态管理
│ └── kv_transfer/ # KV Cache 传输
│
├── kernels/ # CUDA/Triton 内核
├── lora/ # LoRA 适配器
├── multimodal/ # 多模态输入处理
├── config/ # 配置类
└── sampling_params.py # 采样参数
记住两个原则:
- V1 的代码在
v1/目录下 ------如果你在vllm/engine/或vllm/core/中看到类似的代码,那是 V0 遗留的,不要混淆 - 从
v1/engine/core.py开始读------这是整个系统的入口点,从这里出发你可以到达任何一个子系统
1.5 本章小结
本章建立了对 vLLM 的全局认知:
- 一个推理请求经历六个阶段:HTTP 接收 → 分词入队 → 调度决策 → KV Cache 分配 → GPU 前向计算 → 去分词输出
- V1 架构采用多进程设计,API Server、EngineCore、Worker 各司其职,通过 ZMQ 和共享内存通信,彻底消除 GIL 瓶颈
- 五大子系统------入口层、引擎核心、执行层、模型层、内核层------构成了 vLLM 的骨架
- V1 相比 V0 的核心改进:多进程、统一调度、有状态 Worker、零开销前缀缓存
这张地图将在后续章节中逐步展开。下一章,我们将走进 EngineCore------那个驱动一切运转的心脏。
延伸阅读
- vLLM V1 Alpha 发布博文:vllm.ai/blog/v1-alp...
- PagedAttention 论文:Kwon et al., "Efficient Memory Management for Large Language Model Serving with PagedAttention", SOSP 2023
- V1 引擎架构讨论:GitHub Issue #8779