NOTE 本文基于 vLLM v0.27.1(tag
6e448d0, 2026-08-11)源码深度剖析。文中所有文件路径、类名和行号均以该版本为准;vLLM 迭代很快,阅读时请以你手上的版本对照。
前九章回答了「是什么」和「为什么」,这一章回答「怎么实现」。
现在你已经知道 Scheduler 为什么要按 token 预算调度、KV Cache 为什么要分块、Attention Backend 为什么要按 batch 形态分派。带着这些「为什么」回头看数据结构,它们就不再是需要背的字段列表,而是每一个都能对应到一个设计约束。
这一章刻意放在最后:如果它出现在第二章,你只会看到一堆 class;出现在这里,你会看到设计决策留下的痕迹。
1. 控制面与数据面的分离
graph LR
subgraph CP["控制面(Python)"]
direction LR
A[API Server] --> B[AsyncLLM] --> C[EngineCore] --> D[Scheduler] --> E[KVCacheManager]
end
subgraph DP["数据面(C++ / CUDA)"]
direction LR
F[Worker] --> G[ModelRunner] --> H[GPU Kernels] --> I[NCCL]
end
CP -->|"SchedulerOutput<br/>(req_ids, block_table, …)"| DP
| 控制面(Python) | 数据面(C++ / CUDA) | |
|---|---|---|
| 组件 | API Server → AsyncLLM → EngineCore → Scheduler → KVCacheManager | Worker → ModelRunner → GPU Kernels → NCCL |
| 职责 | 请求接收与参数解析、Tokenization / Detokenization、请求状态机、流式响应、数据并行协调 | 输入张量准备、模型 Forward(GEMM / Attention / MLP)、KV Cache 物理读写 |
| 调度决策、KV 块分配与释放、Prefix Cache 查找、抢占决策、停止条件检测 | 采样(top-p / top-k / temperature)、集合通信、CUDA Graph 捕获与重放 | |
| 技术栈 | ZMQ IPC、msgspec 序列化、asyncio | CUDA Stream 驱动、零拷贝传输 |
为什么控制流和数据流需要解耦?
- 语言特性匹配:调度逻辑复杂多变(频繁的条件判断、动态数据结构操作),适合 Python;数值计算追求极致性能,适合 C++/CUDA
- 迭代速度:调度算法是 vLLM 最频繁迭代的模块,Python 的开发效率远胜 C++
- 控制面开销可被摊薄:只要调度、序列化和输入准备开销显著小于 GPU 执行时间,Python 控制面的影响就可以被流水线化和批处理摊薄;具体是否成为瓶颈取决于 batch、模型和硬件
- Batch Queue 机制:vLLM 通过
batch_queue等机制让调度与执行尽量流水线化,当 GPU 执行当前 batch 时,CPU 侧可以准备后续 batch
说明 为什么控制面开销可被摊薄? 这里用数字解释说明一下。下图以一个较大模型的 decode 步(forward 约 15 ms 量级,例如 70B 级别多卡部署)为例——第 10.5 节那张 7B 单卡的表里 decode 是 8–12 ms,量级不同但结论一致:只要 GPU 侧是十毫秒量级,Python 侧的零点几毫秒就淹没在里面。
单次 Decode 迭代
Python 控制面 ──[schedule ~0.05ms][prepare ~0.1ms]
GPU 数据面 ────────────────────────────[model forward ~15ms][sample ~0.05ms]
→ Python 开销占比 0.15ms / 15ms ≈ 1%
流水线化后(Batch Queue)
CPU ──[sched N][prep N]──[sched N+1][prep N+1]──
GPU ──────────────────[forward N]──────────[forward N+1]──
↑ GPU 在算 N 的同时,CPU 已在准备 N+1
→ Python 延迟被摊薄甚至完全隐藏
Python/C++语言核心分工原则:
| 层面 | 语言 | 职责 | 为什么选这个语言 |
|---|---|---|---|
| API + 调度 | Python | 请求管理、调度策略、KV块分配 | 逻辑复杂多变,需要快速迭代 |
| 输入准备 | Python + PyTorch | 张量构建、block table 更新 | 利用 PyTorch 的张量接口 |
| 模型 Forward | C++/CUDA (via PyTorch) | GEMM、Attention、MLP | 极致性能 |
| 自定义算子 | CUDA / Triton | RMSNorm、RoPE、Fused Attention | 硬件特化 |
| 集合通信 | C++ (NCCL) | All-Reduce、All-Gather | 零拷贝、内核级调度 |
1.1 Python如何调用C++:PyBind11 与 Triton
vLLM 的 C++/CUDA 扩展通过 PyTorch 的 Custom Op 机制注册(位于 csrc/ 目录),使用 PyBind11 绑定 Python 接口。同时,许多算子(尤其是 Attention 和 MoE 相关)使用 OpenAI Triton 编写,兼顾性能和开发效率:
| Python 层 | → | C++ / CUDA 层 | → | GPU 硬件 |
|---|---|---|---|---|
torch.ops.vllm.rms_norm() |
csrc/libtorch_stable/layernorm_kernels.cu |
CUDA Cores | ||
attention_backend() |
FlashAttention(C++ lib)或 Triton kernel(.py) |
Tensor Cores | ||
fused_moe() |
csrc/libtorch_stable/moe/(CUDA)或 Triton experts(.py) |
Tensor Cores | ||
torch.matmul() |
cuBLAS GEMM | Tensor Cores |
2. 四个域:给源码里的每个对象定位
vLLM V1 的核心数据对象(定义在 vllm/v1/request.py、vllm/v1/core/sched/output.py、vllm/v1/outputs.py)可以分为四个域:
- 请求域:
Request是调度器内部对请求的完整表示,包含 token ids、采样参数、状态机状态、块哈希等 - 调度域:
SchedulerOutput封装每一轮的调度决策,ModelRunnerOutput封装 GPU 执行结果 - 显存域:
KVCacheBlock是物理块的元数据(含引用计数和哈希),Block Table 维护虚拟→物理映射 - 模型域:模型权重常驻 GPU,激活张量在前向计算中动态生成和销毁
这四个域和第一章的四问是对应的:请求域是四问的输入,调度域是第一问的产物,显存域是第二问的产物,模型域是第三问的战场。
┌─────────────────────────────────────────────────────────────────────┐
│ 核心数据对象全景图 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────── 请求域 ────────────────────────┐ │
│ │ │ │
│ │ EngineCoreRequest ──→ Request │ │
│ │ (IPC 传输对象) (调度器内部对象) │ │
│ │ · request_id · status (状态机) │ │
│ │ · prompt (text) · prompt_token_ids │ │
│ │ · sampling_params · output_token_ids │ │
│ │ · lora_request · num_computed_tokens │ │
│ │ · block_hashes │ │
│ │ · spec_token_ids │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────── 调度域 ────────────────────────┐ │
│ │ │ │
│ │ SchedulerOutput ModelRunnerOutput │ │
│ │ (调度决策) (模型执行结果) │ │
│ │ · scheduled_requests · req_ids │ │
│ │ · num_scheduled_tokens · sampled_token_ids │ │
│ │ · finished_req_ids · logprobs_tensors │ │
│ │ · preempted_req_ids · draft_tokens │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────── 显存域 ────────────────────────┐ │
│ │ │ │
│ │ KVCacheBlock Block Table │ │
│ │ (物理块元数据) (虚拟→物理映射) │ │
│ │ · block_id · req → [block_ids] │ │
│ │ · ref_cnt │ │
│ │ · block_hash │ │
│ │ │ │
│ │ KV Cache Tensor (GPU HBM) │ │
│ │ · shape: [num_blocks, block_size, num_heads, d] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────── 模型域 ────────────────────────┐ │
│ │ │ │
│ │ Model Weights (参数) Activations (激活) │ │
│ │ · Linear.weight · hidden_states │ │
│ │ · LayerNorm.weight · Q, K, V tensors │ │
│ │ · attention_output │ │
│ │ · logits │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
两条边界值得留意:
EngineCoreRequest→Request是一次跨进程翻译。前者是能被 msgspec 序列化、走 ZMQ 的扁平数据;后者是带状态机、会被反复修改的活对象。这条边界就是第 2.3 节控制面的进程边界。- 显存域是唯一”跨请求共享”的域。请求域、调度域、模型域的对象都属于某一次请求或某一轮迭代,而
KVCacheBlock会被多个请求通过ref_cnt共享——Prefix Cache 的全部魔法都发生在这一行。
3. 请求状态机:系统如何决定”下一步做什么”
3.1.1 请求状态机
vLLM V1 的请求状态机(RequestStatus,定义在 vllm/v1/request.py)是理解控制流的关键:
stateDiagram-v2
[*] --> WAITING: 请求到达
WAITING --> WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR: 需要编译约束语法
WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR --> WAITING: 语法编译完成
WAITING --> WAITING_FOR_REMOTE_KVS: PD分离,等待KV传输
WAITING_FOR_REMOTE_KVS --> RUNNING: KV加载完成
WAITING --> RUNNING: 调度器选中,分配KV块
RUNNING --> RUNNING: 继续Decode(逐token)
RUNNING --> PREEMPTED: 显存不足,被抢占
PREEMPTED --> WAITING: 重新入队(需重算)
RUNNING --> FINISHED_STOPPED: 遇到stop token
RUNNING --> FINISHED_LENGTH_CAPPED: 达到max_tokens
RUNNING --> FINISHED_ABORTED: 被用户取消
RUNNING --> FINISHED_ERROR: 运行时错误
RUNNING --> FINISHED_REPETITION: 重复检测
RUNNING --> WAITING_FOR_STREAMING_REQ: 流式会话暂停
WAITING_FOR_STREAMING_REQ --> WAITING: 收到新输入继续
# vllm/v1/request.py
class RequestStatus(enum.IntEnum):
WAITING = enum.auto()
WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR = enum.auto()
WAITING_FOR_REMOTE_KVS = enum.auto()
WAITING_FOR_STREAMING_REQ = enum.auto()
RUNNING = enum.auto()
PREEMPTED = enum.auto()
# Note: anything after PREEMPTED will be considered as a finished status.
FINISHED_STOPPED = enum.auto()
FINISHED_LENGTH_CAPPED = enum.auto()
FINISHED_ABORTED = enum.auto()
FINISHED_IGNORED = enum.auto()
FINISHED_ERROR = enum.auto()
FINISHED_REPETITION = enum.auto()
状态机里值得特别注意的是 PREEMPTED:它只有一条出路,即回到 WAITING。被抢占的请求会释放 KV 块,等调度器重新分配资源后再继续。因此系统不能假设”已经开始跑的请求一定能跑完”,Scheduler 每轮都要同时面对新请求和恢复请求。
3.1.2 Scheduler 的调度决策
在 vLLM V1 中,Scheduler.schedule() 的核心不是把系统硬切成 Prefill 阶段和 Decode 阶段,而是在每一轮迭代里分配统一的 token 预算。源码注释明确指出:调度器内部没有严格的 “decoding phase” 或 “prefill phase”;每个请求维护 num_computed_tokens,调度器尝试让它追赶 num_tokens_with_spec。
┌─────────────────────────────────────────────────────────────────┐
│ Scheduler.schedule() 核心问题 │
├─────────────────────────────────────────────────────────────────┤
│ 输入: 当前 running / waiting 请求、KV Cache 状态、token budget │
│ │
│ 对每个可推进请求,计算本轮可以新增多少 token: │
│ · 新请求可能推进一段 prompt tokens │
│ · 已运行请求通常推进下一个 decode token │
│ · spec decode / MTP 可能需要额外 lookahead tokens │
│ · 长 prompt 可能被 long_prefill_token_threshold 截断 │
│ │
│ 同时满足: │
│ · max_num_batched_tokens / max_num_scheduled_tokens │
│ · max_num_seqs │
│ · KV Cache 可用块数 │
│ · encoder / multimodal / structured output 等附加约束 │
│ │
│ 输出: SchedulerOutput │
│ · 本轮调度的请求与 token 数 │
│ · block table / slot mapping 相关更新 │
│ · preemption、KV transfer、spec decode 等执行提示 │
└─────────────────────────────────────────────────────────────────┘
这里可以和第一章的结论对上了:Prefill / Decode 的区分在性能分析层面依然成立(第 1.2 节),但在 Scheduler 的实现层面它们被统一到”本轮给这个请求推进多少 token”这一个模型里——这正是第四章反复强调的那句话在源码里的样子。
4. 翻译层:SchedulerOutput 如何变成 GPU 张量
第四章的调度决策和第五章的 GPU 执行之间,隔着一层谁都没细讲的翻译:调度器交出来的是”哪个请求本轮推进多少 token”,而 kernel 要的是”这个 token 的 KV 写到哪个 slot、这个请求能读哪些块”。做这件翻译的是 ModelRunner.prepare_inputs()。
Scheduler 交出来的东西(SchedulerOutput):
| 字段 | 内容 | 含义 |
|---|---|---|
scheduled_new_reqs |
{req_id: num_tokens} |
首次调度的请求 |
scheduled_running_reqs |
{req_id: num_tokens} |
继续执行的请求 |
req_to_new_blocks |
{req_id: [(block_id, n)]} |
本轮的块分配 |
finished_req_ids / preempted_req_ids |
— | 状态通知 |
ModelRunner.prepare_inputs() 把它翻译成 GPU 数据结构,四步:
| 步骤 | 做什么 | 细节 |
|---|---|---|
① InputBatch 构造 |
按 scheduled tokens 扁平化成一条 token_id 序列 | Req A(已缓存 256、新推 256)→ 追加 256 个 Req B(decode)→ 追加 1 个 Req C(spec decode)→ 追加 1+N 个 |
② slot_mapping 构造 |
每个 token → (block_id, offset) |
新块从头写;已有块追加到尾部;Prefix Cache 命中的 token 不写,直接复用 |
| ③ attention metadata | 告诉 kernel 每个请求能读哪些块 | block_table(per req)、query_lens / kv_lens / is_prompt / spec flags |
| ④ 执行模式选择 | 决定走 Graph 还是 Eager | 纯 decode 且 size 匹配 → CUDA Graph replay;含 prefill / mixed / size 不匹配 → Eager |
最终给 GPU 的是 input_ids, positions, attn_metadata,从 GPU 拿回的是 hidden_states → logits → sampled_token_ids。
大致流程如下所示:
┌─────── SchedulerOutput → ModelRunner 映射 ───────────────────────────┐
│ │
│ Scheduler 输出: │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ scheduled_new_reqs: {req_id: num_tokens} ← 首次调度 │ │
│ │ scheduled_running_reqs: {req_id: num_tokens} ← 继续执行 │ │
│ │ req_to_new_blocks: {req_id: [(block_id, n)]} ← 块分配 │ │
│ │ finished_req_ids, preempted_req_ids, ... ← 状态通知 │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ModelRunner.prepare_inputs(): │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ 1. InputBatch 构造: 根据 scheduled tokens 扁平化为 token_id 序列 │ │
│ │ · Req A (已缓存 256, 新推 256) → 追加 256 个 token_ids │ │
│ │ · Req B (decode) → 追加 1 个 token_id │ │
│ │ · Req C (spec decode) → 追加 1 + N 个 token_ids │ │
│ │ │ │
│ │ 2. slot_mapping 构造: token → (block_id, offset) │ │
│ │ · 新分配的块 → 从头写入 │ │
│ │ · 已存在块 → 追加到尾部 │ │
│ │ · Prefix Cache 命中的 token → 不写入, 直接复用 │ │
│ │ │ │
│ │ 3. attention metadata 构造: │ │
│ │ · block_table (per req): 虚拟块 → 物理块的映射 │ │
│ │ · query_lens / kv_lens / is_prompt / spec_decode flags │ │
│ │ │ │
│ │ 4. CUDA Graph / Eager 模式选择: │ │
│ │ · Pure decode + size matched → CUDA Graph replay │ │
│ │ · 含 prefill / mixed / size mismatch → Eager 模式 │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
│ 给 GPU: input_ids, positions, attn_metadata │
│ 从 GPU: hidden_states → logits → sampled_token_ids │
└──────────────────────────────────────────────────────────────────────┘
图中 SchedulerOutput 到 GPU 计算中间隔着一层的 ModelRunner.prepare_inputs。这一层不是调模型,而是把调度决策翻译成 GPU 数据结构:slot_mapping 告诉每个 token 的 KV 写去哪,block_table 告诉每个请求能读到哪些块。CUDA Graph / Eager 的选择也在这里决定。这层翻译是看懂后面 Prefill、Decode 和 Mixed Batch 执行差异的前提。
现在可以回头看这一层为什么必须存在:slot_mapping 是第三章分块存储的直接产物(KV 不连续,所以必须逐 token 给出落点),block_table 是 Prefix Cache 共享的直接产物(多个请求可能指向同一个物理块),而”走 Graph 还是 Eager”则是第 5.1 节那个约束的落地位置(图里的形状必须固定)。三个字段,三章的设计约束。
5. 从请求到 GPU Kernel 的完整调用链
把前面所有环节串成一条链,可以清楚看到语言边界(Python → C++ → CUDA)落在哪几个位置:
HTTP Request ("Hello")
│
│ ① Python (FastAPI)
▼
api_server.py: create_chat_completion()
│
│ ② Python (async)
▼
AsyncLLM.add_request() → Tokenizer → [15496, 11, ...]
│
│ ③ Python (IPC: ZMQ + msgspec)
▼
EngineCore.add_request() → Scheduler.add_request()
│
│ ④ Python (调度算法)
▼
Scheduler.schedule() → SchedulerOutput
│
│ ⑤ Python → C++ 边界
▼
Executor.execute_model() → Worker.execute_model()
│
│ ⑥ Python (张量准备)
▼
ModelRunner._execute_model()
→ prepare_inputs(): 构建 input_ids, positions, block_table 张量
│
│ ⑦ Python → CUDA 边界 (PyTorch dispatch)
▼
model.forward(input_ids, positions, kv_caches, attn_metadata)
→ 每层: RMSNorm → QKV → RoPE → Attention → O_proj → MLP
│
│ ⑧ CUDA Kernel Launch (C++ runtime)
▼
Attention Backend (FlashAttention / FlashInfer)
→ flash_attn_varlen_func() 或 flashinfer.decode()
│
│ ⑨ CUDA Graph (可选: Decode 阶段)
▼
cudagraph_manager.run_fullgraph(batch_desc)
→ 预捕获的完整执行图一次性重放
│
│ ⑩ GPU → CPU
▼
Sampling: logits → sampled_token_ids (GPU tensor → CPU list)
│
│ ⑪ Python (输出处理)
▼
ModelRunnerOutput → Scheduler.update_from_output()
→ Detokenizer → "Sure" → SSE Stream → Client
| # | 层 | 调用 | 语言 / 边界 |
|---|---|---|---|
| ① | HTTP 入口 | api_server.py: create_chat_completion() |
Python(FastAPI) |
| ② | 异步引擎 | AsyncLLM.add_request() → Tokenizer → [15496, 11, …] |
Python(async) |
| ③ | 进程边界 | EngineCore.add_request() → Scheduler.add_request() |
Python IPC:ZMQ + msgspec |
| ④ | 调度 | Scheduler.schedule() → SchedulerOutput |
Python(调度算法) |
| ⑤ | 执行分发 | Executor.execute_model() → Worker.execute_model() |
Python → C++ 边界 |
| ⑥ | 张量准备 | ModelRunner._execute_model() → prepare_inputs():构建 input_ids、positions、block_table |
Python |
| ⑦ | 模型前向 | model.forward(...);每层 RMSNorm → QKV → RoPE → Attention → O_proj → MLP |
Python → CUDA 边界(PyTorch dispatch) |
| ⑧ | Attention kernel | Attention Backend → flash_attn_varlen_func() 或 flashinfer.decode() |
CUDA Kernel Launch(C++ runtime) |
| ⑨ | 图重放(可选) | cudagraph_manager.run_fullgraph(batch_desc):预捕获的完整图一次性重放 |
CUDA Graph,仅 Decode |
| ⑩ | 取回结果 | Sampling:logits → sampled_token_ids(GPU tensor → CPU list) |
GPU → CPU |
| ⑪ | 输出处理 | ModelRunnerOutput → Scheduler.update_from_output() → Detokenizer → SSE Stream → Client |
Python |
三条语言边界(③⑤⑦)恰好把这条链切成了四段,而它们的位置不是随意的:
- ③ 是进程边界 —— API 层与引擎核心分离,为的是不让 HTTP 处理阻塞调度循环;
- ⑤ 是控制面与数据面的边界 —— 上游全是决策,下游全是计算(第 2.3 节);
- ⑦ 是 Python 与 GPU 的边界 —— 过了这里就再没有 Python 开销可言。
前面说”Python 控制面只占 ~1%”,指的正是 ①–⑥ 这一段相对 ⑦–⑩ 的耗时占比。
6. 附录:各环节耗时量级
测试口径(不写清口径的耗时表没有意义):Llama-2-7B、FP16、A100 80GB 单卡(HBM 带宽约 2.0 TB/s)、TP=1、prompt 512 tokens、无 prefix cache 命中、CUDA Graph 开启。换任何一个条件,下面的数字都会变。
| 环节 | 典型耗时 | 说明 |
|---|---|---|
| Tokenization | 0.1–0.5 ms | CPU,可忽略 |
Scheduler.schedule() |
0.01–0.1 ms | Python,随请求数增长 |
| CPU→GPU 输入拷贝 | 0.01–0.05 ms | PCIe DMA,数据量极小 |
| Kernel Launch | ~2–5 μs/个 | Decode 一步数百个(7B / 32 层);开 CUDA Graph 后合并为 1 次 |
| Prefill(512 tokens) | 5–15 ms | Compute-bound,GEMM 主导 |
| Decode 一步(batch=1) | 8–12 ms | Memory-bound,下界由权重读取决定 |
| Decode 一步(batch=32) | 10–18 ms | 注意:不是 ×32 |
| 权重读取 | ≈ 6.6 ms | 13.5 GB ÷ 2.0 TB/s,batch=1 时 decode 耗时的大头 |
| KV Cache 读取 | 随上下文线性增长 | 512 ctx 时很小;32K ctx 时会反超权重成为主导 |
| Sampling | 0.01–0.1 ms | GPU kernel |
| GPU→CPU token 拷贝 | ~5 μs | 数据量极小 |
| Detokenization | 0.01–0.05 ms | CPU,可忽略 |
派生指标:
| 指标 | batch=1 | batch=32 | 说明 |
|---|---|---|---|
| TTFT | 10–20 ms | 随排队增加 | ≈ queueing + prefill |
| TPOT | 8–12 ms | 10–18 ms | ≈ 一次 decode 步 |
| 系统吞吐 | ~100 tok/s | ~2000 tok/s | batch 放大的是这一行 |
这张表里最值得盯住的是加粗的那两行。batch 从 1 涨到 32,单步耗时只从 ~10 ms 涨到 ~15 ms,远不是 32 倍——因为那 13.5 GB 权重无论 batch 多大都只需要从 HBM 读一遍,32 个请求把这笔固定成本摊薄了。
这正是第 1.3 节那条吞吐-延迟权衡曲线的微观解释,也是 Continuous Batching 全部收益的来源:在 memory-bound 区间,增大 batch 几乎是免费的吞吐。 直到 batch 大到让 KV Cache 读取或计算本身成为新瓶颈为止——那时曲线才会掉头。
顺带澄清一个常见误解:ITL 不等于 TPOT × batch。 稳态下 ITL 约等于 TPOT;它真正的意义在于反映波动——当一个长 prompt 的 chunked prefill 插进来、或者发生抢占时,个别 token 的间隔会出现尖峰。所以优化 ITL 靠的是稳定调度,不是缩小 batch。
7. 结语
我们从第 1.6 节起跟踪的那个请求,现在可以完整地复盘一遍了:
| 章 | 这个请求在这一章遭遇了什么 | 数字 |
|---|---|---|
| 三 | 它的 KV 被切成块存放;system prompt 那 125 个整块可被后续请求复用 | 147 块 / 734 MB |
| 四 | 它没有”prefill 阶段”,只是被持续发放 token 额度,直到追平 2050 | 若 chunk=512 则分 5 段 |
| 五 | 97% 的时间花在 300 次逐 token 的 decode 上 | 92 ms + 3000 ms |
| 六 | 每个 token 每步在 8 张卡间同步约 2.5 MB | NVLink 上 ~0.09 ms |
| 九 | 若拆成 PD 两池,它的 641 MB KV 要跨节点搬一次 | ≈ 13 ms |
同一个请求,五个视角,五笔完全不同的账。 这正是 Serving Infra 的日常——没有哪一个数字能单独说明问题,但它们合在一起就是系统的全貌。
回到第 1.5 节那四个问题,现在每一个都有了答案:
| 问题 | vLLM 的回答 |
|---|---|
| 这一轮谁执行、执行多少 | Continuous Batching + 统一 token 预算 + Chunked Prefill |
| 状态放哪、怎么复用 | PagedAttention + Prefix Cache + GQA/MLA + KV 量化 |
| 怎么算得更快 | FlashAttention + CUDA Graph + 算子融合 + 低精度 + 投机解码 |
| 怎么扩出去 | TP / PP / EP / CP / DP + 通信重叠 |
但如果只把这些当成一份优化清单,就错过了最重要的东西。这些技术之所以能共存于一个系统,是因为它们背后有一套统一的世界观。 如果这篇文章只留下三句话,我希望是这三句:
其一,KV Cache 是一切约束的源头。 它是 LLM 推理里唯一随时间无限增长的状态,所以它同时决定了并发上限、上下文上限和抢占时机。看不懂显存,就看不懂调度——第九章那三个未来方向,最后都撞回了这堵墙。
其二,调度的单位是 token,不是 request。 Scheduler 内部没有”prefill 阶段”和”decode 阶段”,只有”这一轮给这个请求推进多少 token”。理解了这一点,Continuous Batching、Chunked Prefill、投机解码、混合批次就不再是四种技巧,而是同一个模型的四种取值。
其三,文中每个性能数字都只是量级示意。 接受率、加速比、耗时表——它们随模型、硬件、batch、上下文长度剧烈漂移。真正可迁移的是判断方法(先定位瓶颈在 Prefill 还是 Decode,再选手段),而不是具体数值。请在你自己的 workload 上重测。
所以最后,我更愿意这样概括它:
vLLM 不是一堆推理优化技术的集合,而是一套围绕「动态请求 + KV 状态 + GPU 资源」构建起来的推理操作系统。
它调度任务、管理内存、抽象硬件、隔离故障——操作系统做的事,它都在做,只不过管的不是进程和物理内存页,而是请求和 KV 块。理解了这个类比,你就不只是理解了 vLLM,而是拿到了看懂下一个 Serving 系统的钥匙。
-
Previous
大模型推理系统揭秘(11):Serving Infra 的下一站:从模型执行器到分布式智能操作系统 -
Next
大模型推理系统揭秘:从 vLLM 看 LLM Serving Infra 核心技术(目录)