vLLM Study · 六章学习指南
把 vLLM 从「会用」
学到「读懂源码」
vLLM 是目前使用最广的开源 LLM 推理引擎,出自 UC Berkeley Sky Computing Lab。本指南按六章展开:先补齐推理性能的物理直觉,再上手使用,然后分两章吃透核心机制(显存与调度 / 加速技术),进而深入 V1 引擎源码,最后走向分布式与生产实践。每章分成多个小节,配交互动画、图示与论文引用。
全书地图
每章开头有小节目录,结尾有产出目标清单(可勾选,自动保存)。建议顺序阅读;如果你已经在用 vLLM,可以直接从第 3 章的机制动画切入。
如何使用本页
- 左侧(移动端为顶部)导航切换章节;章内目录可跳转小节。导航栏的主题按钮支持自动 / 浅色 / 深色三档。
- 每章末尾有可勾选的完成清单和笔记框;选中任意正文文字可以添加行内评论(金色高亮,点击可改可删)。清单、笔记、评论都自动保存在本机浏览器,左下角可一键导出为 Markdown,也可清除进度或全部数据。
- 正文里带虚线下划线的术语,悬停(或点击)即可查看解释;附录有完整术语表。动画都有「播放 / 重播」按钮,配合正文阅读效果最好。
- 版本说明:vLLM 迭代很快(2026-07 已到 v0.26,约每月一个 minor 版本),本页以 V1 引擎为准。V0 旧架构代码已于 2025 年移除——网上大量源码解析讲的是 V0(特征:
LLMEngine.step()、swap in/out、BlockSpaceManager),概念可参考,路径已失效。 - 正文里的 链接↗ 是论文或文档出处;标着「深入」的折叠块是选读的技术细节。
Chapter 1 · 0x01 · Prerequisites
前置知识:推理到底在算什么
本章内容
vLLM 解决的所有问题,根源都在 Transformer 自回归推理的两个物理事实:生成是逐 token 的,以及历史计算结果(KV cache)必须留在显存里。本章把这两件事从公式到硬件彻底讲透——后面五章的每个设计决策都会回指到这里。
1.1自回归生成:prefill 与 decode
LLM 生成文本的过程是:把整段 prompt 算一遍得到第一个新 token,再把这个新 token 喂回去得到下一个……直到遇到停止符。这个循环天然分成两个性质完全不同的阶段:
- Prefill(预填充):prompt 的所有 token 一次性并行通过模型。计算量大、并行度高,通常是 compute-bound(算力受限),决定了首 token 延迟 TTFT。
- Decode(解码):每一步只处理 1 个新 token,但要读取全部模型权重和整段 KV cache。计算量小、访存量大,是 memory-bound(带宽受限),决定了 token 间延迟 ITL。
观察下方 KV cache 条:prefill 一次写入整段 prompt 的 KV;此后每个 decode step 只追加一格,但每一步都要读取前面所有格子(金色描边闪烁)。
1.1.1为什么 prefill 能并行、decode 不能
回忆 attention 的计算:每个位置的输出是 softmax(QKᵀ/√d)·V,配合因果掩码(causal mask)——位置 t 只能看见 ≤ t 的 token。关键在于:prompt 里每个 token 要看的内容都已经在手上,所以整段 prompt 的 Q、K、V 可以拼成大矩阵一次算完,矩阵乘的形状是 [T×d] @ [d×T],GPU 吃得很饱。而生成阶段,第 t+1 个 token 依赖第 t 个 token 的采样结果——数据依赖是串行的,每一步只能做 [1×d] 的矩阵-向量乘(GEMV),GPU 的并行度用不上。
这条「串行依赖」正是第 4 章投机解码要攻击的目标:既然依赖链断不开,那就先猜出几个 token,再用一次并行前向去验证。
1.1.2decode 为什么是 memory-bound:一笔粗账
生成 1 个 token,需要把全部权重从显存搬进计算单元(8B 模型 FP16 权重约 16 GB),而这个 token 引发的浮点运算约为 2 × 参数量 = 16 GFLOPs(每个参数一次乘一次加)。H100 每秒能做约 990 TFLOPS、搬 3.35 TB——也就是每搬 1 字节能做约 295 次运算,而 batch=1 的 decode 每搬 1 字节只做约 1 次运算。时间全花在「搬数据」上,算力闲置 99% 以上。
两个上限,一眼看穿推理性能
带宽上限(batch=1 decode):3.35 TB/s ÷ 16 GB ≈ 209 token/s —— 单流生成速度的物理天花板。
算力上限(batch 足够大):990 TFLOPS ÷ 16 GFLOPs ≈ 62,000 token/s —— 吞吐的理论天花板。两者相差约 300 倍,这 300 倍就是 batching 的全部利润空间,vLLM 的一切设计都是为了吃掉它。
1.2KV cache:由来、大小与 GQA/MLA 演进
1.2.1为什么必须缓存 K 和 V
attention 要求每个新 token 与之前所有 token 的 Key/Value 做运算。如果不缓存,生成第 t 个 token 就要把前 t-1 个 token 全部重算一遍,整段生成的总计算量是 O(T²) 次完整前向——完全不可行。于是引擎把每层算出的 K、V 张量存下来复用,代价从「重算」变成「存储」:KV cache 是拿显存换计算的经典 trade-off。注意只缓存 K、V 而不缓存 Q——因为历史 token 的 Q 之后再也用不到(未来的 token 用自己的 Q 来查历史的 K/V)。
1.2.2大小公式与计算器
以 Llama-3.1-8B(32 层、8 个 KV head、head_dim 128、FP16)为例:2 × 32 × 8 × 128 × 2 = 128 KB/token。一条 8K 上下文的请求就要 1 GB。KV cache 是推理服务的第二大显存开销,且随并发线性增长——它就是并发能力的天花板。
选一个模型(或手动改参数),调上下文与并发,感受显存是怎么被吃掉的。试试把精度换成 FP8,或对比 Qwen2.5-7B(4 个 KV head)与 Llama-3.1-8B(8 个)——KV head 数的差异直接反映在账单上。
1.2.3模型侧的自救:MHA → MQA → GQA → MLA
KV cache 太贵,模型结构本身也在进化以缩小它。这条演进线值得记住,因为它解释了为什么现代模型的 num_kv_heads 远小于 num_heads,也解释了 DeepSeek 系列为什么能把超长上下文做便宜:
三代注意力的 KV 开销。GQA(grouped-query attention)让一组 Q head 共享一份 KV;MLA(multi-head latent attention)更进一步,把 KV 压缩为一个 512 维的 latent 向量 c_KV(外加 64 维解耦的 RoPE key),推理时再投影展开。MLA 对推理引擎有特殊要求——第 4、6 章会看到它催生了专用 kernel(FlashMLA)和数据并行 attention 的部署形态。
深入:GQA 为什么几乎无损?MLA 的「吸收」技巧
GQA 的直觉:attention 里真正「个性化」的是 Q(每个 head 关注不同模式),而 K/V 携带的是 token 的内容表征,冗余度高,几组共享一份损失很小。Llama-2-70B(MHA→GQA 的过渡期实验)与后续大量模型验证了 8 组 KV 基本无损。MLA 则依赖一个代数技巧:把 K、V 的还原矩阵「吸收」进 Q 投影和输出投影里(W_UK 并入 W_UQ,W_UV 并入 W_O),使得 attention 可以直接在 latent 空间里做,永远不用真正展开 K/V——这就是为什么缓存 576 维就够。细节见 DeepSeek-V2 论文 附录。
1.3GPU 体系结构速成
读 vLLM 的日志、文档和 kernel 代码,绕不开这些硬件概念。不需要会写 CUDA,但每个词都要能对上一个物理位置——先看一张地图:
1.3.1芯片鸟瞰:SM、显存与互联
执行模型三层楼:CPU 发射一个 kernel → kernel 由成千上万个线程组成,按 block(线程块)为单位分派到各 SM → 每个 block 内的线程按 32 个一组(warp)在 SIMT 模式下同步执行——同一 warp 的 32 个线程每拍执行同一条指令,只是操作不同数据。写 GPU 程序的本质就是把问题切成「几千个可以并行的小块」;矩阵乘天然适配(每个输出 tile 一个 block),而 decode 的逐 token 循环不适配——这就是 1.1 节「并行度用不上」的硬件语言版本。
1.3.2显存层级与合并访问
图右下的速度阶梯是所有 kernel 优化的地形图:寄存器 → shared memory → L2 → HBM,每下一级带宽掉几倍、延迟涨几倍。两个推理相关的直接推论:
- 数据复用是第一美德:一个数从 HBM 搬上来,能用几次就用几次再丢。矩阵乘的 tile 化、FlashAttention 的分块(4.3 节)都是在最大化「每字节复用次数」——这正是 roofline 横轴的物理含义。
- 合并访问(coalescing):HBM 按 128 字节的事务读写。一个 warp 的 32 个线程若访问连续地址,硬件合并成几次大事务;若地址零散,同样的数据量要拆成几十次事务,有效带宽掉一个量级。这解释了为什么 KV cache 的 block 内布局要精心设计(token 连续、head 连续),也解释了 PagedAttention「按块寻址」为什么能做到几乎不损性能——块内仍是连续的。
1.3.3Tensor Core:算力的附加条件
H100 的 990 TFLOPS 几乎全部来自 Tensor Core——它每拍完成一个小矩阵乘累加(MMA,如 16×8×16 的 tile),而普通 CUDA core 一拍只做一次乘加,两者算力差约 15 倍。但 Tensor Core 有附加条件:输入必须组织成矩阵 tile。prefill 的大矩阵乘完美适配;batch=1 的 decode 是矩阵-向量乘(GEMV),凑不满 tile 的行维,Tensor Core 大量空转——所以「decode 吃不满算力」在硬件层面是双重的:既缺带宽,又缺可用的矩阵形状。batch 增大同时缓解这两件事。另外 Tensor Core 对低精度有硬件倍率:FP8 吞吐是 BF16 的 2 倍(Blackwell 的 FP4 再翻倍)——4.2 节激活量化的加速收益直接来自这里。
1.3.4延迟隐藏与 kernel 发射
GPU 不靠缓存命中来掩盖 HBM 的 ~500 周期延迟,而是靠超额并发:每个 SM 上驻留几十个 warp,谁的数据到了就执行谁(图中的 warp scheduler 每拍挑选),访存延迟被计算「织」进去。这要求 kernel 有足够的并行度(occupancy)——又一次指向「batch 要大」。最后一个数字:CPU 发射一个 kernel 约 5~10 微秒,一个 decode step 要发几百个 kernel;当 batch 小、每个 kernel 只算几十微秒时,CPU 发射速度追不上 GPU 消化速度,GPU 反过来等 CPU——这是第三种瓶颈(launch-bound),第 4 章的 CUDA Graph 专治它。
| GPU | BF16 算力 | 显存 / 带宽 | 平衡点 (FLOPs/byte) |
|---|---|---|---|
| RTX 4090 | 165 TFLOPS | 24 GB / 1.0 TB/s | ~165 |
| A100 SXM | 312 TFLOPS | 80 GB / 2.0 TB/s | ~156 |
| H100 SXM | 990 TFLOPS | 80 GB / 3.35 TB/s | ~295 |
| B200 | ~2250 TFLOPS | 192 GB / 8.0 TB/s | ~280 |
深入:想真正学 CUDA,从哪里开始?
本指南不要求会写 kernel,但如果你想走性能方向(第 6 章),推荐路径:① NVIDIA 官方 CUDA C++ Programming Guide 前几章建立执行模型;② 手写一个 tiled matmul 并用 Nsight Compute 看它的 roofline 位置;③ 读 CUTLASS 文档理解生产级 GEMM 的层级抽象;④ 用 Triton 写 fused kernel——vLLM 里大量 kernel 就是 Triton 写的,比裸 CUDA 好读得多,是「读得懂 vLLM kernel」的最短路径。
1.4Roofline 模型与每 token 的性能账
把 1.1 的粗账正式化。任何一段计算都有一个操作强度(arithmetic intensity):每从显存搬 1 字节能做多少次浮点运算。把它画在横轴、把实际性能画在纵轴,就是 roofline 模型——性能要么撞上斜线(带宽顶)、要么撞上平线(算力顶):
batch 的作用是在横轴上向右移动。decode 的操作强度近似等于 batch 大小(同一次权重搬运服务 B 个请求,运算量 ×B、访存量不变)。batch 从 1 加到 ~300,吞吐几乎线性上涨、单步时间几乎不变;越过平衡点后进入 compute-bound,收益消失、延迟开始上升。
1.4.1为什么增大 batch「几乎不伤」延迟
decode 时权重搬运是固定成本:batch=1 和 batch=32 都要把 16 GB 权重完整读一遍。batch 增大后同一次搬运摊到更多请求头上——吞吐近似线性上涨,而单步时间在带宽吃满前几乎不变,每个用户感受到的 ITL 只轻微上升。推理服务的核心游戏:在显存装得下的前提下,把 batch 推到尽可能大。显存里最有弹性的正是 KV cache,于是「如何高效管理 KV cache」成了主战场——这就是第 3 章的 PagedAttention。
深入:把 attention 也算进账里——长上下文为什么让 decode 更慢
上面的账只算了权重。decode 每步还要读整段 KV cache:一条 32K 上下文的 Llama-8B 请求,每步要多搬 32768 × 128 KB ≈ 4 GB 的 KV——已经和 16 GB 权重同数量级。而且 KV 的读取不随 batch 摊薄(每个请求读自己的 KV),所以长上下文场景里 attention 部分会逐渐成为主导成本,batch 的边际收益下降。这解释了:① 为什么 GQA/MLA/KV 量化如此重要(直接砍这 4 GB);② 为什么 attention kernel(第 4 章)值得单独优化;③ 为什么超长上下文服务的定价更贵。
1.5需要的工程底子与练习
- Python 工程能力:能读中大型项目;了解
asyncio、多进程、类型标注、dataclass。vLLM 的调度器和引擎层是纯 Python。 - PyTorch 基础:tensor 操作、
nn.Module、CUDA tensor;知道什么是 kernel launch、什么是 stream。 - Transformer 结构:能画出 decoder-only 模型的一层——Attention(RoPE、GQA)+ FFN(SwiGLU)+ RMSNorm,知道残差连接在哪里。推荐动手实现过一次 nanoGPT 级别的小模型;视频课首推 Karpathy 的 Let's build GPT。
动手练习(强烈推荐)
- 用 PyTorch 手写一个带 KV cache 的贪心解码循环(30 行以内),对比有无 cache 的耗时随长度的曲线——亲手感受 O(T²) 到 O(T) 的差异。
- 对你手头的 GPU 和目标模型,手算:① 权重占用;② 每 token KV 大小;③ batch=1 的 decode 速度上限;④ 达到算力顶所需的 batch。把四个数字写进笔记,后面每章都会用到。
第 1 章 · 我的笔记
第 1 章 · 完成清单
Chapter 2 · 0x02 · Hands On
上手与使用:建立可量化的手感
本章内容
这一章的目标是可量化的手感:跑通两种使用方式、把主要功能都摸一遍、做一次像样的压测。每个实验都记下「改了什么 → 吞吐 / 显存 / 延迟怎么变」,这份笔记在第 3、4 章会反复用来印证原理。
2.1安装与硬件前提
shell · 推荐用 uv 建独立环境
# uv 是 vLLM 官方文档推荐的安装方式,秒级解析依赖
uv venv --python 3.12 && source .venv/bin/activate
uv pip install vllm
# 或者用官方 Docker 镜像(生产环境推荐,CUDA 依赖全部打包)
docker run --gpus all -p 8000:8000 vllm/vllm-openai:latest \
--model Qwen/Qwen2.5-7B-Instruct
硬件前提
vLLM 的主战场是 Linux + NVIDIA GPU(CUDA 12.x),同时官方支持 AMD ROCm、Google TPU、Intel XPU/Gaudi,以及活跃的社区后端(华为昇腾 vllm-ascend 等)。你的本机是 macOS——vLLM 对 Apple Silicon 仅有有限的 CPU 后端,只够验证 API 走通,跑不出真实性能。建议在云 GPU 上做本章实验:一张 24GB 的 4090 / A10 就能跑 7B 模型;按小时租用的 spot 实例足够。模型下载走 HuggingFace,国内环境可设 HF_ENDPOINT=https://hf-mirror.com。
2.2离线推理 API 与 SamplingParams 详解
vLLM 有两种形态:离线批量推理(LLM 类,适合评测、数据合成、RL rollout)和在线服务(vllm serve)。先从离线开始:
python · offline_infer.py
from vllm import LLM, SamplingParams
# 首次运行自动下载权重;0.5B 模型 CPU 也能勉强跑通
llm = LLM(model="Qwen/Qwen2.5-0.5B-Instruct")
params = SamplingParams(temperature=0.7, top_p=0.9, max_tokens=256)
# generate() 走原始补全;chat() 自动套用模型的 chat template
outs = llm.chat([{"role": "user", "content": "用一句话解释 KV cache"}], params)
print(outs[0].outputs[0].text)
值得留意的第一个现象:启动日志里有一行 GPU KV cache size: ... tokens——vLLM 在加载完权重后做了一次显存测量(profiling run),把剩余显存全部预分配为 KV cache 的 block 池。这个「先量出能装多少 token」的数字决定了并发能力,第 3 章的一切都发生在这个池子里。
2.2.1SamplingParams:每个字段都在控制什么
| 字段 | 作用 | 值得知道的细节 |
|---|---|---|
temperature | logits 除以 T 再 softmax,控制随机性 | 0 = 贪心解码(可复现);>1 更发散。评测用 0,创意任务 0.7~1.0 |
top_p / top_k | 核采样 / 截断采样 | 与 temperature 叠加生效;top_p 按累积概率截断,对长尾更稳健 |
max_tokens | 最多生成多少 token | 影响调度器对显存的预估——别无脑设超大值 |
stop / stop_token_ids | 停止字符串 / 停止 token | 停止串检测发生在 detokenize 之后(API 进程),支持跨 token 匹配 |
n | 一条 prompt 采样 n 个候选 | n 个序列共享 prompt 的 KV(copy-on-write 分叉),比发 n 次请求省显存 |
logprobs | 返回每步 top-k 的对数概率 | 评测与 RLHF 常用;会增大输出体积 |
seed | 该请求的随机种子 | 同 seed + 同 batch 环境下可复现采样 |
presence_penalty 等 | 重复惩罚族 | frequency / presence / repetition 三种口味,作用于 logits |
深入:采样发生在哪里?logits → token 的完整路径
模型 forward 输出的是每个请求最后一个位置的 logits(形状 [batch, vocab],vocab 约 15 万)。vLLM 的 Sampler 在 GPU 上依次施加:重复惩罚 → temperature 缩放 → top-k/top-p 过滤 → 按概率采样(或 argmax)。每个请求可以有不同的采样参数,所以这一步是「按行异构」的——V1 用张量化技巧把不同参数的请求批在一起处理,避免 Python 循环。第 5 章读 gpu_model_runner.py 时会看到 Sampler 的实现;结构化输出(2.3.2)也在这一步之前用 grammar bitmask 把非法 token 的 logits 置为 -inf。
2.3OpenAI 兼容 server 全功能
shell · 服务端
vllm serve Qwen/Qwen2.5-7B-Instruct \
--max-model-len 8192 \
--gpu-memory-utilization 0.90
python · 客户端 — 任何 OpenAI SDK 直接对接
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
stream = client.chat.completions.create(
model="Qwen/Qwen2.5-7B-Instruct",
messages=[{"role": "user", "content": "解释一下 PagedAttention"}],
stream=True,
)
for chunk in stream:
print(chunk.choices[0].delta.content or "", end="")
浏览器打开 http://localhost:8000/docs 能看到完整 API 面:/v1/chat/completions、/v1/completions、/v1/embeddings(embedding 模型)、/v1/models、/metrics 等。以下四个能力在生产里最常用,建议逐个跑通:
2.3.1Chat template 与多轮对话
chat 接口收到 messages 数组后,server 会套用模型自带的 chat template(Jinja 模板,在 tokenizer 配置里)把对话拼成一段带特殊 token 的文本。理解这一点很重要:多轮对话就是把历史重新发一遍——所以第 3 章的 prefix caching 对多轮场景收益巨大(历史部分的 KV 全部命中)。用 --chat-template 可以覆盖模板,调试时用 /tokenize 端点检查最终 token 序列。
2.3.2结构化输出(structured outputs)
约束模型只能输出符合 JSON Schema / 正则 / 语法的内容。vLLM V1 的实现方式是语法约束解码:后端(默认 xgrammar,也支持 guidance 等)把 schema 编译成自动机,每个 decode step 生成一个 bitmask 把不合法的 token 置为 -inf——保证输出 100% 合法,而且几乎零开销。在 API 里用 response_format={"type": "json_schema", ...} 或 vLLM 扩展参数 guided_json / guided_regex / guided_choice。
2.3.3Tool calling 与推理模型
传 tools=[...] 即可用 OpenAI 风格的函数调用;server 端需要 --enable-auto-tool-choice 和对应模型的 --tool-call-parser(如 hermes、llama3_json)——因为每家模型的工具调用格式不同,parser 负责把模型输出解析回结构化的 tool_calls。同理,DeepSeek-R1 / Qwen3 这类推理模型需要 --reasoning-parser 把 <think> 段拆到响应的 reasoning_content 字段。
2.3.4多模态与 multi-LoRA
- 多模态:Qwen2.5-VL 等模型直接 serve,chat 消息里传
image_url即可。V1 对多模态做了专门设计:图片经 vision encoder 的结果有独立缓存(encoder cache),且图片内容参与 prefix cache 的哈希——同一张图的重复请求不会重复过 encoder。 - Multi-LoRA:
--enable-lora --lora-modules name=path可同时挂多个 LoRA adapter,请求里按 model 名选用;底层用 Punica 风格的分组 kernel(SGMV)让不同 adapter 的请求同 batch 执行——「一个 base 模型 + 百个定制头」的多租户场景就是这么做的。
2.4关键参数逐个讲
每个参数改一次、压测一次,观察吞吐 / 显存 / 延迟的变化,比读十篇文章都有用:
| 参数 | 作用 | 动手实验建议 |
|---|---|---|
--gpu-memory-utilization | vLLM 可用显存比例(默认 0.9),扣除权重与激活后剩下的全给 KV cache | 调成 0.5 再看启动日志里 KV cache 容量掉多少;留意它是「比例」不是「上限」,同卡跑其他进程时要调低 |
--max-model-len | 单请求最大上下文(prompt+输出) | 调大到显存装不下时读报错——学会看它的显存账;长上下文模型常需手动调低此值才能启动 |
--max-num-seqs | 一个 step 里最多同时跑多少请求 | 调成 1 对比吞吐——这就是「没有 batching」的世界;默认值通常不是瓶颈 |
--max-num-batched-tokens | 每个调度 step 的 token 总预算,chunked prefill 的「刀」 | 调小(如 512)观察 TTFT 与 ITL 此消彼长;3.5 节解释机制 |
--tensor-parallel-size | 张量并行卡数,切分权重与 KV cache | 多卡对比 TP=1/2/4 的延迟与吞吐;注意 KV head 数需能被 TP 整除 |
--kv-cache-dtype fp8 | KV cache 用 FP8 存储,容量翻倍 | 对照 1.2 计算器验证容量变化;长上下文任务上做精度对比 |
--no-enable-prefix-caching | V1 默认开启前缀缓存,此开关用于关闭对照 | 同一 system prompt 压两遍,对比开关时的 TTFT 差距 |
--enforce-eager | 关闭 CUDA Graph 与编译,纯 eager 执行 | 对比小 batch decode 的 ITL——直观感受 4.4 节讲的 CPU 开销;调试崩溃时也用它排除编译因素 |
--speculative-config | 启用投机解码(4.1 节详解) | 低并发下测 ITL 提升;高并发下观察收益消失 |
--quantization / 量化 checkpoint | 加载量化模型(通常自动识别) | 同一模型 FP16 vs FP8 vs AWQ 三连压测(4.2 节) |
2.5压测方法学:四个指标与延迟-吞吐曲线
shell · vllm bench — 内置压测三件套
# 在线压测:向已启动的 server 发 200 条请求、每秒 8 条的泊松流量
vllm bench serve --model Qwen/Qwen2.5-7B-Instruct \
--num-prompts 200 --request-rate 8
# 另外两个模式:离线极限吞吐 / 单请求延迟
vllm bench throughput --model ... --input-len 1024 --output-len 256
vllm bench latency --model ... --batch-size 1
读结果需要四个词,整个学习过程都会围绕它们:
请求发出到第一个字回来。由排队 + prefill 决定,交互体验的关键。
后续每个字之间的间隔,「打字速度」。由 decode 单步时间决定。
每秒产出 token 数(区分 output tok/s 与 total tok/s)。服务方的成本视角。
满足延迟 SLO(如 TTFT<1s 且 ITL<50ms)前提下的吞吐。生产环境真正优化的目标。
方法学要点:吞吐和延迟是一对根本矛盾——batch 越大吞吐越高、每个请求等得越久,所有调度参数本质上都在这条曲线上选位置。规范做法:固定模型与数据集(用 ShareGPT 或贴近业务的真实分布),扫 --request-rate 从低到高,对每个点记录 P50/P99 的 TTFT 与 ITL,画出「负载-延迟」曲线;曲线的拐点(延迟开始飙升处)就是这套配置的容量。报告 P99 而不只是均值——长尾才是用户体验。
2.6可观测性:读懂 vLLM 在想什么
/metrics 端点暴露 Prometheus 指标,生产部署配 Grafana 看板(官方有现成模板)。学习期最值得盯的几个:
| 指标 | 含义 | 它在告诉你什么 |
|---|---|---|
vllm:num_requests_running | 当前 batch 里的请求数 | 实际并发;贴着 max_num_seqs 说明该扩容了 |
vllm:num_requests_waiting | 排队中的请求数 | 持续 >0 = 过载,TTFT 会恶化 |
vllm:gpu_cache_usage_perc | KV cache block 池使用率 | 接近 100% 时会发生抢占(3.4 节) |
vllm:prefix_cache_hit_rate | 前缀缓存命中率 | 多轮对话业务应该很高;低了检查 prompt 结构 |
vllm:time_to_first_token_seconds | TTFT 直方图 | SLO 报警的数据源 |
vllm:num_preemptions_total | 累计抢占次数 | 频繁抢占 = KV cache 不够,性能大损(3.4 节) |
日志侧:VLLM_LOGGING_LEVEL=DEBUG 可看到调度细节;启动时打印的配置快照(选中的 attention backend、编译模式、KV cache 容量)值得逐行读一遍——每一行都对应后面章节的一个机制。
第 2 章 · 我的笔记
第 2 章 · 完成清单
Chapter 3 · 0x03 · Memory & Scheduling
显存与调度:vLLM 的两大支柱
本章内容
朴素的推理服务浪费在两个地方:显存被碎片吃掉(装不下更大的 batch),以及 GPU 在等待中空转(batch 没被填满)。vLLM 的两大支柱分别对付它们:PagedAttention 治显存,continuous batching 治空转。本章每个机制都配动画——先玩动画,再读文字,最后读折叠块里的实现细节。
3.1问题定量化:显存都浪费在哪
KV cache 的麻烦在于长度不可预知:请求进来时没人知道它会生成 10 个还是 1000 个 token。vLLM 之前的系统(FasterTransformer、早期 Orca)按「可能的最大长度」为每个请求预留一段连续显存,产生三种浪费:
- 内部碎片:预留 2048 格实际用 300 格,剩下 1748 格锁死。这是最大头。
- 外部碎片:请求完成后留下大小不一的空洞,总空闲不少,却凑不出连续段给新请求。
- 无法共享:并行采样(n>1)、beam search、共享 system prompt 的场景里,相同内容在显存里存多份。
PagedAttention 论文(SOSP 2023)实测:这些系统里 KV cache 显存的实际利用率只有 20%~40%。也就是说,一多半显存在「站着茅坑」。由第 1 章的结论——吞吐近似正比于 batch 大小——把利用率从 30% 提到 95%,吞吐直接翻 2~4 倍。这就是 vLLM 一战成名的全部秘密:不是算得更快,而是装得更多。
把浪费算一笔具体的账。设 max_model_len=2048,真实流量的平均长度(prompt+输出)为 400 token。连续预分配下每条请求锁定 2048 格:内部碎片 = (2048−400)/2048 ≈ 80%,这还没算外部碎片和「为还没生成的部分提前留位」的预留浪费。论文把预分配方案的浪费拆成三类统计:已预留但尚未用到(reserved)、为最大长度多留的(internal fragmentation)、空洞(external fragmentation)——在 ShareGPT 负载上三者合计吃掉 60%~80% 的 KV 显存。而分页方案的浪费只剩「每条序列最后一个没写满的块」,期望值是半个块(8 个 token 位),400 token 的请求浪费率 = 8/400 = 2%。80% vs 2%,就是那 2~4 倍吞吐的全部来源。
3.2PagedAttention:机制、页表与代价
解法直接借自操作系统的虚拟内存分页:把 KV cache 切成固定大小的 block(默认 16 个 token 一块),请求按需一块一块地拿,块与块在物理显存上无需连续,由一张 block table(页表)记录逻辑块 → 物理块的映射,attention kernel 读 KV 时按表寻址。
同一批请求先后到达和离开,左右是两种显存管理方式。留意左侧的两种浪费:斜纹格是预留未用(内部碎片);请求离开后的空洞凑不出连续段(外部碎片),最后一个请求会分配失败。右侧按 block 领用,几乎装满。
3.2.1页表长什么样
3.2.2分页免费送的两个能力:共享与写时复制
一旦 KV 的所有权从「请求」变成「页表指向」,两个高级能力就自然出现了:
- 共享(sharing):多个请求的页表可以指向同一物理块,配合引用计数管理生命周期——这是 3.6 节 prefix caching 的基础。
- 写时复制(copy-on-write):并行采样
n=4时,4 个候选序列共享 prompt 的所有块;当某个候选要往「共享的最后一块」写新 token 时,先复制这一块再写(ref_cnt>1 触发 CoW)。beam search 的序列分叉同理。论文里这两项让并行采样场景额外省 30%~55% 显存。
深入:block size 为什么是 16?分页有没有性能代价?
block size 的权衡:块越小内部碎片越少(最多浪费 15 个 token 位),但页表更长、kernel 里地址计算更频繁、prefix cache 的哈希粒度更细(命中判定更零碎);块越大反之。16 是论文在多个负载上扫出来的平衡点,vLLM 一直沿用(部分 attention backend 支持 32/64 等其他值,用 --block-size 修改)。
分页的代价:attention kernel 必须支持非连续内存访问——读第 t 个 token 的 K 时先查页表算物理地址。这会损失一点访存合并效率,早期 vLLM 为此手写了 fused kernel;如今 FlashAttention、FlashInfer 等主流库都原生支持 paged KV,代价已被工程消化到可忽略。用几个百分点的 kernel 开销,换 2~4 倍的 batch 容量——这买卖闭眼做。
和 OS 分页的一个差异:vLLM 没有「缺页中断」——调度器在提交请求前就保证所有需要的块已分配(3.4 节),kernel 执行期间绝不会发现块不存在。V0 时代还有 GPU↔CPU 的 swap 机制(类似换页到磁盘),V1 把它删了,统一用「抢占 + 重算」,因为 prefix cache 让重算变得便宜。
深入:物理块长什么样?kernel 怎么按页读 KV?
物理布局:block 池是模型加载后一次性 torch.zeros 出来的两个大张量(K 池和 V 池),每层一份,形状约为 [num_blocks, block_size, num_kv_heads, head_dim]——第 0 维是「页号」,页内 token 连续、head 连续,保证 1.3.2 讲的合并访问。所谓「给请求分配一个块」只是把某个页号写进它的 block table,没有任何数据搬运;「释放」也只是改元数据。这就是分页操作近乎零成本的原因。
kernel 视角:attention kernel 收到的是一个 int32 张量 block_tables[num_seqs, max_blocks_per_seq]。计算第 s 条序列时的伪代码:
for blk_idx in range(seq_len[s] // BLOCK_SIZE + 1):
phys = block_tables[s][blk_idx] # 查页表:逻辑块号 → 物理页号
K_tile = K_pool[phys] # [16, kv_heads, head_dim],页内连续读
scores[blk_idx*16 : ...] = Q[s] @ K_tile.T # 与该页的 16 个 K 做点积
# 之后对 scores 做(online)softmax,再按同样方式逐页读 V 加权求和
对比非分页 kernel,唯一的额外工作是每 16 个 token 查一次表(一次寄存器级读取)——这就是「分页税」的全部,也是它能被工程消化到可忽略的原因。写满一页的 K/V 此后永远不会移动,直到整块被释放或逐出,所以共享该页的所有请求读到的物理地址是同一个。
3.3Continuous batching:iteration 级调度
解决了「装得下」,还要解决「随时上车」。传统静态 batching 以整个 batch 为单位调度:一批请求一起进、等最慢的那条生成完才全体退场,期间完成的请求占着座位空转。而 LLM 请求的输出长度天差地别——短的几十 token、长的几千——静态 batch 的 GPU 利用率惨不忍睹。
Continuous batching(Orca, OSDI 2022 提出,原文叫 iteration-level scheduling)把调度粒度细化到每个 decode step:每步结束都重新组班——完成的立刻离开、等待的立刻补位,GPU 面前永远是尽可能满的 batch。
4 个槽位,横轴是时间(每列一个 decode step)。上方静态 batching:请求完成后槽位空转(灰色斜纹),要等整批结束才换人。下方 continuous batching:谁完成谁下车,等待队列里的请求下一个 step 就顶上。
量化这笔收益:设输出长度服从重尾分布(LLM 流量的常态,均值 300、P99 可到 2000+)。静态 batch 的完成时间由 batch 内最长的请求决定——batch 越大,抽中长尾的概率越高,平均空转率随 batch 规模上升,形成「batch 越大越浪费」的反常识现象;iteration 级调度把这个相关性直接消灭,槽位利用率与长度分布解耦。工程上还有一个隐藏收益:新请求不再需要「凑批」等待窗口(静态方案常见的 batching timeout),TTFT 的排队分量也随之消失。两大支柱到这里合成一句话:PagedAttention 让显存装下更多请求,continuous batching 让这些请求无缝挤满每一个 GPU 时钟周期。
深入:Orca 还提出了什么?vLLM 与它的差异
Orca 的另一半贡献是 selective batching:不同请求长度不齐,怎么批在一起算?答案是把「能拼」的算子(Linear、LayerNorm——对 token 逐个独立)把所有请求的 token 摊平成一个大矩阵一起算,把「不能拼」的算子(attention——每个请求要读自己的 KV)拆开逐请求处理。vLLM V1 的 ModelRunner 正是这个思路:输入张量是扁平化的 token 流(没有 batch 维,用元数据记录每个请求的边界),attention 交给支持变长的专用 kernel。第 5 章读 gpu_model_runner.py 时你会亲眼看到这个「摊平」。另一个差异:Orca 用请求级 FCFS + 最大长度预留显存,vLLM 用 PagedAttention 按需分配——两篇论文合起来才是现代推理引擎的完整地基。
3.4V1 统一调度器:token 预算与抢占
朴素 continuous batching 还有一根刺:新请求插进来要先做 prefill,一段长 prompt 会独占 GPU 几百毫秒,正在 decode 的所有请求集体卡一拍(ITL 尖刺)。vLLM V1 的调度器把解法(chunked prefill,见 3.5)推到了极致,形成一个非常优雅的抽象,也是读源码前必须建立的心智模型:
V1 调度器的世界观
没有 prefill 和 decode 之分,只有「每个请求这一步算几个 token」。每个 step,调度器在 max_num_batched_tokens 的总预算内产出一张分配表:
{ R1: 320(prompt 分块), R2: 1(decode), R3: 1(decode), R4: 128(prompt 收尾,本步即出首字)}
prefill 只是「一次要算很多 token 的请求」,decode 只是「一次要算 1 个 token 的请求」,二者在同一个 batch 里混合执行。这个统一让调度逻辑从 V0 的几千行状态机缩到几百行清晰代码。
调度顺序与策略:先服务 running 队列(保证已有请求不断流),预算有余再从 waiting 队列收新请求;策略默认 FCFS(先来先服务),也支持 priority(请求带优先级,配合抢占实现分级 SLO)。
抢占(preemption):block 池耗尽时,调度器把最低优先级的 running 请求整个踢回 waiting——释放它的全部 block,之后重新调度时靠重算 prefill 恢复(prefix cache 命中的部分免算,所以代价可控)。压测日志里的 WARNING ... preempted 就来自这里:偶发无妨,频繁出现说明 KV cache 太小,该加卡、调小 max_model_len 或开 KV 量化了。抢占的受害者选择规则:FCFS 策略下踢最晚到达的(保护排队久的),priority 策略下踢优先级最低的——这保证系统过载时牺牲的总是「最不重要」的请求,而不是随机抖动。
请求在引擎里的状态机很简单:WAITING → RUNNING →(抢占时退回 WAITING)→ FINISHED。但每步调度不只分配 token 数,还要同步推进一串「随行状态」:结构化输出的语法自动机走到哪个节点(决定下一步的 logits mask)、投机解码的草稿 token 是否计入预算、多模态的 encoder 预算是否够用、KV connector 是否有外部缓存可查——这些都是 schedule() 真实代码比 5.4 节伪代码长的原因,主干逻辑不变。
深入:调度本身也有成本——异步调度与 CPU/GPU 重叠
调度器跑在 CPU 上,一次 schedule() 加上输入准备要几百微秒;如果「调度 → GPU 执行 → 等结果 → 再调度」严格串行,GPU 每步之间就有一段空等。V1 的解法是流水线化:在 GPU 执行第 N 步的同时,CPU 已经在为第 N+1 步做调度与输入准备(异步调度);输出的处理(detokenize、流式发送)也在另一个进程并行进行(5.2 节的两进程架构)。极限情况下 CPU 与 GPU 完全重叠,GPU 步间空隙趋近于零——用 profiler 看时间线(6.1 节),健康的部署里 GPU 的 kernel 序列应该是「无缝」的,出现规律性空洞就说明 CPU 侧拖了后腿(常见元凶:超长 prompt 的 tokenize、Python GC、日志同步写盘)。
3.5Chunked prefill:把大石头敲碎
思想源自 Sarathi-Serve 论文:把长 prompt 的 prefill 切成固定大小的 chunk,每个 step 只算一块,与其他请求的 decode 混在同一 batch。这样每个 step 的计算量被「预算」封顶,decode 请求的 ITL 保持平稳;而 decode 请求们也顺便「搭了 prefill 的车」——prefill 是 compute-bound、decode 是 memory-bound,混合执行让算力和带宽同时被利用(论文称之为 piggybacking)。
预算就是那把刀。max_num_batched_tokens 调小 → 每步更轻、ITL 更平滑,但长 prompt 要切更多刀、TTFT 变长;调大 → 反过来。第 2 章实验里那对此消彼长的曲线,机制就在这里。V1 中 chunked prefill 始终开启,这个参数是你手里最重要的延迟-吞吐旋钮之一。
算一个具体例子感受量级。H100 上 8B 模型,decode batch 每步约 15ms;一条 8000 token 的 prompt 若整段 prefill 约需 150ms。不分块:混在批里的所有 decode 请求这一步 ITL 从 15ms 跳到 150ms——用户可感的卡顿。按预算 2048 分块:prompt 切成 4 刀,每步只多花 ~35ms,ITL 峰值 50ms 上下,而 TTFT 只多了三步的排队(几十 ms)。分块把一次 10 倍的尖刺换成四次 1.3 倍的微扰——这就是它成为默认行为的原因。Sarathi 论文进一步指出最优 chunk 大小应贴着「刚好吃满算力」的临界点选:太小浪费算力(每 chunk 有固定开销),太大重新引入尖刺。
两个实现细节值得知道:① 只有 prompt 的最后一个 chunk 需要产出 logits 并采样(前面的 chunk 只为填充 KV cache),所以中间 chunk 连 LM head 的计算都省了;② 长 prompt 请求可能连续多步占据大额预算、饿着排队的短请求,V1 用 long_prefill_token_threshold 限制单个请求每步的份额,保证公平性。
3.6Prefix caching:哈希链与逐出策略
生产流量里大量请求共享前缀:同一段 system prompt、多轮对话的历史、RAG 的公共模板。分页让复用变得自然——给每个写满的 block 建索引,新请求逐块查表,命中就直接把页表指过去,prefill 从第一个未命中的块算起。
请求 1 完整计算并缓存了 system prompt 的 3 个 block;请求 2、3 到达时逐块哈希命中(金色高亮),页表直接指向同一批物理块,只需计算各自的用户问题部分。
3.6.1哈希链:命中判定的精确语义
关键设计是 block 的哈希不是只看块内 token,而是前缀累积哈希:
父哈希的链式传递保证:两个块哈希相等 ⟺ 从序列开头到这个块的全部内容都相同——所以命中判定是严格的前缀匹配,第 1 个 token 不同则全线失配。「额外键」包括多模态输入的内容哈希、LoRA adapter id、cache salt 等——保证「同 token 不同图」或「同 prompt 不同 adapter」不会错误命中。工程含义直白:把稳定的内容放 prompt 开头,把会变的(时间戳、用户名、随机 id)放末尾,这是部署 vLLM 最重要的一条 prompt 结构纪律。
3.6.2逐出与「零开销」设计
请求结束后它的块不立即清空,而是保留哈希、进入空闲队列尾部;池子紧张时从空闲队列头部拿块——天然形成 LRU 逐出(最久未用的缓存块最先被覆盖)。V1 把这套机制做到「零开销」:即使 0 命中,额外成本也只是每块一次哈希计算,所以默认开启、生产环境没有理由关掉。多轮对话场景的 TTFT 提升常达一个数量级;观测指标是 2.6 节的 prefix_cache_hit_rate。第 5 章会在 block_pool.py 里看到这套「空闲队列 + 哈希索引」的具体数据结构。
3.6.3边界情况:安全、滑动窗口与跨实例
- 多租户安全:缓存共享引入了一个微妙的侧信道——攻击者可以用「探测 prompt + 观测 TTFT」推断缓存里有没有某段前缀(命中则明显更快),从而探知其他租户的 prompt 内容。vLLM 的对策是
cache_salt:请求携带盐值参与块哈希,不同租户盐值不同则永不互相命中。默认哈希也可切换为 SHA-256 以防哈希碰撞攻击。 - 滑动窗口与混合模型:滑动窗口 attention(Mistral 类)只看最近 W 个 token——窗口外的 KV 理论上可丢弃,但「前缀命中」的语义也变了(不再是任意长前缀可复用)。Gemma、混合 Mamba 类模型每层的 KV 需求还不一样。V1 为此把 KV cache 组织成分组(不同 attention 类型一组一套页池与管理逻辑),是
kv_cache_manager里为数不多的复杂度来源,读源码时可以先跳过、知道为什么存在即可。 - 跨实例共享:单机的 prefix cache 只在本实例有效;多副本部署时要么用前缀感知路由把同前缀请求送到同一实例(6.4 节),要么用 LMCache 这类外部 KV 池做集群级共享(6.3 节)——本节的哈希链设计是这两者共同的地基。
第 3 章 · 我的笔记
第 3 章 · 完成清单
Chapter 4 · 0x04 · Acceleration
推理加速技术:在物理极限内偷时间
本章内容
第 3 章的显存与调度决定了「装多少、排多满」,本章的四类技术则各自攻击一段具体的物理成本:投机解码攻击 decode 的串行依赖,量化攻击访存字节数,attention kernel 攻击 HBM 往返,CUDA Graph 与编译攻击 CPU 开销。理解每项技术「偷的是哪段时间」,选型就不会凭感觉。
4.1投机解码:原理、谱系与 vLLM 的选择
4.1.1原理:起草-验证,快了但没变笨
第 1 章说过 decode 的两个事实:① token 之间是串行依赖,一步只能出一个;② 一步前向的时间被搬权重主导,多算几个 token 几乎免费。投机解码(speculative decoding)把它们拼成一个漂亮的杠杆:用便宜的「起草者」快速猜出 k 个候选 token,再让大模型一次前向并行验证这 k 个位置——验证通过几个就收下几个。
正确性靠拒绝采样保证:设起草分布为 q、目标分布为 p,对每个候选 token x 以概率 min(1, p(x)/q(x)) 接受;一旦拒绝,从修正分布 (p−q)₊ 归一化后重新采样一个 token 并终止本轮。Leviathan et al. 2022 与 Chen et al. 2023(DeepMind,同期独立提出)证明了这个过程的输出分布与直接从大模型采样严格相同——快了,但数学上没变笨。若单 token 接受率为 α,每轮期望产出 (1−α^{k+1})/(1−α) 个 token;α≈0.8、k=4 时约 3.3 个/轮,对应 2~3 倍的延迟改善。
起草者逐个猜出 4 个候选(灰色,很快很便宜);目标大模型一次前向并行验证 4 个位置:前 2 个接受(绿),第 3 个拒绝并当场修正(金色是大模型自己采样的 token),其后的草稿作废。
4.1.2谱系:起草者的五个流派
投机解码的研究全部围绕一个问题展开:去哪找一个又快又准的起草者?五个流派按时间线依次登场:
| 流派 | 起草方式 | 优点 / 缺点 | 代表论文 |
|---|---|---|---|
| 独立 draft 模型 | 同族小模型(如 68M 草稿 + 70B 目标)自回归起草 | 思路直接;但要单独训练/维护小模型,占显存,词表还得对齐 | Leviathan 22 / Chen 23 |
| n-gram 匹配 | 不用模型:拿序列末尾 n 个 token 在 prompt 里找重复片段,把后续当草稿 | 零成本、零显存;只在输出大量复述输入的任务(改写、RAG、代码编辑)有效 | Prompt Lookup Decoding (PLD) |
| Medusa | 在目标模型头上加多个并行解码头,一次猜多个位置,树状候选 | 不需独立模型;并行头相互独立、接受率一般,已基本被 EAGLE 系取代 | Medusa 2024 |
| EAGLE 系 | 轻量头在目标模型最后一层隐状态上自回归——在特征空间起草而非词空间 | 接受率当前最高(隐状态信息远多于 token id);需为每个目标模型训练一个头。EAGLE-2 引入动态草稿树,EAGLE-3 用多层特征融合再提一档 | EAGLE / EAGLE-3 |
| MTP | 模型预训练时就自带 multi-token prediction 模块,天生会猜下一个之后的 token | 与模型联合训练、接受率高且「出厂自带」;只有 DeepSeek-V3/R1 等原生支持 | DeepSeek-V3 |
深入:EAGLE 的结构与「树状起草」到底是什么
EAGLE 的关键洞察:直接在 token 序列上训小模型很难猜准,因为 token 是采样出来的、天然高熵;但目标模型最后一层的隐状态(feature)是确定性计算的产物,规律性强得多。EAGLE 的起草头只有一层 decoder:输入是「上一位置的 feature + 已采样 token 的 embedding」拼接,输出预测下一位置的 feature,再过目标模型自己的 LM head 得到草稿 token。因为复用了目标模型的隐状态和词表头,一层网络就能到 80% 左右的接受率;EAGLE-3 进一步融合低/中/高多层特征、并改为直接预测 token,把可扩展性又提了一档(训练时测试技巧详见论文)。
树状起草与树注意力:链式起草有个弱点——第 1 个猜错,后面全废。既然验证是并行的,不如一次验证一棵候选树:第一步取 top-2 候选、每个再展开 top-2……把整棵树的所有路径拍平成一个序列送进目标模型,用特制的 tree attention mask(每个节点只能 attend 自己的祖先路径)一次前向验证所有分支,最后收下「存活最深」的那条路径。EAGLE-2 的改进是按节点置信度动态生长这棵树,把固定形状树的浪费砍掉。这也是 FlashInfer 这类 kernel 库专门支持树状验证 mask 的原因(4.3 节)。
验证的两种口味:贪心场景(temperature=0)验证退化为「草稿 token == 目标 argmax」的精确匹配;采样场景用 4.1.1 的拒绝采样。vLLM 的 rejection sampler 是专门写的 kernel——因为要对 batch 里每个请求、每个草稿位置并行做接受判定,Python 循环会把收益吃光(5.6 节可以看到它)。
4.1.3vLLM 用的是哪种
vLLM V1 走的是「不依赖独立 draft 模型」的路线,当前支持 ngram、eagle / eagle3、medusa、以及 DeepSeek 系的 mtp(V0 时代的独立小模型起草未在 V1 延续,因为调度两个模型的复杂度高、收益不如 EAGLE)。配置通过 --speculative-config 传 JSON:
# n-gram:零成本,先拿它验证你的业务是否「有草可偷」
vllm serve meta-llama/Llama-3.1-8B-Instruct \
--speculative-config '{"method": "ngram", "num_speculative_tokens": 4,
"prompt_lookup_max": 3}'
# EAGLE-3:接受率最高的通用方案,社区为主流模型都训好了头
vllm serve meta-llama/Llama-3.1-8B-Instruct \
--speculative-config '{"method": "eagle3",
"model": "yuhuili/EAGLE3-LLaMA3.1-Instruct-8B",
"num_speculative_tokens": 4}'
运行时日志会周期性打印 acceptance rate / mean acceptance length——这是投机解码唯一重要的健康指标:平均接受长度 2.5~3 以上收益明显,接近 1 说明起草全被拒、纯赔本。(字段名随版本可能微调,以 官方文档 为准。)
什么时候不要用投机解码
「验证几乎免费」的前提是 decode 处于 memory-bound(第 1 章 roofline 的左半边)。高并发、大 batch 场景下带宽已被吃满,验证的额外 FLOPs 不再免费,投机解码反而挤占吞吐。经验法则:低 QPS 的延迟敏感场景(代码补全、对话助手)收益最大;吞吐优先的离线批处理基本不用。这也是为什么它是「开关」而不是默认行为。
4.2量化:格式、kernel 与选型
4.2.1先分清三个对象
「量化」在推理里其实是三件独立的事,收益机制不同,可以叠加:
- 权重量化(W):16-bit → 8/4-bit,权重体积÷2~4。因为 decode 被搬权重主导,搬得少 = 单步更快,小 batch 下 W4 模型 decode 常快近一倍;同时省下的显存变成 KV cache。
- 激活量化(A):矩阵乘的输入也降精度,才能用上 FP8/INT8 tensor core 的双倍算力——这是大 batch / prefill(compute-bound 区域)提速的关键。难点是激活里的 outlier。
- KV cache 量化:
--kv-cache-dtype fp8,容量翻倍 → 并发翻倍,长上下文场景还直接减半 attention 的访存(1.4 节深入块算过这笔账)。
4.2.2主流格式各自的招
| 格式 | 位宽 | 核心思想 | 适用 |
|---|---|---|---|
| GPTQ | W4A16 | 逐层用二阶信息(Hessian)做误差补偿的贪心量化,需校准数据 | 老牌 4-bit,生态最广 |
| AWQ | W4A16 | 观察到 ~1% 的「显著权重通道」决定精度,按激活幅度缩放保护它们 | 4-bit 首选,精度稳、校准轻 |
| FP8 | W8A8 | E4M3 浮点格式,动态范围大不怕 outlier;Hopper 起硬件原生支持 | H100+ 上的吞吐默认选择,几乎无损 |
| SmoothQuant | W8A8 INT8 | 把激活的 outlier 幅度「迁移」到权重上抹平,让两边都好量 | Ampere 等无 FP8 硬件的 8-bit 方案 |
| NVFP4 / MXFP4 | W4A4 | 微缩放块格式(每 16/32 个数共享一个缩放因子),Blackwell 硬件原生 | B 系列 GPU 的新前沿 |
| bitsandbytes / GGUF | W4~W8 | 运行时即时量化 / llama.cpp 生态格式 | 快速实验方便;生产不如预量化 checkpoint |
工作流:用 vLLM 官方的 llm-compressor 做量化(或直接下载 HuggingFace 上现成的量化 checkpoint,如 RedHatAI/NM 出品的 FP8 系列),vLLM 加载时自动识别 checkpoint 里的量化配置并选择对应 kernel——W4A16 的矩阵乘由 Marlin / Machete 这类混合精度 GEMM kernel 执行,它们把「反量化」融合进矩阵乘流水线,这是 4-bit 能真跑出加速的关键。
深入:每种格式的数学一句话 + Marlin 为什么快
GPTQ:源自 Optimal Brain Quantization——逐列量化权重,每量化一列,用 Hessian 逆(由校准数据的 X·Xᵀ 估计)把该列的舍入误差补偿到还没量化的列上,让层输出误差最小而非权重误差最小。group size 128 指每 128 个权重共享一个 scale,精度与体积的折中。
AWQ:不动权重的值,只找每通道缩放 s——观察到激活幅度大的通道对输出影响大(salient),给这些通道的权重乘上 s(量化前放大、有效比特更多),激活侧除以 s 抵消,s 通过网格搜索最小化层输出误差。全程只需前向、无需反传,校准比 GPTQ 轻。
SmoothQuant:W8A8 的难点在激活 outlier(个别通道幅度大 100 倍)。做恒等变换 Y = (X/s)(s·W):激活除以 s 抹平 outlier、权重乘 s 接住它——把「激活难量化」的问题迁移一部分给「权重好量化」,两边都落在 INT8 的舒适区。
FP8:E4M3(4 位指数)动态范围约 ±448,靠浮点指数天然容纳 outlier,所以权重用 per-tensor 静态 scale、激活用 per-token 动态 scale 就够,几乎无需校准——这是它「即插即用几乎无损」的原因。E5M2 范围更大精度更低,主要用于梯度(训练)与部分 KV cache 场景。
Marlin / Machete:W4A16 的矩阵乘没有硬件原生支持(Tensor Core 不认 INT4×FP16),必须先反量化再乘。朴素做法(反量化到显存再 GEMM)反而更慢;Marlin 把反量化做进 GEMM 流水线——INT4 权重以打包格式驻留,加载进寄存器的瞬间展开成 FP16 立刻喂给 Tensor Core,访存量保持 4-bit、计算保持 FP16,两头占全。Machete 是其 Hopper 世代的重写(用上 TMA 等新硬件)。
4.2.3选型直觉与验收
- 单卡装不下模型 / 追求低延迟 → AWQ / GPTQ(W4A16);
- Hopper+ 卡上追求吞吐与精度平衡 → FP8(已是服务部署的事实标准);
- 长上下文高并发 → 叠加 KV cache FP8;
- 验收别只看困惑度:用 lm-eval 跑任务指标 + 抽真实业务样本人工对比,重点看格式遵循与长输出稳定性——量化伤的往往是这些,榜单看不出来。
4.3Attention kernel 生态
4.3.1FlashAttention:不落地的 softmax
朴素 attention 要把 T×T 的分数矩阵 S = QKᵀ 完整写进 HBM、读回来做 softmax、再写读一轮乘 V——长序列下这个中间矩阵的搬运量是 O(T²),远超 Q/K/V 本身。FlashAttention 的解法:把 Q/K/V 切成小块装进片上 SRAM,用 online softmax(边扫边维护运行中的最大值与归一化和)在片上一次算完,T×T 矩阵从头到尾不存在。这是「IO 感知算法设计」的教科书案例——不减少 FLOPs,只减少 HBM 往返,恰好打中 1.4 节 roofline 的要害。FA2 改进了并行切分与非矩阵开销;FA3 面向 Hopper 用上 warp 专业化、TMA 异步拷贝与 FP8。
深入:online softmax 的三行数学与 FA 的分块循环
softmax 的麻烦在于分母要看到全部分数才能算:exp(sᵢ)/Σexp(sⱼ)(数值稳定还要先减全局最大值 m)。online softmax 证明可以边扫边修:维护运行中的最大值 m 和部分和 l,每来一个新分块,若出现更大的 m',把已累积的 l 与输出乘上 exp(m−m') 整体「改口」,再累加新块的贡献。于是 attention 可以写成一个单遍循环:
# 每个 Q tile 常驻 SRAM;K/V 分块流过
for (K_j, V_j) in 分块序列:
S = Q_tile @ K_j.T # 小块分数,只存在于 SRAM
m_new = max(m, rowmax(S))
P = exp(S - m_new) # 本块的未归一化概率
O = O * exp(m - m_new) + P @ V_j # 旧输出「改口」+ 新块贡献
l = l * exp(m - m_new) + rowsum(P); m = m_new
O = O / l # 最后一次性归一化
T×T 矩阵全程没有落进 HBM——HBM 流量从 O(T²) 降到 O(T²·d/SRAM),长序列下往往是 5~10 倍的 attention 提速。反直觉的是它的 FLOPs 反而更多(重算了指数修正),但 FLOPs 便宜、字节贵——roofline 思维的完美案例。PagedAttention 与它正交:K/V 分块本来就要流式加载,改成「按页表取块」即可,这就是两者能组合的原因。
4.3.2vLLM 的 backend 选择
vLLM 把「哪个 kernel 算 attention」抽象为可插拔的 attention backend,按硬件与模型自动选择,也可用 VLLM_ATTENTION_BACKEND 强制指定:
- FlashAttention 2/3:NVIDIA 卡默认,原生支持 paged KV 与变长 batch;
- FlashInfer:面向服务场景特化的 kernel 库——分页 KV、GQA 分组、投机解码的树状验证等场景常更快,部分新特性(如某些采样融合)首发在这里;
- Triton 实现:用 OpenAI Triton 写的后备实现,保证 ROCm 等非 CUDA 平台可用;
- FlashMLA 等专用 kernel:MLA(1.2.3 节)的 latent 结构与标准 attention 不同,DeepSeek 开源了专用实现。
学习建议:不必读 kernel 源码,但要会做一个实验——同一负载切换 backend 压测一次,你会对「kernel 也是变量」建立体感;第 5 章会看它们在 v1/attention/backends/ 里怎么接进执行流。
4.4CUDA Graph 与 torch.compile
4.4.1CPU 开销:被忽视的第三种瓶颈
一个 decode step 要发射几百个 kernel,每个 launch 有 5~10 微秒 CPU 开销;小 batch 时 GPU 每个 kernel 只算几十微秒——CPU 发射速度追不上 GPU 消化速度,GPU 在等指令而不是等数据。这就是第三种瓶颈:launch-bound。CUDA Graph 的解法是把一整步的 kernel 序列「录制」成图,之后每步一次性「重放」,CPU 开销从几百次 launch 变成一次 graph launch。
V1 默认用 piecewise CUDA Graph:attention 前后的稠密计算段录成图,attention 本身留在图外灵活执行(因为分页寻址、变长 batch 让它的输入形状难以固化);对形状规整的纯 decode batch 也支持整图模式。启动时那串 Capturing CUDA graphs 日志就是在预录不同 batch 尺寸的图;--enforce-eager 关掉它,对比 ITL 你能直接量出 CPU 开销有多大。
深入:录制与重放的机制细节
录制:CUDA Graph 记录的是「kernel 序列 + 它们读写的显存地址」,所以被录制的计算必须用固定地址的静态缓冲区——vLLM 为输入 token、位置、block table 等预分配了持久张量,每步只往里拷新数据,图重放时读的还是同一块地址。形状也必须固定:因此启动时按一组预设 batch 尺寸(1、2、4、8…512)各录一张图,运行时把实际 batch padding 到最近的档位再重放——padding 的少量浪费换掉几百次 launch,小 batch 下稳赚。
为什么 attention 难录:每步的序列长度、页表内容都在变,attention kernel 的网格形状与访存模式随之变化,录死就错。piecewise 方案把模型按 attention 切段:段与段之间 attention 以 eager 方式执行,其余部分(layernorm、GEMM、激活)按段重放——CPU 开销消掉九成,灵活性无损。对纯 decode 的规整 batch,vLLM 也支持整图模式(连 attention 一起录,配合支持动态 seq_len 的 kernel),再省最后一截。代价:每张图占几十 MB 显存、启动多花几十秒——这就是那两行启动日志背后的交易。
4.4.2torch.compile:定制 kernel 的量产线
V1 的模型执行默认经过 torch.compile(Inductor 后端):自动做算子融合(RMSNorm+量化、SiLU+mul 等)、为当前硬件生成特化 kernel——过去要手写 CUDA 的优化现在由编译器量产。启动时的「编译中」等待就是它,产物缓存在 ~/.cache/vllm,二次启动跳过。编译等级用 -O0~-O3 控制;调试崩溃或数值问题时先降到 -O0 / --enforce-eager 排除编译因素——这是实战里最常用的排障开关。vLLM 团队博客有一篇 torch.compile 集成详解 值得读。
第 4 章 · 我的笔记
第 4 章 · 完成清单
Chapter 5 · 0x05 · Source Dive
源码精读:跟着一个请求走完全程
本章内容
读 vLLM 源码最有效的方式不是按目录顺序读文件,而是自顶向下跟踪一个请求的完整生命周期,再对两个核心类(Scheduler、KVCacheManager)做伪代码级精读。前面三章的所有概念,在这里都会落到具体的类和函数上。
5.1准备:能跳转、能断点的代码
git clone https://github.com/vllm-project/vllm.git && cd vllm
# editable 安装 + 复用预编译的 CUDA kernel(不用等半小时编译)
VLLM_USE_PRECOMPILED=1 uv pip install -e .
Python 部分改完即生效。两个让 debugger 好用的开关:VLLM_ENABLE_V1_MULTIPROCESSING=0 让引擎回到单进程内运行(断点直接命中,不用 attach 子进程);--enforce-eager 关掉编译与 CUDA Graph(栈干净、行号准)。再备一个 2 条 prompt 的最小离线脚本当「探针」。
5.2骨架:两个进程,一条消息队列
V1 最重要的架构决策是把引擎拆成两个进程,用 ZeroMQ 连接。CPU 重活(HTTP、tokenize、detokenize)与 GPU 调度循环彻底隔离:EngineCore 忙循环里每一毫秒都很贵,不能被 Python 字符串处理拖住——detokenize 一条长输出可能花几毫秒,若与调度同进程,所有请求的 decode 都会被它卡一拍。
两侧只交换轻量的 token id 消息。官方 Architecture Overview 与 V1 发布博客 是本章的官方参照,开工前通读一遍。
几个架构细节,读代码时能少走弯路:① API 侧通过 EngineCoreClient 抽象与引擎对话——同一套接口有单进程内联(调试用,VLLM_ENABLE_V1_MULTIPROCESSING=0 就是切到它)、多进程 ZMQ、多引擎数据并行三种实现,这就是「断点能不能直接命中」的开关原理;② 跨进程消息用 msgpack 序列化(比 pickle 快且安全),大的张量类数据(多模态 embedding)走共享内存旁路;③ LLM 离线类和 vllm serve 共用同一个 EngineCore——离线只是把 AsyncLLM 换成同步壳,所以你在离线脚本里调试的调度行为与线上完全一致;④ 高 QPS 下单个 API 进程的 tokenize/detokenize 会成瓶颈,V1 支持多 API 进程共享一个引擎(--api-server-count),这是「两侧独立伸缩」的具体形态。
5.3主线:请求的一生(七站)
下面是一条 chat 请求走过的每一站。把它当作阅读顺序:每站打开对应文件、找到入口函数、用 debugger 停一次,观察括号里的关键数据结构:
entrypoints/openai/api_server.pyAPI 进程generate() 拿到一个异步流。v1/engine/processor.pyAPI 进程EngineCoreRequest(看这个 dataclass 的字段——它就是跨进程协议),经 ZeroMQ 发往引擎进程。v1/engine/core.pyEngineCore 进程EngineCore.run_busy_loop()——整个引擎的心跳:收新请求 → step() → 发结果,无限循环。从这里往下都是每个 step 发生一次的事。v1/core/sched/scheduler.pyEngineCore 进程Scheduler.schedule() 产出 SchedulerOutput:本步每个请求算几个 token、block table 增量。5.4 节逐行精读。v1/core/kv_cache_manager.pyEngineCore 进程get_computed_blocks() 查 prefix cache 命中,allocate_slots() 领新块。5.5 节精读。v1/worker/gpu_model_runner.pyGPUexecute_model():按调度结果准备扁平化输入与 attention 元数据(block table 在此喂给 kernel),跑 forward,Sampler 采样出 token id,返回 ModelRunnerOutput。v1/engine/output_processor.pyAPI 进程| 路径 | 内容 | 精读优先级 |
|---|---|---|
vllm/v1/engine/ | AsyncLLM、EngineCore、输入/输出处理——编排层 | ★★★ |
vllm/v1/core/sched/ | 调度器与 SchedulerOutput 数据结构 | ★★★ |
vllm/v1/core/ | KVCacheManager、BlockPool、前缀哈希工具 | ★★★ |
vllm/v1/worker/ | GPU Worker 与 ModelRunner——执行层核心 | ★★☆ |
vllm/v1/attention/backends/ | 各 attention kernel 的适配层(元数据构建) | ★☆☆ |
vllm/model_executor/models/ | 300+ 模型定义,从 llama.py 读起 | ★★☆ |
vllm/model_executor/layers/ | 张量并行层、量化层、RoPE、Sampler | ★★☆ |
vllm/entrypoints/ | CLI 与 OpenAI 兼容 server | ★☆☆ |
vllm/distributed/ | 进程组、TP/PP 通信原语、自定义 allreduce | 第 6 章 |
csrc/ | C++/CUDA kernel 源码(paged attention 等) | 选读 |
路径以 v0.2x 为准,小版本间可能微调;对不上时用 git grep 类名 定位。
5.4精读 Scheduler.schedule()
全项目最值得逐行读的函数。先带着这份伪代码框架去读,真实代码就只是它加上边界处理(投机解码的草稿 token、结构化输出的语法状态、encoder 预算等):
def schedule() -> SchedulerOutput:
budget = max_num_batched_tokens # 本步 token 总预算
# ── 第一优先级:running 队列(已在跑的请求不断流)──
for req in running:
n = min(req.未算的token数, budget) # decode 时 n=1;prompt 没算完时是下一个 chunk
while not kv.allocate_slots(req, n): # 块不够 → 抢占别人
victim = running.pop_lowest_priority()
kv.free(victim); victim → waiting 队头 # 释放全部块,回头重算
if victim is req: break # 自己被踢,本步作罢
schedule req 算 n 个 token; budget -= n
# ── 第二优先级:waiting 队列(预算有余才收新人)──
for req in waiting: # FCFS 或 priority 顺序
if len(running) == max_num_seqs or budget == 0: break
cached = kv.get_computed_blocks(req) # prefix cache 命中的部分免算
n = min(req.prompt长度 - cached, budget)
if not kv.allocate_slots(req, n): break
req → running; schedule req 算 n 个 token; budget -= n
return SchedulerOutput({req_id: n, ...}, block_table增量, ...)
对照第 3 章验证你的理解:统一 token 预算(3.4)、chunked prefill 是「prompt 没算完的请求每步领一个 chunk」(3.5)、抢占是 allocate 失败时的连锁反应(3.4)、prefix cache 在新请求入场时查表(3.6)。读完的检验标准:给你一个 running/waiting 队列快照和预算数字,你能手推出这一步的分配表。配套测试 tests/v1/core/test_scheduler.py 里就是一堆这样的场景,直接拿来自测。
对着真实代码读时的几个锚点:调度结果的核心载体是 num_scheduled_tokens: dict[str, int](就是 3.4 那张分配表的本体);新入场请求与继续运行的请求在 SchedulerOutput 里分开描述(前者要带完整 prompt 与采样参数,后者只带增量),这是为了让跨进程消息尽量小;update_from_output() 是 schedule() 的「下半场」——模型跑完后回填每个请求的新 token、推进结构化输出的自动机、判定完成与否,一进一出合起来才是调度器的完整职责。投机解码启用时,上一步被接受的草稿 token 也会出现在预算里(验证 k 个草稿意味着本步要为该请求调度 k+1 个 token)——读到 spec_token_ids 相关分支时对照 4.1 节即可。
5.5精读 KVCacheManager 与 BlockPool
三个数据结构撑起整个分页系统(v1/core/block_pool.py、kv_cache_utils.py):
KVCacheBlock:物理块的元数据——block_id、ref_cnt(几个请求在用)、block_hash(3.6 的前缀累积哈希,未满的块为 None)、双向链表前后指针。- 空闲队列(free_block_queue):双向链表串起所有
ref_cnt==0的块。头部 = 最久未用。妙处在于:空闲块仍保留哈希,还能被命中「复活」——所以它同时是 free list 和 LRU 缓存。 - 哈希索引(cached_block_hash_to_block):hash → 块的字典,prefix cache 查表就是查它。
两个核心操作的逻辑:
def get_computed_blocks(req): # 入场时查前缀命中
for 每个满块的hash in req 的哈希链:
if hash in 哈希索引: 命中块.ref_cnt += 1 # 在空闲队列里则先摘出(复活)
else: break # 链式哈希:断一环则后面全断
return 命中的块列表
def allocate_slots(req, n_tokens): # 为新 token 领块
需要的新块数 = 按 n_tokens 折算
if 空闲队列长度不够: return False # → 触发调度器的抢占逻辑
从队列头 pop 块(最久未用优先):
if 块还带着旧哈希: 从哈希索引删除 # ← LRU 逐出就发生在这一刻
块.ref_cnt = 1; 写进 req 的 block table
新写满的块: 计算哈希、登记进索引 # ← 缓存的产生
逐出不是后台任务,而是分配的副作用——这个设计让 prefix caching 做到「零开销」(3.6.2)。读完可以自答:为什么请求结束时块要从队列尾部入队?(答:刚用过的最「热」,应最晚被逐出。)
再记三个读码锚点:① BlockHash 的构成与 3.6.1 的公式一一对应——父哈希 + 块内 token 元组 + extra keys(多模态哈希、LoRA id、cache_salt),在 kv_cache_utils.py 里能找到打包函数;② 池里有一个特殊的 null_block(0 号块)用来占位——比如滑动窗口把窗口外的位置指向它,省得页表里出现空洞判断;③ 每个请求持有的块列表由 KVCacheManager 按请求 id 记账,请求结束统一 free()(引用计数减一、归还队尾)——顺着一个 request_id grep 这三处,分配-使用-归还的闭环就完整了。混合模型(滑动窗口/Mamba 层)会把这套结构复制成多个「KV cache group」各管一类层(3.6.3),首次阅读建议直接读单组路径。
5.6执行层:ModelRunner 与 Sampler
gpu_model_runner.py 把 SchedulerOutput 变成 GPU 上的一次前向。三个值得专门观察的机制:
- 扁平化输入:本步所有请求要算的 token 拼成一条一维序列(3.3 深入块讲的 selective batching)——R1 的 320 个 + R2 的 1 个 + ……attention 元数据记录各自边界与 block table,交给变长 kernel。
- Persistent batch:batch 的张量状态(token id、位置、采样参数)常驻 GPU,每步只做增量修改(新增/移除请求),避免每步全量重建输入张量的 CPU 开销。
- Sampler:logits → 重复惩罚 → temperature → top-k/p → 采样。不同请求参数不同,用张量化技巧按行批处理;结构化输出的 grammar bitmask 也在此生效(2.3.2)。
观察方法:在 execute_model() 入口断点,打印 scheduler_output 与输入张量形状,对照 5.4 手推的分配表——三章理论在这一刻闭环。
深入:execute_model 的一步都做了什么
- 同步 batch 状态:按 SchedulerOutput 增删
InputBatch里的请求条目(persistent batch 的「增量修改」就发生在这里),把新 token id 拷进常驻张量。 - 构建 attention 元数据:每条请求的 seq_len、query 起止偏移、block table 行——打包成当前 backend 需要的 metadata 结构(
v1/attention/backends/里每个 backend 一个 builder,这就是 4.3 的「适配层」)。 - 选择执行形态:batch 形状落在预录档位 → 重放 CUDA Graph;否则 eager(4.4)。
- forward:模型输出最后位置的 hidden,过 LM head 得 logits(TP 下 logits 是分片的,先 gather)。
- 采样:结构化输出 bitmask → 惩罚/温度/top-k/p → 采样。投机解码时这里换成 rejection sampler:并行判定每条请求的 k 个草稿接受几个(4.1),一次产出可变数量的 token。
- 回传:token id 拷回 CPU(pinned memory 异步拷贝),打包
ModelRunnerOutput交还 EngineCore。
5.7模型定义、TP 层与阅读方法论
打开 model_executor/models/llama.py 对照 HuggingFace 版本读,vLLM 重写模型只为三件事:
- 并行层替换:
nn.Linear换成QKVParallelLinear(QKV 合并 + 按列切)、MergedColumnParallelLinear(gate/up 合并)、RowParallelLinear(按行切,出口处 allreduce)。TP 的通信结构就藏在类名里——第 6 章展开这笔通信账。 - 算子融合:RMSNorm、RoPE、SiLU-mul 用融合 kernel;attention 接到第 4 章的 backend 上。
- 权重加载映射:
load_weights()把 HF checkpoint 的张量名映射并切分到并行层——给 vLLM 添加新模型 80% 的工作量在这里(官方新模型指南)。
深入:并行层的切分与加载细节
切分方向的逻辑:列切(Column)把输出维度分掉——QKV 投影按 head 分(每 rank 拿 num_heads/tp 个 Q head、num_kv_heads/tp 个 KV head),gate/up 按中间维分;切完各 rank 的后续计算天然独立,不需要通信。行切(Row)把输入维度分掉——o_proj、down_proj 的输入正是上一步的分片输出,各 rank 算出「部分和」,必须 AllReduce 相加还原。列切→行切成对出现,让每层只在两个固定位置通信(6.2 的图)。词表也一样:VocabParallelEmbedding 按词表分片,LM head 的 logits 分片后 gather。
合并层与加载映射:把 Q、K、V 三个矩阵合成一个 QKVParallelLinear、gate/up 合成 MergedColumnParallelLinear,是为了把三次小 GEMM 合成一次大 GEMM(GPU 吃大矩阵,1.3.3)。代价转嫁给加载期:HF checkpoint 里是 q_proj/k_proj/v_proj 三个张量,load_weights() 靠 stacked_params_mapping 表把它们逐个写进合并矩阵的对应行段、同时按 TP rank 取自己的分片。给新模型写支持时报错多半出在这张映射表——形状对不上就从这里查。
KV head 少于 TP 数怎么办:GQA 模型 8 个 KV head 遇上 TP=16 时无法整除,vLLM 会把 KV head 复制到多个 rank(每份 KV 服务多组 Q head),KV cache 相应多占——这就是 2.4 节「KV head 数需能被 TP 整除(或反之)」限制的来源。
方法论收尾
- 动态优于静态:debugger 跟一遍真实请求,胜过读十遍代码。热身可以先读 nano-vllm——约 1200 行的迷你实现,麻雀虽小五脏俱全。
- 用测试当文档:
tests/v1/core/直接构造抢占、命中、分块等边界场景,是行为定义的最快入口。 - 用 RFC 补动机:GitHub issue 搜
[RFC],V1 重构、spec decode、混合调度的设计讨论都公开——「为什么这么设计」的一手材料。 - 笔记写在本仓库:为每一站建一个 markdown,最终产出:不看代码画出七站生命周期图,标注每站的关键数据结构。
第 5 章 · 我的笔记
第 5 章 · 完成清单
Chapter 6 · 0x06 · Scale & Production
分布式与生产:从一张卡到一个集群
本章内容
走到这里,你已经「读得懂、改得动」。本章把视野从单卡扩展到集群:先学会量化性能(不量化就没有优化),再理解四种并行各自的通信账,然后是 2025 年以来最活跃的方向——PD 分离与大规模编排。最后,把学到的还给社区。
6.1性能剖析实战
- 先量化再动手:设
VLLM_TORCH_PROFILER_DIR=/tmp/traces启动,压测工具带--profile开关抓 torch profiler trace,在 Perfetto 里看时间线:每个 step 里 CPU 在忙什么、GPU 空隙在哪、哪个 kernel 最长。更底层用 Nsight Systems(nsys profile)。 - 诊断三分法:GPU 空隙大 → launch-bound(查 CUDA Graph 是否生效、CPU 是否被 tokenize 拖住);GEMM kernel 占主导且 batch 小 → memory-bound(上量化、投机解码);attention kernel 占主导 → 长上下文效应(KV 量化、换 backend)。第 1 章的 roofline 是分诊台。
- 调度层调优:拿真实流量分布,在 TTFT SLO 约束下扫
max_num_batched_tokens与max_num_seqs最大化 goodput——生产环境最常见也最体现理解深度的实战。
深入:一次完整的剖析流程与常见「病相」图谱
shell · 抓一段 20 秒的系统级 trace(服务已在跑压测)
nsys profile -t cuda,nvtx --duration=20 -o vllm_trace \
--attach-pid $(pgrep -f "EngineCore")
# 或者用 torch profiler:启动前设 VLLM_TORCH_PROFILER_DIR=/tmp/traces,
# 压测端 vllm bench serve ... --profile,产物拖进 ui.perfetto.dev
打开时间线后按「病相」对号入座:
- GPU 行有规律空洞(每步之间一条缝)→ CPU/launch-bound:查 CUDA Graph 是否被 enforce-eager 关了、tokenize 是否挤在引擎进程、Python 侧有没有热点(py-spy 抓一把)。
- 时间线被少数超长 kernel 段占据、且是 GEMM → compute-bound:正常的 prefill 形态;若 decode 也如此说明 batch 已过 roofline 平衡点,加卡分流而不是继续加 batch。
- attention kernel 占比随时间越来越大 → 长上下文效应:会话在变长,KV 读取成为主导(1.4 深入块的账);上 KV FP8、考虑 PD 分离。
- NCCL kernel(AllReduce)占比高 → 通信瓶颈:TP 跨了 NUMA/PCIe,或该试试降 TP 升 DP;小 batch 下留意 custom allreduce 是否启用。
- 规律性整段停顿 → 看是不是抢占风暴(对照
num_preemptions_total)或 prefix cache 逐出抖动。
6.2并行全景:TP / PP / EP / DP 的通信账
所有并行策略都在回答同一个问题:模型 / 数据怎么切,通信代价放在哪。先看最常用的 TP 在一层 Transformer 里的通信结构:
| 并行方式 | 切什么 | 通信模式 | 什么时候用 |
|---|---|---|---|
| TP 张量并行 | 每层权重 + KV cache | 每层 2 次 AllReduce,在关键路径上,要求 NVLink 级互联 | 单卡装不下模型;降延迟首选;一般不跨机 |
| PP 流水线并行 | 按层切成段 | 只在段边界传激活,点对点、量小,容忍慢网络 | 跨机扩展;代价是流水线气泡,需要足够并发填充 |
| EP 专家并行 | MoE 的专家分到多卡 | 每层 2 次 All-to-All(dispatch/combine),依赖高效 kernel(DeepEP 等) | DeepSeek-V3、Qwen-MoE 等稀疏模型;专家负载均衡是难点 |
| DP 数据并行 | 不切模型,复制多份分流量 | 推理时几乎无通信(MoE 场景须协同步进) | 吞吐横向扩展;MLA 模型的 attention 部分常用 DP 而非 TP |
实践组合:单机内 TP、跨机 PP 是经典搭配(-tp 8 -pp 2);MoE 大模型主流是 attention 走 DP + FFN 专家走大规模 EP(DeepSeek 公开架构即此形态)。多机由 Ray 或 multiprocessing 编排。深入阅读:官方并行文档 与 vllm/distributed/。
深入:四种并行的通信账本与实现要点
TP 的账:每 token 每层两次 AllReduce,每次搬 hidden_size × 2 字节。Llama-70B(hidden 8192、80 层)TP 内每生成 1 个 token 的通信量 ≈ 2 × 80 × 8192 × 2B ≈ 2.6 MB/token/方向;batch 512 时每步 1.3 GB——NVLink(900 GB/s)下约 1.5ms,尚可;换成 PCIe(64 GB/s)就是 20ms,直接吞掉整步时间。这就是「TP 不出机、不出 NVLink 域」的定量依据。此外 AllReduce 在小消息下延迟主导,vLLM 为此内置 custom allreduce(单发/双发算法,绕开 NCCL 的固定开销),小 batch decode 收益明显。
PP 的账:只在段边界传一次激活(batch_tokens × hidden × 2B,与层数无关),跨机友好。代价是气泡:粗略比例 (p−1)/m(p 段流水、m 个并发微批)——推理场景里 m 就是「在途 batch 数」,continuous batching 天然提供了填充流水线的并发,所以 vLLM 的 PP 在高负载下气泡很小、低负载下则不划算。
EP 的账:每层两次 All-to-All——dispatch(把每个 token 发给它命中的 top-k 专家所在 rank)与 combine(收回加权结果)。通信量与 top-k 成正比、与专家总数无关,但延迟极其敏感(所有 rank 互等),所以有 DeepEP 这类专用通信 kernel(普通/低延迟双模式)。另一半难题是负载均衡:热门专家所在 rank 成为木桶短板,EPLB(专家复制与再分布)通过给热专家开副本来抹平——DeepSeek-V3 论文第 3 章是这套体系最完整的公开描述。
DP 的 MoE 陷阱:attention 层各 DP rank 独立,但 EP 的 All-to-All 要求所有 rank 同步步进——某个 rank 没请求可跑时必须垫一个 dummy batch 陪跑,否则集体死锁。vLLM 的 DP coordinator 就在协调这件事(vllm/v1/engine/coordinator.py)。理解了这一点,「MoE 部署为什么怕流量不均」就不再神秘。
深入:为什么 MLA 模型的 attention 用 DP 而不用 TP?
TP 切 attention 的前提是「head 可以分家」——GQA 有 8 个 KV head,TP=8 时每卡一个,KV cache 也随之切开。但 MLA 的 KV 是一个共享的 latent 向量(1.2.3),没有 head 维可切:TP 下每张卡都得存一份完整的 latent cache,显存 ×TP 倍,纯浪费。所以 DeepSeek 类模型的标准形态是:attention 部分各卡独立处理不同请求(DP),FFN 的专家散布全集群(EP)——两种并行在同一个模型里按层混用。这也是 vLLM 支持 --data-parallel-size 与 expert parallel 组合的动机。
6.3PD 分离:prefill 与 decode 分家
第 1 章讲过:prefill 是 compute-bound、decode 是 memory-bound——两种负载抢同一块 GPU 注定互相干扰,chunked prefill 只是缓解。Prefill-Decode disaggregation 干脆把它们放到不同 GPU 组:prefill 集群算完 prompt,把 KV cache 经 RDMA / NVLink 传给 decode 集群继续生成(思想源自 DistServe、Splitwise;Kimi 的 Mooncake 给出了超大规模的工程实录)。
vLLM 侧的抽象是 KV connector:把「KV cache 从哪来 / 到哪去」插件化——NIXL 走 RDMA 点对点传输,LMCache 提供 GPU/CPU/磁盘三级 KV 缓存池(跨实例复用前缀)。编排层交给 Kubernetes 生态:llm-d、vLLM production-stack、NVIDIA Dynamo 都以 vLLM 为引擎实现 PD 分离 + 前缀感知路由。什么时候值得上:流量大、prompt 长(KV 传输摊得开)、SLO 严;小规模部署里 chunked prefill 混跑往往更划算——别为了架构而架构。
深入:KV 怎么搬?传输的账与工程细节
传输量:就是 1.2 节的 KV cache 大小——Llama-8B 一条 8K prompt 约 1 GB。400 Gb/s 的 RDMA 网卡搬 1 GB 约 20ms:相对 150ms+ 的 prefill 是小头,但若 prompt 只有几百 token,固定开销(建连、元数据握手)占比飙升——「短请求不分离」是普遍共识,Mooncake 和 llm-d 都支持按 prompt 长度条件分离(短的就地跑完)。
隐藏延迟:好的实现按层流水传输——prefill 算完第 i 层,该层 KV 立即启程,传输与计算重叠,decode 侧几乎无感。NIXL 把 RDMA/GPUDirect/NVLink 等传输后端抽象成统一 API,vLLM 的 NixlConnector 用它实现 GPU-to-GPU 直传(不过 CPU、不落盘)。
形态速览:vLLM 侧 --kv-transfer-config 指定 connector 与角色(producer/consumer),外面套一个代理把请求先发 prefill 实例、拿到「KV 就绪」信号后转 decode 实例。生产级编排(llm-d/Dynamo)在此之上加:全局前缀池查询(算之前先问集群里有没有现成 KV)、P/D 池独立 HPA、按 SLO 的动态 P:D 配比(长 prompt 流量多配 P,长输出流量多配 D)。心智锚点:PD 分离的本质是把「KV cache」从实例私有资产变成集群一等公民。
6.4生产部署:路由、扩缩容与多实例
- 前缀感知路由:多实例时把同一会话 / 同一 system prompt 的请求路由到同一实例,prefix cache 命中率决定成败——随机轮询会把命中率打掉一半以上。
- 扩缩容信号:用 2.6 的
num_requests_waiting与 KV 使用率做 HPA 指标,比 GPU 利用率灵敏得多(GPU util 常年 100% 不代表没容量)。 - 发布与容错:模型加载要几分钟,滚动升级须配合就绪探针(
/health);长请求 drain 策略、抢占告警、OOM 自动重启都要提前演练。 - 睡眠模式与弹性:vLLM 支持 sleep/wake(权重下卸到 CPU 释放显存),配合弹性调度应对潮汐流量。
6.5生态对比:vLLM 之外的世界
| 引擎 | 定位与取舍 | 值得借鉴的设计 |
|---|---|---|
| SGLang | 与 vLLM 同级的开源引擎,吞吐互有胜负,社区同样活跃 | RadixAttention:用基数树(radix tree)组织 prefix cache,天然支持树状分叉(多轮、并行探索)的复用与换入换出——对比 vLLM 的哈希链设计,是同一问题的另一种优雅解法 |
| TensorRT-LLM | NVIDIA 官方,编译期极限优化,绑定 N 卡生态 | kernel 优化的天花板参照;FP4 等新硬件特性首发地 |
| llama.cpp / Ollama | 端侧与个人设备,CPU/Metal 友好,GGUF 生态 | 极致的量化格式工程;与 vLLM 是互补而非竞争 |
对比阅读的价值:同一个问题(如 prefix 复用)看两家解法,才能理解设计空间的取舍,而不是把 vLLM 的选择当唯一答案。SGLang 的 RadixAttention 论文 建议精读。
深入:RadixAttention vs 哈希链——同一问题的两种数据结构
SGLang 把所有已缓存序列组织成一棵基数树:边上是 token 片段,节点对应 KV 页,公共前缀天然共享同一条根路径。命中查询 = 从根走最长匹配路径(无需哈希,且能命中「任意长度」的前缀而非整块);逐出 = 摘引用计数为零的叶子(LRU);调度器还能按「谁与树的匹配最长」重排队列(cache-aware scheduling),把命中率再推高一截。代价是树是全局可变数据结构,并发管理更复杂。vLLM 的哈希链则把「结构」压扁成了字典查询:无锁、O(1)、块粒度——命中粒度粗一点(16 的倍数),换来实现的极简与「零开销」承诺。一棵树 vs 一张表:工程里没有免费的优雅,两边都值得读一遍实现。
6.6参与社区:从 issue 到 PR
- 从 good first issue 入手;复现并修复一个 bug 是深入代码库的最快路径。
- 经典第一个实质性 PR:给新模型添加支持——5.7 的知识刚好够用,跟着 官方指南 走。
- 加入 vLLM Slack,蹲双周 office hours;关注 官方博客 与 release notes 跟进版本节奏。
- 提 PR 的软技能:小步提交、benchmark 数据说话、CI 全绿再请 review——vLLM 的 reviewer 吞吐有限,让 PR「好审」是被合并的最大加分项。
第 6 章 · 我的笔记
第 6 章 · 完成清单
Appendix · 0x07 · References
资料清单
论文
| 论文 | 主题 | 章节 | 优先级 |
|---|---|---|---|
| PagedAttention (vLLM) | 分页 KV cache,SOSP 2023,vLLM 立身之作 | 3 | ★★★ |
| Orca | continuous batching 与 selective batching,OSDI 2022 | 3 | ★★★ |
| Sarathi-Serve | chunked prefill 与延迟-吞吐平衡 | 3 | ★★☆ |
| Leviathan et al. / Chen et al. | 投机解码理论(拒绝采样保分布) | 4 | ★★★ |
| EAGLE / EAGLE-3 | 特征空间起草,当前最强投机方案 | 4 | ★★☆ |
| Medusa | 多解码头 + 树状验证 | 4 | ★☆☆ |
| DeepSeek-V3 | MTP、MLA、大规模 EP 的集大成者 | 1/4/6 | ★★☆ |
| FlashAttention / FA2 / FA3 | IO 感知 attention kernel 三部曲 | 4 | ★★☆ |
| AWQ / GPTQ / SmoothQuant | 三种主流训练后量化方法 | 4 | ★★☆ |
| DeepSeek-V2 (MLA) | latent attention 的原始出处与吸收技巧 | 1 | ★☆☆ |
| DistServe / Splitwise | PD 分离的开山双璧 | 6 | ★★☆ |
| Mooncake | Kimi 的 KV-centric 分离架构工程实录 | 6 | ★☆☆ |
| SGLang (RadixAttention) | 基数树 prefix cache——对照 vLLM 的另一种解法 | 6 | ★★☆ |
官方文档与代码
- docs.vllm.ai —— 总入口。重点:Design Documents(架构与各子系统设计)、Features(spec decode / structured outputs / LoRA)、Serving(并行与部署)、Contributing(新模型指南)。★★★
- blog.vllm.ai —— 每个大特性的动机与实测。必读三篇:V1 架构发布、torch.compile 集成、各版本性能回顾。★★★
- github.com/vllm-project/vllm —— 源码、issue、
[RFC]设计讨论、release notes。★★★
动手与工具
- nanoGPT + Let's build GPT —— 补 Transformer 基础(第 1 章)。
- nano-vllm —— 约 1200 行的迷你 vLLM(分页 KV + 调度器),读正式源码前的最佳热身(第 5 章)。
- llm-compressor —— 官方量化工具链(第 4 章);lm-eval —— 量化验收评测。
- LMCache / llm-d / production-stack —— KV 缓存池与 K8s 编排(第 6 章)。
- Perfetto / Nsight Systems —— trace 分析(第 6 章)。
术语表
正文里带虚线下划线的词都可以悬停查看解释;这里是全部术语的汇总,供集中查阅。
中文社区
- 知乎搜「vLLM 源码解析」有大量系列文章——注意分辨 V0/V1:凡是讲
LLMEngine.step()、swap in/out、BlockSpaceManager 的都是 V0 旧架构,概念可参考、路径已失效;认准EngineCore、kv_cache_manager、统一调度等 V1 关键词。 - vLLM 官方公众号、vllm-ascend(昇腾)等中文活跃子项目的文档,质量高且更新及时。
保持更新的姿势
vLLM 当前约每月一个 minor 版本(2026-07 已到 v0.26),特性节奏很快。不必追每个版本——盯两处即可:官方博客的特性发布文,和 release notes 里的 breaking changes。核心概念(分页、统一调度、并行抽象、KV connector)已经稳定,本页内容的保鲜期远长于具体参数名;具体字段以当版本 --help 与文档为准。