理解 vLLM 的推理链路时,很容易先被 API Server、Engine、Scheduler、Executor、Worker、ModelRunner、KV Cache 这些名字绕晕。与其一开始就追所有对象,不如先抓住一条更小的主线:Engine 在 CPU 侧做调度,Worker 把调度结果翻译成 GPU work,GPU 负责真正的 forward 和 sample

本文就沿着这条主线展开。先看一次普通 EngineCore.step() 里 CPU 和 GPU 如何错开,再看开启 batch queue 后为什么会出现“提交这一拍、回收上一拍”的节奏,最后把这个节奏放进 PD 分离场景,解释 NIXL 的 KV Cache 搬运如何加入主循环。这里的 PD 分离,指的是把 prefill 和 decode 拆到不同实例或资源池上执行。

vLLM 异步执行架构

先把全局层次摆出来,再把后文图里的角色对齐进去。vLLM 的对象很多,但这篇文章只跟踪四个和异步执行最相关的参与者:Engine、Worker、GPU,以及 PD 分离时额外出现的 NIXL / 远端 Prefill 实例。API Server 和 DP Coordinator 当然重要,不过它们不是后面几张时序图的主角。

1 个 LLMEngine 对象  (per vLLM 实例)
├─ A 个 API Server 进程                          [A = api_server_count, 默认 1]
├─ DP 个 Engine Core 进程                        [DP = data_parallel_size]  ← 图中的 Engine
│  ├─ 1 个 Scheduler 实例                       (per Engine Core)
│  ├─ 1 个 KVCacheManager 实例                  (per Engine Core)
│  └─ 1 个 Executor 实例                        (per Engine Core)
│       └─ TP × PP 个 Worker 进程               (per Executor)             ← 图中的 Worker
│            └─ 每个 Worker 持有 1 个 ModelRunner
│                 └─ 绑定 CUDA device / KV cache 显存                       ← 图中的 GPU
└─ 1 个 DP Coordinator 进程                      [仅当 DP > 1]

PD 分离时:
Worker / ModelRunner 还会通过 KV connector 调用 NIXL,与远端 Prefill 实例搬运 KV Cache。

把这棵树压缩成执行链路,就是下面这张图:请求进入 Engine,Engine 做调度并通过 RPC 派给 Worker;Worker 操作 GPU 执行 forward/sample;如果是 PD 分离,Worker 还会和 NIXL 协作,把远端 prefill 生成的 KV Cache 搬到本地。

flowchart LR API[API Server / LLMEngine] --> E[Engine Core 主线程<br/>Scheduler + KVCacheManager] E -->|execute_model / sample_tokens RPC| W[Worker 进程<br/>Executor RPC 入口 + ModelRunner] W -->|launch kernels / D2H copy| G[GPU<br/>forward + sampler] W -.->|PD 分离时拉取 KV Cache| N[NIXL / 远端 Prefill 实例]
图中元素对应 vLLM 对象本文关注的职责
EngineEngineCore 主线程,内部持有 SchedulerKVCacheManager每步执行 schedule -> execute -> update;维护 request 状态;决定何时派发推理或 KV 拉取任务。
WorkerWorker 进程 + ModelRunner,由 Executor RPC 调用接收 Engine 派发的 SchedulerOutput,把它翻译成 tensor,触发 forward、sample 和 KV 传输相关操作。
GPUWorker 绑定的 CUDA device执行 transformer/lm_head/sampler kernel,并把采样 token 通过 D2H copy 带回 CPU。
NIXL / 远端 Prefill 实例NIXL KV transfer 后端,以及提供 KV Cache 的远端 prefill 实例异步搬运远端已经生成的 KV block,并通过 finished_recving 通知本地 scheduler。

后面的图都会使用这几个角色。这样读的时候可以先判断“谁在等谁、谁和谁并行”,再回头把图里的角色映射到具体源码对象上。

一个基本单位:EngineCore step

vLLM 的主循环可以粗略理解成不断执行 EngineCore.step()。每一轮里,Engine 先让 Scheduler 选出本轮要跑的请求,生成 SchedulerOutput;然后把这份输出交给 Worker;Worker 再把它变成 GPU 上的 forward、sample、D2H copy 等操作;最后 Engine 根据返回的 token 更新请求状态。

先看最简单的一种情况。这里把 max_concurrent_batches 记作 K,K=1 表示 Engine 不提前保留多个在途 batch。代码结构大致如下:

简化版本:K=1

def step(self) -> tuple[dict[int, EngineCoreOutputs], bool]:
    if not self.scheduler.has_requests():
        return {}, False

    # ① schedule
    scheduler_output = self.scheduler.schedule()

    # ② 发 execute_model RPC, 立即返回 Future
    future = self.model_executor.execute_model(scheduler_output, non_block=True)

    # ③ CPU 干活, 与 GPU forward 并行
    grammar_output = self.scheduler.get_grammar_bitmask(scheduler_output)

    with (
        self.log_error_detail(scheduler_output),
        self.log_iteration_details(scheduler_output),
    ):
        # ④ 阻塞等 execute_model 完成信号
        model_output = future.result()

    # K=1 路径: execute_model 返回 None, 再发第二发 RPC
    if model_output is None:
        # ⑤ sample_tokens RPC
        model_output = self.model_executor.sample_tokens(grammar_output)

    self._process_aborts_queue()

    # ⑥ 拿 token, 更新调度器
    engine_core_outputs = self.scheduler.update_from_output(
        scheduler_output, model_output)

    return engine_core_outputs, scheduler_output.total_num_scheduled_tokens > 0

这段代码的关键,不是“先 schedule 再 execute”这个顺序本身,而是中间那段 CPU/GPU 并行窗口:Engine 发出 execute_model(non_block=True) 后不会马上阻塞,而是趁 GPU 跑 forward 的时候,在 CPU 侧准备 grammar bitmask,也就是约束解码时用于屏蔽非法 token 的掩码。等 GPU forward 完成,Engine 再发起 sample,把 token 拿回来更新 Scheduler。

sequenceDiagram participant E as Engine 主线程 participant W as Worker participant G as GPU Note over E,G: 一次 EngineCore.step() (K=1) E->>E: ① schedule (生成 SchedulerOutput) E->>W: ② execute_model RPC activate W W->>G: launch: transformer layers + lm_head activate G Note over G: 算 hidden_states → 算 logits<br/>(留在显存,未拷出) Note over E: ③ grammar_bitmask<br/>(CPU,与 GPU 并行) W-->>E: ④ 完成信号 (logits 仍在 GPU) deactivate W E->>W: ⑤ sample_tokens RPC activate W W->>G: launch: sampler kernel<br/>(读显存里的 logits) Note over G: logits → sampled_token_ids (GPU) W->>G: launch: cudaMemcpyAsync (D2H) Note over G: token_ids: GPU → CPU 内存 G-->>W: event.synchronize() 返回 deactivate G W-->>E: 返回 token_ids (CPU) deactivate W E->>E: ⑥ update_from_output

这就是 vLLM 异步执行的第一层:Engine 不会每发一个 GPU kernel 就原地等待,而是尽量把 CPU 侧能做的事情塞进 GPU 执行期间。K=1 时,这个并行窗口还比较短;Engine 最终仍要等这一轮结果回来,才能完成本轮 update。

启用 batch queue:K=2

如果 max_concurrent_batches > 1,EngineCore 会走 step_with_batch_queue。这时 Engine 做的不只是同一轮里的 CPU/GPU 重叠,还会把不同轮次错开:当前循环提交 step N 的 GPU work,同时回收 step N-1 的结果。

下面用 K=2 表示队列最多保留两个在途 batch。稳定状态下,可以把它想成一条小流水线:Engine 每轮推进一次,GPU 也每轮完成一次,只是两者相差一拍。

sequenceDiagram participant E as Engine participant W as Worker participant G as GPU Note over E,G: K=2 稳态 - engine step 与 GPU step 1:1 同步推进, 错开一拍 Note over E: 入场 q=[future_{N-1}] activate G Note over G: GPU 正在跑 step N-1 rect rgb(245,245,255) Note right of E: 迭代 A · engine 发 N, GPU 跑 N-1 E->>E: schedule(N) E->>+W: exec(N) W->>G: enqueue forward_N W-->>-E: E->>E: grammar(N) E->>+W: sample(N) W->>G: enqueue sampler_N + D2H_N W-->>-E: Note over E: appendleft → q=[N, N-1] Note over E: pop 队尾 → future_{N-1}.result() G-->>-W: step N-1 D2H 落地 W-->>E: token_{N-1} E->>E: update(N-1) end Note over G: GPU 接着跑 step N (队列里早已排好) activate G rect rgb(245,245,255) Note right of E: 迭代 B · engine 发 N+1, GPU 跑 N E->>E: schedule(N+1) E->>+W: exec(N+1) W->>G: enqueue forward_N+1 W-->>-E: E->>E: grammar(N+1) E->>+W: sample(N+1) W->>G: enqueue sampler_N+1 + D2H_N+1 W-->>-E: Note over E: q=[N+1, N] Note over E: pop → future_N.result() G-->>-W: step N D2H 落地 W-->>E: token_N E->>E: update(N) end Note over E,G: 稳态下通常每迭代 1 个 GPU step 完工、1 个新 step 入队

K=2 的直观收益,是减少 Engine 和 GPU 之间的空档。Engine 不必等 step N 的 token 已经回到 CPU 才开始安排 step N+1;只要调度条件允许,它可以先把下一拍的 forward/sample enqueue 到 Worker/GPU,再从队列尾部拿上一拍的 future 结果。

这张图表达的是稳态下最核心的节奏:新任务入队和旧结果出队错开一拍。真实路径不会总是这么整齐,队列深度会受到请求数量、结构化输出、pooling 等特殊执行路径,以及是否还有可调度 token 等条件影响。

PD 分离场景下的 NIXL 协同

理解了 Engine/Worker/GPU 之间的异步节奏,再看 PD 分离就清楚很多:系统里只是多了一条“KV Cache 搬运线”。

PD 分离把 prefill 和 decode 拆到不同实例上。对 decode 侧来说,一个请求 R 可能还不能立刻参与 decode,因为它需要的 KV Cache 还在远端 prefill 侧。此时 Scheduler 不能直接让 R 进入 GPU forward,而是要先把 R 标记成等待远端 KV 的状态,并让 Worker/NIXL 异步拉取 KV block。

这里的关键点是:KV 搬运不会阻塞整个 Engine step。R 在等 KV 的时候,Engine 仍然可以继续调度其他已经就绪的请求,GPU 也可以继续跑其他 batch。

sequenceDiagram participant E as Engine (Scheduler) participant W as Worker participant G as GPU participant N as NIXL / 远端 Prefill 实例 Note over E,N: 请求 R 的 KV 生命周期 — 跨 step N → N+k+1 rect rgb(245,245,255) Note right of E: step N · R 首次入队 E->>E: schedule Note over E: R: WAITING → WAITING_FOR_REMOTE_KVS<br/>派发指令包 = [推理: 其他请求] + [拉 KV: R] E->>+W: exec(N) W->>+G: forward 其他请求 W->>+N: start_load_kv (发起 R 的 KV 接收) Note over N: NIXL 后台传输 KV block G-->>-W: forward 完成 W-->>-E: token (其他请求) + kv_connector_output (R 未 finished) E->>E: update_from_output<br/>R 状态保持 WAITING_FOR_REMOTE_KVS end rect rgb(250,250,250) Note right of E: step N+1 ... N+k-1 · R 还在传, scheduler 持续跳过 Note over E: 每步都派发 [推理: 其他请求]<br/>不重复派发 [拉 KV: R] E->>+W: exec(N+1) ... exec(N+k-1) W->>+G: forward 其他请求 G-->>-W: W-->>-E: kv_connector_output (R 仍未 finished) end rect rgb(255,250,240) Note right of E: step N+k · KV 到位 E->>+W: exec(N+k) W->>+G: forward 其他请求 N-->>-W: R 的全部 KV 块到位 W->>W: get_finished 收集 R ∈ finished_recving G-->>-W: W-->>-E: kv_connector_output.finished_recving = {R} E->>E: update_from_output<br/>记录 R ∈ finished_recving end rect rgb(245,255,245) Note right of E: step N+k+1 · R 终于参与推理 E->>E: schedule Note over E: R 从 WAITING_FOR_REMOTE_KVS<br/>提升回 WAITING/PREEMPTED 后进入可调度路径 E->>+W: exec(N+k+1) W->>+G: forward (含 R, KV 已在本地) G-->>-W: token_R 等 W-->>-E: end

这张图里最值得关注的是状态切换:

  1. R 首次被调度到 decode 侧,但本地 KV 还没准备好,于是从 WAITING 进入 WAITING_FOR_REMOTE_KVS
  2. Worker 触发 NIXL 的 start_load_kv,实际的远端 KV 传输在后台进行。
  3. 后续几个 Engine step 里,Scheduler 会跳过 R,但继续服务其他请求。
  4. NIXL 搬运完成后,Worker 在 finished_recving 里上报 R。
  5. Scheduler 收到后把 R 重新放回可调度路径,下一轮才真正让 R 进入 forward。

所以,PD 分离下的异步不是单一的 CPU/GPU 重叠,而是两条异步链路叠在一起:一条是 Engine 和 GPU 的执行流水线,另一条是 Worker/NIXL 的 KV Cache 搬运流水线。vLLM 的调度器要做的,就是在这两条链路之间维护请求状态,让还没准备好的请求不阻塞已经准备好的请求。

小结

如果只看对象层次,vLLM 会显得很复杂;如果只看 GPU kernel,又很难解释 Scheduler 为什么要维护那么多状态。更合适的切入点是 EngineCore.step():它是调度、执行、采样、状态更新交汇的最小循环。

普通推理里,Engine 尽量让 CPU 工作和 GPU forward/sample 重叠;开启 batch queue 后,Engine 进一步把不同 step 错开成一条小流水线;到了 PD 分离场景,NIXL 的远端 KV 搬运又作为另一条异步链路加入进来。理解这三层之后,再读 vLLM 的 PD 分离源码时,很多状态名、future 和 queue 的设计就会自然很多。

参考资料:vLLM GitHub Repository