跳到主要内容

数据截至 (上游 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 × Nforward + 采样一个进程绑定一张(或一组)GPU

层与层之间传的不是张量、是元数据:前端 → 核心是 EngineCoreRequestvllm/v1/engine/__init__.py:107,token id + 采样参数 + 哈希链);核心 → 前端是 EngineCoreOutputsvllm/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_loopvllm/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 在构造时创建 Schedulervllm/v1/engine/core.py:162)和 Executor,并完成一件昂贵的事——_initialize_kv_cachesvllm/v1/engine/core.py:254):收集各层的 KV cache 规格 → 让 worker 实跑一次 profiling 测出剩余显存(determine_available_memoryvllm/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 schedulingscheduler_config.async_schedulingvllm/config/scheduler.py:148EngineCore 读于 vllm/v1/engine/core.py:238调度下一批时,上一批的采样结果还没回来——用「在途 token 计数 + 输出占位符」保持账本一致
batch queuemax_concurrent_batchesvllm/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 与输出处理链

AsyncLLMvllm/v1/engine/async_llm.py:72)的 add_request(:296)做校验、tokenize、包 EngineCoreRequest 发给核心;generate(:564)是调用方看到的流式入口。

真正有意思的是回程_run_output_handler(:679)起一个后台协程循环:

  1. await engine_core.get_output_async() 拉一批 EngineCoreOutputs
  2. VLLM_V1_OUTPUT_PROC_CHUNK_SIZE 切块处理,块间 await asyncio.sleep(0) 让出事件循环(:712-737)——防止一次大批输出把 HTTP 协程饿死
  3. OutputProcessor.process_outputsvllm/v1/engine/output_processor.py:607)把 token 增量喂给增量 detokenizer(IncrementalDetokenizervllm/v1/engine/detokenizer.py:31;快速路径 FastIncrementalDetokenizer :168),生成 RequestOutput 推到各请求自己的队列;
  4. 遇到 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
DPLBAsyncMPClientDP + 负载均衡路由:1435

DP 部署时一个前端连多个 EngineCore,DPLBAsyncMPClient 按各核心自报的 SchedulerStats(队列长度、KV 占用率;核心侧 _maybe_publish_request_countsvllm/v1/engine/core.py:1424)挑最闲的引擎发新请求。

4.5 同步形态:LLMEngine

离线库 LLM 背后是同构的同步封装 LLMEnginevllm/v1/engine/llm_engine.py:48):同一个 EngineCore、同一个 Scheduler,只是前端不是 asyncio 而是阻塞式 step()(:312)。核心逻辑只有一份,两个前端壳——这是 V1 相对 V0 最大的卫生改善。


5. 关键细节与坑

  • 启动握手很贵EngineCoreProc 起来后要和前端做多轮握手(_perform_handshakesvllm/v1/engine/core.py:1149)确认地址、DP 排名等;叠加 KV profiling 与编译热身,冷启动以十秒计。
  • 前端崩 ≠ 核心崩,反之亦然。AsyncLLM 侧有 EngineDeadError 守卫(add_request 开头);核心侧失败回调能反过来杀 executor。
  • abort 是异步生效的aborts_queuestep() 里模型执行期间被处理(_process_aborts_queuevllm/v1/engine/core.py:754),所以「取消一条流式请求」最晚下一个 step 生效。
  • 输出切块大小是延迟旋钮VLLM_V1_OUTPUT_PROC_CHUNK_SIZE 太小则输出处理变慢,太大则事件循环被独占、并发连接的首 token 延迟抖动。
  • DP 负载均衡是「自报式」的:各引擎广播自己的统计,前端据此路由——实现简单,但统计有时滞,突发流量下可能瞬间失衡(inferred:代码中没有中央仲裁者)。