vLLM Study · 六章学习指南

把 vLLM 从「会用」
学到「读懂源码」

vLLM 是目前使用最广的开源 LLM 推理引擎,出自 UC Berkeley Sky Computing Lab。本指南按六章展开:先补齐推理性能的物理直觉,再上手使用,然后分两章吃透核心机制(显存与调度 / 加速技术),进而深入 V1 引擎源码,最后走向分布式与生产实践。每章分成多个小节,配交互动画、图示与论文引用。

内容基准 vLLM v0.26 V1 引擎架构 更新于 2026-07
KV cache 分页示意 · 环境氛围动画

全书地图

每章开头有小节目录,结尾有产出目标清单(可勾选,自动保存)。建议顺序阅读;如果你已经在用 vLLM,可以直接从第 3 章的机制动画切入。

如何使用本页

  • 左侧(移动端为顶部)导航切换章节;章内目录可跳转小节。导航栏的主题按钮支持自动 / 浅色 / 深色三档。
  • 每章末尾有可勾选的完成清单和笔记框;选中任意正文文字可以添加行内评论(金色高亮,点击可改可删)。清单、笔记、评论都自动保存在本机浏览器,左下角可一键导出为 Markdown,也可清除进度或全部数据。
  • 正文里带虚线下划线的术语,悬停(或点击)即可查看解释;附录有完整术语表。动画都有「播放 / 重播」按钮,配合正文阅读效果最好。
  • 版本说明:vLLM 迭代很快(2026-07 已到 v0.26,约每月一个 minor 版本),本页以 V1 引擎为准。V0 旧架构代码已于 2025 年移除——网上大量源码解析讲的是 V0(特征:LLMEngine.step()、swap in/out、BlockSpaceManager),概念可参考,路径已失效。
  • 正文里的 链接↗ 是论文或文档出处;标着「深入」的折叠块是选读的技术细节。

Chapter 1 · 0x01 · Prerequisites

前置知识:推理到底在算什么

建议时长 3~5 天 产出 能白板推导 KV cache 大小与 decode 瓶颈

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。
动画 · Prefill 一次并行,Decode 逐个吐字

观察下方 KV cache 条:prefill 一次写入整段 prompt 的 KV;此后每个 decode step 只追加一格,但每一步都要读取前面所有格子(金色描边闪烁)。

prompt token(prefill 一次算完) 生成 token(decode 逐个产出) 尚未使用

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大小公式与计算器

每 token 字节数 = 2 (K 和 V) × num_layers × num_kv_heads × head_dim × dtype_bytes

以 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 是推理服务的第二大显存开销,且随并发线性增长——它就是并发能力的天花板。

计算器 · 你的 KV cache 有多大

选一个模型(或手动改参数),调上下文与并发,感受显存是怎么被吃掉的。试试把精度换成 FP8,或对比 Qwen2.5-7B(4 个 KV head)与 Llama-3.1-8B(8 个)——KV head 数的差异直接反映在账单上。

每 token128 KB 单请求 (满上下文)1.0 GB 全部并发合计16.0 GB 相当于 80GB 卡的20%

1.2.3模型侧的自救:MHA → MQA → GQA → MLA

KV cache 太贵,模型结构本身也在进化以缩小它。这条演进线值得记住,因为它解释了为什么现代模型的 num_kv_heads 远小于 num_heads,也解释了 DeepSeek 系列为什么能把超长上下文做便宜:

MHA · 每个 Q head 配一份 KV KV = heads 份 · 最贵 GQA · 一组 Q 共享一份 KV KV = 组数份 · 主流(Llama/Qwen) MLA · 压缩成低秩 latent c_KV latent KV = 一个压缩向量 · DeepSeek 每 token 每层需要缓存的元素数(以 64 个 Q head、head_dim 128 为例): MHA: 2×64×128 = 16384 GQA-8: 2×8×128 = 2048(↓8×) MLA: 512+64 = 576(↓28×) 上排 = Q heads,下排 = 缓存的 K/V;箭头表示「谁查谁」

三代注意力的 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、显存与互联

H100 芯片鸟瞰 HBM HBM SM … ×132 个 SM L2 Cache · 50 MB(全芯片共享) Memory Controller NVLink 900 GB/s PCIe 5.0 ~64 GB/s 一个 SM 的内部(放大) Warp Scheduler ×4(每拍挑就绪的 warp) CUDA Cores 向量/标量运算 Tensor Core ×4 小矩阵乘 MMA Register File · 256 KB(每线程私有变量) Shared Memory / L1 · 228 KB(块内协作) 数据离计算越近越快(近似量级): 寄存器 ≈ 数百 TB/s(零延迟) SharedMem ≈ 20+ TB/s(~30 周期) L2 ≈ 10 TB/s(~200 周期) HBM = 3.35 TB/s(~500 周期) · 一个 kernel = 一批线程块(block)撒到各 SM 上执行;块内线程按 32 个一组(warp)同步推进(SIMT) · FlashAttention 的全部意义:把中间结果关在 SharedMem/寄存器里,别让它碰 HBM(4.3 节) · 互联带宽逐级掉一个量级:HBM 3.35TB/s → NVLink 900GB/s → PCIe 64GB/s → 跨机 IB ~50GB/s(第 6 章选并行策略的依据)

执行模型三层楼: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 专治它。

GPUBF16 算力显存 / 带宽平衡点 (FLOPs/byte)
RTX 4090165 TFLOPS24 GB / 1.0 TB/s~165
A100 SXM312 TFLOPS80 GB / 2.0 TB/s~156
H100 SXM990 TFLOPS80 GB / 3.35 TB/s~295
B200~2250 TFLOPS192 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 模型——性能要么撞上斜线(带宽顶)、要么撞上平线(算力顶):

decode (batch=1) 强度 ≈ 1,被带宽压死 decode (batch=256) prefill / 大 batch 强度 > 平衡点,吃满算力 平衡点 ≈ 295 FLOPs/byte (H100) 操作强度 → 性能 带宽顶 3.35 TB/s 算力顶 990 TFLOPS

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

上手与使用:建立可量化的手感

建议时长 1~2 周 产出 一份自己的部署与压测笔记

这一章的目标是可量化的手感:跑通两种使用方式、把主要功能都摸一遍、做一次像样的压测。每个实验都记下「改了什么 → 吞吐 / 显存 / 延迟怎么变」,这份笔记在第 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:每个字段都在控制什么

字段作用值得知道的细节
temperaturelogits 除以 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-utilizationvLLM 可用显存比例(默认 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 fp8KV cache 用 FP8 存储,容量翻倍对照 1.2 计算器验证容量变化;长上下文任务上做精度对比
--no-enable-prefix-cachingV1 默认开启前缀缓存,此开关用于关闭对照同一 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

读结果需要四个词,整个学习过程都会围绕它们:

TTFT
Time To First Token

请求发出到第一个字回来。由排队 + prefill 决定,交互体验的关键。

ITL / TPOT
Inter-Token Latency

后续每个字之间的间隔,「打字速度」。由 decode 单步时间决定。

Throughput
系统总吞吐

每秒产出 token 数(区分 output tok/s 与 total tok/s)。服务方的成本视角。

Goodput
有效吞吐

满足延迟 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_percKV cache block 池使用率接近 100% 时会发生抢占(3.4 节)
vllm:prefix_cache_hit_rate前缀缓存命中率多轮对话业务应该很高;低了检查 prompt 结构
vllm:time_to_first_token_secondsTTFT 直方图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 的两大支柱

建议时长 2 周 产出 能给别人讲清 PagedAttention 与 continuous batching

朴素的推理服务浪费在两个地方:显存被碎片吃掉(装不下更大的 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 时按表寻址。

动画 · 连续预分配 vs PagedAttention

同一批请求先后到达和离开,左右是两种显存管理方式。留意左侧的两种浪费:斜纹格是预留未用(内部碎片);请求离开后的空洞凑不出连续段(外部碎片),最后一个请求会分配失败。右侧按 block 领用,几乎装满。

请求 A 请求 B 请求 C 请求 D 预留未用(浪费) 空闲

3.2.1页表长什么样

请求 A 的逻辑视角 block 0 block 1 block 2 token 0-15 token 16-31 写入中… Block table(页表) 逻辑 0 → 物理 7 逻辑 1 → 物理 1 逻辑 2 → 物理 4 GPU 物理 block 池 0 1 2 3 4 5 6 7 · 逻辑上连续的序列,物理上散落在池中任意位置(黄色 = 请求 A 的三个 block) · 生成新 token 写满 block 2 后,再从空闲池领下一块 —— 浪费最多只有「最后一块没写满」 · attention kernel 逐块寻址读取 K/V,这就是 "Paged" Attention 名字的由来

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。

动画 · 静态 batching vs continuous batching

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)。

一个 step 的 token 预算(max_num_batched_tokens = 2048) R1 · 长 prompt 的第 3 块(1400) R7 收尾(500) R2~R6 · decode 各 1 token 预算封顶 → 每步耗时稳定 → ITL 平稳;prefill 与 decode 混批 → 算力和带宽同时吃满

预算就是那把刀。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 从第一个未命中的块算起。

动画 · 三个请求共享 system prompt

请求 1 完整计算并缓存了 system prompt 的 3 个 block;请求 2、3 到达时逐块哈希命中(金色高亮),页表直接指向同一批物理块,只需计算各自的用户问题部分。

3.6.1哈希链:命中判定的精确语义

关键设计是 block 的哈希不是只看块内 token,而是前缀累积哈希:

hash(block_i) = hash( hash(block_i-1), 块内 16 个 token id, 额外键 )

父哈希的链式传递保证:两个块哈希相等 ⟺ 从序列开头到这个块的全部内容都相同——所以命中判定是严格的前缀匹配,第 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

推理加速技术:在物理极限内偷时间

建议时长 2 周 产出 能为一个具体场景给出加速技术选型

第 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 个,收获 3 个

起草者逐个猜出 4 个候选(灰色,很快很便宜);目标大模型一次前向并行验证 4 个位置:前 2 个接受(绿),第 3 个拒绝并当场修正(金色是大模型自己采样的 token),其后的草稿作废。

草稿 token 验证接受 验证拒绝 大模型修正 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主流格式各自的招

格式位宽核心思想适用
GPTQW4A16逐层用二阶信息(Hessian)做误差补偿的贪心量化,需校准数据老牌 4-bit,生态最广
AWQW4A16观察到 ~1% 的「显著权重通道」决定精度,按激活幅度缩放保护它们4-bit 首选,精度稳、校准轻
FP8W8A8E4M3 浮点格式,动态范围大不怕 outlier;Hopper 起硬件原生支持H100+ 上的吞吐默认选择,几乎无损
SmoothQuantW8A8 INT8把激活的 outlier 幅度「迁移」到权重上抹平,让两边都好量Ampere 等无 FP8 硬件的 8-bit 方案
NVFP4 / MXFP4W4A4微缩放块格式(每 16/32 个数共享一个缩放因子),Blackwell 硬件原生B 系列 GPU 的新前沿
bitsandbytes / GGUFW4~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

源码精读:跟着一个请求走完全程

建议时长 4~6 周 产出 不看代码画出请求生命周期图

读 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 都会被它卡一拍。

进程 0 · API SERVER(CPU 密集) FastAPI · OpenAI 兼容路由 AsyncLLM async_llm.py · 请求编排 Processor tokenize 多模态预处理 OutputProcessor detokenize 增量流式输出 进程 1 · ENGINECORE(GPU 忙循环) Scheduler core/sched/scheduler.py · token 预算分配 KVCacheManager + BlockPool 分页 · 前缀哈希 · 引用计数 Executor → Worker → ModelRunner worker/gpu_model_runner.py forward → sampler → 采样出的 token id ZeroMQ token id · 多卡 TP 时 Worker × N(每卡一个);API server 也可多副本横向扩展 —— 两侧独立伸缩

两侧只交换轻量的 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 停一次,观察括号里的关键数据结构:

1
HTTP 入口 entrypoints/openai/api_server.pyAPI 进程
FastAPI 路由解析 OpenAI 格式请求,套 chat template,调用 AsyncLLM 的 generate() 拿到一个异步流。
2
输入处理 v1/engine/processor.pyAPI 进程
tokenize、长度校验、多模态预处理,打包成 EngineCoreRequest(看这个 dataclass 的字段——它就是跨进程协议),经 ZeroMQ 发往引擎进程。
3
引擎忙循环 v1/engine/core.pyEngineCore 进程
EngineCore.run_busy_loop()——整个引擎的心跳:收新请求 → step() → 发结果,无限循环。从这里往下都是每个 step 发生一次的事。
4
调度决策 v1/core/sched/scheduler.pyEngineCore 进程
Scheduler.schedule() 产出 SchedulerOutput:本步每个请求算几个 token、block table 增量。5.4 节逐行精读。
5
显存记账 v1/core/kv_cache_manager.pyEngineCore 进程
调度器内联调用:get_computed_blocks() 查 prefix cache 命中,allocate_slots() 领新块。5.5 节精读。
6
模型执行 v1/worker/gpu_model_runner.pyGPU
execute_model():按调度结果准备扁平化输入与 attention 元数据(block table 在此喂给 kernel),跑 forward,Sampler 采样出 token id,返回 ModelRunnerOutput。
7
流式返回 v1/engine/output_processor.pyAPI 进程
token id 回到 API 进程,增量 detokenize、判断停止条件,SSE 推给客户端。请求结束时通知引擎释放 block(引用计数 −1,进入缓存池待复用)。
路径内容精读优先级
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 的一步都做了什么
  1. 同步 batch 状态:按 SchedulerOutput 增删 InputBatch 里的请求条目(persistent batch 的「增量修改」就发生在这里),把新 token id 拷进常驻张量。
  2. 构建 attention 元数据:每条请求的 seq_len、query 起止偏移、block table 行——打包成当前 backend 需要的 metadata 结构(v1/attention/backends/ 里每个 backend 一个 builder,这就是 4.3 的「适配层」)。
  3. 选择执行形态:batch 形状落在预录档位 → 重放 CUDA Graph;否则 eager(4.4)。
  4. forward:模型输出最后位置的 hidden,过 LM head 得 logits(TP 下 logits 是分片的,先 gather)。
  5. 采样:结构化输出 bitmask → 惩罚/温度/top-k/p → 采样。投机解码时这里换成 rejection sampler:并行判定每条请求的 k 个草稿接受几个(4.1),一次产出可变数量的 token。
  6. 回传: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

分布式与生产:从一张卡到一个集群

建议时长 长期 产出 一次真实的性能优化,或一个被合并的 PR

走到这里,你已经「读得懂、改得动」。本章把视野从单卡扩展到集群:先学会量化性能(不量化就没有优化),再理解四种并行各自的通信账,然后是 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=2 时一层 Transformer 的数据流(每卡算一半,两处 AllReduce 汇合) 输入 x (完整) QKV proj ↓列切 GPU0: head 0-15 GPU1: head 16-31 Attention 各卡算自己的 head KV cache 也切开 O proj ↓行切 部分和 AllReduce ① gate/up ↓列切 SwiGLU 各半 down ↓行切 部分和 AllRed. ② · 列切(Column)的输出天然分片、无需通信;行切(Row)的输出是部分和,必须 AllReduce —— 每层恰好两次 · 每次 AllReduce 通信量 ≈ batch_tokens × hidden_size × 2 bytes × 每层 2 次 × 层数 —— 这就是 TP 吃 NVLink 的原因
并行方式切什么通信模式什么时候用
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 给出了超大规模的工程实录)。

智能路由 Router 前缀感知 · 负载感知 PREFILL 池 · compute-bound · 保 TTFT vLLM 实例 P1 算力型 GPU 只做 prompt vLLM 实例 P2 独立扩缩容 DECODE 池 · memory-bound · 保 ITL vLLM 实例 D1 大显存/带宽卡 只做生成 vLLM 实例 D2 大 batch 吞吐 KV 传输 NIXL / RDMA / NVLink · 两池独立选卡型、独立扩缩容,TTFT 与 ITL 的 SLO 分别保障;代价是 KV 在网络上搬一次

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-LLMNVIDIA 官方,编译期极限优化,绑定 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★★★
Orcacontinuous batching 与 selective batching,OSDI 20223★★★
Sarathi-Servechunked prefill 与延迟-吞吐平衡3★★☆
Leviathan et al. / Chen et al.投机解码理论(拒绝采样保分布)4★★★
EAGLE / EAGLE-3特征空间起草,当前最强投机方案4★★☆
Medusa多解码头 + 树状验证4★☆☆
DeepSeek-V3MTP、MLA、大规模 EP 的集大成者1/4/6★★☆
FlashAttention / FA2 / FA3IO 感知 attention kernel 三部曲4★★☆
AWQ / GPTQ / SmoothQuant三种主流训练后量化方法4★★☆
DeepSeek-V2 (MLA)latent attention 的原始出处与吸收技巧1★☆☆
DistServe / SplitwisePD 分离的开山双璧6★★☆
MooncakeKimi 的 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 与文档为准。

⌂ 学习中心