LECTURE 10

推理(Inference)

训练是一次性成本,推理是每天要付的账单。这一讲把「生成一个 token」拆到算术强度、显存带宽和 KV cache 的每一个字节上,然后系统性地给出五类加速手段:改架构、量化、剪枝蒸馏、投机采样、动态批处理与分页。

讲师:Percy Liang 日期:2026-04-29 原始材料:lecture_10.py

0. 本讲导读

到目前为止,这门课的所有算账都是围绕训练展开的:FLOPs 预算 $6ND$、MFU、激活显存、数据并行与模型并行、缩放定律。训练的世界有一个非常舒服的性质——所有 token 都已经在那里了。你可以把一个 batch 的所有位置一次性喂进 Transformer,每一层都是又大又胖的矩阵乘法,GPU 的 Tensor Core 吃得饱饱的,算术强度轻松上千。

推理把这个性质彻底打碎了。生成第 $t+1$ 个 token 必须等第 $t$ 个 token 出来,序列维度上的并行性消失了。于是矩阵乘法退化成矩阵-向量乘法,GPU 从「算得慢」变成「等数据」——每生成一个 token,都要把整个模型的参数从 HBM 里搬一遍,而这些参数只被用了一次。H100 有 989 TFLOP/s 的 BF16 算力,但只有 3.35 TB/s 的显存带宽,这个比例决定了:朴素的自回归解码会把 99% 以上的算力浪费掉。

推理系统示意图:请求进入调度器,经过 prefill 与 decode 两个阶段,KV cache 驻留显存
一次推理请求的生命周期:prompt 先被 prefill 成 KV cache(并行、compute-bound),随后逐 token 走 decode 循环(串行、memory-bound)。整讲的所有优化,本质上都在攻击右边这个循环。

这一讲的组织方式很清晰,Percy 把它分成四段:

  1. 理解推理这个 workload——用算术强度把 prefill 和 decode 的差别算清楚,并推导延迟/吞吐的理论上限。
  2. 抄近路(有损)——改小 KV cache(GQA / MLA / CLA / 局部注意力)、量化、剪枝与蒸馏。这些会动到模型本身,必须验证精度不掉。
  3. 抄近路但复查(无损)——投机采样(speculative sampling),用一个便宜模型猜、用目标模型验,数学上保证输出分布与目标模型严格一致。
  4. 应付动态负载——连续批处理(continuous batching)与 PagedAttention,把操作系统里的调度和分页搬进推理引擎。
核心结论
  • Prefill 是 compute-bound,decode 是 memory-bound。MLP 层的算术强度是 $BT$,注意力层是 $\frac{ST}{S+T}$。代入 $T=S$(prefill)得 $S/2$,代入 $T=1$(decode)得 $<1$。
  • H100 的加速器强度(算力/带宽)约为 295。算术强度低于它就是在浪费算力。
  • 增大 batch 只能救 MLP,救不了注意力——因为每条序列有自己的 KV cache,注意力的算术强度里根本不含 $B$。
  • 延迟 $\approx \frac{\text{参数字节数} + B\cdot\text{单序列 KV cache 字节数}}{\text{显存带宽}}$;吞吐 $= B/\text{延迟}$。batch 越大,延迟越差、吞吐越好,这是推理系统最核心的一组取舍。
  • 因此几乎所有架构级推理优化(GQA、MQA、MLA、CLA、滑动窗口、DeepSeek 的稀疏注意力)都在做同一件事:把 KV cache 变小,从而在同样显存里塞下更大的 batch。
  • 投机采样是唯一数学上无损的加速:它利用了「检查比生成便宜」这个不对称性。

1. 推理为什么越来越重要

推理出现在三个地方

很多人下意识把推理理解成「聊天机器人回答问题」,但 Percy 一上来就把范围拉开:

  • 实际使用:聊天机器人、代码补全、agent、批量数据处理。
  • 模型评测:你要在指令遵循、数学、代码这些 benchmark 上评估一个模型,本质就是跑成千上万次生成。研究迭代速度直接被推理速度卡住。
  • 强化学习:RL 训练循环里,每一步都要采样大量 rollout,再用打分函数评价。现代 RLHF / RLVR 训练里,推理(rollout)常常占了整个 pipeline 一半以上的时间。也就是说,即使你只关心训练,你也逃不掉推理。

为什么「效率」在推理上比在训练上更要命

逻辑非常简单:训练是一次性成本,推理是重复无数次的成本。

Percy 给了一组对照数字,值得记住:

量规模说明
OpenAI 每天处理的 token约 8.6 T(8.6 万亿)消费级 AI token 消耗统计
DeepSeek-V4 的训练数据量32 T tokenDeepSeek-V4 技术报告

换句话说,一个头部服务商每四天处理的 token 量,就相当于把一个前沿模型的全部训练语料再过一遍。虽然推理的每 token FLOPs(约 $2N$)只有训练(约 $6N$)的三分之一,但这个「每天」是无限重复的。

直觉

Percy 强调了一个正在发生的转变。聊天机器人时代,绝大部分生成的 token 是给人看的,而人的阅读速度大概只有每秒几十个 token——人是瓶颈,所以你只要生成得比人读得快一点就够了。Agent 时代完全不同:query → 内部思维链/工具调用轨迹 → 最终输出给人。中间那段 trace 没有人看,长度可以无限增长。而「生成的 token 数 = 花掉的算力」,所以 test-time compute(测试时计算)这条 scaling 路线,本质上就是把推理成本当作可以主动加码的旋钮。这就是为什么推理效率突然从工程细节变成了战略问题。

谁在做推理

推理已经是一个独立的产业层。Percy 把玩家分成两类:

  • 服务闭源模型的提供商:OpenAI、Anthropic、Google 等——模型和推理栈都是自己的。
  • 服务开源权重模型的提供商:Together、Fireworks、Baseten、DeepInfra、Groq、Cerebras 等。这些公司卖的就是推理效率本身——同样的 Llama 权重,谁的 token 更便宜、更快,谁就赢。Groq 和 Cerebras 甚至为此做了专用硬件(把权重放进片上 SRAM,绕开 HBM 带宽墙)。

开源推理引擎方面,课上点名了四个:

项目出处特点 / 适用场景
vLLMUC BerkeleyPagedAttention 的发源地,生态最广,默认首选
SGLangUC BerkeleyRadixAttention(前缀树式 KV 复用),擅长 agentic / 多轮 / 结构化输出
TensorRT-LLMNVIDIA对 NVIDIA GPU 做到极致的 kernel 优化,部署重、性能强
llama.cpp社区纯 C++,支持 CPU 推理,本地跑(GGUF 量化生态)

「快」到底指什么

在开始优化之前必须先定义指标,否则会优化错东西。课上给了三个:

  • 首 token 时延(Time-To-First-Token, TTFT):用户按下回车后,等多久才看到第一个字。对交互式应用(聊天、补全)是体感的第一决定因素。TTFT 基本上就是 prefill 时间。
  • 延迟(latency,秒/token):单个请求的 token 出现速度,也常写成 TPOT(Time Per Output Token)或它的倒数「每秒 token 数(单流)」。
  • 吞吐(throughput,token/秒):所有请求合起来每秒产出多少 token。对批处理任务(离线数据标注、RL rollout、大规模评测)这是唯一重要的指标,因为它直接等价于每 token 成本。
注意

延迟和吞吐不是同一件事的两种说法,而是一对互相冲突的目标。第 3 节我们会用具体数字看到:把 batch 从 1 加到 64,吞吐涨了几十倍,但单请求延迟同时也变差了。任何声称「我们又快又便宜」的推理系统,都必须说明它站在这条 Pareto 曲线的哪个位置。

根源:训练能并行,推理不能

为什么推理天然比训练难吃满算力?一句话:

  • 训练(监督式):所有 token 都已知(teacher forcing),可以在序列维度上完全并行。Transformer 里全是大矩阵乘法。
  • 推理:必须一个一个地生成,生成维度上无法并行,所以很难把计算单元喂饱。

接下来两节就是把这句定性的话,变成可以代入数字的公式。

2. Prefill 与 decode:用算术强度算清楚

2.1 记号约定

沿用 Scaling Book 的 Transformer 章节的 einops 风格记号——每个字母代表一个维度:

符号含义典型值(Llama 2 13B)
$B$batch size(并发请求数)1 ~ 256
$S$已经条件化的 token 数(KV cache 长度)1024
$T$本次要产出 logits 的 token 数prefill: $=S$;decode: $=1$
$D$模型维度5120
$F$FFN 中间维度(惯例 $F = 4D$)13824
$N$query 头数(惯例 $D = NH$)40
$K$key/value 头数(GQA:$N = KG$)40(MHA)
$H$每个头的维度128
$L$层数40
$V$词表大小32000

矩阵乘法用「收缩维」和「批维」来读:BTD × DH → BTH 中红色的 $D$ 在两个操作数里都出现、在结果里消失,这是收缩维(contracting);而 BD × BD → B 里的 $B$ 在两边都出现、在结果里保留,这是批维(batching)。这套记号在下面数 FLOPs 和字节时会非常省心。

Transformer 一层的张量流图,标注了各个矩阵的形状与收缩维
一层 Transformer 的完整数据流。注意 MLP 的三个权重矩阵($W_{up}, W_{gate}, W_{down}$)形状与 $B, T$ 无关——这是后面「批处理能救 MLP」的根本原因;而 $Q, K, V$ 都带 $B$ 维,这是「批处理救不了注意力」的根本原因。

2.2 复习:算术强度与「roofline 分界线」

先做最简单的例子:把 $X\ (B \times D)$ 乘上 $W\ (D \times F)$,用 BF16(每个数 2 字节)。逐步记账:

推导
  1. 从 HBM 读 $X\ (B\times D)$:$2BD$ 字节
  2. 从 HBM 读 $W\ (D\times F)$:$2DF$ 字节
  3. 计算 $Y = XW$:$2BDF$ FLOPs(每个输出元素一次乘一次加)
  4. 把 $Y\ (B\times F)$ 写回 HBM:$2BF$ 字节

于是

$$ \text{intensity} = \frac{\text{FLOPs}}{\text{bytes}} = \frac{2BDF}{2BD + 2DF + 2BF} $$

当 $B \ll D, F$ 时(把 $D = cB,\ F = cB$ 代入并令 $c \to \infty$),分母被 $2DF$ 这一项主导:

$$ \text{intensity} \;\longrightarrow\; \frac{2BDF}{2DF} = B $$

这个结果干净得惊人:矩阵乘法的算术强度就等于 batch size。直觉上完全说得通——权重矩阵只被读进来一次,但它被 $B$ 行输入各用了一遍,所以「每读一个字节能做多少次计算」正比于 $B$。

另一边,硬件也有一个强度:

$$ \text{accelerator intensity (H100)} = \frac{989 \times 10^{12}\ \text{FLOP/s}}{3.35 \times 10^{12}\ \text{B/s}} \approx 295 $$

判据于是非常简单:

  • 计算强度 > 295 → compute-bound(好,算力被用满了)
  • 计算强度 < 295 → memory-bound(坏,GPU 在等 HBM)

也就是说,在 H100 上做 BF16 矩阵乘法,必须 $B > 295$ 才能吃满算力。

常见误区

极端情形 $B = 1$,也就是矩阵-向量乘法:算术强度是 1。你把整个 $D\times F$ 的权重矩阵从 HBM 搬进来,只做了 $2DF$ 次 FLOPs,然后就把它丢掉。相对 295 的分界线,你浪费了 99.7% 的算力。这几乎就是朴素自回归解码在做的事情——「推理慢」不是因为模型算不过来,而是因为显存带宽根本喂不动。买更强的算力卡对它毫无帮助。

2.3 从朴素推理到 KV cache

朴素推理示意:每生成一个 token 都把完整历史重新前向一遍,注意力矩阵不断变大
朴素推理:为了生成第 $t$ 个 token,把长度为 $t$ 的全部历史重新喂进 Transformer。单次前向是 $O(t^2)$,生成 $T$ 个 token 累计 $O(T^3)$ FLOPs。绝大部分计算是在重算之前已经算过的东西。

关键观察是:前缀部分的 key 和 value 每一步都一模一样。于是把它们存下来——KV cache:对每条序列($B$)、每个 token($S$)、每层($L$)、每个 KV 头($K$),存一个 $H$ 维的 key 和一个 $H$ 维的 value,放在 HBM 里。

带 KV cache 的推理:只对新 token 计算 Q/K/V,与缓存中的历史 K/V 做注意力
有了 KV cache 之后,每步只需要为新的那一个 token 计算 $Q, K, V$,然后和缓存里的全部历史 $K, V$ 做一次注意力。单步从 $O(t^2)$ 降到 $O(t)$,总量从 $O(T^3)$ 降到 $O(T^2)$。代价是:KV cache 本身要占显存,而且每一步都要把它整个读一遍——这个代价将主宰本讲剩下的全部内容。

于是推理被清晰地切成两个阶段:

  1. Prefill(预填充):给定 prompt,一次性把所有 token 编码成 KV cache。可以像训练一样在序列维度并行。
  2. Generation / Decode(生成):逐个生成回复 token,严格串行。

2.4 MLP 层的算术强度:$BT$

现在对现代 Transformer 的 gated MLP($W_{up}, W_{gate}, W_{down}$)逐步记账。输入 $X$ 形状 $B\times T\times D$:

推导
步骤FLOPs字节
读 $X\ (B\times T\times D)$—$2BTD$
读 $W_{up}, W_{gate}, W_{down}$(各 $D\times F$)—$3\cdot 2DF$
$U = X W_{up}$$2BTDF$写 $U$:$2BTF$
$G = X W_{gate}$$2BTDF$写 $G$:$2BTF$
$Y = (\text{GeLU}(G)\odot U)\,W_{down}$$2BTDF$写 $Y$:$2BTD$

合计 $\text{FLOPs} = 6BTDF$,$\text{bytes} = 4BTD + 4BTF + 6DF$。当 $BT \ll D, F$ 时,分母由权重项 $6DF$ 主导:

$$ \text{intensity}_{\text{MLP}} = \frac{6BTDF}{4BTD + 4BTF + 6DF} \;\longrightarrow\; \frac{6BTDF}{6DF} = BT $$

结论:MLP 的算术强度是 $BT$——「有效并行的 token 总数」。代入两个阶段:

  • Prefill:$T$ 就是 prompt 长度,动辄几百上千。哪怕 $B=1$,$BT$ 也轻松超过 295 → compute-bound,很好。
  • Decode:$T = 1$,强度退化为 $B$。要 compute-bound 就必须凑够近 300 个并发请求。而对交互式服务,$B$ 是不可控的——它取决于此刻有多少用户在线。

2.5 注意力层的算术强度:$\frac{ST}{S+T}$

注意力层假设用了 FlashAttention(中间的 $S\times T$ 分数矩阵不落 HBM),只数进出 HBM 的量。$Q$ 是 $B\times T\times D$,$K, V$ 是 $B\times S\times D$:

推导
  1. 读 $Q, K, V$:$2BTD + 2BSD + 2BSD$ 字节
  2. $A = QK^\top$:$2BSTD$ FLOPs
  3. $Y = \softmax(A)V$:$2BSTD$ FLOPs
  4. 写 $Y\ (B\times T\times D)$:$2BTD$ 字节

合计 $\text{FLOPs} = 4BSTD$,$\text{bytes} = 4BSD + 4BTD$。于是

$$ \text{intensity}_{\text{attn}} = \frac{4BSTD}{4BSD + 4BTD} = \frac{ST}{S+T} $$

这个式子是本讲最重要的一行。代入两个阶段:

$$ \text{prefill}\ (T=S):\quad \frac{S\cdot S}{2S} = \frac{S}{2} \qquad\qquad \text{decode}\ (T=1):\quad \frac{S}{S+1} < 1 $$

Prefill 的强度随序列长度线性增长($S=1024$ 时是 512,超过 295,compute-bound)。而 decode 的强度永远小于 1——比矩阵-向量乘法还糟,而且无论 $S$ 多大都不会变好。

核心结论

注意到 $\frac{ST}{S+T}$ 里根本没有 $B$。这意味着加大 batch 完全不能改善注意力层的算术强度。为什么 MLP 可以而注意力不行?

  • MLP 层:所有序列打的是同一套权重 $W_{up}, W_{gate}, W_{down}$——权重读一次,被 $B$ 条序列共享,所以强度 $\propto B$。
  • 注意力层:每条序列有自己的 KV cache——$Q, K, V$ 都带 $B$ 维,读的字节数和算的 FLOPs 同时正比于 $B$,比值不变。
MLP 强度注意力强度结论
Prefill($T=S$)$BS$$S/2$容易 compute-bound(好)
Decode($T=1$)$B$$<1$memory-bound(坏),注意力部分原理上无法改善

「注意力部分原理上无法改善」这句话要小心理解:强度不能改善,但绝对的字节数可以改善。既然读 KV cache 的时间就是 decode 的时间,那把 KV cache 做小 $k$ 倍,decode 就快 $k$ 倍。第 6 节的 GQA / MLA / CLA / 滑动窗口全部建立在这一句话上。

3. 延迟、吞吐与它们的理论上限

既然 decode 是 memory-bound 的,那么生成一个 token 需要的时间,就等于把「必须读的所有字节」搬过 HBM 总线的时间。这给了我们一个非常干净的性能模型——不需要模拟器,一张纸就能算出上限。

3.1 性能模型

先数参数。一个标准 Transformer 的参数量(含输入/输出 embedding、gated MLP 的三个矩阵、注意力的 $W_Q,W_O$ 与 $W_K,W_V$):

$$ N_{\text{param}} = \underbrace{2VD}_{\text{embed + unembed}} + \underbrace{3DFL}_{\text{MLP}} + \underbrace{(2DNH + 2DKH)L}_{\text{attention}} $$

BF16 存储,参数占 $2N_{\text{param}}$ 字节。KV cache 每条序列占:

$$ M_{\text{KV/seq}} = \underbrace{S}_{\text{token}} \times \underbrace{KH}_{\text{每层每 token 的 K 向量维度}} \times \underbrace{L}_{\text{层}} \times \underbrace{2}_{\text{K 和 V}} \times \underbrace{2}_{\text{bf16}} = 4\,S\,K\,H\,L $$

于是每一步 decode 必须读的总字节数、以及由此得到的延迟与吞吐:

$$ M_{\text{total}} = 2N_{\text{param}} + B \cdot M_{\text{KV/seq}}, \qquad \text{latency} = \frac{M_{\text{total}}}{\text{bandwidth}}, \qquad \text{throughput} = \frac{B}{\text{latency}} $$
直觉

为什么延迟直接等于「显存流量 ÷ 带宽」?因为每生成一个 token,你必须(a)把全部模型参数读一遍——每个权重只被一个(或 $B$ 个)token 用一次,(b)把 batch 里全部序列的 KV cache 读一遍。计算时间被完全隐藏在这些读取之下。这里假设计算与通信完美重叠、忽略一切 kernel launch 开销,所以得到的是理论最优——真实系统只会更慢。

3.2 代入 Llama 2 13B + H100

配置:$S=1024,\ D=5120,\ F=13824,\ N=K=40,\ H=128,\ L=40,\ V=32000$,带宽 $3.35\times10^{12}$ B/s,H100 显存 80 GB。

V, D, F, N, K, H, L, S = 32000, 5120, 13824, 40, 40, 128, 40, 1024
BW = 3.35e12          # H100 HBM3 bandwidth, bytes/s

num_params = 2*V*D + 3*D*F*L + (2*D*N*H + 2*D*K*H)*L   # 13.0e9
param_bytes = 2 * num_params                            # 26.0 GB
kv_per_seq  = 4 * S * K * H * L                         # 0.839 GB

def stats(B):
    mem = param_bytes + B * kv_per_seq
    latency = mem / BW                 # seconds per token
    return mem, latency, B / latency   # bytes, s/token, tokens/s
$B$KV cache总显存延迟(ms/token)吞吐(token/s)能否装进 80 GB
10.84 GB26.9 GB8.0125✅
6453.7 GB79.7 GB23.82 690⚠️ 刚好卡满
256214.7 GB240.8 GB71.93 562❌ 装不下

这张表要读出三件事:

  1. $B: 1 \to 64$:延迟变差 3 倍,吞吐变好 21.5 倍。非常划算——因为读参数的 26 GB 成本被 64 条序列摊薄了。
  2. $B: 64 \to 256$:延迟又差 3 倍,吞吐只涨 1.32 倍。收益急剧递减——此时总流量已经被 KV cache 主导(214.7 GB vs 26.0 GB),参数摊薄的红利吃完了,再加 batch 基本是「加多少 KV 就多读多少」,$\text{throughput} \to \text{bandwidth} / M_{\text{KV/seq}}$ 触顶。
  3. $B=256$ 根本装不下 80 GB。所以吞吐上限不是被算力卡住的,是被显存容量卡住的:显存装不下更大的 batch,batch 上不去,吞吐就上不去。
核心结论

增大 batch size 的两个效应互相拉扯:

  • 恶化延迟:KV cache 是 $O(B)$ 的,每步要读的字节线性增长。
  • 改善吞吐:读参数的固定成本被 $B$ 条序列摊薄。

于是有一条硬性取舍:小 batch = 好延迟 / 差吞吐;大 batch = 好吞吐 / 差延迟。交互式产品(聊天)往延迟一端调,离线任务(评测、RL rollout、批量标注)往吞吐一端调。你没法同时要两个。

3.3 每 token 成本 = 吞吐的倒数

吞吐之所以重要,是因为它直接就是钱。一张 H100 每小时租金记作 $c$ 美元,那么

$$ \text{每百万 token 成本} = \frac{10^6}{\text{throughput} \times 3600} \times c $$

用上表:$B=64$ 时 2690 token/s,按 $c \approx 2$ 美元/小时算,约 0.21 美元 / 百万 token;$B=1$ 时同样的卡只有 125 token/s,成本是 4.4 美元 / 百万 token,贵 21 倍。这就是为什么所有推理服务商都在拼命想办法「把 batch 做大」——第 5、6 节的连续批处理和 PagedAttention,本质上都是把 batch 做大的工程手段。

3.4 两种并行,以及 TTFT 的处理

  • 简单的并行:启动 $M$ 份模型副本(数据并行式的服务扩容)。延迟不变,吞吐 $\times M$。这是最省事的扩容方式,前提是单卡(单节点)装得下模型。
  • 困难的并行:把模型和 KV cache 都切开(张量并行、流水线并行、以及针对 KV cache 的序列/头维度切分)。这能降低单请求延迟(因为每张卡只需要读 $1/M$ 的字节),但引入通信开销。Scaling Book 的推理章节对此有详尽讨论。

最后是 TTFT。TTFT 基本上就是 prefill 时间,而 prefill 是 compute-bound 的——它不需要靠大 batch 来提高利用率($BT$ 里的 $T$ 已经够大了)。所以最佳实践是把两个阶段的 batch 策略分开:

直觉

prefill 用小 batch(甚至 $B=1$)以尽快返回第一个 token;decode 用大 batch以提高吞吐。这个「两个阶段想要的东西不一样」的观察,最终会被推到极致,演化成prefill / decode 分离部署(第 10 节)。

4. KV cache 的显存账:它如何成为吞吐的天花板

上一节的表已经暴露了真正的瓶颈:显存装不下更多序列。把 KV cache 的公式再写一遍并逐项拆解:

$$ M_{\text{KV}} = \underbrace{2}_{\text{K, V}} \times \underbrace{b}_{\text{每元素字节}} \times L \times K \times H \times S \times B $$

注意这个式子里的每一个因子,后面都会有对应的优化手段去砍它:

因子含义对应的优化
$b$(字节数)BF16 = 2KV cache 量化(FP8 / INT8 / INT4)→ 第 7 节
$K \cdot H$(每层每 token 维度)KV 头数 × 头维MQA / GQA / MLA → 第 8 节
$L$(层数)每层都存一份CLA(跨层共享 KV)→ 第 8 节
$S$(序列长度)线性增长,长上下文的噩梦滑动窗口 / 稀疏注意力 → 第 8 节
$B$(并发数)你想要它大这是目标,不是要砍的对象
「实际占用 ≫ 实际使用」预留、碎片、重复前缀PagedAttention → 第 6 节

4.1 每 token 多少字节?几个真实数字

把 $S=B=1$ 代入,得到「每个 token 的 KV 占用」这个最有用的量 $= 4KHL$ 字节(BF16):

模型$L$$K \times H$每 token KV1 万 token 上下文
Llama 2 13B(MHA, $K=40$)40$40\times128=5120$800 KB8.0 GB
Llama 2 13B 若改 GQA($K=8$)40$8\times128=1024$160 KB1.6 GB
Llama 3 70B(GQA, $K=8$)80$8\times128=1024$320 KB3.2 GB
注意

看第一行:MHA 的 Llama 2 13B,单条 1 万 token 的对话就要吃掉 8 GB 显存——而模型权重才 26 GB。在一张 80 GB 的 H100 上,扣掉权重只剩 54 GB,也就是说最多只能并发 6 条这样的长对话。$B=6$ 意味着 MLP 的算术强度只有 6(远低于 295),意味着你在烧钱。这就是「KV cache 是吞吐天花板」的完整含义:它不是通过拖慢计算来伤害你的,而是通过占满显存、逼你把 batch 调小来伤害你的。

4.2 KV cache 与参数:谁主导?

令 KV 总量等于参数总量,可以解出一个「临界并发-长度积」:

$$ B\cdot S \;=\; \frac{2N_{\text{param}}}{4KHL} $$

对 Llama 2 13B(MHA):$\frac{26.0\times10^9}{4\times40\times128\times40} = \frac{26.0\times10^9}{819200} \approx 31\,700$ token。也就是说,只要总共缓存的 token 数超过约 3 万(比如 32 条 1000-token 的对话),KV cache 的显存流量就超过了参数——此后每加一条序列,边际吞吐收益趋近于零。这个数字很小,小到令人意外。长上下文 + 高并发的服务,几乎总是运行在「KV 主导」的区间里。

4.3 一个最小的 KV cache decode 循环

import torch

class KVCache:
    """预分配 [B, L, 2, K, S_max, H] 的连续缓冲(这正是 PagedAttention 要废掉的做法)"""
    def __init__(self, B, L, K, S_max, H, dtype=torch.bfloat16, device="cuda"):
        self.k = torch.empty(L, B, K, S_max, H, dtype=dtype, device=device)
        self.v = torch.empty(L, B, K, S_max, H, dtype=dtype, device=device)
        self.len = 0                      # 已填充长度

    def append(self, layer, k_new, v_new):     # k_new: [B, K, t, H]
        t = k_new.shape[2]
        self.k[layer, :, :, self.len:self.len + t] = k_new
        self.v[layer, :, :, self.len:self.len + t] = v_new
        return (self.k[layer, :, :, :self.len + t],
                self.v[layer, :, :, :self.len + t])

@torch.no_grad()
def generate(model, prompt_ids, cache, max_new_tokens):
    # ---- Prefill:一次性喂 S 个 token,compute-bound ----
    logits = model(prompt_ids, cache)          # [B, S, V]
    cache.len += prompt_ids.shape[1]
    next_id = sample(logits[:, -1])            # [B, 1]

    out = [next_id]
    # ---- Decode:每次只喂 1 个 token,memory-bound ----
    for _ in range(max_new_tokens - 1):
        logits = model(next_id, cache)         # [B, 1, V]
        cache.len += 1
        next_id = sample(logits[:, -1])
        out.append(next_id)
    return torch.cat(out, dim=1)

这段代码里唯一「昂贵」的东西不在代码里:循环的每一次迭代,model(...) 内部都要把全部 26 GB 权重和全部 KV cache 从 HBM 拉一遍。Python 循环的开销可以忽略,HBM 的往返不能。

5. 批处理:从 static batching 到 continuous batching

第 3 节告诉我们「batch 越大吞吐越好」,但那是在假设 batch 是一个整齐的矩形。真实线上流量完全不是这样。Percy 列了三个麻烦:

  1. 请求在不同时刻到达——凑够一个 batch 再开跑,对先到的请求是灾难性的延迟。
  2. 序列之间有共享前缀——系统提示词、few-shot 示例、同一个 prompt 采样多个回答。重复存 KV 是纯浪费。
  3. 序列长度各不相同——padding 到最长会浪费大量算力和显存。

这一节处理第 1、3 个问题(连续批处理),第 6 节处理第 2 个问题(PagedAttention 的前缀共享)。

5.1 Static batching 的浪费

静态批处理时间线:四条序列组成一个 batch,短序列生成完 EOS 后其槽位一直空转到最长序列结束
静态批处理:$S_1 \sim S_4$ 被打包成一个 batch 一起跑。$S_1$ 只生成 2 个 token 就吐出 EOS,$S_3$ 生成了 6 个——但整个 batch 必须等最慢的那条结束才能释放。白色格子全是被浪费掉的计算槽位。而且在此期间新到的请求只能干等,TTFT 被拉长到「上一批全部完成」。

浪费有多严重?如果生成长度服从一个方差较大的分布(真实流量确实如此,从 5 个 token 到 2000 个 token 都有),batch 的有效利用率大致是 $\frac{\E[\text{len}]}{\max(\text{len})}$。当 batch 里混了一条超长请求时,利用率可以低到 10~20%。

5.2 迭代级调度(iteration-level scheduling)

解法来自 Orca (OSDI 2022),工业界通常叫连续批处理(continuous batching)或 in-flight batching。核心思想只有一句话:

核心结论

调度的粒度从「请求」下降到「迭代」。每完成一步 decode,调度器就重新审视一次 batch:把已经生成 EOS 的序列立即移出并返回给用户,把队列里等待的新请求立即塞进空出来的槽位。没有序列需要等待其他序列结束。

# 连续批处理调度器的骨架(概念版)
running = []                      # 正在解码的序列
while True:
    # 1) 回收已完成的槽位
    for seq in list(running):
        if seq.finished:
            running.remove(seq); seq.respond(); free_kv_blocks(seq)

    # 2) 用新到达的请求填满 batch(受显存约束)
    while waiting and kv_memory_available() and len(running) < MAX_BATCH:
        seq = waiting.popleft()
        prefill(seq)              # 计算 prompt 的 KV cache
        running.append(seq)

    # 3) 所有 running 序列一起走一步 decode
    step_decode(running)          # 每条序列各产出 1 个 token

收益是巨大的:在长度分布方差大的真实负载上,连续批处理相对静态批处理常见的吞吐提升是数倍到十几倍,同时因为新请求不用等待,TTFT 也同步改善——这是罕见的「延迟和吞吐同时变好」的优化,因为它消灭的是纯粹的浪费而不是在做取舍。

5.3 选择性批处理(selective batching)

但立刻撞上一个问题:批处理要求所有序列形状一致。running 里的序列长度是 3、9、5……怎么拼成一个张量?

Orca 的答案是选择性批处理——按算子类型分别处理:

算子是否依赖序列边界处理方式
LayerNorm、MLP、QKV/O 投影、激活否,逐 token 独立全部拉平拼接成 $[3+9+5, D]$ 的二维张量,一次大矩阵乘搞定
Attention是,每条序列只能看自己的 KV按序列分别计算(或用 varlen / ragged kernel,如 FlashAttention 的 varlen 接口)
直觉

为什么可以这么拆?回到第 2 节的记号:MLP 的权重形状是 $D\times F$,和 $B$、$T$ 都无关——所以对它来说「批」只是一堆互相独立的行向量,来自哪条序列毫不重要,拼在一起反而让矩阵更「胖」、算术强度更高。而注意力的 $K, V$ 是每条序列私有的,跨序列拼接会造成信息泄漏。这正是第 2.5 节「MLP 能被 batch 拯救、attention 不能」在系统层面的镜像。

今天所有主流引擎(vLLM、SGLang、TensorRT-LLM)都默认开启连续批处理 + varlen 注意力 kernel,这已经是推理系统的基线而不是优化项。

6. PagedAttention 与 vLLM:把操作系统搬进推理引擎

连续批处理解决了「时间维度」的浪费,但「空间维度」——显存——的浪费还在。vLLM 论文(SOSP 2023)指出:在当时的系统里,KV cache 的实际有效利用率只有 20%~40%。也就是说六成以上的显存在空转,而我们刚刚论证过,显存就是吞吐。

6.1 问题:碎片化

此前的做法很自然:请求进来 → 为「prompt + 可能生成的最长回复」预留一整段连续显存。

显存布局示意:每个请求占据一段连续区域,区域内大部分槽位未被使用,区域之间还有无法利用的空隙
连续预分配带来的三种浪费:内部碎片(internal fragmentation)——按最大长度 2048 预留,实际只生成了 30 个 token,剩下的 2018 个槽位从头空到尾;外部碎片(external fragmentation)——请求释放后留下大小不一的空洞,新请求要连续空间而放不进去;预留浪费(reservation)——即使这些槽位最终会被用到,在被用到之前它们也一直被锁着,不能借给别人。
直觉

Percy 的类比是硬盘。这套毛病操作系统在 1960 年代就见过了:给每个进程分配连续物理内存,结果就是碎片。操作系统的答案是分页(paging)——把物理内存切成固定大小的页,进程看到的是连续的虚拟地址,页表负责把虚拟页映射到任意物理页。PagedAttention 就是把这套东西原样搬到 KV cache 上。

6.2 解法:把 KV cache 分块

一条序列的 KV cache 被切成若干固定大小的 block,通过 block table 映射到显存中不连续的物理块
PagedAttention 的核心数据结构。一条序列的 KV cache 被切成固定大小的 block(vLLM 默认每块 16 个 token),这些块在物理显存里可以完全不连续。每条序列维护一张 block table(就是页表),记录「逻辑块 $i$ → 物理块 $p$」。序列增长时按需分配新块,最多只浪费最后一块内的若干槽位——内部碎片从「最大长度级别」降到「一个 block 级别」,外部碎片被完全消灭。
# 概念版:block table 与按需分配
BLOCK = 16                      # 每块 16 个 token

class BlockAllocator:
    def __init__(self, n_blocks): self.free = list(range(n_blocks))
    def alloc(self):  return self.free.pop()
    def free_(self, p): self.free.append(p)

class Sequence:
    def __init__(self): self.block_table = []; self.n_tokens = 0
    def append_token(self, alloc):
        if self.n_tokens % BLOCK == 0:            # 当前块满了,才申请新块
            self.block_table.append(alloc.alloc())
        self.n_tokens += 1

# 注意力 kernel 读 KV 时不再是一段连续 slice,
# 而是按 block_table 逐块 gather —— 这就是 "PagedAttention" 这个名字的由来。

注意这里的关键工程点:注意力 kernel 必须改写。标准 FlashAttention 假设 K/V 在显存里连续,PagedAttention 的 kernel 要根据 block table 做间接寻址(gather)。vLLM 把「按 block 读取」和「注意力计算」融合进同一个 kernel,避免先 gather 到临时缓冲再算——否则收益会被额外的显存搬运吃掉。

6.3 意外的红利:前缀共享

分页带来了一个设计之初未必是主要目标、但事后看极其重要的能力:不同序列可以指向同一个物理块。

两个请求的逻辑块序列,前几个逻辑块通过各自的 block table 指向同一批物理块
两个请求共享 KV cache:它们的逻辑视图各自独立、连续,但 block table 把开头几个逻辑块映射到同一批物理块上。共享的 KV 在显存里只存一份,prefill 也只算一次。
两类共享场景的示意:多个请求共享同一段系统提示词;同一个 prompt 采样出多条不同的续写
两类高价值的共享场景。左:共享系统提示词——线上服务里成千上万个请求前面挂着同一段几百到几千 token 的 system prompt / few-shot 示例,如果每个请求都独立 prefill 一遍,纯属重复劳动。右:同一 prompt 采样多个回答——程序合成里的 best-of-$n$、RL 训练里的 group rollout(GRPO 一次要为同一个 prompt 采 8~64 条),prompt 部分的 KV 只需存一份。
两条采样分支共享前缀块,当某条分支要写入共享块时触发 copy-on-write,复制出私有块
写时复制(copy-on-write):共享块的引用计数大于 1 时,任何一条序列要往这块里写新 token,就先把该块复制一份变成私有块,再写。粒度是「块」而不是「整条序列」,所以复制成本极低。这又是从操作系统 fork() 直接借来的机制。
核心结论

PagedAttention 的收益链条是:消灭碎片 → 同样显存能装下更多序列 → batch 变大 → 参数读取成本被摊薄得更薄 → 吞吐上升。vLLM 论文报告的端到端吞吐提升相对当时的 HuggingFace / FasterTransformer 是2~4 倍,且不损失任何精度——这是一个纯粹的系统优化,模型输出逐 bit 相同。

6.4 vLLM 的其他优化

  • 融合 kernel:把 block 读取和注意力计算合并,减少 kernel launch 次数和中间张量。
  • 用最新 kernel:FlashAttention(prefill 用)、FlashDecoding(decode 用——decode 时 $T=1$,并行度不足,FlashDecoding 通过在 $S$ 维上切分并行来填满 SM)。
  • CUDA Graphs:decode 步骤本身极短(几毫秒甚至更短),Python + CUDA 的 kernel launch 开销占比会变得可观。把整个 decode step 录制成 CUDA Graph 一次性回放,可以显著削掉 launch 开销。

SGLang 把前缀共享推得更远:RadixAttention 用一棵基数树(radix tree)在全局维护所有历史请求的 KV 前缀,并配合 LRU 淘汰。这样跨请求、跨轮次的复用是自动发生的——对多轮对话和 agent 轨迹(同一条 trace 反复追加)收益尤其大。

Percy 对这一节的总结是一句方法论:面对动态负载,去操作系统的历史里找答案。分页、调度、写时复制、缓存淘汰——这些问题计算机系统领域已经想了五十年。

7. 量化与模型压缩

7.1 量化的机制

核心思想直白得几乎不像一个技术:用更少的比特存数。既然推理是 memory-bound,字节数减半就意味着延迟减半、吞吐翻倍。

最基本的仿射量化(affine quantization):

$$ x_{\text{quant}} = \text{round}\!\left(\frac{x}{s}\right) + z, \qquad \hat{x} = (x_{\text{quant}} - z)\cdot s $$

其中 $s$ 是 scale(缩放),$z$ 是 zero-point(零点)。课上的数值例子:$x = 5.2342$,$s = 0.1$,$z = 4$,则 $x_{\text{quant}} = \text{round}(52.342) + 4 = 56$,反量化得 $\hat{x} = (56-4)\times 0.1 = 5.2$。误差 0.0342,来自 round 的舍入——这就是量化误差的全部来源。

x = 5.2342
scale, zero_point = 0.1, 4
x_quant  = round(x / scale) + zero_point     # 56  (可以存进 int8)
x_approx = (x_quant - zero_point) * scale    # 5.2
fp32 / fp16 / bf16 / fp8-e4m3 / fp8-e5m2 的位布局对比图,标出符号位、指数位、尾数位
几种浮点格式的位布局。指数位决定动态范围,尾数位决定精度。BF16 相对 FP16 的设计取舍是「保住 FP32 的 8 位指数、砍尾数」,因为深度学习对范围(不溢出)比对精度更敏感。FP8 有 e4m3(更精确)和 e5m2(更大范围)两种。
格式字节范围用途
fp324—训练时的参数主副本与优化器状态
bf162—推理的默认格式
fp8 (e4m3)1$[-240, 240]$(H100 原生支持)敢的话可以直接训练(FP8-LM)
int81$[-128, 127]$比 fp8 便宜但更不准,仅用于推理(对比研究)
int40.5$[-8, 7]$更便宜、更不准;本地部署与显存极度受限时用

7.2 QAT vs PTQ

量化感知训练(QAT)训练后量化(PTQ)
做法训练时在前向里插入「量化-反量化」,模拟量化误差(反向用 straight-through estimator 绕过不可导的 round)训练完成后做,用少量校准数据跑一遍,为每层/每张量确定 $s$ 和 $z$
优点权重被训练成能容忍量化,精度损失最小极其便宜,几分钟到几小时
缺点需要昂贵的大规模训练精度损失更大,低比特(int4)时尤其明显

GPTQ(arXiv:2210.17323)是 PTQ 的代表作。它逐列量化权重矩阵,每量化一列,就用Hessian 信息去更新尚未量化的列,以补偿刚刚引入的误差。直觉上:量化第 $i$ 列造成的输出扰动,可以部分地由第 $i+1, i+2, \dots$ 列吸收掉。这让 int4 也能保持相当好的精度。

7.3 AWQ:按激活重要性分配精度

AWQ 方法示意:左为直接量化全部权重,中为保留 0.1% 显著权重为 FP16 的混合精度方案,右为等价的按通道缩放再全量化方案
AWQ 的三步论证。(a) 直接 RTN 量化所有权重 → 精度掉得多。(b) 观察到少数激活通道幅值特别大,与之相乘的那些权重对输出影响也特别大;只把这 0.1%~1% 的「显著权重」保留 FP16,精度几乎完全恢复——但混合精度对硬件不友好(要处理不规则的数据布局)。(c) 于是改成等价做法:先按激活幅值给每个通道乘上一个缩放因子 $s$(把显著权重放大,使它在量化网格上占更多有效位),再全部量化成 int3/int4,缩放因子事后折进上一层。这样既拿到了精度,又保持了规整的低比特布局。

关键洞察是 「重要的不是权重本身有多大,而是它乘的那个激活有多大」——所以要用激活的统计量(activation-aware)而不是权重的统计量来决定精度分配。课上给的数字:fp16 → int3,显存降低 4 倍,实测加速 3.2 倍。

注意

别忘了 KV cache 也可以量化,而且在长上下文场景下它比权重更值得量化(回顾 4.2 节:$BS > 3$ 万时 KV 就超过参数了)。FP8 KV cache 现在是 vLLM / TensorRT-LLM 的常见选项,直接把 $M_{\text{KV}}$ 减半,等价于并发翻倍。经验上 KV cache 对量化比权重更宽容(尤其是 value),但 key 在有 RoPE 的情况下存在异常值通道,需要 per-channel 而非 per-tensor 的 scale。

7.4 剪枝 + 蒸馏

量化是「让每个数变小」,剪枝是「直接把一部分结构扔掉」。核心思想:把一个贵模型的零件拆掉一些,然后再把它修好。

迭代式剪枝-蒸馏流程图:从大模型出发,估计重要性、剪枝、蒸馏修复,得到一系列逐级变小的模型
NVIDIA 的剪枝-蒸馏流程(Minitron / Llama-3.1-Minitron 系列就是这么造出来的)。这是一个可以反复迭代的循环:每一轮都把当前模型剪小一档、再蒸馏修复,于是一次训练投入可以产出一整个模型家族。

算法三步:

  1. 估计重要性:在一个很小的校准集上(论文用 1024 条样本)前向若干次,统计每个 {层、注意力头、隐藏维度、FFN 中间维度} 的激活幅值/方差作为重要性分数。注意这里不需要梯度、不需要反向传播,所以极其便宜。
  2. 剪枝:按分数移除不重要的结构,得到一个更小的模型。可以在宽度(头、通道)和深度(整层)两个方向剪。
  3. 蒸馏修复:以原模型为 teacher、剪枝后的模型为 student,做知识蒸馏。损失通常是 logits 上的 KL 加上中间层隐状态的 L2:
$$ \mathcal{L} = \KL\big(p_{\text{teacher}}(\cdot\mid x)\,\|\,p_{\text{student}}(\cdot\mid x)\big) + \lambda \sum_{\ell} \big\| h^{(\ell)}_{\text{teacher}} - h^{(\ell)}_{\text{student}} \big\|^2 $$
剪枝蒸馏结果表:剪枝得到的小模型在多个基准上优于同规模从头训练的模型,且所需 token 数少一个数量级以上
结果:剪枝+蒸馏得到的模型,在同等参数量下准确率优于从头训练的模型,而所需训练 token 少一到两个数量级。原因很直白——你不是从随机初始化开始,而是从一个已经学好的模型出发,只需要「修复」被剪掉部分造成的损伤。
核心结论

Percy 把「不损失精度地降低推理复杂度」总结成两个配方:

  • 从零配方:1) 设计一个更快的架构;2) 从头训练它。干净,但每次都要付全额训练成本。
  • 蒸馏配方:1) 设计更快的架构;2) 用原模型的权重来初始化(哪怕架构不同,也可以做结构映射/剪枝式初始化);3) 用蒸馏「修复」这个更快的模型。这是当今生产环境的主流做法——GQA 化、层数减半、MHA→MLA 转换、乃至 Transformer→Mamba 混合的转换,都可以走这条路。

8. 架构层面的推理优化:把 KV cache 变小

回到第 4 节的公式 $M_{\text{KV}} = 2bLKHSB$。这一节讲的所有架构改动,都是在砍其中的某个因子,目标只有一个:在同样的显存里塞下更大的 batch,从而买到更高的吞吐。每一项都必须回答同一个问题——精度掉了吗?

8.1 GQA / MQA:砍 $K$

MHA、GQA、MQA 三种注意力的对比图:query 头数相同,key/value 头数从 N 降到 K 再降到 1
三种变体的关系。MHA($K=N$):每个 query 头配一套自己的 K/V。MQA($K=1$):所有 query 头共用一套 K/V,KV cache 缩小 $N$ 倍,但精度损失明显。GQA($1 < K < N$):把 $N$ 个 query 头分成 $K$ 组($N = KG$),组内共享一套 K/V——是精度和显存之间的一个可调旋钮。

注意一个重要细节:GQA 不减少注意力的 FLOPs(每个 query 头照样要和一套 K/V 做完整的注意力,只是那套 K/V 是共享的),它减少的是参数量($W_K, W_V$ 变小)和KV cache 字节数($N/K$ 倍)。既然 decode 是 memory-bound 的,减字节就等于减时间。

不同 KV 头数下的推理时间条形图,MQA 与 GQA 相对 MHA 大幅降低单样本时间
GQA 论文的速度结果:从 MHA 换到 MQA/GQA,单样本推理时间大幅下降,且 GQA 与 MQA 的速度非常接近——说明只要把 $K$ 降到个位数,KV cache 就已经不是主要瓶颈了,再往下降到 1 收益很小、精度代价却更大。

把 Llama 2 13B 从 MHA($K=40$)改成 GQA($K=8$,即 1:5),重跑第 3 节的性能模型:

配置参数量KV/序列$B$总显存延迟吞吐
MHA $K=40$13.0 B0.839 GB6479.7 GB23.8 ms2 690 tok/s
GQA $K=8$11.3 B0.168 GB6433.4 GB10.0 ms6 420 tok/s
GQA $K=8$11.3 B0.168 GB25665.6 GB19.6 ms13 070 tok/s

读法:GQA 把 KV cache 缩小 5 倍,于是在 $B=64$ 时显存从 79.7 GB 降到 33.4 GB(延迟和吞吐同时改善)。更重要的是它解锁了 $B=256$——MHA 版本在 $B=256$ 时要 240 GB、根本装不下,GQA 版本只要 65.6 GB,稳稳装进一张 H100。最终吞吐相对 MHA 提升 4.9 倍,代价是单请求延迟从 23.8 ms 变成 19.6 ms(其实还更好)。这就是「架构改动 → 显存下降 → batch 上升 → 吞吐飞跃」的完整链条。

GQA 论文的精度表:MQA 在多个摘要与翻译任务上落后 MHA,GQA-8 基本追平 MHA
精度验证。MQA($K=1$)确实掉点,而 GQA 在 $K=8$ 左右基本追平 MHA。这就是为什么今天几乎所有开源模型(Llama 2 70B 起、Llama 3 全系、Mistral、Qwen)都用 $K=8$ 的 GQA——它是这条曲线上的「甜点」。

8.2 MLA:把 KV 压成一个低维隐向量

MHA / GQA / MQA / MLA 四者对比图,MLA 通过一个下投影得到低维隐向量 c,再上投影还原 K 和 V
Multi-head Latent Attention(MLA,DeepSeek-V2)。普通注意力把 $K = W_K h$、$V = W_V h$(共 $2NH$ 维)存进 cache;MLA 只存一个压缩隐向量 $c = W_c h$($C$ 维),用到的时候再上投影回 $K = W_K^{\uparrow} c$、$V = W_V^{\uparrow} c$。

DeepSeek-V2 的具体数字:把 $NH = 16384$ 维压到 $C = 512$ 维。

注意

MLA 有一个麻烦:它和 RoPE 不兼容。原因是 RoPE 的旋转是位置相关的,作用在 $K$ 上,而 MLA 想把「上投影 $W_K^{\uparrow}$」提前折进 $W_Q$ 里以避免真的还原出 $K$;一旦中间插入一个位置相关的旋转矩阵,这个吸收就做不成了。DeepSeek 的解法是解耦(decoupled)RoPE:额外拿出 64 维专门承载 RoPE 信息、不参与压缩,其余维度走压缩路径。所以实际每 token 每层存 $512 + 64 = 576$ 维——相对 16384 维仍然是 28 倍的压缩。

消融表 8:在相同规模下 MHA 的评测分数高于 GQA 和 MQA
先确立参照系(论文 Table 8):MHA 的精度是优于 GQA / MQA 的——GQA 省显存不是白省的,它确实付出了一点精度。所以真正的问题是:有没有办法既省显存又不掉精度?
消融表 9:MLA 的评测分数略高于 MHA,同时 KV cache 大幅更小
答案(论文 Table 9):MLA 甚至比 MHA 还略好一点,而 KV cache 便宜得多。这有点反直觉——压缩怎么会提升精度?一个合理解释是低秩瓶颈起到了正则化作用,另一个是 MLA 的上投影矩阵给了模型额外的表达自由度。无论如何,MLA 在这条 Pareto 曲线上是严格占优的,这也是它被 DeepSeek 系列一直沿用的原因。

8.3 CLA:砍 $L$

跨层注意力示意:相邻若干层共用同一份 KV,只有部分层真正计算并存储 K/V
Cross-Layer Attention(CLA):既然 GQA 能跨头共享 KV,为什么不能跨层共享?CLA 让相邻的若干层复用同一份 K/V,KV cache 直接按共享因子缩小。
困惑度对 KV cache 大小的 Pareto 曲线,CLA 的曲线位于纯 GQA 曲线的左下方
结果:在「困惑度 vs KV cache 大小」的 Pareto 平面上,CLA 把前沿整体往左下方推。也就是说在同样的 KV 预算下 CLA 能做到更低的困惑度,或在同样困惑度下用更少的 KV。CLA 和 GQA 是正交的,可以叠加使用。

8.4 局部(滑动窗口)注意力:砍 $S$

Longformer 的注意力模式图:完整注意力、滑动窗口、扩张滑动窗口、全局+滑动窗口混合四种掩码
局部注意力的几种掩码模式(Longformer;另见 Sparse Transformer、Mistral 7B)。核心想法:最相关的上下文通常就在附近,所以每个 token 只看前 $w$ 个 token。

两个关键性质:

  • 有效感受野随层数线性增长:单层看 $w$ 个 token,$L$ 层堆起来信息可以传播 $L\cdot w$ 的距离。Mistral 7B 用 $w = 4096$、$L=32$,理论感受野 131k。
  • KV cache 与序列长度无关!这是最诱人的一点——窗口满了就把最老的块丢掉,$M_{\text{KV}} \propto w$ 而不是 $\propto S$。无限长对话的显存占用变成常数。
常见误区

「感受野够大」不等于「能真的用上远处的信息」。局部注意力对精确检索类任务(needle-in-a-haystack、长文档问答)伤害很大,因为信息要经过多层传递才能到达,中途会被稀释。所以实践中的解法是混合(hybrid):大部分层用局部注意力,每隔几层插一层全局注意力(Gemma 2 的 1:1、Llama 4 与 Character.AI 的类似设计都是这个思路)。这样 KV cache 主要由少数全局层贡献,既省显存又保住长程能力。

8.5 DeepSeek-V4:更激进的稀疏与压缩

DeepSeek-V4 的注意力结构图,包含 CSA 压缩分支、DSA 稀疏选择分支与 HCA 高压缩分支
DeepSeek-V4 为支持 100 万 token 上下文而组合的三种机制:CSA(Compressed Sparse Attention)——每 $m$ 个 token 压缩成 1 个,得到一个粗粒度摘要序列;DSA(DeepSeek Sparse Attention)——对每个 query 动态选出 top-$k$ 个最相关的 KV,只对它们做注意力;HCA(Heavily Compressed Attention)——压得更狠的一路,用于最远的历史。

这套设计的思路是分层的信息保真度:近处保留全精度,中距离用选择性稀疏(top-$k$ 保证重要 token 不丢),远处用重压缩摘要。它把 KV cache 从 $O(S)$ 拉回到接近 $O(\sqrt{S})$ 甚至常数,同时 top-$k$ 机制保住了精确检索能力——这正是纯滑动窗口做不到的。

8.6 MoE 的推理特点

混合专家(Mixture-of-Experts, MoE)在推理上有一组很独特的性质,值得单独说:

Prefill(compute-bound)Decode(memory-bound)
MoE 的效果大赢:FLOPs 只按激活参数算,稀疏度 $=$ 加速比可能不赢甚至更糟
原因算力是瓶颈,而 MoE 每 token 只激活一小部分专家带宽是瓶颈,而 batch 里不同 token 会路由到不同专家;$B$ 稍大,几乎所有专家都会被至少一个 token 命中 → 仍然要把全部专家权重读进来
直觉

MoE 在训练时的卖点是「用 $1/k$ 的 FLOPs 拿到大模型的容量」,但推理 decode 阶段的成本函数是字节不是 FLOPs。$B=1$ 时 MoE 确实只需读激活的那几个专家(这就是为什么 MoE 特别适合本地单用户部署);但 $B$ 一大,专家命中就趋于全覆盖,读的字节量退化成稠密模型。所以生产环境部署 MoE 必须配专家并行(EP)——把专家分散到多张卡上,每张卡只持有一部分专家权重,让「读全部专家」这件事被并行摊开。这也是 DeepSeek 推理系统里大规模 EP 的由来。

8.7 小结与其他方向

方法砍的因子KV 缩减精度影响
MQA$K \to 1$$N$ 倍明显下降
GQA$K \to 8$$N/8$ 倍接近无损(略低于 MHA)
MLA$KH \to C{+}64$约 28 倍(DeepSeek-V2)略优于 MHA
CLA$L$共享因子倍Pareto 前沿改善
滑动窗口$S \to w$与长度无关长程检索受损,需混合层
CSA / DSA / HCA$S$(自适应)支持 1M 上下文设计目标即无损
KV 量化(FP8)$b: 2 \to 1$2 倍基本无损

Percy 最后提了两条更激进的路线:线性注意力 / 状态空间模型(Mamba 2、Gated DeltaNet)——它们把 KV cache 换成固定大小的循环状态,显存占用与序列长度完全无关,代价是精确回忆能力受限(所以现在流行 Mamba-Transformer 混合);以及扩散语言模型——放弃自回归,一次并行生成多个位置,从根本上绕开「decode 无法并行」这个诅咒。他的判断是:新架构在推理效率上还有巨大的改进空间,这是目前最值得做的方向之一。

9. 投机解码:唯一无损的加速

前面所有的手段——改架构、量化、剪枝——都动了模型本身,都需要「验一下精度别掉太多」。这一节的方法不一样:它在数学上保证输出分布与原模型逐字严格一致,不需要重新训练目标模型,也不需要做任何精度权衡。

9.1 核心的不对称性

回到第 2 节的结论,把它换个说法:

  • Prefill:给定一段序列,并行编码所有 token(compute-bound)。注意——它顺带给出了每个位置上的概率分布。
  • Decode:一次生成一个 token(memory-bound)。

也就是说,「检查 $\gamma$ 个 token 对不对」和「生成 1 个 token」花的时间几乎一样(都是把模型权重读一遍)。检查便宜,生成昂贵——这个不对称性就是一切的来源。

直觉

这在系统领域早就有名字:投机执行(speculative execution)。CPU 的分支预测就是这么干的——先猜一个分支往下跑,猜对了白赚,猜错了回滚。这里的「回滚」几乎免费,因为错误的 token 只是丢掉不用。Percy 反复强调的方法论:推理系统的很多好点子,是从计算机系统的历史里搬过来的。

9.2 算法

设目标模型(贵、要保真)的分布是 $q$,草稿模型(draft model,便宜)的分布是 $p$。

投机采样算法伪代码:草稿模型自回归生成 gamma 个 token,目标模型并行打分,逐位置做接受-拒绝判定,拒绝时从残差分布重采样
Leviathan et al. / Chen et al. 的投机采样算法。注意最后一行:如果 $\gamma$ 个草稿 token 全部被接受,还可以从目标模型在最后一个位置的分布里免费再采一个——因为那次前向已经算过了。所以一轮最多推进 $\gamma+1$ 个 token。
@torch.no_grad()
def speculative_step(draft, target, prefix, gamma):
    # ---- 1) 草稿模型自回归生成 gamma 个候选(便宜,每步只读小模型权重)----
    xs, ps = [], []
    ctx = prefix
    for _ in range(gamma):
        p = draft(ctx)[:, -1].softmax(-1)      # p(· | ctx)
        x = torch.multinomial(p, 1)
        xs.append(x); ps.append(p); ctx = torch.cat([ctx, x], dim=1)

    # ---- 2) 目标模型一次前向给出 gamma+1 个位置的分布(并行,只读一次大模型权重)----
    qs = target(ctx).softmax(-1)               # [B, len(prefix)+gamma, V]

    # ---- 3) 逐位置接受-拒绝 ----
    out = []
    for i, x in enumerate(xs):
        q_i, p_i = qs[:, len(prefix) - 1 + i], ps[i]
        r = torch.rand(1)
        if r < (q_i.gather(-1, x) / p_i.gather(-1, x)).clamp(max=1.0):
            out.append(x)                      # 接受
        else:
            resid = (q_i - p_i).clamp(min=0)   # 残差分布 max(q - p, 0)
            resid = resid / resid.sum()        # 归一化
            out.append(torch.multinomial(resid, 1))
            return out                         # 拒绝后立刻结束本轮
    # ---- 4) 全部接受:从目标模型多白拿一个 token ----
    out.append(torch.multinomial(qs[:, -1], 1))
    return out

三点说明:

  1. 接受概率是 $\min\!\left(1, \frac{q(x)}{p(x)}\right)$——草稿模型「过度采样」某个 token($p > q$)时才可能被拒。
  2. 拒绝时不是重新从 $q$ 采样,而是从残差分布 $\frac{\max(q - p,\,0)}{\sum_{x'}\max(q(x') - p(x'),\,0)}$ 采样。这一步是正确性的关键,下面会证明。
  3. 这是被修改过的拒绝采样:标准拒绝采样在被拒时会一直重试,而这里被拒时直接从残差分布出一个 token——保证每一轮至少产出一个 token,永不空转。

9.3 正确性证明:输出分布严格等于 $q$

推导

只需证明单个位置上输出 token 的边缘分布就是 $q$(有了这个,配合链式法则,整条序列的联合分布自然等于目标模型的联合分布)。设最终输出为随机变量 $X$。它有两条产生路径:

$$ \Pr[X = x] \;=\; \underbrace{\Pr[\text{草稿出 } x \text{ 且被接受}]}_{\text{(I)}} \;+\; \underbrace{\Pr[\text{被拒}]\cdot \Pr[\text{残差分布出 } x]}_{\text{(II)}} $$

第 (I) 项:草稿以概率 $p(x)$ 产出 $x$,然后以概率 $\min(1, q(x)/p(x))$ 被接受:

$$ \text{(I)} = p(x)\cdot\min\!\left(1, \frac{q(x)}{p(x)}\right) = \min\big(p(x),\, q(x)\big) $$

被拒的总概率 $\beta$:对所有可能的草稿 token 求和,

$$ \beta = \sum_{x'} p(x')\left[1 - \min\!\left(1, \tfrac{q(x')}{p(x')}\right)\right] = \sum_{x'}\Big[p(x') - \min\big(p(x'), q(x')\big)\Big] = 1 - \sum_{x'}\min\big(p(x'), q(x')\big) $$

残差分布的归一化常数 $Z$:

$$ Z = \sum_{x'}\max\big(q(x') - p(x'),\, 0\big) = \sum_{x'}\Big[q(x') - \min\big(p(x'), q(x')\big)\Big] = 1 - \sum_{x'}\min\big(p(x'), q(x')\big) $$

(用到恒等式 $\max(a-b,0) = a - \min(a,b)$。)于是发现一个漂亮的巧合——其实是设计使然:

$$ \boxed{\;Z = \beta\;} $$

因此第 (II) 项里的 $\beta$ 和 $1/Z$ 正好抵消:

$$ \text{(II)} = \beta \cdot \frac{\max(q(x) - p(x),\,0)}{Z} = \max\big(q(x) - p(x),\, 0\big) $$

两项相加:

$$ \Pr[X = x] = \min\big(p(x), q(x)\big) + \max\big(q(x) - p(x),\, 0\big) $$

分两种情况验证:若 $q(x) \ge p(x)$,得 $p(x) + \big(q(x)-p(x)\big) = q(x)$;若 $q(x) < p(x)$,得 $q(x) + 0 = q(x)$。两种情况都等于 $q(x)$。$\blacksquare$

Percy 在课上用一个两元素词表 $\{A, B\}$ 的例子把这个证明「具象化」了,这里完整复述一遍:

推导(课上的二元例子)

设 $p(A) > q(A)$(草稿模型过度采样 A),则必然 $p(B) < q(B)$(欠采样 B)。残差 $\max(q-p, 0)$ 在 A 上是 0、在 B 上是正的,归一化后就是 $[0, 1]$——一旦被拒,必定输出 B。

  • $\Pr[\text{输出 } A] = \underbrace{p(A)\cdot\frac{q(A)}{p(A)}}_{\text{草稿出 A 且接受}} + \underbrace{p(B)\cdot 1 \cdot 0}_{\text{草稿出 B,必接受,残差不会给 A}} = q(A)$ ✓
  • $\Pr[\text{输出 } B] = \underbrace{p(B)\cdot 1}_{\text{草稿出 B,因 } q(B)>p(B) \text{ 必接受}} + \underbrace{p(A)\cdot\left(1 - \tfrac{q(A)}{p(A)}\right)\cdot 1}_{\text{草稿出 A 被拒,残差必出 B}} = p(B) + p(A) - q(A) = q(B)$ ✓

(最后一步用了 $p(A)+p(B)=1$ 和 $q(A)+q(B)=1$。)直观理解:草稿模型多给 A 的那部分概率质量,被拒绝机制精确地「退还」并转移给了 B。

核心结论

投机采样产生的是目标模型的精确样本(exact sample)。不是「近似」、不是「统计上接近」,是分布严格相同。所以它不需要做精度评估——这在工程上是巨大的优势:你可以在生产环境里随时开关它,不会改变模型行为。唯一的风险是随机数消耗方式变了,逐 token 复现需要额外注意。

9.4 加速比:能快多少

投机采样在不同任务与不同 gamma 下的加速比结果表
实测加速比。收益强烈依赖于任务:代码、结构化输出、有大量可预测样板文本的任务,草稿模型接受率高,加速最明显;而高熵的开放式创作接受率低,收益小。同时依赖 采样温度——温度越低(越接近贪心),$p$ 和 $q$ 越容易一致,接受率越高。
接受率 alpha 随草稿模型规模变化的统计图,以及每轮平均接受 token 数
接受率统计。草稿模型越大,接受率 $\alpha$ 越高,但草稿本身也越慢——这是一个必须调的取舍,$\gamma$(每轮猜几个)同理:猜太少不划算,猜太多则后面的 token 大概率被丢掉。
推导(期望加速比)

设每个位置的接受率为 $\alpha$(近似独立),一轮猜 $\gamma$ 个。一轮内被接受的 token 数是首次失败前的成功次数,服从截断几何分布,期望产出(含全接受时白拿的那一个)为

$$ \E[\#\text{tokens per round}] = \sum_{i=0}^{\gamma}\alpha^i = \frac{1 - \alpha^{\gamma+1}}{1 - \alpha} $$

设草稿模型单步成本与目标模型单步成本之比为 $c$(例如 8B 草稿配 70B 目标,$c \approx 1/9$),一轮的总成本是 $\gamma c + 1$ 个「目标模型单步」。于是

$$ \text{speedup} = \frac{1 - \alpha^{\gamma+1}}{(1-\alpha)\,(\gamma c + 1)} $$

数值感受:$\alpha = 0.8,\ \gamma = 4,\ c = 0.1$ 时,分子 $1 - 0.8^5 = 0.672$,加速比 $= \frac{0.672}{0.2 \times 1.4} = 2.4$ 倍。若 $\alpha$ 掉到 $0.5$,加速比只剩 $\frac{0.969}{0.5\times1.4} = 1.38$ 倍。接受率是决定性的。另外注意 $\alpha$ 与总变差距离的关系:$\alpha = 1 - \mathrm{TV}(p, q)$,所以「让草稿贴近目标」就是「最小化 TV 距离」。

9.5 怎么选/造草稿模型

课上给的实践搭配:

  • 目标 70B ← 草稿 8B
  • 目标 8B ← 草稿 1B
  • 尽量让草稿模型贴近目标模型——最直接的办法就是用目标模型蒸馏出草稿模型(这样 TV 距离最小,$\alpha$ 最高)。
Medusa 与 EAGLE 的结构对比图:Medusa 在目标模型顶部挂多个预测头并行猜多个位置;EAGLE 用一个轻量自回归头在目标模型的特征空间上做预测
两条改进草稿模型的主流路线。Medusa:不用独立的草稿模型,而是在目标模型最后一层并联多个预测头,第 $k$ 个头直接预测「往后第 $k$ 个 token」,一次前向同时猜出多个位置,再用树状注意力批量验证。优点是不需要维护第二个模型、只训练几个小头;缺点是各头之间独立,联合分布建模较弱。EAGLE:让草稿头在目标模型的高层特征空间(而不是 token 空间)上做自回归——因为特征比 token 更有规律、更好预测。EAGLE 系列的接受长度显著高于 Medusa,是目前的强基线。

还有一条几乎零成本的路线值得知道:n-gram / prompt lookup decoding——不用任何神经网络当草稿,直接在已有的上下文里查找与当前后缀匹配的片段,把它后面的 token 当作草稿。对「总结长文」「按模板改写」「代码编辑」这类输出大量复制输入的任务,接受率极高,且草稿成本 $c \approx 0$。

核心结论
  • 投机采样从目标模型精确采样——靠的是那个 $Z = \beta$ 的数学巧合。
  • 它利用的是「检查」与「生成」之间的成本不对称:一次目标模型前向可以验证任意多个 token。
  • 草稿模型的设计空间还很大(涉及训练),是一个仍在快速推进的方向。
  • 注意适用边界:投机采样把 memory-bound 的 decode 换成了「用更多算力去买带宽」。当服务已经在大 batch、算力接近饱和的状态运行时,它的收益会大幅缩水甚至变负——它最适合低并发、追求低延迟的场景(本地部署、单用户、延迟敏感的 agent 步骤)。

10. Prefill/Decode 分离与推理引擎生态

10.1 两个阶段的诉求彻底相反

把前面的结论并排放在一起,会发现 prefill 和 decode 简直是两种不同的 workload:

PrefillDecode
瓶颈算力(compute-bound)显存带宽(memory-bound)
算术强度MLP $BS$,注意力 $S/2$MLP $B$,注意力 $<1$
想要的 batch小(已经吃满算力,大 batch 只会拉长 TTFT)越大越好(摊薄参数读取)
对应指标TTFTTPOT / 吞吐
理想硬件算力强的卡带宽大、显存大的卡

如果把两者混在同一个 batch 里(传统做法),就会出现互相干扰:一个长 prompt 的 prefill 会占用整个 step 的时间,让同 batch 里所有正在 decode 的请求「卡顿」一下——用户看到的是输出忽快忽慢。业界把这个现象叫 decode 被 prefill 打断。

10.2 两种解法

  • 分块预填充(chunked prefill):把长 prompt 的 prefill 切成小块,每一步 decode 里顺带处理一小块 prefill。这样每个 step 的时长稳定,decode 不再被长时间打断,同时用 prefill 的计算「填满」了 decode 空转的算力——单实例内的调和方案。vLLM 的默认策略。
  • Prefill/Decode 分离部署(P/D disaggregation):更彻底——用两组不同的 GPU 池,一组专做 prefill,一组专做 decode,中间通过高速互联把 KV cache 传过去。好处是两组可以各自选择最合适的硬件、并行策略(prefill 适合 TP 高一些以压 TTFT,decode 适合大 batch + EP)和扩缩容比例;坏处是要传输 KV cache(一个 8k 上下文的 70B 模型 KV 有数 GB),对互联带宽要求高。代表工作有 DistServe、Splitwise,NVIDIA Dynamo、vLLM 与 SGLang 都已经支持这一模式,DeepSeek 公开的推理系统同样是 P/D 分离的。
直觉

为什么分离是「对」的方向?因为它把一个混合负载拆成了两个各自同质的负载,而同质负载才能被优化到极致。这跟数据中心里把 OLTP 和 OLAP 分开、把在线服务和批处理分开是同一个道理。代价永远是「中间那条数据通路」——这里就是 KV cache 的传输。

10.3 一张图看完全部优化

层次技术攻击的对象是否无损
模型架构GQA / MQA / MLA / CLAKV cache 的 $K,H,L$需验证(MLA 近似无损)
滑动窗口 / 稀疏注意力 / 线性注意力KV cache 的 $S$需验证
数值权重量化(GPTQ / AWQ / FP8)参数字节数需验证
KV cache 量化(FP8 / INT8)KV 字节数基本无损
模型压缩剪枝 + 蒸馏参数量、层数需验证
解码算法投机采样 / Medusa / EAGLE串行步数数学上严格无损
服务系统连续批处理 + 选择性批处理时间维度的空转无损
PagedAttention / RadixAttention显存碎片、重复前缀无损
Chunked prefill / P​/D 分离两阶段互相干扰无损
KernelFlashAttention / FlashDecoding / CUDA GraphsHBM 往返、launch 开销无损

一个实用的排序建议:先把所有「无损」的做完(换一个现代引擎基本就白送了:连续批处理 + PagedAttention + FlashAttention + CUDA Graphs),再考虑有损的(量化通常是性价比最高的第一步,架构改动成本最高但天花板也最高)。

本讲小结

一、必须记住的五个公式

量公式Llama 2 13B / H100 的数值
加速器强度$\dfrac{\text{FLOP/s}}{\text{B/s}}$$\dfrac{989\times10^{12}}{3.35\times10^{12}} \approx 295$
MLP 算术强度$BT$prefill $BS$(好);decode $B$(要 $>295$)
注意力算术强度$\dfrac{ST}{S+T}$prefill $S/2 = 512$(好);decode $<1$(无法改善)
KV cache 大小$2\,b\,L\,K\,H\,S\,B$MHA:800 KB / token;GQA-8:160 KB / token
延迟 / 吞吐$\dfrac{2N_{\text{param}} + B M_{\text{KV}}}{\text{BW}}$ / $\dfrac{B}{\text{latency}}$$B{=}64$:23.8 ms、2690 tok/s

二、四句话概括整讲

  1. 推理很重要——实际使用、模型评测、强化学习 rollout 三条线都靠它;训练是一次性成本,推理是每天重复的成本(OpenAI 每天 8.6T token,而一个前沿模型的全部训练语料才 32T token)。
  2. 推理和训练的性质完全不同——memory-bound 而非 compute-bound,且负载是动态的(请求随时到达、长度不一、前缀重复)。所以训练时代的直觉(「加大 batch 提高 MFU」「买算力更强的卡」)在这里大部分失效。
  3. 手段分四类:新架构(GQA / MLA / CLA / 局部与稀疏注意力)、量化(权重 + KV cache)、剪枝与蒸馏、投机采样。前三类有损、要验精度;投机采样无损。
  4. 很多好点子来自系统领域——投机执行(→ 投机解码)、分页与写时复制(→ PagedAttention)、迭代级调度(→ 连续批处理)、负载分离(→ P/D 分离)。

三、Percy 的判断

核心结论
  • 「注意力在 decode 阶段的算术强度小于 1 且无法改善」——这句话应该被理解为一个结构性事实:只要还在做自回归 + 全局注意力,就一定 memory-bound。所有优化都是在这个约束下腾挪。
  • 「新架构在推理效率上还有巨大的改进空间」——这是他给出的最强的方向性判断。MLA 的例子说明:更省显存的架构甚至可以更准,说明目前的 Transformer 远没有摸到 Pareto 前沿。线性注意力 / SSM 混合、扩散语言模型都是这条路上的候选。
  • 压缩模型的现代配方是「设计快架构 → 用原模型初始化 → 蒸馏修复」,而不是从零训练——因为你不该浪费掉已经付出的训练算力。

四、动手做什么

  • 用第 3 节的性能模型,给你手上的模型 + 显卡算一遍延迟/吞吐/最大 batch,再和 vLLM 实测对比——差距就是系统开销。
  • 实现一个带 KV cache 的 decode 循环,测 $B=1$ 和 $B=32$ 的每 token 时间,验证「延迟变差、吞吐变好」。
  • 用一个 1B 模型当草稿、8B 当目标,实现 9.2 节的投机采样,统计接受率 $\alpha$,代入 9.4 的公式核对预测的加速比。

延伸阅读

基础参考

KV cache 与注意力架构

量化与压缩

投机解码

推理服务系统