仓库地址:https://github.com/hhk-png/cycle-agent
02 核心原理
本章目标:深入拆解 vLLM 高性能背后的每一个关键机制。每个概念都按 为什么(motivation)→ 怎么做(mechanism)→ 具体例子 展开。
1. Transformer 推理基础:prefill 与 decode
为什么
LLM 生成文本本质是自回归(autoregressive):一次只预测下一个 token,把新 token 拼回输入,再预测下一个。这个过程天然分成了两个计算特征截然不同的阶段。
怎么做
- Prefill(预填充):把整个输入 prompt 一次性喂给模型,并行计算出每个位置的注意力,并生成第一个输出 token。此时输入很长,是**计算密集(compute-bound)**的,能用 GPU 矩阵乘吃满算力。
- Decode(解码):每步只输入"上一个 token",串行生成下一个 token,直到遇到结束符或达到最大长度。此时每个 token 的矩阵运算量很小,而权重 + KV cache 要从显存/内存搬到计算单元,所以是**内存带宽密集(memory-bandwidth-bound)**的。
例子
一个 8B 参数模型,权重 fp16 约 16GB。decode 每生成 1 个 token 至少要读完这 16GB 权重(假设 batch=1),即使计算量很小。A100 40GB 显存带宽约 1.5 TB/s,那么单 token 延迟下限 ≈ 16GB / 1.5TB/s ≈ 10ms 。这解释了为什么 decode 慢------瓶颈在"搬数据",不在"算"。
关键洞察:既然 decode 受内存带宽限制,那么 batch 越大,每个 token 摊到的带宽成本越低,吞吐量(tokens/s)越高。这就是 continuous batching 的理论基础。
1.1 decode 为什么是带宽受限:定量推导
把上面的 16GB 例子做完整:设一个 8B 模型,fp16 权重 ≈ 16GB ;单卡显存带宽 B(A100 40GB ≈ 1.5 TB/s,H100 ≈ 3.35 TB/s)。decode 每步都要把整套权重从显存搬到计算单元------这是每步绕不开的"固定成本"。
每步必须搬运 ≈ 16GB(权重)+ 本批新增的 KV
8B 模型每 token 的 KV 很小:2 × 32层 × 8 KV头 × 128 dim × 2B = 128 KB
(相比 16GB 权重,可忽略,见 §10)
batch = 1:每步搬 16GB,只产出 1 个 token
单 token 延迟 ≈ 16GB / 1.5TB/s ≈ 10.7ms
吞吐 ≈ 1 / 10.7ms ≈ 93 tokens/s
batch = 32:每步仍搬 16GB 权重(KV 增量 32×128KB ≈ 4MB,可忽略),产出 32 个 token
单步耗时 ≈ 16GB / 1.5TB/s ≈ 10.7ms
吞吐 ≈ 32 / 10.7ms ≈ 3000 tokens/s ← 提高约 32 倍
每个 token 摊到的带宽 = 16GB / 32 = 0.5GB → 平均每 token 延迟 ≈ 0.33ms
关键结论:
- 带宽是每步的"固定成本":batch 再大,权重也要完整读一遍;
- 吞吐 ≈ batch × (带宽 / 权重大小):batch 越大,摊销越薄,吞吐近似线性上升;
- 天花板 :这个线性增长不会无限持续------batch 大到一定程度后,要么算力 成为瓶颈(矩阵乘已满),要么 KV 显存不够(第 17 章附录 B 的显存墙)。
这就是为什么 vLLM 拼命增大有效 batch(continuous batching) 、减小每 token 搬运量(量化、KV 量化) 、以及一次多产 token(投机解码)------三招都是同一个公式在起作用。
2. KV Cache
为什么
注意力计算需要 query、key、value 三者。生成第 t 个 token 时,需要与前面所有位置的 K、V 做注意力。若每步都重算前面所有 token 的 K、V,成本随序列长度平方级 增长,完全不可行。所以要把每个位置算出的 K 和 V 缓存起来,decode 时只算新 token 的 K、V 并追加到缓存。
怎么做
KV cache 是张量,形状大致为:
(num_layers, num_kv_heads, seq_len, head_dim)
num_layers:模型层数(每层一套 K/V);num_kv_heads:KV 头数(MQA/GQA 下小于 Q 头数,8B 模型常为 8);seq_len:序列长度(动态增长);head_dim:每个头的维度(Llama 系列常为 128)。
显存膨胀公式:每个 token 的 KV 占用为
每token字节数 = 2(K 和 V)× num_layers × num_kv_heads × head_dim × 每个元素字节数
= 2 × num_layers × d_model × bytes_per_element (当 num_kv_heads×head_dim = d_model)
fp16 下,Llama-70B(80 层,d_model=8192)每个 token 约 2.5 MB;一个 2048-token 的序列就要约 5 GB 显存。批量并发时,KV cache 往往是显存的最大消耗者,甚至超过模型权重。
KV cache 量化 :上面的
bytes_per_element默认 fp16=2 字节。vLLM 支持--kv-cache-dtype fp8(E4M3/E5M2)把 KV 压到 1 字节,每个 token 的 KV 显存直接减半,同样显存下并发近似翻倍------这是"省显存"与"省带宽"(decode 带宽受限)双收的关键旋钮,机制详见第 08 章 §2 与第 09 章 §9.3.9。
例子
text
模型:Llama-70B,L=80 层,hidden=8192,fp16
每 token:2 × 80 × 8192 × 2B = 2,621,440 B ≈ 2.5 MB
一条 4096-token 的对话:≈ 10 GB
并发 64 条这样的对话:≈ 640 GB >> 单卡 80GB
这就是必须精细管理 KV cache 的原因,也直接引出 PagedAttention。
3. PagedAttention(分页注意力)
为什么
传统 serving 为每个请求预留连续的、按最大序列长度分配的内存块。序列长度动态变化导致:
- 预留空间用不满 → 内部碎片;
- 不同长度请求交错 → 外部碎片,即使总量够也找不到连续块。
论文数据显示,这种方案 KV 利用率只有 20%--40%。
怎么做
借鉴操作系统虚拟内存分页:
- 把 KV cache 逻辑上切成固定大小的块(block) ,默认 block_size = 16(16 个 token 一块);
- 每个块在物理显存里不要求连续 ,通过 block table(块表) 维护"逻辑块 → 物理块"的映射;
- 分配/释放按块进行,碎片只可能出现在最后一个块,浪费被压到最小;
- 块可以共享------多个序列指向同一物理块(前缀复用、并行采样),这是传统连续分配做不到的。
代码上这就是 vllm/attention/ 中 PagedAttention 内核做的事,调度层(vllm/engine/llm_engine.py、vllm/core/scheduler.py)负责块表的增删查改。
版本锚点(V0 vs V1) :上述
vllm/engine/llm_engine.py、vllm/core/scheduler.py是 V0 引擎 的源码路径;vLLM 1.0 起 V0 已被移除,V1 引擎把这些职责迁到vllm/v1/下(vllm/v1/core/sched/scheduler.py、vllm/v1/engine/等)。本文的源码坐标默认是"经典 V0 路径 + V1 等价物对照"------机制本身没有变,变的只是模块归属与工程实现(详见第 03 章 §1.1 与第 12 章 §10.1)。
例子(OS 分页类比)
传统方式:请求 A 申请 100 页的连续内存,只用 20 页 → 80 页空转(内部碎片)
PagedAttention:请求 A 需要 20 个 token → 分配 2 个 block(16+4)
物理块散落各处,block table 记录:
logical_block[0] -> physical_block[7]
logical_block[1] -> physical_block[3]
释放时只释放已用块,其余块立刻可被请求 B 复用
收益 :KV 利用率从 ~30% 提升到接近 90%+,同样显存能服务数倍并发,吞吐随之提升。
3.1 块表的完整数值算例
把上面的 OS 类比换成一个能动手验算 的完整例子。设 block_size = 16(vLLM 默认),一条序列已经长到 50 个 token:
text
需要的逻辑块数 = ceil(50 / 16) = 4
逻辑块 对应 token 范围 物理块 说明
─────────────────────────────────────────────────────
block 0 tokens 0--15 phys[7] 满块(16/16)
block 1 tokens 16--31 phys[3] 满块(16/16)
block 2 tokens 32--47 phys[19] 满块(16/16)
block 3 tokens 48--49 phys[5] 残块(2/16)★
块表(block table)就是这条序列的 [7, 3, 19, 5]------它把逻辑位置 (第几个 16-token 段)翻译成物理块 id 。调度器只认这张表,attention 内核用 logical_pos // 16 找块、logical_pos % 16 找块内偏移。
内部碎片(internal fragmentation) :只有最后一个块填不满------50 - 3×16 = 2 个槽位被用掉,剩余 14 个槽位空置。
text
碎片 = 1 个残块 × (16 - 2) = 14 个空槽
浪费比例 = 14 / 64 ≈ 21.9%
内存利用率:
text
利用率 = 有用的槽位 / 已分配的槽位
= 50 / (4 × 16)
= 50 / 64
≈ 78.1%
(注意不是 50/48------50 个 token 至少需要 4 个块、即 64 个槽位;"3 个块"只装得下 48 个 token。)
为什么说分页"消灭了碎片"? 对比 naive 方案:naive 按 max_len 连续预留,假设 max_len = 2048:
text
naive 利用率 = 50 / 2048 ≈ 2.4%
分页 利用率 = 50 / 64 ≈ 78%
碎片被限制在最后一个块(最多 15/16 的空槽),其余块永远满装。50 个 token 到底,分页只浪费 14 个槽位,而 naive 浪费 1998 个槽位。这就是"同样显存能服务数倍并发"的数学来源。
补充:上面的
num_kv_heads决定一个块里每个 slot 的字节数,进而决定一块能"装下多少上下文"------GQA/MLA 这类架构变体正是从这里影响 KV 显存的。想深入算每一块的具体字节数,见第 19 章《模型架构基础》与第 17 章附录 B。
4. Continuous Batching(连续批处理)
为什么
静态批处理(static batching)下,一批请求要"同生共死":要么整批都结束,要么新请求要等当前整批跑完才能进。批内请求长短不一,短的跑完了还得等长的 → GPU 出现空闲,吞吐低、尾延迟高。
怎么做
Continuous batching(又称 iteration-level scheduling,迭代级调度):在**每个解码步骤(iteration)**都检查批次:
- 某个请求生成了结束符 / 达到 max_tokens → 立即移出批次;
- 新到达的请求 → 立即做 prefill 并插入当前批次继续 decode;
- 因此批次是一个动态集合,每步都在变化,GPU 始终满载。
在 vLLM 里,这个调度逻辑由 Scheduler 执行,每步通过 schedule() 决定本步要跑哪些请求、分配哪些 KV 块。
例子
step 1: batch = {A} # A 是 prefill
step 2: batch = {A, B} # B 到达,插入
step 3: batch = {A, B} # A 生成了 <eos>,即将离开
step 4: batch = {B, C} # A 离开,C 到达
step 5: batch = {B, C, D} # D 到达
每步最多一个新请求的 prefill 开销与现有 decode 请求交错,GPU 利用率持续很高。
对比:request-level scheduling(请求级)以"整个请求"为单位调度,对应静态批处理;vLLM 是 iteration-level,单位是"一个 token 步"。
4.1 多步调度(Multi-Step Scheduling):减少 CPU 开销的"批处理升级"
连续批处理里,每一步(iteration)都要做一次 CPU 端调度 + 一次 GPU kernel 启动 。当 decode 阶段每个 token 的 GPU 计算极短(几微秒到几十微秒)时,"CPU 决定跑什么 → 启动 kernel"的固定开销会占掉一大部分时间------这就是调度墙(scheduling wall)。
Multi-step scheduling(--num-scheduler-steps N) 让调度器一次决定未来 N 步 :第 1 步跑完,第 2 步直接复用同一个批次和 kernel 启动序列,中间不再回 CPU 做 schedule()。
- 收益 :CPU 调度次数与 kernel 启动次数降到原来的
1/N,GPU 利用率与吞吐明显提升;V1 引擎里它是默认开启的核心吞吐机制之一。 - 代价 :批次在 N 步内不再变动 ------新请求要等这批 N 步跑完才能插入;N 越大,批次的"新鲜度"越差(吞吐换响应性)。这正是 V1 调度器把
max_num_batched_tokens做成动态预算的原因之一:既想吃多步的吞吐红利,又想尽量快地吸收新请求。 - 与正确性的关系 :多步调度是乐观调度 ------它会在一步里给序列"预分配" N 个 token 的输出预算,若某序列在中间步提前触发 stop,这 N 步里的空 slot 就浪费了(不会产出错误的 token,只是少算)。这也是 §7.4"调度不影响正确性"里唯一的例外形态:浪费而非错误。
多步调度与 CUDA Graphs(第 08 章 §3)是"一对好搭档":多步把"调度次数"压下来,CUDA Graphs 把"kernel 启动开销"压下来,两者叠加后单 token 的 CPU 开销可以忽略不计。
5. Chunked Prefill(分块预填充)
为什么
decode 是内存密集、prefill 是计算密集。当一个很长的 prompt(比如 10k token)进来时,它的 prefill 计算量大,会占用 GPU 数百毫秒,期间所有正在 decode 的请求都被阻塞,尾延迟暴涨。长 prompt 是 serving 场景最常见的延迟杀手。
怎么做
Chunked prefill :把一个长 prompt 的 prefill 拆成多个 chunk(例如每个 chunk 512 token),在调度循环里与 decode 请求交错执行:跑一小段 prefill,切出去跑几轮 decode,再回来继续 prefill。这样:
- 长 prompt 不会一次性占满 GPU;
- decode 请求的延迟不会被单次 prefill 拖垮;
- 代价:prefill 整体完成时间略有增加(因为被切碎)。
版本差异 :V0 中 chunked prefill 默认关闭 ,用
--enable-chunked-prefill显式开启;V1 中恒开启 (该 flag 在 V1 已移除),--max-num-batched-tokens也变为动态预算。教程正文以 V1 行为为主线,V0 只作对照。
例子
不启用:prefill(10000 tokens) 一次性占 GPU 500ms,期间所有 decode 冻结
启用后(chunk=512):
step 1: prefill chunk 0 (512 tok)
step 2: decode A, B
step 3: decode A, B
step 4: prefill chunk 1 (512 tok)
...
prefill 总时长略长,但 decode 每次最多等 1 个 chunk 的时间
6. Prefix Caching(前缀缓存)
为什么
真实场景大量请求共享公共前缀:固定的 system prompt、RAG 注入的相同文档、多轮对话的历史上下文。这些前缀的 KV 每来一个新请求都重算一遍,既浪费算力又占显存。既然前缀相同,生成的 KV 也相同,为什么不算第二次?
怎么做
Prefix caching:把每个 KV block 的哈希(由内容 + 位置计算)记录在**哈希表(hash table)**中。新请求做 prefill 时,逐块查哈希表:
- 命中 → 直接**共享(引用)**已有的物理块,跳过计算,也跳过新分配;
- 未命中 → 计算并从该位置重新开始匹配。
块级共享正是 PagedAttention 的块表能轻松支持的。用法:
bash
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-3.1-8B-Instruct \
--enable-prefix-caching
在代码里由 PrefixCachingBlockAllocator(vllm/core/block/prefix_caching_block.py)管理。
例子
text
请求1: "你是一个中文助手。用户说:帮我总结文档A" → 前缀 KV 已缓存
请求2: "你是一个中文助手。用户说:帮我总结文档B"
→ "你是一个中文助手。" 的 KV 块直接命中缓存,只重算后半段
命中部分 prefill 时间 ≈ 0
对 RAG、agent、多轮对话这类场景,命中率可以到 50%+,吞吐提升非常可观。
7. Preemption(抢占):KV 满时怎么办
为什么
并发请求多、显存有限时,KV cache 会耗尽 。此时来了新请求,要么拒绝,要么想办法腾出空间。vLLM 的策略是抢占:暂停某些请求,腾出 KV 块给新请求。
怎么做
vLLM 有两种抢占模式:
- RECOMPUTE(重计算,默认) :把被抢占请求占用的 KV 块释放 ,等以后有空间了重新 prefill 该请求(从保存的 prompt 从头重算)。代价是重算的算力开销,但不需要交换到 CPU。
- SWAP(换出) :把被抢占请求的 KV 块拷贝到 CPU 内存,空间够了再拷回 GPU。代价是 PCIe 拷贝带宽,适合"显存小但 CPU 内存充足"的机器。
选择依据:重计算吃 GPU 算力,换出吃 PCIe 带宽;一般显存紧张 + 带宽好 → SWAP,否则 → RECOMPUTE。调度器在 vllm/core/scheduler.py 中决定抢占顺序(一般按最老或优先级最低的请求先被抢占)。
例子
text
显存耗尽,新请求 D 到达
调度器选择 RECOMPUTE:把请求 B 的 KV 块释放,D 进入批次
D 结束后,调度器把 B 的 prompt 重新 prefill,B 继续 decode
用户感知:B 的延迟变长(因为被抢占了),但不会出错
8. Speculative Decoding(投机解码)
为什么
decode 是串行的、内存带宽密集的,每步只产出一个 token。能不能一次多猜几个 token,让目标大模型一次验证多个,从而摊薄带宽成本?投机解码就是干这个的。
怎么做
- 用一个更小更快的 draft model 自回归地快速生成 K 个候选 token(K 通常 4--8);
- 目标大模型对这 K 个 token 并行做一次 forward(只算一遍,像 prefill),得到每位的概率;
- 从第 0 位开始逐位比对:draft 预测对了就接受,第一个不一致的位置之后全部拒绝,并用目标模型在该位置的采样结果作为真实 token(称为 bonus token);
- 因为并行验证 K 个 token 的算力成本 ≈ 验证 1 个,所以只要 draft 命中率高,解码就加速。
关键性质:无损(lossless)------最终输出分布与不用投机解码完全一致,只是更快。投机在 GPU 空闲(如 batch 小、带宽有余)时效果最好。
8.1 为什么无损:修正拒绝采样(modified rejection sampling)
"输出分布不变"不是拍脑袋的断言,而是由修正拒绝采样 这一数学机制保证的。设目标模型(大)对某个位置的分布为 p,draft 模型(小)的分布为 q,它们都从同一个候选 token 集合里选。验证算法逐位进行:
对 draft 提出的候选 token x(其 draft 概率为 q(x)):
计算目标模型对 x 的概率 p(x)
接受规则:
以概率 min(1, p(x) / q(x)) 接受 x ------ 接受后直接作为本步输出
否则(以概率 1 − min(1, p/q))拒绝:
拒绝后,按 "p 扣除已接受部分" 的归一化分布
max(0, p(x') − q(x')) / Σ_z max(0, p(z) − q(z))
重新采样一个 token x'(称为 bonus token)
为什么这样能保证无损?把"接受 draft token x"的概率算出来:
P(输出 = x) = 接受路径:q(x) × min(1, p(x)/q(x))
= 拒绝路径:在位置 x 被拒后、bonus 恰好采到 x
逐项展开可得 P(输出 = x) = p(x)------也就是说,无论 draft 模型猜得有多差,最终每个 token 的边际分布都和直接采样目标模型完全一致(证明细节见投机解码原论文)。这正是"投机解码只改速度、不改分布"的数学根源:
- 接受率高 → 每个目标前向产出多个 token → 加速;
- 接受率趋近 0 → 退化为"每步重采样 bonus token",等价于普通采样,不会更差。
工程要点:验证时必须用同一套种子/随机源 做采样,且 draft 与 target 的 logits 都要完整(不能只给 top-1),否则修正采样无法正确计算
p(x)/q(x)。mini-vLLM 的minivllm/speculative.py演示了同一套 draft→verify→accept/reject 流程(接受率输出见第 06 章 §6.12)。
例子
text
draft 模型猜: "I", "love", "vLLM", "because" (K=4)
目标模型并行验证这 4 个位置:
位置0 接受 "I",位置1 接受 "love",位置2 拒绝(目标要的是 "LLM")
→ 实际接受 2 个 token + 1 个 bonus token(位置2的目标token)= 本步产出 3 个 token
而只做了 1 次大模型 forward → 理论加速 ~3x
相关家族 :vLLM 原生支持多种 draft 方法(--speculative-config),除标准 draft 模型外还有 EAGLE (用特征层做自回归预测,效果更强)和 Medusa(在模型头并联多个解码头)等。SGLang 也有类似支持,但 vLLM 生态最全。
9. 量化(Quantization)
为什么
模型权重是 fp16(2 字节/参数),8B 模型就 16GB,70B 模型 140GB。显存放不下权重,就放不下 KV cache。且 decode 受带宽限制,权重越小,每 token 搬的字节越少,越快。量化就是把权重(以及激活)用更少的 bit 表示。
怎么做
- Weight-only 量化(只量化权重) :典型如 GPTQ 、AWQ ,把权重降到 4bit(配合 group size,如 128 个权重共享一个 scale),激活保持 fp16。8B 模型权重从 16GB → ~4GB,几乎无损(用校准数据找到让输出误差最小的量化方案)。
- Weight + Activation 量化(权重和激活都量化) :典型如 FP8 W8A8,权重和激活都是 8bit 浮点。激活量化的好处是计算也能用低精度加速,但需要硬件支持(H100 等)且校准要求更高。
vLLM 用法(GPTQ/AWQ 模型会自动识别;也可显式指定):
bash
# 加载已量化的模型,或指定量化方式
python -m vllm.entrypoints.openai.api_server \
--model Qwen/Qwen2.5-7B-Instruct-AWQ \
--quantization awq
注:早期教程常用
TheBloke/*仓库的量化模型,但该组织已于 2024 年底下架全部仓库,示例统一改用 Qwen 官方量化版或casperhansen/*等仍在维护的镜像。vLLM 实际执行 int4 GEMM 依赖 Marlin 系 kernel (gptq_marlin/awq_marlin,group size 需为 128 等),只有格式匹配才走快速内核,否则回退通用 kernel 明显变慢------落地坑详见第 09 章 §9.7。
例子
text
Llama-2-7B,fp16:14GB 权重
→ AWQ/GPTQ 4bit:~4GB 权重(-70%)
→ 省下的显存给 KV cache,可并发请求数大幅上升
→ decode 每 token 搬运字节减少 → 吞吐提升
FP8 W8A8(如 Qwen2.5 系列官方 FP8 权重):8GB 权重,精度损失很小
取舍:量化越低,显存越省、越快,但精度损失越大。4bit 是目前"省显存 + 保质量"的甜点,FP8 是"几乎无损 + 显存减半"的现代选择。
10. 注意力架构变体:KV cache 大小的"结构性"决定因素
前面算 KV cache 时反复用到 num_kv_heads。这个数字不是模型作者随手定的------它来自注意力头架构的选择,是模型架构层面决定 KV cache 大小的第一因素:
| 变体 | K/V 头数 | 相对 MHA 的 KV | 代表模型 |
|---|---|---|---|
| MHA | n_head(每头独立 K/V) |
1x | GPT-2、LLaMA-1 |
| MQA | 1(所有 Q 头共享) | 1/n_head |
Falcon、PaLM |
| GQA | g(分组共享,g < n_head) |
1/g |
LLaMA-2/3、Mistral、Qwen2 |
| MLA | 低秩潜在向量 | ~1/10 或更小 |
DeepSeek-V2/V3 |
- GQA 是当前开源模型的主流 :LLaMA-3.1-8B 用
n_head=32, num_kv_heads=8,KV cache 只有 MHA 的 1/4------这就是第 02 章算例里128 KB/token(而不是 512 KB)的原因。 - MLA 更进一步:DeepSeek 把 K/V 压缩进低秩潜在向量,推理时再展开,KV cache 相比 MHA 可缩到 1/10 以下,是 128K 超长上下文的现实基础。
- 对 vLLM 的影响 :
num_kv_heads直接进CacheConfig的显存预算公式;GQA/MLA 还需要对应的 attention 内核(MLA 有专用mla后端)。
一句话:GQA/MLA 不是性能噱头,而是"单卡能不能服务长上下文 + 大并发"的物理前提。 完整讲透见第 19 章《模型架构基础》。
11. PagedAttention 与 FlashAttention 的关系
FlashAttention 和 PagedAttention 常被放在一起说,容易混淆。它们的本质区别一句话就能讲清:
- FlashAttention 解决"快" :用分块(tiling)算法 + 在线 softmax(滚动
m/l),避免把完整QK^T写回显存,把注意力做到显存高效、IO 友好。 - PagedAttention 解决"碎":把 KV cache 从"连续大张量"改成"按块分配 + 块表间接寻址",解决内存碎片与浪费。
它们不冲突,而是叠加:
PagedAttention = FlashAttention 的分块在线 softmax 算法
+ block table 间接寻址(把"连续 KV 行"换成"散落的物理块")
vLLM 里两者是可选的 attention 后端 (VLLM_ATTENTION_BACKEND=FLASH_ATTN / FLASHINFER / TRITON_ATTN ...),要分清"算法"与"内存布局"两层:
- prefill 阶段 :KV 是按序列连续排布的 varlen 张量,可走 flash-attn 库 (
FLASH_ATTN后端)的 varlen 路径,把长 prompt 的注意力做得 IO 高效; - decode 阶段 :每个新 token 的 KV 都必须写进分页的 block 布局 (否则下一轮读不到),所以 decode 注意力一律走 PagedAttention 内核(块表间接寻址 + 在线 softmax),不因序列数量而改变;
FLASH_ATTN只是"后端名",不代表 decode 跳过分页------它内部对 decode 仍用分页布局的注意力内核。
也就是说:"快"(FlashAttention 的 tiling/在线 softmax)与"碎"(PagedAttention 的块表寻址)在 vLLM 里始终叠加使用,只是 prefill 偏重前者、decode 偏重后者。
与 mini-vLLM 的对照 :mini 版的
attention.py用gather_kv(按块表把散落 KV 抓成连续数组)+ 因果注意力两步复刻了 PagedAttention 的数值语义,只是把"抓取"和"计算"分离了------真实 vLLM 把它们融合进一个 Triton/CUDA kernel(详见第 08 章 §1 与 §9)。
12. 一张图串起所有机制
text
请求到达
│
├─ 查 Prefix Cache(哈希表):命中 → 复用 KV 块,跳过计算
│
├─ Prefill(可被 Chunked 切块):算 KV → 写入 PagedAttention 的物理块
│
├─ Continuous Batching 调度:每步动态增删请求;KV 不足 → Preemption(RECOMPUTE/SWAP)
│
├─ Decode:内存带宽密集;配合 Speculative Decoding 一次多产 token
│
└─ 权重/KV 都经 Quantization 压缩,显存与带宽双省
所有机制围绕同一个目标:让 GPU 在单位时间内产出更多有效 token。
12.1 每个机制对应的 vLLM flag 与 mini-vLLM 文件
把本章七个机制 + 注意力架构变体,一张表对应到 真实 vLLM 的启动参数 / 源码位置 与 mini-vLLM 的可读文件(方便第 06 章逐行对照):
表中未标注 V1 的源码路径均为 V0 经典路径 (vLLM 1.0 起已被
vllm/v1/取代,但机制语义不变);标注 V1 的行是 V1 引擎自己的实现。
| 机制 | 真实 vLLM flag / 配置 | 真实 vLLM 关键源码 | mini-vLLM 文件 |
|---|---|---|---|
| PagedAttention | --block-size(默认 16) |
vllm/attention/ops/paged_attn.py、vllm/worker/model_runner.py(slot 映射) |
attention.py、kv_cache.py |
| Continuous batching | --max-num-seqs、--max-num-batched-tokens |
vllm/core/scheduler.py(schedule()) |
scheduler.py |
| Multi-step scheduling | --num-scheduler-steps(V1 默认开启) |
vllm/v1/core/sched/scheduler.py、MultiStepOutputProcessor |
暂无(见附录 C 练习 8) |
| Chunked prefill | --enable-chunked-prefill(V0);V1 恒开启 |
vllm/core/scheduler.py、vllm/worker/model_runner.py |
scheduler.py |
| Prefix caching | --enable-prefix-caching |
V0:vllm/core/block/prefix_caching_block.py;V1:vllm/v1/core/kv_cache_manager.py |
kv_cache.py(PrefixCache) |
| Preemption | --preemption-mode {recompute,swap} |
vllm/core/scheduler.py(抢占逻辑) |
scheduler.py(仅 recompute) |
| Speculative decoding | --speculative-model、--speculative-config |
vllm/spec_decode/(draft/verify 主循环) |
speculative.py |
| Quantization | --quantization {gptq,awq,fp8,...} |
vllm/model_executor/layers/quantization/ |
quantize.py |
| 注意力架构变体(MHA/GQA/MLA) | 读取 config.json 的 num_key_value_heads |
vllm/attention/(GQA 复用 paged_attn、MLA 有专用 mla 后端) |
model.py(读 n_head/head_dim) |
阅读指引:第 06 章会带着你逐行读右侧的 mini-vLLM 文件,并在每个文件顶部标注它对应的真实 vLLM 路径(中间一列)。GQA/MLA 这类架构变体决定
num_kv_heads、进而决定 KV cache 大小与所需内核,完整展开见第 19 章《模型架构基础》。
13. 小结
| 机制 | 解决的问题 | 一句话原理 |
|---|---|---|
| PagedAttention | KV 内存碎片/浪费 | KV 切 16-token 块 + 块表映射,可共享 |
| Continuous batching | 静态批处理 GPU 空闲 | 每步动态增删请求 |
| Chunked prefill | 长 prompt 拖垮 decode | prefill 切块与 decode 交错 |
| Prefix caching | 公共前缀重复计算 | 按块哈希复用 KV |
| Preemption | KV 耗尽 | RECOMPUTE / SWAP 抢占腾位 |
| Speculative decoding | decode 串行慢 | 小模型猜 + 大模型并行验证,无损 |
| Quantization | 显存/带宽不足 | 权重/激活降到 4bit/8bit |
下一章《03-架构设计.md》将进入代码地图,看这些机制如何落到 vllm/engine/llm_engine.py、vllm/core/scheduler.py 和 PagedAttention 内核里。