home/tutorial/M1 入口

Month 1 · 入口与请求生命周期

目标:能在白板上画出 "curl 进来到 token 流出" 的完整调用链。

这个月不碰 KV cache、不碰 CUDA、不碰调度细节。 你只做一件事:跟着一个真实请求,把它走过的每一层代码都摸一遍。 这一层走通,后面所有深入细节都有"挂靠点" —— 当你 M2 读到 BlockManager.allocate() 时,你已经知道它是谁、什么时候被叫起来、传给它的参数从哪里来。

驱动问题
curl http://localhost:8000/v1/chat/completions ...。 这个 HTTP 请求会经过哪些 Python 对象、被哪些线程/进程处理、最后是谁真的把它送上 GPU? 画一张图,需要画几个框?这些框之间是同步调用还是跨进程消息?哪些跑在 GIL 锁里,哪些不?
先猜一下层数(不用准确)

大概 5–7 层:HTTP server → API handler → engine wrapper (async) → engine core → scheduler → worker → model forward。 每层做的事不一样:HTTP 解析、tokenize、调度入队、batch 组装、GPU forward、detokenize、流式输出。

真实数字(在 v1 架构下):3 类进程(API server、EngineCore、Worker × N), 2 个 IPC 边界(ZMQ 把 1↔2 连起来,多机/多 GPU 时 2↔3 走 PyTorch distributed), 4 个主要的内部队列(input queue、waiting、running、output queue)。这一节就是去把这张图真正画对

01AI infra 的"请求生命周期"到底是什么

在动手挖代码之前,先把"为什么花一个月学这个"想清楚。AI infra 有两种完全不同的生命周期, 你以后做任何决定都要先分清楚自己在哪一边:

训练 (Training)

给定一堆数据,跑 N 个 epoch 的梯度下降,输出是权重文件。 周期以小时/天计;失败容忍度高(多 checkpoint 就行);瓶颈在数据并行 / TP / PP / 通信。

关键词:DDP、FSDP、Megatron、torchtitan、Megatron-LM。

推理 (Serving / Inference)

给定一个权重,处理来自 N 个用户的并发请求流,每个请求要在 P99 SLA 内出第一个 token。 周期以毫秒计;失败容忍度低(用户在等);瓶颈在显存、KV cache、调度。

关键词:vLLM、TGI、SGLang、TensorRT-LLM、batching、prefill / decode。

vLLM 是推理引擎。这意味着它的中心问题是"如何把一台静态的 GPU 变成一台动态的 token 流水线"。 本月你要追踪的"请求生命周期",就是这条流水线上一个 token 从被请求到被吐出的全过程。

为什么 M1 先做这件事,而不是 M5
按下不表的诱惑很强 —— 直接挖 PagedAttention 论文、直接读 CUDA kernel 似乎更"硬"。 但不知道一个调用从哪里来、到哪里去,你读 kernel 就只能背它的签名; 读 scheduler 就只看见循环;读 block manager 就只看见数据结构。 这一个月铺的"骨架"是后面五个月所有"血肉"的挂靠点。这是 OSTEP 第一章先讲"进程"再讲"线程"的同一个道理。

另一个动机:vLLM 的入口(vllm/entrypoints/vllm/v1/engine/)是整个仓库里最 Python 的部分, 没有 CUDA、没有 Triton、没有汇编。你能静态读懂这部分代码,意味着你已经掌握了进入更难层的"密码本"。

02前置:Python async/await 速通

下面这些大致懂就行。模糊的现在补,不熟悉的标记一下,月底再看:

其中 async 是这一章绕不开的"语法地基",把它说透。

Event loop 的单线程心智模型

最关键的一句话:asyncio 不是多线程。它是一个线程里调度多个"协程"(coroutines), 让阻塞 I/O 的时候不傻等而是让出控制权,去跑别的协程。可以这样想象:

t 0 Task A CPU work await network I/O resume Task B CPU work await ZMQ resume Task C CPU work (SSE yield) A awaits B awaits A's I/O done B's I/O done
同一个线程里,3 个协程通过 await 主动让出,event loop 用一个就绪队列调度它们。任何一个协程跑死循环或 CPU 重活,整个 loop 都卡住。

async def / await 实际编译成什么

这是读 vLLM 源码时被反复忽略的关键async def foo() 不是函数调用 —— 它是一个生成器工厂。 调用 foo() 返回的是一个 coroutine 对象,什么都没执行。直到你 await foo() 或塞给 asyncio.create_task(),event loop 才开始拉它的状态机。

# 看起来像普通函数
async def fetch_token():
    request_id = await engine.add_request(prompt)   # ① 在这里 suspend
    async for output in engine.generate(request_id): # ② 每个 yield 是一次 suspend
        yield output

# 调用它不执行任何代码
gen = fetch_token()  # 这是一个 async generator object
# 直到 ↓ 才真正跑起来
async for tok in gen:
    print(tok)

心智模型:把 await x 念作 "暂停当前任务,把 x 交给 event loop,等它有结果再叫醒我"。 这个"叫醒"就是 vLLM 流式输出能边算边返回的关键。

asyncio.Queue vs threading.Queue

vLLM 内部到处是队列。但两种队列不能混用

asyncio.Queuequeue.Queue (threading)
使用方同一个 event loop 内的协程多个 OS 线程
put 阻塞时await 让出(其他协程可跑)OS 真线程阻塞
跨进程?❌(用 ZMQ / multiprocessing.Queue)❌(同上)
vLLM 用在哪AsyncLLM 给每个 request 的 output 队列极少(worker 内部偶有)
⚠ 常见坑
在 async 代码里写了一个 queue.Queue.get()(无 timeout),整个 event loop 卡死, 连 health check 都不响应。看到 vLLM 哪里挂死,第一时间用 py-spy dump --pid X 看是不是这类问题。

SSE / generator 流式响应

SSE (Server-Sent Events) 是 HTTP 的"服务器推送"协议。vLLM 用它把 token 流回 client。FastAPI 的实现:

from fastapi.responses import StreamingResponse

@app.post("/v1/chat/completions")
async def chat(req: ChatRequest):
    if req.stream:
        async def gen():
            async for tok in engine.generate(req):
                # SSE 格式:data: {...}\n\n
                yield f"data: {tok.model_dump_json()}\n\n"
            yield "data: [DONE]\n\n"
        return StreamingResponse(gen(), media_type="text/event-stream")
    # 非流式:等所有 token 攒齐
    full = ""
    async for tok in engine.generate(req):
        full += tok.text
    return {"choices": [{"message": {"content": full}}]}

关键洞察:这个 gen() 是一个 async generator,每个 yield 都把控制权还给 event loop, 让别的请求也能继续推进。如果你在中间不小心写了同步 I/O(比如 requests.get(...) 而不是 httpx.AsyncClient.get(...)), 整个服务就只能"一次一个请求"。

常见 gotcha 速查

表现
忘了 await函数立刻返回一个 coroutine 对象,没执行记得 await,或 asyncio.create_task()
在 async 函数里 time.sleep(1)整个 event loop 卡 1 秒await asyncio.sleep(1)
在 async 里跑 CPU 重活所有协程都被卡await loop.run_in_executor(None, heavy_fn)
在 sync 代码里调 async 函数得到 coroutine 但没执行asyncio.run(coro())
多个 event loop"got Future attached to a different loop"整个进程只用一个 loop
💡 触发的 CS162 章节
这个月会撞到的 OS 概念是 I/O 模型(阻塞 / 非阻塞 / async / epoll)。 深入不必,知道"async 是一种把多个 I/O 操作交错在一个线程里"的实现就够。 真正用得上 OS 调度概念是 Month 3,那时候再展开。如果你完全没接触过 select/epoll, 花 20 分钟看 OSTEP 第 39 章 "Event-based Concurrency" 那一节,正好。

03vLLM v0 vs v1:为什么要拆进程

记住两个版本概念:

新代码统统看 v1。但为什么要拆?这背后是一个非常 OS 风格的故事 —— Python GIL。

v0 的瓶颈:GIL

v0 的设计很自然:一个 Python 进程,一个 event loop。这个 loop 既要:

  1. 处理 HTTP(FastAPI / uvicorn)
  2. 跑 tokenizer(CPU 重活)
  3. 跑 scheduler(决定下一步 batch)
  4. 把 batch 发给 worker(多进程 / 多机)
  5. 等 worker 返回 logits,detokenize
  6. 把 token SSE 推回去

CPython 的 GIL 决定了上面这些 Python 操作不能并行,只能串行(更准确说:交错)。 当 QPS 高时,scheduler 那一步可能要花 1–3 ms,期间 HTTP handler 拿不到 GIL, 新请求积压、SSE 推送变慢、整体延迟劣化。GPU 反而经常空转等 CPU 喂

v0 · 单进程(GIL 卡) Python 进程 GIL 串行所有 Python 字节码 HTTP handler (async) tokenizer (CPU) scheduler.step() ← 1–3 ms / 步 序列化给 worker detokenize SSE push CPU 是瓶颈:GPU 间歇空转 throughput 受限 v1 · 拆进程(GIL 独立) Process A API Server HTTP handler tokenize SSE push detokenize Process B EngineCore run_busy_loop() scheduler.step() execute_model sample ↑↓ ZMQ IPC ↑↓ 两个 GIL 互不干扰 · GPU 喂饱
v0 vs v1 的本质差异:v1 把 HTTP / tokenize 和 scheduler / execute 拆到两个进程, 两边各有自己的 GIL,scheduler 不再阻塞新请求接入。代价是多一道 ZMQ 序列化。

v1 解决了什么

v1 的代价

类比
这个拆分跟 OS 里"把网络栈从内核搬到 user-space"(DPDK / netmap)的逻辑很像: 原本 monolithic 设计简单但被全局锁拖死;拆出去后每个组件能独立优化,代价是多一道 IPC。 vLLM v1 的"engine 子进程"就是它的 user-space data plane。

04v1 架构总览 · 谁拥有什么状态

下图是 v1 架构的"空间视图"(谁在哪个进程):

curl / SDK HTTP POST Process A · API Server FastAPI route /v1/chat/completions tokenize + 包成 EngineRequest AsyncLLM client ZMQ IPC → engine Process B · Engine Core EngineCore.run_busy_loop() Scheduler.schedule() 挑这一步的 batch BlockManager 分配 KV blocks Executor.execute_model() detokenize 输出 Process C..N · Workers (每张 GPU 一个) ModelRunner.execute_model() 组 input、forward model.forward() PyTorch / CUDA attention kernel csrc/ GPU 显存 · KV cache 物理块池 PagedAttention 在这里发挥
v1 的"空间视图":进程边界用虚线框,箭头是数据流。注意 KV cache 物理块只存在 worker 的 GPU 显存里,scheduler 在 EngineCore 进程里只持有"映射表"。

下面是同一个系统的"时间视图",看一次 decode step 里这些组件如何交错:

Client API Server EngineCore Worker (GPU) POST tokenize ZMQ schedule forward (5–20 ms) sample detok SSE token t (ms) ~1 ~2 ~3 ~15 ~16 典型时间分布(7B 模型 / A10 / batch=8)。注意 forward 占 80%+,CPU 路径只占几 ms。
"时间视图":4 条 swim lane 表示 4 类参与者。注意 Engine 这一行的 schedulesample同一个进程的同一个事件循环,但要在 GPU 算的时候让出,让别的请求也能进 schedule 队列。

四类参与者 · 各自的状态

角色持有状态不持有什么
API Server HTTP 连接、每个请求的 output queue (asyncio.Queue)、tokenizer、chat template KV cache、scheduler 状态
EngineCore waiting / running 队列、block 映射表(不是 block 本身)、scheduler 配置、metrics 实际的 KV 张量、模型权重
Executor Worker 集合、TP/PP 拓扑、通信句柄 模型权重(在 Worker 里)
Worker 模型权重、实际 KV cache 张量(GPU 显存)、CUDA stream、CUDA graph 调度决策、HTTP 状态

跨进程消息一览

05请求生命周期 · 13 步详解

下面这套调用链是 v1 在 vLLM main 分支的形态(路径可能微调,但结构稳定)。 拿这张表去对照你 clone 下来的代码

#发生了什么关键代码位置
1HTTP 请求到 FastAPI route handlervllm/entrypoints/openai/api_server.py
2构造 ChatCompletionRequest,绑模板 → prompt textvllm/entrypoints/openai/serving_chat.py
3Tokenize prompt → list[int]vllm/transformers_utils/tokenizer.py
4构造 EngineCoreRequest 通过 ZMQ 发到 enginevllm/v1/engine/async_llm.py
5Engine 主循环把它加入 waiting 队列vllm/v1/engine/core.pyrun_busy_loop()
6每一步 schedule() 决定哪些 request 进入这一步的 batchvllm/v1/core/sched/scheduler.py
7BlockManager 给每个 sequence 分配 KV blocksvllm/v1/core/kv_cache_manager.py
8Executor 把 batch 序列化发给 workervllm/v1/executor/
9Worker 在 GPU 上跑 model.forward()vllm/v1/worker/gpu_model_runner.py
10Attention kernel 用 block table 访问 KVcsrc/attention/ (CUDA) 或 vllm/v1/attention/ (Triton)
11采样得到下一个 token,回写 KVvllm/v1/sample/sampler.py
12Detokenize 这个 token → str,发回 API servervllm/v1/engine/output_processor.py
13SSE 流式 yield 给 HTTP client回到 step 2 的 streaming generator

下面把这 13 步当作 13 个小故事来讲,每一步注明:读什么状态、写什么状态、怎么会出错

步骤 1–4 · 接入阶段(API Server 进程)

步骤 1 · uvicorn 收到 TCP 包,Starlette 解析 HTTP,把请求路由到 chat_completion handler。 状态读:路由表、CORS / auth middleware 缓存。状态写:当前请求的 ASGI scope。 故障点:连接打满(uvicorn 默认 worker 数有限)、JSON 解析失败、内容超长被 middleware 截。

步骤 2 · OpenAIServingChat.create_chat_completion()messages 数组用 Jinja chat template 渲染成单一 prompt 字符串, 顺带处理 tool_choiceresponse_format状态读:模型的 chat template(启动时从 tokenizer 加载)。状态写:内部 ChatCompletionRequest dataclass。 故障点:未知 role、template 渲染失败、tool schema 不合法。

步骤 3 · 渲染好的 prompt 喂给 HuggingFace tokenizer,得到 list[int]状态读:tokenizer vocab、merges。状态写prompt_token_ids故障点:超长(> max_model_len)—— vLLM 会 reject; 特殊 token 不一致 —— 这是相当多模型的兼容性 bug 来源。

步骤 4 · 构造 EngineCoreRequest,通过 AsyncLLM 的 ZMQ socket 发到 EngineCore 进程。 状态读:sampling params、stop tokens。状态写:API server 端为该 request 开一个 asyncio.Queue 接收 output; 发出 EngineCoreRequest 字节流。 故障点:序列化失败(很少)、ZMQ socket 满(背压,理论上会发生但生产配置下罕见)。

步骤 5–8 · 调度阶段(EngineCore 进程)

步骤 5 · EngineCore 的 run_busy_loop 从 ZMQ 收到 request,调用 Scheduler.add_request()waiting 队列。 状态读:当前 running 集合、KV pool 剩余 block。状态写:waiting 队列追加。 故障点:max_num_seqs 达上限(罕见)、请求格式不一致。

步骤 6 · 每次 schedule() 决定:哪些 waiting 进入 prefill、哪些 running 继续 decode、是否要 preempt 出某些 running 来腾 KV block。 状态读:waiting / running / preempted、KV pool。状态写:这一步的 batch 描述、被 evict 的 sequence。 故障点:deadlock(一个长 prompt 占不下任何位置,scheduler 死循环)—— 通过 chunked prefill 缓解。

步骤 7 · BlockManager.allocate() 给新进 prefill 的 sequence 分配 KV block,或给 decode 的 sequence 在写满当前 block 时补一个。 状态读:free block list、prefix cache hash 表。状态写:block 引用计数、sequence 的 block_table。 故障点:没 block 了(应该不发生,scheduler 该兜住);prefix cache miss 算错(M2 会深挖)。

步骤 8 · Executor.execute_model()SchedulerOutput 序列化广播到所有 Worker。 单卡时 Executor 直接 in-process 调 Worker;TP/PP 时走 PyTorch distributed 的 broadcast。 状态读:worker handle、通信 topology。状态写:序列化的请求 payload。 故障点:worker hang、通信 timeout、payload 序列化爆栈(罕见)。

步骤 9–11 · 计算阶段(Worker 进程,GPU)

步骤 9 · gpu_model_runner.execute_model() 把 batch 转成 PyTorch tensor:把所有 sequence 的 input_ids 拼平、构造 positions、查每个 sequence 的 block_table。 状态读:模型权重、当前的 KV cache 池、上一步的 token。状态写:input batch tensor、attn metadata。 故障点:shape 不对(debug 噩梦)、显存 OOM。

步骤 10 · 跑 model.forward(),关键是 attention 层 —— 它用 attn metadata(block table、slot mapping)告诉 PagedAttention kernel 去哪儿读 KV、写新 KV。 状态读:input tensor、attn metadata、KV cache 池。状态写:每个 sequence 的新 KV、最后一层 logits。 故障点:kernel illegal memory(block table 错位最常见)、numerics 不稳定(fp16 vs bf16)。

步骤 11 · Sampler 从 logits 采样下一个 token(greedy / topk / topp / temperature)。多个请求并行采样。 状态读:logits、各请求的 sampling params。状态写:每个 sequence 的 next token id。 故障点:采样器实现 bug(最近的 PR 经常在这里)、bad logits(NaN / Inf)。

步骤 12–13 · 输出阶段(回到 EngineCore 再到 API Server)

步骤 12 · EngineCore 把 token id 流过 output_processor(detokenize + 处理 stop strings + finish reason),打包成 EngineCoreOutput 发回 API server。 状态读:detokenizer 状态、stop strings。状态写:每个 sequence 的累计 text。 故障点:BPE 半字符 token(detokenize 必须维持状态,不能逐 token 解码)—— vLLM 用增量 detokenizer 处理。

步骤 13 · API server 端的 asyncio.Queue 拿到 output,handler 的 async for 拉出来 yield 成 SSE 帧推给 client。 当 finished=True 时 yield data: [DONE]状态读:当前 request 的 output queue。状态写:HTTP 响应 body。 故障点:client 提前断开 —— 需要把 abort 信号反向传回 EngineCore(用 asyncio.CancelledError)。

⚠ 路径可能小变
vLLM 改名/重构频繁。如果上面某个文件找不到,git grep:比如 git grep -l "run_busy_loop" 就能定位。 这是日常技能 —— 教程里的路径只是"线索",不是"地图"。

06代码深读 · api_server.py

这一节起,我们挑 4 段最关键的代码做"深读"。每一段先看结构骨架(伪代码层面),再点出 1–2 个细节"读到这里要停一下"的地方。

vllm/entrypoints/openai/api_server.py 是整个引擎的 HTTP 边界。它做四件事: 启动时加载模型注册路由把每个请求转给引擎关停时清理。 下面是它的骨架(删掉细节,保留结构):

from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse

# ── ① lifespan:启动 / 关停的 hook ──
@asynccontextmanager
async def lifespan(app: FastAPI):
    # 启动:构建 AsyncLLM(会 fork EngineCore 子进程)
    engine = await build_async_engine_client(args)
    app.state.engine = engine
    yield   # ← FastAPI 在这里把控制权还给主线程
    # 关停:发送 abort 给所有 in-flight 请求,shutdown engine
    await engine.shutdown()

app = FastAPI(lifespan=lifespan)

# ── ② 健康检查 ──
@app.get("/health")
async def health(req: Request):
    if req.app.state.engine.is_running():
        return {"status": "ok"}
    raise HTTPException(503)

# ── ③ Chat completion 主路由 ──
@app.post("/v1/chat/completions")
async def create_chat_completion(req: ChatCompletionRequest, raw: Request):
    handler: OpenAIServingChat = raw.app.state.openai_serving_chat
    generator = await handler.create_chat_completion(req, raw)

    if isinstance(generator, ErrorResponse):
        return JSONResponse(content=generator.model_dump(), status_code=generator.code)

    if req.stream:
        # SSE 流式
        return StreamingResponse(content=generator, media_type="text/event-stream")
    else:
        # 非流式:消费完所有 chunk 攒成一个 dict
        return JSONResponse(content=generator.model_dump())

# ── ④ Completions / embeddings / 等等路由略 ──
读这里 · 关键 1
这个函数是整个进程拓扑的起点。它会决定要不要 fork EngineCore 子进程(默认 yes),怎么连 ZMQ socket(IPC 还是 TCP),以及 graceful shutdown 顺序。
如果我设 --disable-async-output-proc,谁会少一个子进程?为什么 v0 模式不能享受这个拆分?

Streaming response 的生命周期

关键魔法在 StreamingResponse(content=generator, ...) 这一行。 这个 generator 是一个 async generator,每次 yield 一个 SSE 帧。 Starlette 拿到它后,会:

  1. 开一个 background task 去消费这个 generator
  2. 每拿到一个 chunk,写到 TCP socket(send
  3. 如果 client 中途断开,sendConnectionResetError,generator 收到 asyncio.CancelledError
  4. generator 的 finally 块负责反向通知 engine把这个 request abort 掉
# serving_chat.py 里的简化版 generator
async def chat_completion_stream_generator(...) -> AsyncIterator[str]:
    request_id = f"chatcmpl-{uuid()}"
    try:
        async for output in engine.generate(prompt, params, request_id):
            chunk = build_sse_chunk(output)
            yield f"data: {chunk}\n\n"   # ← 每次 yield 让出控制权
        yield "data: [DONE]\n\n"
    except asyncio.CancelledError:
        # client 断开
        await engine.abort(request_id)
        raise
    finally:
        # 兜底清理
        pass
💡 这就是 async 的杠杆
上面这个 generator 表面上像一个串行循环,实际上 同时有几十个 这样的 generator 在 event loop 里跑, 每个 async for 都在等 engine 推下一个 token。这是"用 200 行代码服务 1000 并发"的核心机制。

07代码深读 · async_llm.py 与 ZMQ

vllm/v1/engine/async_llm.py 里的 AsyncLLM 类是 API server 进程看到的 engine。 实际 engine 在子进程,AsyncLLM 只是个 RPC 代理。它持有 ZMQ socket,把 add_request / abort / get_output 转成跨进程消息。

骨架:send / receive 双循环

class AsyncLLM:
    def __init__(self, engine_args):
        # fork 出 EngineCore 子进程
        self.engine_proc = start_engine_subprocess(engine_args)
        # 两个 ZMQ socket
        self.input_socket = zmq_ctx.socket(zmq.PUSH)
        self.input_socket.connect(input_path)    # → 给 engine 发请求
        self.output_socket = zmq_ctx.socket(zmq.PULL)
        self.output_socket.connect(output_path)  # ← 从 engine 收 output

        # 每个 request_id → asyncio.Queue,存它的 output
        self.output_queues: dict[str, asyncio.Queue] = {}

        # 后台任务:把 output_socket 收到的消息分发到各 queue
        asyncio.create_task(self._output_handler_loop())

    async def add_request(self, request_id, prompt_token_ids, params):
        # 准备这个 request 的 output queue
        self.output_queues[request_id] = asyncio.Queue()
        # 序列化并通过 PUSH socket 发给 engine
        msg = EngineCoreRequest(request_id, prompt_token_ids, params)
        await self.input_socket.send(msgpack.packb(msg))

    async def generate(self, prompt, params, request_id) -> AsyncIterator[Output]:
        await self.add_request(request_id, ..., params)
        queue = self.output_queues[request_id]
        try:
            while True:
                output = await queue.get()        # ← await 让出
                yield output
                if output.finished:
                    break
        finally:
            del self.output_queues[request_id]

    async def _output_handler_loop(self):
        # 后台循环:不停从 ZMQ 收 output,路由到对应 queue
        while True:
            raw = await self.output_socket.recv()
            output = msgpack.unpackb(raw)
            queue = self.output_queues.get(output.request_id)
            if queue:
                await queue.put(output)
读这里 · 关键 2
整个 streaming 的"心跳"在这里。EngineCore 把 output 通过 ZMQ 推过来,这个后台 task 负责扇出(fan-out)到每个 request 的私有 queue。
如果一个 request 的 client 断了(queue 没人消费),会怎样?背压怎么处理?

ZMQ socket 类型选择

方向socket 类型语义
API → Engine 请求PUSH / PULL单向、消息排队、无应答;多 Producer 时 ZMQ 自动 round-robin
Engine → API outputPUSH / PULL同上,反方向
多 API server replicasDEALER / ROUTER双向、可寻址;用 identity 标识 client;不常用

实际代码可能用 DEALER/ROUTER 以便支持多个 API server replicas 连同一个 engine(如果做 multi-tenant), 但单机最常见的是 PUSH/PULL + Unix domain socket(路径形如 ipc:///tmp/vllm-XXX)。

序列化格式

vLLM 当前主要用 msgpack(带自定义 hook 处理 numpy / dataclass)。 msgpack 比 pickle 安全(不执行任意代码)、比 JSON 紧凑、比 protobuf 简单。 单次开销在几十微秒到几百微秒,跟 forward 的 10+ ms 比是小数。

为什么不直接共享内存
共享内存(mmap / SharedMemory)确实更快,但要自己管同步、生命周期、版本兼容。 vLLM 在 worker 间用过共享内存(vllm/distributed/device_communicators/),但 API↔Engine 这一层用 ZMQ —— 消息量不大,调试方便,扩展性好(同样的代码能跑多机)。这是 trade-off。

背压:当 client 太慢

ZMQ 默认会缓冲消息,缓冲满了发送方会阻塞(HWM,high water mark)。 在 vLLM 里这极少触发 —— output 推送很快,client 通常跟得上。 真正要担心的是反向:client 一次发上千请求把 input socket 塞爆。vLLM 用 max_num_seqs 在更上层兜住。

08代码深读 · EngineCore.run_busy_loop()

这是整个引擎的心跳函数。读懂这 50 行(实际更多但骨架就这些),你就理解了 vLLM 怎么把"接收请求"和"算 token"交错起来。

class EngineCore:
    def run_busy_loop(self):
        while True:
            # ── ① 处理新到的请求(非阻塞 poll)──
            while not self.input_queue.empty():
                req = self.input_queue.get_nowait()
                if isinstance(req, EngineCoreRequest):
                    self.scheduler.add_request(req)
                elif isinstance(req, AbortRequest):
                    self.scheduler.abort(req.request_id)

            # ── ② 没事干就 idle wait ──
            if not self.scheduler.has_unfinished_requests():
                req = self.input_queue.get()   # 阻塞,等新请求
                self._handle_input(req)
                continue

            # ── ③ 调度这一步的 batch ──
            scheduler_output = self.scheduler.schedule()

            # ── ④ 执行模型(这一步会去 GPU 上跑 forward)──
            model_output = self.model_executor.execute_model(scheduler_output)

            # ── ⑤ 更新调度状态(标记已完成的 sequence)──
            engine_outputs = self.scheduler.update_from_output(
                scheduler_output, model_output
            )

            # ── ⑥ detokenize 并推回 API server ──
            for output in engine_outputs:
                detokenized = self.output_processor.process(output)
                self.output_queue.put(detokenized)

关键观察:

  1. 这是一个 sync 循环,不是 async。EngineCore 进程的 main loop 是同步的 —— 因为它只有一件事要做(驱动 GPU),不需要协程交错。
  2. 每一圈 = 一个 step = 一次 forward。一次 forward 可能 produce 几十个新 token(每个 sequence 一个,这一步 batch 多大就多少个)。
  3. 请求接入是"轮询":每圈先 drain input queue 一次。不会阻塞太久(GPU 在算的时候 CPU 这边可以处理新接入),但也不是真的 zero-latency。
  4. 有一个隐藏的优化:execute_model 的 GPU 时间和下一轮的 schedule 可以重叠(vLLM 内部用 CUDA event 或单独 CPU 线程做 pipeline),这就是 "async output processing"。
① drain input ③ schedule() ④ execute_model ⑤ update state ⑥ detok + push ② idle wait? (只有空闲时) run_busy_loop() 一圈 ≈ 一次 forward ≈ 一个 token / sequence
EngineCore 一圈循环。在 7B / A10 / batch=16 的典型场景下,一圈大约 15–25 ms:execute_model 占 80%+,schedule 几 ms,其他都是 sub-ms。
读这里 · 关键 3
对照上面的骨架找真实代码。重点是 schedule → execute → update 这个三段式,跟 OS 调度器的 dispatch loop 同构。
如果我想在一圈里跑 2 次 forward(speculative decoding 风格),最少要改哪几行?

09代码深读 · worker 端的 input prep

到这里 scheduler 已经给出 "下一步算哪些 token",但真的开始算之前,worker 还有一段非常微妙的 input prep。 这一段是 vLLM 性能的核心,看懂了你才能在 M4 读 attention kernel 时不迷路。

从 SchedulerOutput 到模型输入

SchedulerOutput 是一个紧凑的描述:"这一步 batch 里有 N 个 sequence,sequence A 是 prefill(要算 1024 个 token),sequence B 是 decode(只算 1 个 token)..."。 gpu_model_runner.prepare_inputs() 要把它翻译成 PyTorch 能直接 forward 的 tensor:

# 简化的伪代码 —— 真实代码在 vllm/v1/worker/gpu_model_runner.py
def prepare_inputs(scheduler_output: SchedulerOutput):
    # ── ① 拼接所有 sequence 这一步要算的 token ──
    # prefill sequence 把整段 prompt 都放进来;decode sequence 只放最后一个 token
    input_ids = []     # shape: [sum_of_lens]
    positions = []     # 每个 token 在它所在 sequence 里的位置
    seq_lens = []      # 每个 sequence 在 input_ids 里的长度
    for seq in scheduler_output.sequences:
        if seq.is_prefill:
            input_ids.extend(seq.prompt_token_ids[seq.computed_len:])
            positions.extend(range(seq.computed_len, seq.total_len))
            seq_lens.append(seq.total_len - seq.computed_len)
        else:  # decode
            input_ids.append(seq.last_token_id)
            positions.append(seq.total_len - 1)
            seq_lens.append(1)

    # ── ② 构造 attn metadata ──
    attn_metadata = AttentionMetadata(
        # block_table: 每个 sequence 用了哪些物理 KV block
        block_tables=stack_block_tables(scheduler_output.sequences),
        # slot_mapping: 每个新 token 的 KV 要写到 KV pool 里哪个 slot
        slot_mapping=compute_slot_mapping(scheduler_output.sequences),
        # context_lens: 每个 sequence 当前一共看过多少 token (含 prefill)
        context_lens=[s.total_len for s in scheduler_output.sequences],
        seq_lens=seq_lens,
        # ... 还有 num_prefills、num_decode_tokens 等
    )

    return ModelInput(
        input_ids=torch.tensor(input_ids, device="cuda"),
        positions=torch.tensor(positions, device="cuda"),
        attn_metadata=attn_metadata,
    )

三个最容易混的名词

名词形状含义
block_table [num_seqs, max_blocks_per_seq] 每个 sequence 用了哪些物理 KV block(block 编号)。 类比:进程的页表(虚拟页 → 物理页)。
slot_mapping [num_new_tokens] 这一步要新算的每个 token,它的 KV 要写到 KV pool 里哪个 slot(slot = block_id × block_size + offset)。 这是 kernel 写 KV 的目标地址
context_lens [num_seqs] 每个 sequence 当前累计的 token 数(含 prompt + 已 decode)。 attention kernel 用它来决定 causal mask 的范围。
为什么这一步是性能关键
prepare_inputs 跑在 CPU 上,单步可能要 1–2 ms。 vLLM 的优化方向:把这一步缓存(CUDA graph capture)、用 torch.compile、提前 prepare 下一步(pipelining)。 M1 只需要看懂结构;M4 你会真正去测它。
读这里 · 关键 4
对照上面的伪代码看真实实现。重点:slot_mapping 是怎么算的?block_table 里"还没分配"的位置怎么处理?
PagedAttention kernel 在哪里"接通" KV cache?看 attn_metadata 怎么从这里流到 kernel。

10完整时序图 · 从 curl 到 token

把前面 10 节合到一张图上。下图是一个 single chat request 从发出到收到第一个 token 的完整时序, 时间是典型值(7B 模型、A10 卡、batch_size=8、prompt 长度 200):

curl API Server EngineCore Worker (GPU) t=0 POST /v1/chat... tokenize ~0.5 ms build req ~0.2 ms ZMQ (~50 µs) add_request schedule allocate KV ~2 ms total prefill 200 tok ~30 ms sample ~1 ms detok SSE push first token t ≈ 35 ms t
完整 first-token 时序(典型值,因模型/硬件而异)。第一个 token 的延迟主要在 prefill(~30 ms),其后每个 decode token 大约 15–25 ms / token,整段 200 token 输出大概 5 秒。

对照上面的图,SLO 优化的杠杆点分布如下:

11动手 · curl 一个真实请求并追踪它

下面是一个必做练习。读了 10 节代码,现在让一个真实请求在你眼前过一遍。

步骤 A · 起一个带 DEBUG 日志的 vLLM

# 单卡 A10 / 7B 模型
VLLM_LOGGING_LEVEL=DEBUG \
  vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --port 8000 \
  --max-model-len 4096 \
  --gpu-memory-utilization 0.85 \
  2>&1 | tee /tmp/vllm.log

等到日志里出现 Application startup complete.,你就准备好了。 另开一个 terminal:

步骤 B · 发一个 chat completion 请求

curl http://localhost:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "meta-llama/Llama-3.1-8B-Instruct",
    "messages": [{"role": "user", "content": "Explain async/await in 2 sentences."}],
    "stream": true,
    "max_tokens": 64
  }'

你会看到 SSE 帧一条条流出来。

步骤 C · 把日志和 13 步对齐

打开 /tmp/vllm.log,搜下面这些关键词,把它们和上面的 13 步对齐:

关键日志(截断 / 大致样貌)对应步骤
POST /v1/chat/completions步骤 1(HTTP 到达)
Received request chatcmpl-xxx步骤 2–4
Added request chatcmpl-xxx to waiting步骤 5
Engine step 12345: scheduled X prefill + Y decode步骤 6
KV cache: allocated N blocks步骤 7
Executor: forward took A ms步骤 8–10
Sampled token X for chatcmpl-xxx步骤 11
Finished request chatcmpl-xxx步骤 12–13
⚠ 日志格式因版本而异
上面的字符串是示意。真实日志可能不完全匹配。 用 grep -i + 关键词搜(scheduleblockforwardtoken), 把能找到的都标上 13 步对照号。

步骤 D · 用 py-spy 看一眼运行时栈

这是非常有教育意义的一招。在请求处理时(保持 vLLM 跑着、curl 一边发请求),另开 terminal:

# 找到 EngineCore 子进程 PID
ps aux | grep EngineCore

# 抓一个栈
py-spy dump --pid 

你应该能看到栈最顶上是 run_busy_loop,往下是 execute_modelmodel.forward → 一堆 attention / matmul。 这是你画的那张图的物证

✓ 你的状态
做完上面 4 步,你应该有:(a) 一份日志截图,13 步对齐过; (b) 一张 py-spy 抓的栈截图;(c) 心里能复述这次请求走了多少 ms 在每一段。 这就是 M1 的实际产出物

12这个月你要读的 5 段代码

下面 5 个 anchor,按顺序读完,每段带一个核心问题。前面深读章节是"导读",下面是你自己要去做的事。

读 ① · 1–2 小时
HTTP 入口。看它怎么注册 routes、怎么启动 engine、怎么 graceful shutdown。
这里有几个 async 的"分叉点"?streaming response 是怎么从 engine 拿数据的?
读 ② · 1 小时
Chat 协议的具体处理:应用 chat template、处理 tool calling、构造 prompt。这是协议层,与 vLLM 引擎无关
如果我想加一个新的 sampling 参数,要改几个文件?
读 ③ · 2–3 小时(最重要)
两个进程通过 ZMQ 通信的本体。这里读懂,你就理解了"为什么 v1 比 v0 快"
API server 进程和 engine 进程各持有什么状态?它们之间发什么消息?为什么不放一个进程?
读 ④ · 30 分钟(先看结构,不细究算法)
不要试图全看懂。只看:函数签名、return 什么、循环结构。 Month 3 会深入。
这个函数的 input 是什么 state?output 是什么 state?
读 ⑤ · 30 分钟
Worker 这一头。看它怎么把 scheduler 给的 batch 转成 PyTorch 输入。
PagedAttention 在哪里"接通"了 KV cache?看 attn_metadata 怎么构造的。

13本月作业 · 画图 + PR

作业 A · 画一张请求生命周期图

读完上面 5 段,不看任何东西,自己在白板/Notion/纸上画:

  1. 所有出现的进程 / 线程框(标注哪个是 async 的、哪个是 worker subprocess)
  2. 每个框里存的关键状态(队列、cache、KV pool)
  3. 跨进程的消息箭头(ZMQ payload 是什么形状)
  4. "一次 decode step" 这条主路径标红

画完后跟本文 §04 那两张图对照。发现的不一致就是你下个月的研究方向

作业 B · 第一个非 trivial 的 PR(可选)

Month 1 KPI 是至少一个 PR 已 merge。除了 Week 0 的 typo PR,本月可选挑战:

💡 找 issue 的诀窍
label:good-first-issue 标签下的 issue 通常已被人盯,抢手。 更可靠的来源:看最近 merged PR 的 reviewer 留言里 "we should also do X..." 这种隐藏 todo,主动 ping 那位 reviewer。

作业 C · 把请求追踪日志贴到你的笔记里

§11 那个动手练习的产出物。用 markdown 表格记下:日志关键行 → 13 步号 → 你的一句话解释。 这份笔记会在 M2 / M3 反复用到。

14本页自检

Month 1 结束时这些应该全部 ✓

勾选状态会保存在你的浏览器 localStorage 里,下次打开继续。

15延伸阅读