《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章 设计模式与架构哲学
第6章 Worker 与 Executor:GPU 军团
"An army of sheep led by a lion can defeat an army of lions led by a sheep." -- Alexander the Great
:::tip 本章要点
- 理解 Executor 的抽象层设计:为什么要在 EngineCore 和 Worker 之间加一层
- 掌握三种 Executor 实现的适用场景:UniProc、Multiproc、Ray
- 深入 Worker 的有状态设计:为什么 V1 的 Worker 缓存请求状态
- 理解
collective_rpc通信模式:如何一条命令驱动所有 GPU - 认识 Worker 的生命周期:初始化、显存探测、模型加载、推理循环 :::
6.1 为什么需要 Executor
一个直觉的设计是让 EngineCore 直接操控 Worker:调度器产出结果,EngineCore 直接发给 Worker。为什么中间还要插一个 Executor?
因为 Worker 的部署拓扑是多变的。
单卡推理时,只有一个 Worker,运行在主进程内------不需要任何进程间通信。多卡单机时,每张卡一个 Worker 子进程------需要共享内存通信。多卡多机时,Worker 分布在不同物理机上------需要 Ray 远程调用。
如果 EngineCore 直接管理 Worker,它就需要为每种拓扑写不同的通信代码。Executor 将这些差异封装起来,向 EngineCore 暴露统一的接口:
python
# vllm/v1/executor/abstract.py(简化)
class Executor:
def collective_rpc(self, method: str, args, kwargs) -> list:
"""在所有 Worker 上调用同一个方法,返回所有结果。"""
raise NotImplementedError
def execute(self, scheduler_output) -> ExecutorOutput:
"""执行一步推理。"""
return self.collective_rpc("execute_model", scheduler_output)
collective_rpc 是核心抽象------"在所有 Worker 上集体调用一个方法"。不管底层是函数调用、共享内存消息还是 Ray 远程调用,上层看到的接口完全一样。
直接函数调用"] EXE --> |多卡单机| MP["MultiprocExecutor
共享内存 MessageQueue"] EXE --> |多卡多机| RAY["RayExecutor
Ray Compiled DAG"] UNI --> W1["Worker 0"] MP --> W2["Worker 0"] MP --> W3["Worker 1"] MP --> W4["Worker 2"] RAY --> W5["Worker 0 (Node A)"] RAY --> W6["Worker 1 (Node B)"] style EC fill:#ec4899,color:#fff,stroke:none style EXE fill:#f59e0b,color:#fff,stroke:none style UNI fill:#10b981,color:#fff,stroke:none style MP fill:#10b981,color:#fff,stroke:none style RAY fill:#10b981,color:#fff,stroke:none
6.2 三种 Executor
源码 :
vllm/v1/executor/abstract.py
V1 的 Executor 选择逻辑定义在 Executor.get_class()(abstract.py:27)中,根据 distributed_executor_backend 配置自动选择:
python
# vllm/v1/executor/abstract.py:27-47 (简化)
@staticmethod
def get_class(vllm_config) -> type["Executor"]:
backend = parallel_config.distributed_executor_backend
if backend == "ray":
from vllm.v1.executor.ray_distributed_executor import RayDistributedExecutor
return RayDistributedExecutor
elif backend == "mp":
from vllm.v1.executor.multiproc_executor import MultiprocExecutor
return MultiprocExecutor
elif backend == "uni":
return UniProcExecutor
UniProcExecutor:最简单的情况
单卡推理时,只有一个 Worker,直接在 EngineCore 进程内运行。collective_rpc 退化为普通的函数调用:
python
class UniProcExecutor(Executor):
def collective_rpc(self, method, args=(), kwargs=None):
fn = getattr(self.worker, method)
result = fn(*args, **(kwargs or {}))
return [result] # 包装为列表,保持接口一致
没有进程创建,没有序列化,没有网络传输------开销为零。这也是调试和开发时最方便的模式。
MultiprocExecutor:多卡单机
当模型太大(如 70B 参数)需要多张卡时,MultiprocExecutor(vllm/v1/executor/multiproc_executor.py)登场。
它为每张 GPU 启动一个 Worker 子进程,通过共享内存 MessageQueue 通信:
共享内存 MessageQueue 的实现值得一提。它使用 multiprocessing.shared_memory 分配一块所有进程可访问的内存区域,配合信号量(semaphore)做同步。消息的写入和读取是零拷贝的------Writer 直接写入共享内存,Reader 直接从共享内存读取,不需要序列化或反序列化。
这比用 multiprocessing.Queue(内部使用管道 + pickle)快得多。对于每步数百 KB 到几 MB 的调度数据,零拷贝带来的加速是显著的。
RayExecutor:多卡多机
当模型大到单机放不下(如 405B 参数),需要跨多台机器部署时,RayExecutor 出场。
Ray 是一个分布式计算框架,提供了 Actor 模型和远程方法调用。vLLM 将每个 Worker 包装为一个 Ray Actor,部署到集群中的不同节点上:
python
# 简化逻辑
class RayExecutor(Executor):
def __init__(self, ...):
# 在 Ray 集群中创建 Worker Actor
self.workers = [
ray.remote(Worker).options(
num_gpus=1,
scheduling_strategy=NodeAffinitySchedulingStrategy(node_id)
).remote()
for node_id in node_ids
]
def collective_rpc(self, method, args=(), kwargs=None):
# 对所有 Worker 并行调用
futures = [
getattr(w, method).remote(*args, **(kwargs or {}))
for w in self.workers
]
return ray.get(futures) # 等待所有完成
V1 还引入了 Ray Compiled DAG------一种预编译的执行图,将多次远程调用编译为一个固定的通信 DAG,减少每步的调度开销。这对于流水线并行尤为重要(详见第 14 章)。
6.3 Worker:有状态的 GPU 士兵
V1 的 Worker(vllm/v1/worker/gpu_worker.py)与 V0 有一个根本区别:它是有状态的。
这个设计决策影响深远。在 V0 中,Worker 是无状态的------EngineCore 每步都将完整的调度结果(包括所有请求的 Token IDs、位置、块表等)序列化后发送给 Worker。Worker 执行完前向传播后将结果返回给 EngineCore,自身不保留任何请求信息。
这种无状态设计的优势是简单:Worker 可以被随时替换(因为没有持久状态),调试时可以独立重放单步执行。但代价是每步都要传输大量冗余数据。假设有 200 个并发解码请求,每个请求只新增 1 个 Token,但 V0 仍然需要重新传输所有 200 个请求的完整块表------其中 99.5% 的数据与上一步相同。
V1 的有状态 Worker 消除了这个冗余。Worker 在内存中维护所有活跃请求的状态(InputBatch 持久化张量),EngineCore 只传输差量更新:新请求的加入、已完成请求的移除、解码请求的 Token 追加。传输数据量从 O(总请求数 × 每请求块数) 降低到 O(变更请求数)。
这种有状态设计的代价是复杂性增加------Worker 和 EngineCore 必须保持状态同步。如果某步的差量更新丢失或乱序,后续所有步骤都会出错。V1 通过共享内存 IPC(EngineCore 和 Worker 在同一台机器上)和顺序执行保证来避免这个问题。
V0 的 Worker 是无状态的------每一步,Executor 将完整的请求信息广播给所有 Worker,Worker 不记忆任何上一步的信息。这种设计简单但效率低:假设有 100 个并发请求,每步需要广播 100 个请求的全部状态,数据量随并发数线性增长。
V1 的 Worker 在本地缓存了所有活跃请求的状态。每一步,Executor 只发送差量更新(diff):
python
# 差量信息包含:
{
"new_requests": [...], # 新加入的请求
"finished_requests": [...], # 已完成的请求 ID
"resumed_requests": [...], # 从抢占中恢复的请求
"num_tokens": {req_id: n}, # 每个请求本步处理的 Token 数
}
Worker 收到 diff 后,更新本地缓存的状态,然后执行前向传播。这将通信量从 O(并发数 × 请求大小) 降到了 O(变化数)------在稳态下(大部分请求只是解码 1 个 Token),变化数远小于并发数。
有状态设计的代价
有状态意味着 Worker 需要保证本地状态与 EngineCore 的全局状态一致。如果消息丢失或乱序,状态就会出现分歧。
V1 通过两个机制保证一致性:
- 有序可靠通信------共享内存 MessageQueue 保证消息有序且不丢失(同机通信本身就是可靠的)
- 状态校验------Worker 可以周期性地与 EngineCore 同步全量状态,检测并修复任何不一致
在实践中,同机场景(MultiprocExecutor)几乎不会出现不一致。跨机场景(RayExecutor)则需要更谨慎的错误处理------网络故障可能导致消息丢失。
6.4 Worker 的生命周期
一个 Worker 从创建到开始推理,经历以下阶段:
初始化 GPU 设备"] --> B["2. load_model()
加载模型权重"] B --> C["3. determine_available_memory()
探测可用显存"] C --> D["4. initialize_from_config()
分配 KV Cache"] D --> E["5. compile_or_warm_up()
编译 CUDA 图"] E --> F["6. 就绪
等待推理指令"] 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
步骤 1:初始化设备------设置 CUDA 设备、随机种子、分布式通信组(如果是多卡)。
步骤 2:加载模型------从 HuggingFace Hub 或本地路径加载模型权重到 GPU。对于量化模型(FP8、GPTQ 等),这一步还包括反量化或量化参数的加载。
步骤 3:探测可用显存------执行一次试探性前向传播,测量 GPU 显存的峰值使用量,计算剩余多少显存可用于 KV Cache。这个数值被汇报给 EngineCore,用于初始化 BlockPool。
步骤 4:分配 KV Cache ------根据 EngineCore 告知的块数量,在 GPU 显存中分配 KV Cache 数组。这是一次大的 cudaMalloc,之后不再有显存分配操作。
步骤 5:编译或预热 ------如果启用了 CUDA 图或 torch.compile,这一步会做编译和预热,确保后续推理不会触发 JIT 编译导致的延迟毛刺。
步骤 6:就绪------Worker 进入等待状态,准备接收 Executor 的推理指令。
整个初始化过程可能耗时几十秒到几分钟(取决于模型大小和是否需要从网络下载)。这是一次性成本,之后的推理步骤只需要毫秒级。
6.5 Worker 的睡眠模式
V1 引入了 Worker 的睡眠模式(Sleep Levels),用于在空闲时释放 GPU 资源:
Level 1(轻睡眠)------模型权重从 GPU 显存卸载到 CPU 内存,但 KV Cache 保留在 GPU 上。唤醒时只需重新加载权重,KV Cache 不受影响。适用于短时间空闲。
Level 2(深睡眠)------模型权重和 KV Cache 都卸载到 CPU 内存。GPU 显存完全释放,可以被其他进程使用。唤醒时需要重新加载权重和 KV Cache,耗时更长。适用于长时间空闲。
这个设计在多租户部署场景中非常有用------多个 vLLM 实例共享 GPU 资源,空闲的实例让出显存给繁忙的实例。
6.6 本章小结
执行层是 vLLM 从"决策"到"行动"的桥梁:
- Executor 抽象层 屏蔽了部署拓扑的差异,EngineCore 通过
collective_rpc统一调用所有 Worker - 三种实现 覆盖了从单卡到多机的全部场景:UniProc(直接调用)、Multiproc(共享内存)、Ray(分布式)
- 有状态 Worker 通过差量更新大幅减少通信量,但需要保证状态一致性
- Worker 生命周期 经历初始化、模型加载、显存探测、KV Cache 分配、编译预热五个阶段
- 睡眠模式 允许空闲 Worker 释放 GPU 资源
下一章,我们将进入 Worker 内部最核心的组件------ModelRunner,看看一次前向传播是如何在 GPU 上高效执行的。
源码导航
- Executor 抽象基类:
vllm/v1/executor/abstract.py- UniProcExecutor:
vllm/executor/uniproc_executor.py- MultiprocExecutor:
vllm/v1/executor/multiproc_executor.py- RayExecutor:
vllm/v1/executor/ray_distributed_executor.py- GPU Worker:
vllm/v1/worker/gpu_worker.py- Worker 基类:
vllm/v1/worker/worker_base.py