Skip to content

第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

本章要点

  • 理解 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 远程调用,上层看到的接口完全一样。

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 参数)需要多张卡时,MultiprocExecutorvllm/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 是无状态的——每一步,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 通过两个机制保证一致性:

  1. 有序可靠通信——共享内存 MessageQueue 保证消息有序且不丢失(同机通信本身就是可靠的)
  2. 状态校验——Worker 可以周期性地与 EngineCore 同步全量状态,检测并修复任何不一致

在实践中,同机场景(MultiprocExecutor)几乎不会出现不一致。跨机场景(RayExecutor)则需要更谨慎的错误处理——网络故障可能导致消息丢失。

6.4 Worker 的生命周期

一个 Worker 从创建到开始推理,经历以下阶段:

步骤 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

基于 VitePress 构建