数据截至 (上游 commit f25c580af159)
03 · V1 引擎架构(多进程流水线)
这一章讲什么: 调度器和块池住在哪个进程?GPU 执行在谁手里?token 怎么流回用户?V1 重构(相对旧版 V0,本 commit 里 V0 已移除)的答案是一套多进程流水线。读完你会知道一个请求从前端到 GPU 再回到用户,跨了几个进程、各进程之间传的是什么。
1. 它要解决的小问题
单进程引擎有个结构性矛盾:调度、detokenize、HTTP 收发都是 CPU 活,forward 是 GPU 活。放在同一个事件循环里,CPU 每干一次活,GPU 就停一次。模型越大、批越大,GPU 一步越久,这个问题越小;但小批量 decode 一步只有几毫秒,CPU 开销直接吃掉吞吐。
V1 的回答:按「CPU 密度」切进程,让 CPU 活和 GPU 活在时间上重叠。
2. 思路:三层进程,各干各的,只传数据
| 层 | 进程 | 干什么 | 为什么单独拆出来 |
|---|---|---|---|
| 前端 | AsyncLLM(asyncio) | 收请求、tokenize、推流 | asyncio 生态在这层;tokenize/detokenize 是 CPU 开销大头 |
| 引擎核心 | EngineCoreProc | 调度 + 驱动执行 | 调度要快、要独占 CPU 全力跑 busy loop |
| 执行 | WorkerProc × N | forward + 采样 | 一个进程绑定一张(或一组 )GPU |
层与层之间传的不是张量、是元数据:前端 → 核心是 EngineCoreRequest(vllm/v1/engine/__init__.py:107,token id + 采样参数 + 哈希链);核心 → 前端是 EngineCoreOutputs(vllm/v1/engine/__init__.py:253,token id + 统计);核心 → Worker 是 SchedulerOutput(上一章)。张量只在 Worker 内部和 Worker 之间(NCCL)流动。
图示:进程拓扑与通信
用户/HTTP ──► ┌─────────────┐ ① EngineCoreRequest ┌──────────────────┐
│ 前端进程 │ ──(zmq PUSH/ROUTER)──► │ EngineCore 进程 │
│ AsyncLLM │ │ run_busy_loop: │
│ tokenize / │ ◄──(zmq, 共享内存)───── │ schedule→execute │
│ detokenize │ ④ EngineCoreOutputs └────────┬─────────┘
└─────────────┘ │ ② SchedulerOutput
(rpc_broadcast_mq, 共享内存)
┌───────────────▼────────┐
│ Worker × N(每 GPU 一个) │
│ ③ forward + sample │
└────────────────────────┘
怎么读这张图: ①④ 跨主机也成立(zmq over TCP),②③ 同机用共享内存消息队列。前端和核心可以不在同一台机器——这是服务化部署(API server 水平扩展)的地基。
3. 原理演示:busy loop 的骨架
EngineCore 的主循环小得惊人(EngineCoreProc.run_busy_loop,vllm/v1/engine/core.py:1411)。用示意代码演它的形状:
# 示意,非源码
def run_busy_loop(self):
while self._handle_shutdown():
self._process_input_queue() # 收新请求/中止,直到有活干
if self.scheduler.has_requests():
outputs, executed = self.step() # 一跳:调度→执行→回灌
for out in outputs.items():
self.output_queue.put_nowait(out) # 丢给前端,不等待
重点:没有锁、没有 await,一个 while True 撑到底。所有异步性都靠「队列非阻塞 put + 下一个 step 再处理」实现。GPU 在跑的时候,CPU 已经在跑下一个 schedule()——这就是「调度与执行重叠」的全部机关。
4. 真实实现
4.1 一跳:EngineCore.step
在 vllm/v1/engine/core.py:597,一个 step 四件事:
scheduler_output = self.scheduler.schedule(self._should_throttle_prefills())
future = self.model_executor.execute_model(scheduler_output, non_block=True)
grammar_output = self.scheduler.get_grammar_bitmask(scheduler_output)
...
model_output = future.result()
...
engine_core_outputs = self.scheduler.update_from_output(scheduler_output, model_output)
注意 non_block=True:执行请求发出去后立刻去做别的事(算结构化输出的 grammar bitmask),要用结果时才 future.result()。这一步内就在挤 CPU/GPU 的重叠。
EngineCore 在构造时创建 Scheduler(vllm/v1/engine/core.py:162)和 Executor,并完成一件昂贵的事——_initialize_kv_caches(vllm/v1/engine/core.py:254):收集各层的 KV cache 规格 → 让 worker 实跑一次 profiling 测出剩余显存(determine_available_memory,vllm/v1/worker/gpu_worker.py:512)→ 据此算出块数 → 分配 KV 张量 → 编译与 CUDA graph 热身。KV cache 的大小不是配置出来的,是量出来的(gpu_memory_utilization 默认 0.92,见 vllm/config/cache.py:111——留给 KV cache 的是「占满 92% 显存后剩下的部分」)。
4.2 重叠的两种档位
| 机制 | 开关 | 重叠了什么 |
|---|---|---|
| async scheduling | scheduler_config.async_scheduling(vllm/config/scheduler.py:148;EngineCore 读于 vllm/v1/engine/core.py:238) | 调度下一批时,上一批的采样结果还没回来——用「在途 token 计数 + 输出占位符」保持账本一致 |
| batch queue | max_concurrent_batches(vllm/v1/engine/core.py:210 附近,batch_queue_size) | 连着备好几个 step 的输入再依次发射,流水线更深 |
async scheduling 的副作用在调度器里可见:上一章 _preempt_request 里那段「stale output」账本(vllm/v1/core/sched/scheduler.py:1415-1426)就是为「抢 占发生时它上一步的输出还在飞」准备的。
4.3 前端:AsyncLLM 与输出处理链
AsyncLLM(vllm/v1/engine/async_llm.py:72)的 add_request(:296)做校验、tokenize、包 EngineCoreRequest 发给核心;generate(:564)是调用方看到的流式入口。
真正有意思的是回程:_run_output_handler(:679)起一个后台协程循环:
await engine_core.get_output_async()拉一批EngineCoreOutputs;- 按
VLLM_V1_OUTPUT_PROC_CHUNK_SIZE切块处理,块间await asyncio.sleep(0)让出事件循环(:712-737)——防止一次大批输出把 HTTP 协程饿死; OutputProcessor.process_outputs(vllm/v1/engine/output_processor.py:607)把 token 增量喂给增量 detokenizer(IncrementalDetokenizer,vllm/v1/engine/detokenizer.py:31;快速路径FastIncrementalDetokenizer:168),生成RequestOutput推到各请求自己的队列;- 遇到 stop 字符串命中的,回头调
abort_requests_async通知核心停掉。
detokenize 是增量的:每个请求维护已解码前缀,新 token 只解差分部分——避免每步重解整段文本的 O(n²)。
4.4 进程间客户端:EngineCoreClient 家族
前端持有的「核心」其实是一个客户端代理(vllm/v1/engine/core_client.py):
| 类 | 场景 | 行号 |
|---|---|---|
InprocClient | 同进程(LLM 离线、调试) | :306 |
SyncMPClient | 跨进程同步(LLM) | :806 |
AsyncMPClient | 跨进程异步(AsyncLLM/API server) | :978 |
DPAsyncMPClient | 数据并行多引擎 | :1253 |
DPLBAsyncMPClient | DP + 负载均衡路由 | :1435 |
DP 部署时一个前端连多个 EngineCore,DPLBAsyncMPClient 按各核心自报的 SchedulerStats(队列长度、KV 占用率;核心侧 _maybe_publish_request_counts,vllm/v1/engine/core.py:1424)挑最闲的引擎发新请求。
4.5 同步形态:LLMEngine
离线库 LLM 背后是同构的同步封装 LLMEngine(vllm/v1/engine/llm_engine.py:48):同一个 EngineCore、同一个 Scheduler,只是前端不是 asyncio 而是阻塞式 step()(:312)。核心逻辑只有一份,两个前端壳——这是 V1 相对 V0 最大的卫生改善。
5. 关键细节与坑
- 启动握手很贵。
EngineCoreProc起来后要和前端做多轮握手(_perform_handshakes,vllm/v1/engine/core.py:1149)确认地址、DP 排名等;叠加 KV profiling 与编译热身,冷启动以十秒计。 - 前端崩 ≠ 核心崩,反之亦然。
AsyncLLM侧有EngineDeadError守卫(add_request开头);核心侧失败回调能反过来杀 executor。 - abort 是异步生效的:
aborts_queue在step()里模型执行期间被处理(_process_aborts_queue,vllm/v1/engine/core.py:754),所以「取消一条流式请求」最晚下一个 step 生效。 - 输出切块大小是延迟旋钮:
VLLM_V1_OUTPUT_PROC_CHUNK_SIZE太小则输出处理变慢,太大则事件循环被独占、并发连接的首 token 延迟抖动。 - DP 负载均衡是「自报式」的:各引擎广播自己的统计,前端据此路由——实现简单,但统计有时滞,突发流量下可能瞬间失衡(inferred:代码中没有中央仲裁者)。