理解 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 搬到本地。
| 图中元素 | 对应 vLLM 对象 | 本文关注的职责 |
|---|---|---|
| Engine | EngineCore 主线程,内部持有 Scheduler、KVCacheManager | 每步执行 schedule -> execute -> update;维护 request 状态;决定何时派发推理或 KV 拉取任务。 |
| Worker | Worker 进程 + ModelRunner,由 Executor RPC 调用 | 接收 Engine 派发的 SchedulerOutput,把它翻译成 tensor,触发 forward、sample 和 KV 传输相关操作。 |
| GPU | Worker 绑定的 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。
这就是 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 也每轮完成一次,只是两者相差一拍。
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。
这张图里最值得关注的是状态切换:
- R 首次被调度到 decode 侧,但本地 KV 还没准备好,于是从
WAITING进入WAITING_FOR_REMOTE_KVS。 - Worker 触发 NIXL 的
start_load_kv,实际的远端 KV 传输在后台进行。 - 后续几个 Engine step 里,Scheduler 会跳过 R,但继续服务其他请求。
- NIXL 搬运完成后,Worker 在
finished_recving里上报 R。 - 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 的设计就会自然很多。