vLLM内核探秘-第6章 Worker 与 Executor:GPU 军团

《vLLM 内核探秘》完整目录

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

graph TB EC["EngineCore"] --> |"collective_rpc()"| EXE["Executor 接口"] EXE --> |单卡| UNI["UniProcExecutor
直接函数调用"] 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
flowchart TD Config["distributed_executor_backend"] --> D{"值?"} D -->|"uni (单卡)"| Uni["UniProcExecutor\n直接函数调用"] D -->|"mp (多卡单机)"| MP["MultiprocExecutor\n多进程 + 共享内存"] D -->|"ray (多卡多机)"| Ray["RayDistributedExecutor\nRay 集群调度"] style Uni fill:#10b981,color:#fff,stroke:none style MP fill:#3b82f6,color:#fff,stroke:none style Ray fill:#8b5cf6,color:#fff,stroke:none

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 通信:

sequenceDiagram participant EC as EngineCore participant EXE as MultiprocExecutor participant MQ as 共享内存 MessageQueue participant W0 as Worker 0 (GPU 0) participant W1 as Worker 1 (GPU 1) EC->>EXE: execute(scheduler_output) EXE->>MQ: 广播调度结果 MQ->>W0: 读取(零拷贝) MQ->>W1: 读取(零拷贝) par 并行执行 W0->>W0: GPU 0 前向传播 W1->>W1: GPU 1 前向传播 end W0->>MQ: 写入结果 W1->>MQ: 写入结果 MQ->>EXE: 收集所有结果 EXE->>EC: 返回 ExecutorOutput

共享内存 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 通过两个机制保证一致性:

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

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

6.4 Worker 的生命周期

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

graph TD A["1. init_device()
初始化 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
相关推荐
杨艺韬4 小时前
vLLM内核探秘-第11章 分块预填充与混合批处理
agent
杨艺韬4 小时前
vLLM内核探秘-第2章 EngineCore:引擎的心脏
agent
杨艺韬4 小时前
vLLM内核探秘-第17章 API 服务器与生产部署
agent
杨艺韬4 小时前
vLLM内核探秘-第3章 调度器:Token 的交通指挥
agent
杨艺韬4 小时前
vLLM内核探秘-第5章 KV Cache 管理:寸土寸金的显存
agent
杨艺韬4 小时前
vLLM内核探秘-第8章 前向计算与 CUDA Graph
agent
杨艺韬4 小时前
vLLM内核探秘-前言
agent
杨艺韬4 小时前
vLLM内核探秘-第16章 LoRA 适配器热切换
agent
Aaron_Chou3136 小时前
保姆级codex配置教程
gpt·ai·agent·ai编程·codex