对应:Stanford CS336 Spring 2026 Lecture 10(Inference,Percy Liang 的可执行讲义
lecture_10.py)。Spring 2025 的第 10 讲内容相近,有公开录像。 配套代码:code/lecture10/预计阅读时间:3–4 小时 硬件说明:普通电脑即可。配套代码在第 2、3 讲的小模型上实现 KV 缓存、量化和推测解码;第一次运行会花约 45 秒训练这两个小模型。
0. 本讲导航
0.1 学完本讲,你应该能够
- 说出推理在哪些地方出现、为什么推理效率比训练效率更重要,以及三个指标:首 token 延迟(TTFT)、延迟、吞吐量。
- 解释 KV 缓存为什么能把生成的计算量从 降下来,并实现带 KV 缓存的生成。
- 用逐项统计 FLOPs 与读写字节的方法,推出”预填充计算受限、解码内存受限”,以及”批处理能帮 MLP、帮不了注意力”。
- 用”每步读一遍参数和 KV 缓存”的模型估算延迟、吞吐量,解释批次大小带来的权衡。
- 说出减小 KV 缓存的几种方法(GQA、MQA、MLA、跨层共享、局部注意力、混合架构),并能计算 KV 缓存的大小。
- 说出量化的基本操作、粒度的作用、QAT 与 PTQ、GPTQ 与 AWQ 的思路。
- 描述剪枝 + 蒸馏的流程。
- 推导推测采样为什么精确地保持目标模型的分布,并估算它的加速比。
- 解释连续批处理和 PagedAttention 怎样应对真实服务中”请求随时到来、长度各不相同、前缀常常相同”的负载。
0.2 先修知识
| 知识点 | 用在哪里 | 最低要求 |
|---|---|---|
| 第 3 讲 | 全文 | Transformer 的结构、GQA、RoPE、采样(温度、top-p) |
| 第 5 讲 | 第 3 节 | roofline、算术强度、H100 的临界点约 295 |
| 第 2 讲 | 第 5 节 | 浮点格式(FP32、BF16、FP8) |
| 第 4 讲 | 第 5.1 节 | MLA、滑动窗口、线性注意力 / 状态空间模型 |
| 概率 | 第 6 节 | 条件概率、拒绝采样的直觉 |
0.3 本讲地图
第1节 推理无处不在:为什么要关心推理效率
第2节 从朴素生成到 KV 缓存;预填充与解码
第3节 算术强度:为什么解码是内存受限的
第4节 延迟与吞吐量的权衡
第5节 抄近路(有损):减小 KV 缓存、量化、剪枝与蒸馏
第6节 抄近路但仔细检查(无损):推测解码
第7节 动态负载:连续批处理与 PagedAttention
CS336 课上的组织方式:先理解推理的工作负载(第 2–4 节),再看两类加速方法——有损的捷径(改模型,第 5 节)和无损的捷径(不改输出分布,第 6 节),最后看真实服务的动态负载(第 7 节)。
1. 推理无处不在
1.1 推理出现在哪里
- 实际使用:聊天机器人、代码补全、智能体(agent)、批量数据处理;
- 模型评估:例如评估指令遵循能力,要让模型生成大量回答(第 12 讲);
- 强化学习:采样很多个回答,再打分(第 16 讲的 RLVR 中,采样往往是最花时间的部分)。
1.2 为什么效率这么重要
**训练是一次性的成本,推理要重复无数次。**CS336 课上引用的数字:OpenAI 每天处理约 8.6 万亿 token;作为对比,DeepSeek V4 的整个预训练用了约 32 万亿 token——也就是说,一个大型服务几天的推理量就相当于一次前沿模型的预训练。
而且:
- 聊天机器人生成的 token 大多是给人看的,人读得慢,还算”有上限”;
- 智能体不一样:一个请求会引出长长的内部推理轨迹和工具调用,生成的 token 数可以无限增长;
- 生成的 token 数 = 花掉的算力。
做推理的公司:提供闭源模型服务的(OpenAI、Anthropic、Google 等),提供开放权重模型服务的(Together、Fireworks、Baseten、DeepInfra、Groq、Cerebras 等)。常用的开源推理引擎:
- vLLM(伯克利):首创 PagedAttention,流行、默认的好选择;
- SGLang(伯克利):首创 RadixAttention,适合智能体类负载;
- TensorRT-LLM(NVIDIA):针对 NVIDIA GPU 高度优化;
- llama.cpp:纯 C++,支持 CPU,适合在本地运行。
1.3 “快”是什么意思
| 指标 | 含义 | 对谁重要 |
|---|---|---|
| 首 token 延迟(TTFT) | 用户发出请求后,要等多久才看到第一个字 | 交互式应用 |
| 延迟(秒 / token) | 对一个请求,token 出现得有多快 | 交互式应用 |
| 吞吐量(token / 秒) | 对很多请求,总共每秒生成多少 token | 批处理、服务成本 |
1.4 推理为什么比训练难优化
- 训练(有监督):所有 token 都已知,可以沿序列并行,Transformer 中都是大矩阵乘法,容易喂饱 GPU;
- 推理:必须一个一个地生成,第 个 token 要等第 个生成出来,沿生成方向无法并行,GPU 很难被用满。
2. 从朴素生成到 KV 缓存
2.1 朴素生成的浪费
最朴素的生成方法(第 2、3 讲的 generate 函数就是这样写的):每生成一个 token,都把整个序列(提示词 + 已生成的部分)重新送进模型,只取最后一个位置的输出。一次前向是 (注意力),生成 个 token 就是 。
2.2 KV 缓存
观察:很多工作可以在不同前缀之间共享。在因果注意力中,前面 token 的 K、V 向量永远不会变,因为它们看不到后面的 token。所以把每一层的 K、V 存在显存里(KV 缓存),每一步只需要:
- 为新 token 计算 Q、K、V;
- 把新的 K、V 追加到缓存;
- 新 token 的 Q 与缓存中所有的 K、V 做注意力。
KV 缓存的大小:对每个序列()、每个 token()、每一层()、每个键值头(),存一个 维的 K 向量和一个 维的 V 向量。
于是推理分成两个阶段:
- 预填充(prefill):把提示词一次性编码成向量、填满缓存——所有位置并行计算,和训练一样;
- 解码(decode / generation):一个一个地生成新 token——每步只处理 1 个新 token。
2.3 亲手实现
03_kv_cache_generation.py 在第 3 讲训练好的 Transformer 上实现了带缓存的前向传播,直接复用它的权重和子模块:
for i, layer in enumerate(model.layers):
h = layer.ln1(x)
q, k, v = 投影(h)
q, k = rope(q, pos), rope(k, pos) # 注意:用全局位置 start…end-1,不是从 0 开始
cache.k[i][:, :, start:end] = k # 追加到缓存
cache.v[i][:, :, start:end] = v
out = attention(q, cache.k[i][:, :, :end], cache.v[i][:, :, :end], 因果掩码)
x = x + output_proj(out)
x = x + ffn(layer.ln2(x))
正确性:贪心和温度 0.8 采样两种设置下,带缓存与不带缓存生成的 120 个 token 完全相同,每一步 logits 的最大差异约 (只来自浮点运算的顺序)。
速度(生成 200 个 token,本机 CPU):
| 第 1~10 步平均 | 第 191~200 步平均 | 总耗时 | |
|---|---|---|---|
| 不带缓存 | 1.15 ms | 3.19 ms | 0.42 s |
| 带 KV 缓存 | 0.79 ms | 0.79 ms | 0.15 s |
不带缓存时越往后越慢;带缓存时每步耗时基本不变。代价是显存:这个小模型每个 token 的 KV 缓存是 4 KB,而 Llama 2 13B(BF16)是 800 KB / token。
预填充 vs 逐个解码:同样把 200 个 token 的 K、V 算出来存进缓存,一次性预填充只要 2.9 ms,逐个解码要 149 ms,慢 51 倍。预填充把 200 个 token 拼成一个大矩阵乘法,权重只读一次;逐个解码每次只有 1 个 token,权重要读 200 次。下一节会算清楚为什么。
3. 算术强度:为什么解码是内存受限的
3.1 复习:矩阵乘法的算术强度
CS336 课上的方法:逐项统计 FLOPs 和读写的字节数(BF16,每个数 2 字节)。()乘 ():
| 步骤 | 读写字节 | FLOPs |
|---|---|---|
| 从显存读 | ||
| 从显存读 | ||
| 计算 | ||
| 把 写回显存 |
H100 的临界算术强度是 (第 5 讲)。所以:** 时计算受限(好),否则内存受限(差)。**极端情况 (矩阵乘向量):强度为 1,读了 的矩阵,却只做了 次运算。这基本上就是推理时的情况。
3.2 MLP 层
输入 (),SwiGLU 的三个矩阵。统计后:
- 预填充():通过增大 (大批次、长序列)很容易做到计算受限;
- 解码():强度 ≈ ,而 是并发请求的数量,在交互式应用中是不可控的。
3.3 注意力层(用 FlashAttention,不写出注意力矩阵)
是已有的 token 数, 是这次要算的新 token 数:
- 预填充():强度 ,很好;
- 解码():强度 ,很差。
**关键: 被约掉了——批处理帮不了注意力!**为什么?
- MLP 中,每个序列都用同样的权重(、、 与 无关),批次越大,读一次权重被用的次数越多;
- 注意力中,每个序列都有自己的 KV 缓存(Q、K、V 都与 有关),批次加倍,要读的数据也加倍。
3.4 小结与实测
| MLP 的强度 | 注意力的强度 | |
|---|---|---|
| 预填充 | ||
| 解码 | (需要大量并发请求) | (无法改善) |
01_arithmetic_intensity.py 在本机(Apple M4 Pro 的 GPU,FP16)上实测解码的一步:
- 矩阵乘法( 个 token 乘同一个 4096×4096 的权重): 从 1 增加到 64,token 数是 64 倍,耗时只变成约 2.6 倍,每个 token 的耗时下降了约 25 倍;
- 注意力( 个序列,各看自己的 2048 个历史 token): 从 1 到 64,耗时变成约 32 倍,每个序列的耗时只下降了约 2 倍。
**结论:预填充是计算受限的,解码是内存受限的。**解码的每一步都要把全部参数和 KV 缓存从显存读一遍,读出来的每个字节只做一两次运算,GPU 的算力大部分闲着。
4. 延迟与吞吐量的权衡
4.1 一个简单而有用的模型
既然解码是内存受限的,就可以这样估算(假设计算与读取完美重叠,忽略其他开销):
每个序列的 KV 缓存 。
4.2 Llama 2 13B 在 H100 上
02_latency_throughput.py 复现了 CS336 课上的例子(上下文 1024,参数 13.0B,每个序列的 KV 缓存 839 MB):
| 批次 | 总显存 | 延迟 / token | 吞吐量(token/s) |
|---|---|---|---|
| 1 | 26.9 GB | 8.0 ms | 125 |
| 64 | 79.7 GB | 23.8 ms | 2,689 |
| 256 | 240.8 GB(放不下) | 71.9 ms | 3,562 |
增大批次:
- 延迟变差:KV 缓存与 成正比,每步要读的数据更多;
- 吞吐量变好:参数的读取被更多序列分摊;
- 批次再大就放不下显存了,吞吐量的增长也在递减。
这就是推理的核心权衡:小批次 = 低延迟、低吞吐;大批次 = 高吞吐、高延迟。
另外两点(CS336 课件):
- 最简单的并行:部署 份模型副本,延迟不变,吞吐量乘以 。更难的并行是把模型和 KV 缓存切分到多块卡上(第 7、8 讲的思路);
- 首 token 延迟基本由预填充的时间决定。预填充时用小批次(让用户早点看到回复),解码时用大批次(提高吞吐)。
5. 抄近路(有损):改模型,让推理更便宜
既然显存读写是瓶颈,最直接的办法就是让要读的数据变少——缩小 KV 缓存、降低数值精度、减少参数。代价是可能损失一些准确率,所以每种方法都要检查精度。
5.1 减小 KV 缓存
GQA / MQA(第 3 讲): 个查询头只配 个键值头,每个键值头被 个查询头共用。MHA:;MQA:;GQA:介于两者之间。KV 缓存缩小 倍。
把 Llama 2 13B 的键值头从 40 个改成 8 个(02_latency_throughput.py 演示 2):
| 配置 | 批次 | 总显存 | 延迟 | 吞吐量 |
|---|---|---|---|---|
| MHA(K = 40) | 64 | 79.7 GB | 23.8 ms | 2,689 |
| GQA(K = 8) | 64 | 33.4 GB | 10.0 ms | 6,417 |
| GQA(K = 8) | 256 | 65.6 GB | 19.6 ms | 13,068 |
每个序列的 KV 缓存从 839 MB 降到 168 MB:同样的批次,延迟更低、吞吐量更高;而且批次 256 现在放得下了。GQA 论文(Ainslie et al. 2023)也验证了准确率几乎没有下降。
MLA(多头潜在注意力,DeepSeek-V2,第 4 讲):不存每个头的 K、V,而是存一个压缩向量 ,需要时再投影回 、。DeepSeek-V2 把 = 16384 压缩到 512 维。麻烦是 MLA 与 RoPE 不兼容,要额外加 64 维给 RoPE,共 576 维。据 DeepSeek-V2 论文,MHA 比 GQA 准确(但更贵),而 MLA 比 MHA 还好一点(而且便宜得多)。
跨层注意力(CLA):GQA 在”头”之间共享 KV,CLA 在”层”之间共享 KV。实验表明它改善了”准确率 - KV 缓存大小”的帕累托前沿。
局部(滑动窗口)注意力(Longformer、Sparse Transformer、Mistral 7B):只看最近的 个 token。有效上下文随层数线性增长(每层往回看 ),KV 缓存与序列长度无关。问题是可能损失准确率,解决办法是局部层与全局层交替(混合架构)。
更激进的设计:据 CS336 2026 年的课件,DeepSeek V4 支持 100 万 token 的上下文,把几种压缩结合起来:压缩稀疏注意力(每 个 token 压缩成 1 个)、DeepSeek 稀疏注意力(只选最相关的 top-k 个)、以及压缩得更狠的注意力。此外还有第 4 讲的线性注意力 / 状态空间模型(Mamba-2、Gated DeltaNet,状态大小固定),甚至扩散模型等思路。
KV 缓存计算器(02_latency_throughput.py 演示 3,一个 131,072 token 的序列,BF16):
| 模型 | 每 token | 一个 128K 序列 |
|---|---|---|
| Llama 2 13B(MHA) | 800 KB | 107.4 GB |
| Llama 3 70B(GQA,8 个 KV 头) | 320 KB | 42.9 GB |
| Llama 3 405B(GQA,8 个 KV 头) | 504 KB | 67.6 GB |
| DeepSeek-V3(MLA,512 + 64 维) | 69 KB | 9.2 GB |
| Mistral 7B(GQA + 4096 滑动窗口) | 128 KB | 0.5 GB(只存最近 4096 个) |
长上下文推理的瓶颈往往是 KV 缓存,而不是参数。
5.2 量化
**核心想法:降低数值精度。**数据少了,延迟和吞吐量都会变好(因为内存受限)。当然要担心准确率。
基本操作(CS336 课上的例子):,缩放因子 0.1,零点 4:
常见格式:FP32(4 字节,训练的主权重和优化器状态需要);BF16(2 字节,推理的默认);FP8(1 字节,E4M3 最大约 ±448,H100 起有硬件支持,“敢的话可以用来训练”);INT8(1 字节,[−128, 127],比 FP8 便宜但精度略差,一般只用于推理);INT4(0.5 字节,[−8, 7],更省、更不准)。
两种做法:
- 量化感知训练(QAT):训练时在前向传播中”量化再反量化”,模拟量化误差,让权重学会适应。效果好,但需要昂贵的大规模训练;
- 训练后量化(PTQ):训练完再做,便宜得多。用一小批样本数据确定每层 / 每个张量的缩放因子和零点。
粒度(04_quantization.py 演示 2):多少个数共用一个缩放因子?在一个带 20 个离群值的 512×512 矩阵上做 INT4:
| 方案 | INT4 相对误差 | 每个权重的平均比特 |
|---|---|---|
| 整个张量一个缩放因子 | 0.947 | 4.00 |
| 每个输出通道一个 | 0.201 | 4.03 |
| 每 128 个数一组 | 0.136 | 4.12 |
| 每 32 个数一组 | 0.100 | 4.50 |
一个离群值会把它所在那一组的缩放因子撑大,组里其他数就只能用很少几个档位表示。粒度越细越准,代价是多存缩放因子。
GPTQ(Frantar et al. 2022):用二阶信息(海森矩阵)逐列量化,并更新还没量化的权重来补偿已经产生的量化误差。
AWQ(激活感知的权重量化,Lin et al. 2023):
- 观察:有些激活通道特别大,与它们相乘的权重更重要;
- 只需要”保护”约 0.1%~1% 的权重;
- 做法:把这些重要的权重乘以一个放大系数 再量化,同时把对应的激活除以 (输出不变),重要权重的相对量化误差就变小了;
- 论文报告:FP16 → INT3,显存省 4 倍,速度快 3.2 倍。
04_quantization.py 演示 3 在合成数据上验证(1% 的激活通道放大 20 倍):INT4 的输出相对误差从 0.118(直接取整)降到 0.076(AWQ),INT3 从 0.273 降到 0.177。背景:LLM.int8() 的作者发现,模型大到约 67 亿参数以上时,激活中会系统性地出现幅度极大的离群通道——这是大模型量化比小模型难的原因之一。
量化一个真实的(小)模型(演示 4,第 3 讲的 Transformer,只量化线性层权重):
| 方案 | 每权重比特 | 验证损失(比特/字节) | 上升 |
|---|---|---|---|
| FP32 | 32 | 0.2310 | — |
| INT8,每个输出通道 | 8 | 0.2310 | 0% |
| INT4,整个张量 | 4 | 0.2390 | 3.5% |
| INT4,每个输出通道 | 4 | 0.2356 | 2.0% |
| INT4,每 32 个一组 | 4.5 | 0.2328 | 0.8% |
| INT3,每 32 个一组 | 3.5 | 0.2494 | 8.0% |
| INT2,每 32 个一组 | 2.5 | 2.7749 | 模型坏了 |
INT8 几乎无损;INT4 分组量化只上升约 1%;INT3 开始明显变差;INT2 把模型”量坏”了。AWQ 在这个小模型上几乎没有帮助(INT4:0.2342 vs 0.2328;INT3:0.2478 vs 0.2494):它的激活没有明显的离群通道(最大通道与中位通道之比只有 1.3~5.8),没什么需要保护的。这与合成数据的结果并不矛盾:AWQ 针对的正是大模型中系统性出现的离群通道。
**【边界】**这里是”模拟量化”(量化后立即反量化再计算),只衡量精度。真正的加速需要专门的核函数直接读取低比特的权重。这里只量化了权重(W4A16);把激活也量化(W8A8)能用上 INT8 矩阵乘法的算力,但更难;KV 缓存也可以量化。
5.3 剪枝与蒸馏
核心想法:直接把一个昂贵模型的一部分”撕掉”,让它变便宜,然后再修好。
NVIDIA 的 Minitron(Muralidharan et al. 2024)的流程:
- 在一个很小的校准数据集(1024 个样本)上,估计每一层、每个注意力头、每个隐藏维度的重要性;
- 去掉不重要的部分,得到一个更小的模型;
- 用原模型蒸馏(让小模型学习大模型的输出分布)修复它。
论文报告:用这种方法从 150 亿参数的模型得到 80 亿、40 亿参数的模型,每个模型需要的训练 token 比从头训练少最多约 40 倍,效果与同规模的其他开源模型相当。
CS336 课上总结了两种”让推理更便宜”的配方:
- 从头训练:定义更快的模型架构 → 训练它;
- 蒸馏:定义更快的模型架构 → 用原模型的权重初始化(即使架构不同)→ 蒸馏修复。
6. 抄近路但仔细检查(无损):推测解码
6.1 利用”检查比生成快”
回忆推理的两个阶段:
- 预填充:给定一个序列,并行地编码所有 token(计算受限)。注意,这一步顺便给出了每个位置的概率;
- 解码:一次生成一个 token(内存受限)。
换句话说:**检查比生成快。**让目标模型”检查” 个 token(一次前向,像预填充),耗时几乎和”生成” 1 个 token 一样。
推测采样(speculative sampling,Leviathan et al. 2023;Chen et al. 2023):
- 用便宜的草稿模型 一口气猜 个 token(例如 4 个);
- 用目标模型 做一次前向传播,同时得到这 个位置(外加 1 个)的概率;
- 从左到右逐个检查:草稿 token 以概率 被接受;一旦某个被拒绝,就从残差分布 中重新采样一个 token,丢掉后面的草稿,本轮结束;
- 如果 个全部接受,再从目标模型的第 个位置”白送”一个 token。
6.2 为什么输出分布精确等于目标分布
这是一种修改过的拒绝采样(提议分布 ,目标分布 )。修改之处:被拒绝时不重新循环,而是直接从残差分布抽一个,保证每轮至少产出一个 token。
证明:输出 token 有两条途径:
- 草稿抽到 并被接受:;
- 草稿被拒绝(总概率 ),然后从残差分布抽到 :(因为分母恰好等于 )。
两者相加:。✓
CS336 课上的两 token 例子:词表 ,草稿高估了 (,于是 ),残差分布是 :
关键性质:它保证是来自目标模型的精确样本——草稿只影响”快不快”,不影响”对不对”。
05_speculative_decoding.py 验证:
- 小词表(草稿故意猜得很离谱, 与 顺序相反):公式算出的输出分布与 完全相同,抽 10 万次的频率也与 一致;
- 真实模型(目标:第 3 讲的 Transformer;草稿:第 2 讲只看 8 个字节的 MLP,小 17 倍):输出分布与目标分布的最大差异 ;抽 2 万次,推测采样的频率与 的总变差距离是 0.005,比”直接从 抽样”的纯抽样噪声(0.011)还小。
6.3 能快多少?
在真实模型上生成 5 个故事(各 200 个 token, = 4):
- 草稿接受率 = 0.84;
- 每次调用目标模型平均得到 3.56 个 token(普通解码是 1 个);理论公式(假设每个位置独立地以概率 接受)。
被拒绝的位置在哪里?文件用方括号标出了它们:
Once upon a time, there was a sma[l]l m[o][u][s]e named [D]amira. [D]a[m]i[r]a loved to swim in the park.
One day, [D]a[m]i[r][a] [f]ound a shiny stone. Then [D]a[m]i[r][a] [m][e]t a duck named [V]inu. ...
几乎都在名字上。草稿只看最近 8 个字节,看不到几十个字节之前出现过的名字,只能瞎猜;而模板文字它猜得很准。草稿越接近目标模型,接受率越高、加速越多。
加速比估算:设草稿每猜一个 token 的成本是目标模型的 倍(这里 ≈ 参数量之比 0.058),目标模型检查 个 token ≈ 生成 1 个 token 的成本:
| 1 | 2 | 4 | 6 | 8 | 12 | |
|---|---|---|---|---|---|---|
| 加速比 | 1.74 | 2.28 | 2.94 | 3.25 | 3.35 | 3.27 |
太小,一轮得不到几个 token; 太大,后面的草稿大多会被拒绝,还要付草稿的成本。
实践中(CS336 课上的例子):目标 700 亿参数配 80 亿的草稿,或目标 80 亿配 10 亿的草稿;草稿通常从目标模型蒸馏得到。改进草稿的方向:
- Medusa:在目标模型上加几个预测头,并行地猜后面几个 token;
- EAGLE:草稿直接使用目标模型的高层特征来猜。
CS336 的总结:推测采样利用了”检查与生成”的不对称,多亏了数学,输出是目标模型的精确样本;草稿模型还有很大的创新空间(通常需要训练)。
7. 动态负载:连续批处理与 PagedAttention
7.1 真实服务的”乱”
训练时,数据是整整齐齐的一块(批次 × 序列长度)。真实服务中(CS336 课件):
- 请求在不同的时间到达(为了凑批而让早到的请求等待,很糟糕);
- 序列有共同的前缀(系统提示词、对同一个问题生成多个样本);
- 序列长度各不相同(按最长的填充,很浪费)。
7.2 连续批处理
问题:训练得到的是整齐的一块 token,推理中请求在不同时间到达和结束,是一个”参差不齐”的数组。
解决办法:按迭代调度(Orca,Yu et al. 2022):
- 一步一步地解码;
- 新请求一到就加入批次,不必等当前批次全部生成完;已经结束的序列立即离开。
另一个问题:批处理只对形状相同的序列有效?每个请求长度都不同。
选择性批处理:注意力部分,每个序列单独计算(形状 、、……);非注意力部分(MLP 等逐 token 的运算),把所有序列拼接成 一起算。不需要填充。
06_batching_and_paging.py 模拟了 400 个请求(最大批次 32,输出长度 10~300 随机):
| 到达率(个/步) | 方式 | 吞吐(token/步) | 平均延迟(步) |
|---|---|---|---|
| 0.05 | 静态 | 7.3 | 441 |
| 0.05 | 连续 | 7.5 | 147 |
| 0.2 | 静态 | 14.7 | 1034 |
| 0.2 | 连续 | 26.2 | 161 |
请求来得慢时,吞吐量受限于到达速度,两种方式差不多,但连续批处理的延迟低得多;请求来得快时,静态批处理跟不上,请求越积越多,延迟暴涨,而连续批处理的吞吐量高得多。
7.3 PagedAttention
以前的做法:请求一来,就为它的提示词和回答预留一段连续的 KV 缓存空间(按最大长度)。
问题:碎片(就像硬盘的碎片):
- 实际生成的 token 可能远少于最大长度——预留的空间大部分是空的(内部碎片);
- 不同大小的预留区之间还会留下用不上的空隙(外部碎片)。
解决办法:PagedAttention(Kwon et al. 2023,vLLM 的论文;回忆一下操作系统):把每个序列的 KV 缓存切成不连续的块(例如每块 16 个 token),按需分配,用一张”块表”记录每个逻辑块在哪个物理块里。
06_batching_and_paging.py 演示 2(显存能存 16384 个 token 的 KV;请求实际长度 50~600,平均 328;最大允许 1024):
| 方式 | 同时容纳的请求 | 真正用到的槽位 |
|---|---|---|
| 按最大长度连续预分配 | 16 | 33% |
| 分页(每块 16 个 token) | 49 | 96% |
同样的显存,能同时服务约 3 倍的请求,批次更大,吞吐量更高(第 4 节)。
共享前缀:多个序列可以共享同样的 KV 缓存块:
- 共享系统提示词;
- 对同一个提示词采样多个回答(例如程序生成、强化学习)。
实现方法:在块的层面共享前缀,并用写时复制(copy-on-write):物理块带引用计数,要往一个被共享的块里写时,先复制一份给自己。演示 3(提示词 100 个 token,4 个回答各 60 个 token,每块 16 个 token):不共享要 40 个块,共享前缀只要 22 个块(省 45%),写时复制恰好发生 3 次——提示词的第 7 个块只装了 4 个 token,前 3 个回答往里写时各复制一次,第 4 个回答写时它已经不再被共享。
vLLM 的其他优化:把”按块表读取”和注意力融合成一个核函数(减少核函数启动开销);使用最新的核函数(FlashAttention、FlashDecoding);用 CUDA Graph 避免核函数启动开销。SGLang 的 RadixAttention 更进一步:用一棵前缀树缓存所有见过的前缀,新请求自动复用最长的公共前缀。
CS336 的总结:借用操作系统的想法(分页),让显存适应动态的负载。
8. 本讲小结
- 推理很重要:实际使用、评估、强化学习都要推理;训练是一次性的,推理是重复的。指标:TTFT、延迟、吞吐量。
- KV 缓存:前面 token 的 K、V 不会变,存下来复用。推理分预填充(并行)和解码(逐个)。
- 算术强度:矩阵乘法 / MLP ≈ ,注意力 。预填充计算受限,解码内存受限;批处理能帮 MLP,帮不了注意力。
- 延迟 = 显存 / 带宽,吞吐量 = 批次 / 延迟:大批次提高吞吐、恶化延迟,还受显存限制。
- 有损的捷径:缩小 KV 缓存(GQA、MLA、CLA、局部注意力、混合架构);量化(粒度、GPTQ、AWQ;INT8 几乎无损、INT4 分组量化损失很小);剪枝 + 蒸馏。
- 无损的捷径:推测解码利用”检查比生成快”,输出是目标模型的精确样本;加速取决于草稿的接受率和成本。
- 动态负载:连续批处理(按迭代调度、选择性批处理);PagedAttention(分页、前缀共享、写时复制)。
- 新架构潜力巨大:推理效率的很大一部分取决于模型本身(KV 缓存的大小、是否需要逐 token 的注意力),这又回到了第 3、4 讲的架构设计。
9. 与作业的关系
- 作业 5(对齐)的强化学习部分需要对同一个问题采样很多回答,通常会用 vLLM 做生成——本讲的 KV 缓存、连续批处理、前缀共享都在它背后工作。理解这些能帮你判断:采样为什么是训练循环中最慢的一环,批次和采样数怎么设置更划算。
- 作业 1、2 中你写的
generate函数(没有 KV 缓存)可以用本讲03_kv_cache_generation.py的方法加速。 - 资源核算类的问题常常会问:给定模型和 GPU,最大批次是多少、延迟和吞吐量是多少——就是第 4 节的模型。
10. 练习
练习 1(KV 缓存大小):Llama 3 8B:32 层,32 个查询头,8 个键值头,每头 128 维,BF16。(a) 每个 token 的 KV 缓存多大?(b) 上下文 8192、批次 32 时,KV 缓存共多大?(c) 如果改成 MHA 呢?
参考答案
(a) = 131,072 字节 = 128 KB。(b) ≈ 34.4 GB(与 16 GB 的 BF16 参数相比更大)。(c) MHA 有 32 个键值头,是 GQA 的 4 倍,约 137 GB,一块 80 GB 的卡放不下。
练习 2(算术强度):解码时,MLP 的强度约为 。H100 的临界点是 295。(a) 至少要多少个并发请求,MLP 才能计算受限?(b) 为什么注意力无论如何都做不到?(c) 这对”一块 GPU 同时服务多少用户”意味着什么?
参考答案
(a) 约 300 个(实际还要算上 不远小于 、 时的修正)。(b) 注意力的强度 ,与 无关,因为每个序列都要读自己的 KV 缓存。(c) 并发越多,MLP 越高效;但 KV 缓存与并发数成正比地增长,很快会占满显存,而且注意力的读取时间也随之线性增长。所以要缩小 KV 缓存(GQA、MLA、量化),才能容纳更多并发。
练习 3(延迟与吞吐):用第 4 节的模型,Llama 2 13B(GQA,K = 8)在一块 H100 上,(a) 最多能放下多大的批次(显存 80 GB)?(b) 这时延迟和吞吐量是多少?
参考答案
(a) 参数 22.7 GB,每个序列 168 MB,。(b) 显存约 80 GB,延迟 ≈ 80 GB / 3.35 TB/s ≈ 23.9 ms,吞吐量 ≈ 341 / 0.0239 ≈ 14,300 token/s。(实际上批次这么大时计算可能成为瓶颈,而且还要留显存给激活值和碎片。)
练习 4(量化粒度):一个 4096×4096 的权重矩阵用 INT4、每 128 个数一组、缩放因子用 FP16。(a) 平均每个权重多少比特?(b) 整个矩阵占多少字节?与 BF16 相比省了多少?
参考答案
(a) 比特。(b) ≈ 8.65 MB;BF16 是 33.6 MB,省了约 74%。
练习 5(推测采样):词表 ,目标 ,草稿 。(a) 每步接受草稿的概率是多少?(b) 残差分布是什么?(c) 验证输出分布等于 。
参考答案
(a) 。(b) ,归一化为 。(c) 输出 :;:;:。✓
练习 6(最佳猜测长度):接受率 = 0.7,草稿成本 = 0.1。用 6.3 节的公式算出 = 1~6 的加速比,最佳的 是多少?
参考答案
期望 token 数 :=1:1.70,2:2.19,3:2.53,4:2.77,5:2.94,6:3.06。每轮耗时 :1.1、1.2、1.3、1.4、1.5、1.6。加速比:1.55、1.83、1.95、1.98、1.96、1.91。最佳 ≈ 4,约 2 倍。接受率越低,最佳的 越小。
练习 7(为什么批处理对推测解码不太友好)★:在大批次服务中,推测解码的收益通常会变小。结合第 3、4 节,说说为什么。
参考答案
推测解码的前提是”解码内存受限,检查 个 token 和生成 1 个几乎一样快”。批次很大时,MLP 已经接近计算受限,检查 个 token 意味着 倍的计算,不再”免费”;而且被拒绝的草稿 token 浪费了真实的算力。所以推测解码在小批次、低延迟的场景下收益最大,在高吞吐的离线批处理中收益有限。
练习 8(动手):
(a) 在 03_kv_cache_generation.py 中实现 GQA 式的 KV 缓存(只存 个头),并计算缓存大小的变化。
(b) 在 05_speculative_decoding.py 中换一个更好的草稿(例如 1 层的小 Transformer),接受率会提高多少?被拒绝的位置还集中在名字上吗?
(c) 在 06_batching_and_paging.py 的连续批处理中加入显存限制,比较连续预分配与分页的吞吐量。
11. 延伸阅读
- CS336 Spring 2026 Lecture 10(
stanford-cs336/lectures中的lecture_10.py)。 - Google,How to Scale Your Model(Scaling Book)的 Transformers 与 Inference 两章:本讲的记号与分析方法来源于此。
- Pope et al.,Efficiently Scaling Transformer Inference(2022):推理的延迟、吞吐量与切分。
- Ainslie et al.,GQA(2023);DeepSeek-AI,DeepSeek-V2(2024,MLA);Brandon et al.,Reducing Transformer Key-Value Cache Size with Cross-Layer Attention(2024)。
- Dettmers et al.,LLM.int8()(2022);Frantar et al.,GPTQ(2022);Lin et al.,AWQ(2023)。
- Muralidharan et al.,Compact Language Models via Pruning and Knowledge Distillation(2024,Minitron)。
- Leviathan et al.,Fast Inference from Transformers via Speculative Decoding(2023);Chen et al.,Accelerating Large Language Model Decoding with Speculative Sampling(2023);Cai et al.,Medusa(2024);Li et al.,EAGLE(2024)。
- Yu et al.,Orca: A Distributed Serving System for Transformer-Based Generative Models(OSDI 2022);Kwon et al.,Efficient Memory Management for Large Language Model Serving with PagedAttention(SOSP 2023,vLLM);Zheng et al.,SGLang(2024)。
12. 下一讲预告
第 9 讲说明了怎么用小模型的实验预测”训练多大的模型、用多少数据”。但还有一个问题没有解决:超参数。在小模型上调好的学习率、批次大小、初始化,能直接用到大模型上吗?
下一讲(规模定律 II)会回答:
- 为什么简单地把模型变宽,最优学习率就会漂移?
- μP(最大更新参数化)怎样让”小模型上调好的超参数”直接迁移到大模型?
- 学习率调度:为什么越来越多的模型用 WSD(预热 - 稳定 - 衰减)代替余弦调度?它和规模定律实验有什么关系?
- 临界批量大小在实践中怎么用?Cerebras-GPT、MiniCPM、DeepSeek、Llama 3 等模型的规模实验是怎么做的?
下一讲之前,建议你:跑通 code/lecture10/ 下的 6 个脚本,读懂 forward_with_cache 和 speculative_generate,完成练习 1、2、5、6。