Lecture 6 · 优化自回归 decode:连续批处理与投机解码 · Optimizing Autoregressive Decode

Efficient ML Inference at Scale · Lecture 06。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

对应 Maps to:Stanford CS349E / EE339 Efficient ML Inference at Scale(Autumn 2026),Lecture 6 · Thu Oct 8 · Optimizing Autoregressive Decode(Zain Asgar)。阅读材料:G.-I. Yu et al., Orca: A Distributed Serving System for Transformer-Based Generative Models(OSDI 2022);Y. Leviathan, M. Kalman, Y. Matias, Fast Inference from Transformers via Speculative Decoding(ICML 2023)。 依据说明:课程网站只公开了课表和阅读材料,没有 slides。本讲以两篇阅读材料为主线,另外用到这些公开文献:Chen et al. 2023(DeepMind 同期提出的 speculative sampling)、SpecInfer(Miao et al., ASPLOS 2024)、Medusa(Cai et al., ICML 2024)、EAGLE / EAGLE-3(Li et al., ICML 2024 / 2025)、DeepSeek-V3 技术报告(MTP 用于投机解码的数字)。补充材料是我选的,不代表课堂上实际讲了什么。 配套代码 Code:code/lecture06/(numpy;一个共享模块 + 5 个脚本,最长的约 15 秒) 和 CS349D 的关系:本仓库 CS349D 第 2 讲(批处理与调度)和第 5 讲(投机解码)读的是同样的论文,偏服务系统;本讲更多从 roofline 和硬件的角度看这两种技术,两边可以互相参照。 预计阅读时间:3–4 小时(含运行代码)


0. 本讲导航 · How to read this lecture

0.1 学习目标 Learning goals

学完这一讲,你应该能够:

  1. 说清楚 decode 慢在哪里:每一步只给每个序列产出 1 个 token,却要把全部权重读一遍;提高 decode 效率的两条路——每步多放几个序列(批处理)和每步给同一个序列多算几个 token(投机解码)——本质上都是在提高算术强度。
  2. 解释请求级调度的两个问题(早结束的请求不能返回、新请求要等整批结束),以及 Orca 的迭代级调度和选择性批处理怎样解决它们;知道选择性批处理为什么几乎不损失效率(注意力没有权重)。
  3. 理解 KV cache 的预留在调度里的作用:按最大长度预留浪费显存,按声明的上限预留仍然浪费,不预留又可能死锁——这是第 7 讲的出发点。
  4. 证明投机采样不改变输出分布;说出接受概率 β=∑xmin⁡(p(x),q(x))=1−TV(p,q)\beta = \sum_x \min(p(x), q(x)) = 1 - \mathrm{TV}(p, q);认出两种常见的错误实现。
  5. 用 Leviathan 的公式算每轮期望产出的 token 数、墙钟加速比、最优草稿长度和运算量的增加;知道这些公式依赖哪些假设。
  6. 用 roofline 判断投机解码什么时候划算:代价系数 cc 取决于硬件;批一大验证就不再免费;树形验证(Medusa、EAGLE、SpecInfer)在小批时很划算、在大批时不划算。

0.2 先修与本讲范围 Prerequisites & scope

默认你已经会 Assumed本讲会讲 Covered here后面再展开 Later
第 1 讲 §3.6、§4.2:临界批大小、decode 每步读一遍权重Orca:迭代级调度、选择性批处理、KV 预留分页 KV、抢占、前缀缓存(第 7 讲)
第 2 讲 §5:KV cache 与生成循环投机采样的正确性证明、Leviathan 的分析变长注意力内核怎么写(第 10、11 讲)
第 5 讲:TTFT / TPOT、prefill 与 decode 的干扰、分块 prefill投机解码的硬件视角;树形验证与各种草稿草稿放到 SRAM 芯片上(第 5 讲 §8 已讲)

0.3 本讲地图 Map

§1  decode 为什么需要专门优化:一步一个 token,强度太低;两条路
§2  请求级调度的问题:早结束、晚加入、补齐                                  ← 代码 01
§3  迭代级调度(Orca):每次迭代重新选请求;KV 预留与死锁;流水线             ← 代码 01
§4  选择性批处理:拍平做线性层,拆开做注意力                                 ← 代码 02
§5  Orca 的结果,以及它之后发生了什么                                       ← 代码 01
§6  投机解码:猜几个、一次验证                                              ← 代码 03
§7  正确性:投机采样为什么不改变分布                                        ← 代码 03
§8  能快多少:Leviathan 的公式与实测                                       ← 代码 04
§9  硬件视角:c、批大小、上下文长度                                         ← 代码 05
§10 草稿从哪里来:小模型、n-gram、自草稿、Medusa、EAGLE、MTP;树形验证        ← 代码 03、05
§11 两者放在一起:批处理里的投机解码
§12 现状(2025–2026)
§13–17  小结、常见误区、练习(含答案)、延伸阅读、下一讲预告

0.4 记号 Notation

记号含义
MpM_p, p(x)p(x)目标模型(target)和它给出的下一个 token 的分布(已经按温度、top-k 等调整过)
MqM_q, q(x)q(x)草稿模型(draft / approximation model)和它的分布
γ\gamma每轮草稿几个 token
β\beta某一个位置上草稿 token 被接受的概率;α=E[β]\alpha = E[\beta] 是平均接受率
cc代价系数:草稿模型跑一次的时间 / 目标模型跑一次的时间
c^\hat c草稿模型每个 token 的运算量 / 目标模型每个 token 的运算量
τ\tau每次目标模型前向平均产出的 token 数(EAGLE 叫 average acceptance length)
initiation / incrementOrca 对 prefill / decode 的叫法

1. decode 为什么需要专门优化 · Why decode needs its own optimizations

第 1 讲 §4.2 算过:decode 每一步,批里的每个序列只多出 1 个 token,可全部权重都要从显存里读一遍。Llama-3.1-8B 在一块 H100 上、批大小 1 时,一步大约 6.65 ms(代码 05 演示 1 的 roofline 估计),几乎全部时间在读 16 GB 的权重;矩阵乘法的算术强度约等于 1,而 H100 的临界强度约 295。GPU 的算力 99% 以上闲着。

想让 decode 更划算,就要让”读一次权重”服务更多的 token。有两条路:

  1. 每一步多放几个序列(批处理)。一步处理 BB 个序列,权重读一次、给 BB 个 token 用,强度约等于 BB。难点不在数学,在调度:请求随时到达,输入、输出长短不一,有的刚到、有的快完。§2–§5 讲 Orca 怎样解决这个问题。
  2. 每一步给同一个序列多算几个 token(投机解码)。难点是自回归的依赖:第 t+1t+1 个 token 要等第 tt 个出来才知道。投机解码先用便宜的办法猜几个,再让大模型一次验证——验证 γ+1\gamma+1 个位置和生成 1 个位置一样,都只读一遍权重。§6–§11 讲它为什么正确、能快多少、什么时候划算。

两条路抢的是同一种资源:闲着的算力。批处理把批做大以后,留给投机解码的空间就少了(§9)。这一讲的主线就是这句话。


2. 请求级调度的问题 · The trouble with request-level scheduling

代码:01_continuous_batching.py 演示 1

2.1 当时的服务系统怎么做

Orca 之前,生成式模型的服务通常分两层(论文图 2):上层是通用的推理服务框架(如 Triton Inference Server),负责收请求、凑批;下层是执行引擎(如 NVIDIA FasterTransformer),收到一批之后跑完所有迭代才返回。这对 ResNet、BERT 这种”跑一次就出结果”的模型很合适,对生成式模型却有问题:一个请求要跑几十到几千次迭代,而且每个请求要跑几次事先不知道。

2.2 两个问题:早结束、晚加入

论文图 3 的例子:两个请求 x1、x2 一起进引擎,x2 只生成 1 个 token 就结束了,x1 还要再生成 3 个。引擎是按”批”调度的,于是:

  • 早结束的请求不能早返回:x2 已经结束,却要陪着 x1 跑完、和整批一起返回。用户白等。它那一行在后面几次迭代里还在算(图里的”-”)——白算。
  • 晚到的请求要等整批结束:批开始之后到达的请求,哪怕 GPU 有空,也只能在队列里等。

还有第三个问题:一批里的输入长度不同,第一次迭代要把它们补齐(padding)到同一个长度才能拼成一个 [B,L,d][B, L, d] 的张量。

代码 01 演示 1 在 Orca 论文的合成负载上(输入长度 U(32,512)\mathcal U(32, 512)、输出长度 U(1,128)\mathcal U(1, 128),泊松到达)数了数请求级批处理”算过的 token 位置”和”真正需要的”:

到达速率平均批大小白算的比例
1 req/s2.0226.6%
2 req/s5.3238.4%
4 req/s5.9239.0%

负载越高、批越满,白算得越多。不过 decode 受带宽限制,多算几行几乎不花时间——白算本身不是主要损失,时间才是:早结束的请求要等,新请求进不来。

2.3 批为什么凑不大:KV 的预留

还有一个更隐蔽的限制。FasterTransformer 在一批开始前,给每个请求按模型的最大序列长度(GPT-3 是 2048)预留 KV cache。GPT-3 13B 每个 token 的 KV 是 2×40 层×40 头×128×2 字节=819.22 \times 40 \text{ 层} \times 40 \text{ 头} \times 128 \times 2 \text{ 字节} = 819.2 KB;一块 A100 40GB 放下 25.7 GB 的权重后,还能放约 12,596 个 token 的 KV(代码按 10% 的预留算)——只够 ⌊12596/2048⌋=6\lfloor 12596 / 2048 \rfloor = 6 个请求。论文 §6.1 也说 FasterTransformer 在 13B 模型上用不了 8 以上的批。可实际请求平均只有 272+64≈336272 + 64 \approx 336 个 token,按最大长度预留浪费了大约六分之五的显存。


3. 迭代级调度 · Iteration-level scheduling (Orca)

代码:01_continuous_batching.py 演示 2–4

3.1 思路:每次迭代都重新选

Orca 的第一个想法很简单:调度的粒度从”请求”改成”迭代”。调度器反复做三件事(论文 §3 S1):

  1. 从请求池里选出这次迭代要跑的请求;
  2. 让引擎只跑一次迭代(每个请求前进一个 token;刚到的请求处理完整个输入);
  3. 收回结果:结束的请求立刻返回给用户,没结束的放回池里。

于是:结束的请求在它最后一个 token 出来的那次迭代之后就返回;新到的请求最多等一次迭代就能加入。这就是后来被叫作连续批处理(continuous batching,TGI / vLLM 的叫法)或 in-flight batching(TensorRT-LLM 的叫法)的东西。

3.2 选谁:先到先服务 + 最大批大小

Orca 的选择规则(论文算法 1)刻意简单:

  • 迭代级先到先服务(iteration-level FCFS):先到的请求跑过的迭代次数不少于后到的。
  • 最大批大小 max_bs:批越大,吞吐的增长越慢、每次迭代越久,所以给一个上限,由运维按延迟要求调。
  • KV 预留:一个请求第一次被选中时,按它声明的 max_tokens(输入 + 最多生成几个)预留 KV 空间;预留不下就停止接纳新请求(算法 1 第 23–25 行)。这保证已经接纳的请求一定能跑完。

代码 01 的 run_iteration_level 就是这个算法;每次迭代的时间用第 5 讲的 roofline 模型(StepModel.mixed_time:decode 的请求各 1 个 token、新请求的全部输入、权重读一遍)。

3.3 结果:延迟–吞吐曲线

代码 01 演示 2 在 GPT-3 13B、一块 A100 上复现 Orca 图 10 的形状(每格:吞吐 req/s / 每 token 延迟中位数 ms,“每 token 延迟”= 端到端延迟 / 生成的 token 数):

到达 req/s请求级 bs=1请求级 bs=8迭代级 bs=8迭代级 bs=16迭代级 bs=32
0.50.50 / 370.50 / 270.50 / 230.50 / 230.50 / 23
10.69 / 35101.00 / 461.00 / 231.00 / 231.00 / 23
20.63 / 86281.94 / 1712.00 / 242.00 / 242.00 / 24
40.60 / 112031.83 / 23203.96 / 263.97 / 263.97 / 26
80.59 / 124881.72 / 36014.54 / 7487.79 / 367.84 / 31
120.59 / 129221.69 / 40534.33 / 11727.88 / 34311.55 / 40

在”每 token 延迟中位数 ≤ 45 ms(空载的 2 倍)“的要求下二分查找最高速率:请求级 bs=8 只能到 0.94 req/s,迭代级 bs=32 能到 12.4 req/s,吞吐约 13 倍。Orca 论文在 175B、16 块 GPU(含流水线并行)上按”每 token 190 ms”比较,报告 36.9 倍;我们只模拟单卡、没有流水线,倍数取决于延迟要求定在哪里,方向一致。

两个细节值得注意:

  • 迭代级调度时,加大 max_bs 只推迟过载点,几乎不增加低负载时的延迟(23 ms 一列不变)——因为批里没人陪跑、也没人等整批。这和论文图 10 一致。
  • 请求级 bs=8 的过载点很早,原因之一是 §2.3 的预留:它实际只有 6 的批。

3.4 公平的对照:请求一样长时

如果所有请求的输入、输出长度都一样,“早结束”和”补齐”的问题就消失了(论文图 11)。代码 01 演示 3:

负载请求级 bs=8迭代级 bs=8迭代级 bs=32
输入 32、输出 327.8710.8031.27
输入 256、输出 2560.951.203.08

(最高吞吐 req/s。)同样 bs=8 时,迭代级只高 26%–37%,剩下的差别来自”新请求要等整批结束”。bs=32 高得多,主要来自更大的批——请求级被 2048 的预留卡在 6。所以 Orca 的收益要分开看:迭代级调度解决”长短不一”,按请求上限预留解决”批凑不大”。

3.5 KV 预留与死锁

代码 01 演示 4 把问题推得更远:假设每个请求声明 max_gen = 512,但实际生成 U(1,128)\mathcal U(1, 128) 个就遇到 EOS(真实服务里很常见:用户设了很大的上限)。三种预留方式(最大批 64):

速率预留方式平均预留平均实际写入吞吐结果
4按模型最大长度 204811,4881,7833.74每 token 144 ms
4按 prompt + max_gen(Orca)4,6721,8853.9726 ms
4不预留,用多少分配多少1,8851,8853.9726 ms
8按模型最大长度 204811,9811,8603.461298 ms
8按 prompt + max_gen(Orca)10,7824,3507.7746 ms
8不预留4,3704,3707.8431 ms
12按 prompt + max_gen(Orca)11,7044,7227.60384 ms(过载)
12不预留7,7247,724–第 49.7 秒死锁,只完成 516 个

(单位:token;12 req/s 时按 2048 预留的那一行略去。)读这张表:

  • 按 2048 预留:显存几乎全被预留占满,真正写入的不到六分之一。
  • Orca 的预留好得多,但仍是实际写入量的约 2.5 倍——请求声明的上限远大于实际用量。这就是第 7 讲 PagedAttention 论文说的”预留浪费”(reservation waste)。
  • 不预留:显存全给真正写入的 KV,延迟最低。但负载一高,正在生成的请求把显存占满了,每个请求下一步都还要再写 1 个 token 的 KV——谁都动不了。没有抢占(把某些请求换出到 CPU,或者丢掉以后重算)就会死锁。Orca 选择预留,就是为了避开这个问题(论文 §4.2 说”naïve 实现会让调度器陷入死锁”)。

第 7 讲的答案是两件事一起做:KV 按小块(page)按需分配,不预留;显存不够时抢占一部分请求。

3.6 流水线并行:不需要切微批

Orca 还把迭代级调度用在了流水线并行上(论文 §4.2、图 8)。FasterTransformer 做流水线时,要把一批切成几个微批(microbatch)轮流送进各段,否则后面几段会闲着;可微批越小,每段的批处理效率越低——两难。Orca 的调度器不等一批返回就注入下一批(直到在飞的批数等于流水线段数),每一批都是”当前最该跑的那些请求”;某个请求的下一次迭代只需要等它自己上一次迭代返回。所以不需要把批切小,流水线也不会空。我们的模拟只有一块 GPU,没有模拟这一点——这是论文的 36.9 倍比我们的 13 倍大的原因之一。

Orca 的另一个工程细节:控制消息(这次迭代有哪些请求、各自在哪个位置)走 gRPC,张量数据走 NCCL,避免每次迭代 CPU 和 GPU 之间的同步。论文图 9c 显示这让 175B 模型的引擎比 FasterTransformer 快最多 47%。


4. 选择性批处理 · Selective batching

代码:02_selective_batching.py

4.1 问题:形状对不上

迭代级调度带来一个新问题:每次迭代选出来的请求处在不同的状态,它们的张量形状不一样,没法拼成一个 [B,L,d][B, L, d] 的批。论文 §3 C2 列了三种情况:

  1. 都在 initiation(prefill),但输入长度不同;
  2. 都在 increment(decode),但已经处理的 token 数不同——KV cache 的长度不同;
  3. 一个在 initiation、一个在 increment——一个要处理几百个 token,一个只处理 1 个。

只有”同一阶段、同样长度”的请求才能按传统方式批处理。批越大,凑巧形状完全一样的概率越小。

4.2 办法:拍平做线性层,拆开做注意力

Orca 的观察:Transformer 里大部分运算是逐 token 的——线性层、LayerNorm、残差加法、激活函数——它们根本不需要知道哪个 token 属于哪个请求。只有注意力需要:一个 token 只能看同一个请求里的 token。所以(论文图 5):

  1. 把这次迭代所有请求的新 token 拼成一个 [∑L,d][\sum L, d] 的矩阵——没有”批”这一维;
  2. 线性层、归一化等直接在这个矩阵上算;
  3. 注意力之前 Split:按请求切开,每个请求和自己的 KV cache 做注意力;
  4. 注意力之后 Merge:拼回 [∑L,d][\sum L, d],继续往下算。

代码 02 演示 1 在第 2 讲的 numpy Transformer 上复现论文图 5 的例子:x1、x2 在 decode(KV cache 里分别有 3 个、1 个 token),x3、x4 刚到(输入 2 个、3 个 token),一共 7 个 token:

请求阶段已有 KV新 token注意力分数矩阵和单独计算的最大差
x1increment311×41 \times 43.6e-15
x2increment111×21 \times 24.9e-15
x3initiation022×22 \times 20
x4initiation033×33 \times 30

和”每个请求单独前向”一致到 10−1510^{-15}(只是加法顺序不同)。硬补齐的话要 4×3=124 \times 3 = 12 个 token 位置(有用的 7 个),注意力分数要 4×3×4=484 \times 3 \times 4 = 48 个(有用的 19 个),还要额外的掩码。

4.3 为什么几乎不损失效率:注意力没有权重

拆开做注意力,是不是放弃了批处理的好处?代码 02 演示 3 记了一笔账(同样 7 个 token,选择性批处理一次 vs 4 个请求各自单独前向,TINY 模型、BF16 计):

运算FLOPs读权重:选择性读权重:单独读写 KV
线性层(含 lm_head)10.6 M(两者相同)1.52 MB6.06 MB0
注意力核心38.9 K(两者相同)005.63 KB(两者相同)

批处理在 decode 时省的是读权重(第 1 讲 §4.2):权重读一次、给很多 token 用。注意力没有权重,它读的是每个请求自己的 KV——本来就不能共享。所以拆开做注意力,字节一点没多(论文 §3:“applying batching to Attention has no benefit of reducing the amount of GPU memory reads”)。论文图 9 的微基准里,Orca 引擎不批处理注意力,和 FasterTransformer 全部批处理相比只是”相近或略慢”。

4.4 但内核启动不是免费的

账本没算的一项是内核启动。如果真的每层给每个请求单独启动一个注意力内核,代码 02 演示 4(GPT-3 13B、A100)算了一下:

这次迭代里有选择性批处理每个请求单独前向注意力逐请求启动(3 µs / 10 µs)
32 个请求在生成29.29 ms700.23 ms(24×)+13% / +44%
32 个在生成 + 2 个新请求(输入 300、450)108.97 ms805.75 ms(7×)+4% / +12%

纯 decode 的迭代里,40 层 × 32 个请求 = 1280 次启动,每次几微秒,加起来是这次迭代的 13%–44%。所以 Orca 实现时把各个请求的注意力内核拼成一次启动:不同请求的线程块放进同一个内核(论文 §5;它承认这违反”一个内核里的线程块应该做同样的事”的惯例,但实测更快)。

今天的引擎走得更远:FlashAttention 的 varlen 接口、FlashInfer、vLLM 的 PagedAttention 内核,一次启动就处理一批长短不一的请求,每个请求的 KV 按块存放、用一张表找到(第 7 讲)。这就是选择性批处理的现代形态。第 5 讲的分块 prefill(Sarathi-Serve)也站在它的肩膀上:既然 prefill 和 decode 的 token 能放进同一次迭代,就可以控制每次放多少 prefill token。


5. Orca 的结果,以及它之后 · Orca’s results and what came after

论文的端到端实验(GPT-3 13B / 101B / 175B / 341B,最多 32 块 A100):在 175B 模型上,要达到每 token 190 ms 的中位数延迟,FasterTransformer 只有 0.185 req/s,Orca 有 6.81 req/s——36.9 倍。而且 Orca 加大最大批大小只提高吞吐,不明显增加延迟。

四点要记住的:

  1. 调度粒度是这篇论文最持久的贡献。今天所有主流引擎都用迭代级调度,区别只在”每次迭代选谁、放多少 token”。
  2. 选择性批处理的思想被专门的变长注意力内核继承下来。
  3. KV 预留是 Orca 留下的问题:它按 max_tokens 预留来避免死锁,但预留浪费显存,限制了批大小。vLLM 的 PagedAttention(第 7 讲)一年后专门解决这个问题。
  4. 论文的实验假设所有请求都生成到 max_gen(“我们让模型永远不输出 EOS”,因为没有真实的模型权重),所以它看不到预留浪费——演示 4 的问题在它的实验里不存在。

6. 投机解码:猜几个,一次验证 · Speculative decoding

代码:03_speculative_sampling.py、04_spec_decode_speedup.py

6.1 直觉:处理器里的分支预测

处理器遇到条件分支时,不等条件算出来,先猜一个方向往下执行;猜对了,省下等待的时间;猜错了,把猜着执行的结果丢掉重来。这叫投机执行(speculative execution)。只要猜对的概率够高、猜错的代价不大,平均就更快。

Leviathan 等人(以及同期 DeepMind 的 Chen 等人)把它搬到了自回归生成上,基于两个观察:

  1. 有些 token 很容易:句号之后多半是空格或换行,“New York”之后多半是”City”,代码里的括号要配对。一个小得多的模型也能猜对。
  2. 验证比生成便宜:给大模型一段”已经猜好的”序列 x1,…,xγx_1, \dots, x_\gamma,它可以一次前向同时算出每个位置上自己的分布 p1,…,pγ+1p_1, \dots, p_{\gamma+1}——就像 prefill 一次处理整段提示。decode 受带宽限制,一次前向送进 γ+1\gamma+1 个 token 和送进 1 个 token 读的权重一样多,时间几乎一样(§9 量化这个”几乎”)。

所以:小模型先连续猜 γ\gamma 个 token,大模型一次前向检验它们,留下猜对的那一段。每次大模型前向至少产出 1 个 token(不会比普通 decode 更慢地前进),运气好时产出 γ+1\gamma + 1 个。

6.2 算法

Leviathan 算法 1(一轮):

  1. 草稿:用 MqM_q 自回归地抽 γ\gamma 个 token:xi∼qi(x)=Mq(prefix+[x1,…,xi−1])x_i \sim q_i(x) = M_q(\text{prefix} + [x_1, \dots, x_{i-1}])。
  2. 验证:用 MpM_p 一次前向算出 p1,…,pγ+1p_1, \dots, p_{\gamma+1},其中 pi=Mp(prefix+[x1,…,xi−1])p_i = M_p(\text{prefix} + [x_1, \dots, x_{i-1}])。
  3. 逐个检验:对 i=1,2,…i = 1, 2, \dots:抽 ri∼U(0,1)r_i \sim \mathcal U(0, 1),若 ri<pi(xi)/qi(xi)r_i < p_i(x_i) / q_i(x_i) 就接受 xix_i,否则停下。设接受了 nn 个。
  4. 补一个:若 n<γn < \gamma(第 n+1n+1 个被拒绝),从 p′(x)=norm(max⁡(0,pn+1(x)−qn+1(x)))p'(x) = \mathrm{norm}(\max(0, p_{n+1}(x) - q_{n+1}(x))) 抽一个 token;若全部接受,从 pγ+1p_{\gamma+1} 白拿一个。
  5. 输出 x1,…,xnx_1, \dots, x_n 加上补的那一个。

实现时还要处理 KV cache:草稿模型和目标模型都为被拒绝的位置写过 KV,要回滚。代码 decode_lab.speculative_generate 用一个”待定 token”不变式:两个模型的 cache 里都存着已确定序列去掉最后一个 token 的部分;每轮开始时把最后一个已确定的 token 和 γ\gamma 个草稿一起喂给目标模型;结束时把 cache 的长度改回”新的已确定序列 − 1”。第 2 讲的 KVCache 总是从 length 往后写,所以回滚就是把 length 改小,后面的旧数据下次会被覆盖。(分页的 KV 也是改块表里的长度,第 7 讲。)


7. 正确性:投机采样为什么不改变分布 · Why speculative sampling is exact

7.1 一步的证明

只看一个位置。草稿抽到 xx 的概率是 q(x)q(x),接受它的概率是 min⁡(1,p(x)/q(x))\min(1, p(x)/q(x)),所以”草稿抽到 xx 并被接受”的概率是

q(x)⋅min⁡ ⁣(1,p(x)q(x))=min⁡(p(x),q(x)).q(x) \cdot \min\!\left(1, \frac{p(x)}{q(x)}\right) = \min(p(x), q(x)).

被拒绝的总概率是 1−β1 - \beta,其中

β=∑xmin⁡(p(x),q(x)).\beta = \sum_x \min(p(x), q(x)).

被拒绝后从 p′(x)=max⁡(0,p(x)−q(x))/(1−β)p'(x) = \max(0, p(x) - q(x)) / (1 - \beta) 里抽——注意 ∑xmax⁡(0,p(x)−q(x))=∑x(p(x)−min⁡(p(x),q(x)))=1−β\sum_x \max(0, p(x) - q(x)) = \sum_x (p(x) - \min(p(x), q(x))) = 1 - \beta,归一化常数正好是 1−β1 - \beta。所以输出 xx 的总概率是

min⁡(p(x),q(x))+(1−β)⋅max⁡(0,p(x)−q(x))1−β=min⁡(p,q)+max⁡(0,p−q)=p(x).  ■\min(p(x), q(x)) + (1 - \beta) \cdot \frac{\max(0, p(x) - q(x))}{1 - \beta} = \min(p, q) + \max(0, p - q) = p(x). \;\blacksquare

直觉:草稿在某些 xx 上”给多了”(q>pq > p),接受规则按 p/qp/q 的比例把多出来的部分退掉;在另一些 xx 上”给少了”(q<pq < p),差额 p−qp - q 正好由重新采样补上。多轮的情形:被接受的 x1x_1 服从 p1p_1;给定 x1x_1,下一个位置用的是 p2=Mp(prefix+[x1])p_2 = M_p(\text{prefix} + [x_1]),同样的论证再来一遍——归纳下去,整个序列的分布和 MpM_p 自己生成的一样。

代码 03 演示 1 不抽样、直接按概率算出每个位置的输出分布,和 pp 比较(第 3 讲的模型、8 条序列 × 64 个位置):

草稿max⁡∣Pout−p∣\max\lvert P_\text{out} - p\rvertβ\beta 的平均(温度 1)
INT8 量化的自己4.4e-160.993
INT4 量化的自己5.6e-160.900
只用前 3 层6.7e-160.827
只用前 1 层4.4e-160.699
二元语法表5.6e-160.341
无关的小模型4.4e-160.160

草稿再差,输出分布也是 pp——差的草稿只是接受得少、跑得慢,不会让输出变差。这是投机解码最重要的性质:它是一种纯粹的系统优化,模型的行为不变,所以可以默认打开、不需要重新评估模型质量。

7.2 接受概率 = 1 − 总变差距离

β=∑xmin⁡(p,q)\beta = \sum_x \min(p, q) 有一个漂亮的解释。总变差距离(total variation distance)TV(p,q)=12∑x∣p(x)−q(x)∣\mathrm{TV}(p, q) = \frac{1}{2}\sum_x \lvert p(x) - q(x)\rvert 衡量两个分布差多少(0 = 相同,1 = 完全不重叠)。因为 min⁡(a,b)=a+b−∣a−b∣2\min(a, b) = \frac{a + b - \lvert a - b\rvert}{2},

β=∑xp+q−∣p−q∣2=1−TV(p,q).\beta = \sum_x \frac{p + q - \lvert p - q\rvert}{2} = 1 - \mathrm{TV}(p, q).

论文把 TV\mathrm{TV} 记作 DLKD_{LK}(定理 3.5、推论 3.6:α=1−E[DLK(p,q)]=E[min⁡(p,q)]\alpha = 1 - E[D_{LK}(p, q)] = E[\min(p, q)])。代码 03 演示 1 的最后一列验证了 ∣β−(1−TV)∣\lvert\beta - (1 - \mathrm{TV})\rvert 在 10−1610^{-16} 量级。所以”一个草稿好不好”有一个精确的度量:它的分布和目标的总变差距离。

7.3 采样设置:p 和 q 要一起调整

温度、top-k、top-p(nucleus)这些采样方式,都可以写成”从一个调整过的分布里采样”(论文 §2.2 的”标准化采样”):贪心 = 把非最大值清零再归一化;温度 = logits 除以 TT 再 softmax;top-k = 把第 kk 名之后的置为 −∞-\infty。投机采样对调整后的 pp 和 qq 做,输出就服从调整后的 pp。代码 03 演示 1 在温度 0.5、温度 1 + top-50 下也验证到了 10−1610^{-16}。要点是两边用同样的调整;如果只调整 pp、用原始的 qq 去抽草稿,规则依然正确(证明里 qq 是什么都行),只是接受率会变低。

7.4 贪心的特例

温度 0(贪心)时 pp、qq 都是 one-hot:接受 ⇔ 草稿的 token 恰好是目标的 argmax;被拒绝时 max⁡(0,p−q)\max(0, p - q) 的唯一正项就是目标的 argmax。所以输出和目标模型自己贪心生成的逐 token 相同。代码 03 演示 4:三种草稿、4 个提示、各 64 个 token,输出全部相同;目标模型的前向次数从 64 次降到 19.2 次(INT4 草稿)、24.8 次(跳层草稿)、53 次(二元语法表)。这是最好写单元测试的情况,真实引擎也是这么测的。

7.5 两种常见的错误实现

代码 03 演示 2 在一个极小的模型(词表 64)上跑了 4 万个独立的投机解码(γ=3\gamma = 3,温度 1),统计前两个输出 token 的联合分布(4096 格),和目标模型的精确分布比:

做法TV(联合分布)TV(第一个 token)
直接从精确分布抽 4 万个(噪声水平)0.0629 ± 0.0014–
投机解码(正确的规则)0.06460.0123
错误 1:被拒绝后直接从 pp 重抽0.18170.1191
错误 2:p(x)≥q(x)p(x) \ge q(x) 才接受(不掷骰子)0.60900.3998

正确的规则和”直接从正确分布抽样”的噪声水平相当。两个错误:

  • 被拒绝后从 pp 重抽:草稿偏爱的 token 先通过接受规则拿到了 min⁡(p,q)\min(p, q),重抽时又按 pp 再给一次,被多算了。具体到第一个 token 偏得最多的 token 6:正确概率 0.167,错误 1 给了 0.121,错误 2 给了 0.310。
  • 去掉随机性:q(x)>p(x)q(x) > p(x) 就一律拒绝,相当于丢掉了 min⁡(p,q)\min(p, q) 里本该接受的那部分,偏差最大。

所以那个看起来很怪的残差 max⁡(0,p−q)\max(0, p - q) 不是装饰,它正好补上”qq 给少了”的那部分概率。


8. 能快多少 · How much faster

代码:04_spec_decode_speedup.py

8.1 每轮产出几个 token

假设每个位置被接受的概率 β\beta 独立同分布、均值 α\alpha。第 ii 个草稿被接受,要求前 ii 个都被接受(概率 αi\alpha^i),所以每轮产出的 token 数(被接受的草稿 + 补的那一个)的期望是

E[tokens]=1+α+α2+⋯+αγ=1−αγ+11−α.(1)E[\text{tokens}] = 1 + \alpha + \alpha^2 + \dots + \alpha^\gamma = \frac{1 - \alpha^{\gamma+1}}{1 - \alpha}. \tag{1}

γ→∞\gamma \to \infty 时趋于 1/(1−α)1/(1 - \alpha):α=0.8\alpha = 0.8 时最多 5 个,α=0.9\alpha = 0.9 时最多 10 个。这个上界来自”一错就停”:草稿再长,越往后越难全部猜中。

8.2 墙钟时间与最优的 γ

一轮的时间 = γ\gamma 次草稿 + 1 次验证。设草稿一次的时间是目标一次的 cc 倍(代价系数),并假设目标模型一次验证 γ+1\gamma + 1 个 token 和一次普通 decode 一样快,那么(定理 3.8)

加速比=1−αγ+1(1−α)(γc+1).\text{加速比} = \frac{1 - \alpha^{\gamma+1}}{(1 - \alpha)(\gamma c + 1)}.

代码 04 演示 1 复现了论文表 1(c=c^=0c = \hat c = 0),逐格一致:

α\alphaγ\gamma速度运算量
0.621.96×1.53×
0.732.53×1.58×
0.822.44×1.23×
0.853.69×1.63×
0.922.71×1.11×
0.9106.86×1.60×

草稿不免费时,最优的 γ\gamma 在”多猜几个”和”草稿的代价”之间权衡(论文图 3)。代码 04 演示 2 的表(每格:最优 γ∗\gamma^* 和加速比):

α\alpha \ cc0.010.050.10.20.5
0.67, 2.30×4, 1.92×3, 1.67×2, 1.40×1, 1.07×
0.814, 4.23×8, 3.09×6, 2.47×4, 1.87×2, 1.22×
0.924, 7.49×13, 4.67×10, 3.43×7, 2.37×3, 1.38×

推论 3.9:只要 α>c\alpha > c,γ=1\gamma = 1 就有加速,至少 (1+α)/(1+c)(1 + \alpha)/(1 + c) 倍;α=c\alpha = c 时不赚不赔。

cc 不是模型的属性,它取决于硬件和实现(论文 §3.3)。论文里草稿比目标小两个数量级,cc 总是小于 0.05。但 §9 会看到,Llama-3.2-1B 配 Llama-3.1-8B 在 H100 上 c≈0.19c \approx 0.19——草稿只读 15% 的字节,可每一步的固定开销(内核启动、调度、采样)是一样的。同一个草稿放到 SRAM 芯片上,cc 可以小十几倍(第 5 讲 §8.3)。

8.3 独立同分布的假设

式 (1) 把每个位置的接受概率当成独立的。它其实不要求每个位置的 β\beta 一样——只要各位置独立,E[β1β2⋯βn]=αnE[\beta_1 \beta_2 \cdots \beta_n] = \alpha^n 依然成立。真正的假设是独立。

代码 04 演示 3 在第 3 讲的模型上真的跑了投机解码(温度 1、8 个提示 × 96 个新 token),把”每次目标前向产出的 token”和式 (1) 用实测 α\alpha 算出的预测比:

草稿实测 α\alphaγ=1\gamma = 1γ=2\gamma = 2γ=4\gamma = 4γ=8\gamma = 8
INT4 量化的自己0.9001.88 / 1.902.68 / 2.713.73 / 4.106.05 / 6.13
只用前 3 层0.8271.82 / 1.832.53 / 2.513.43 / 3.544.74 / 4.73
只用前 1 层0.6991.69 / 1.702.17 / 2.192.92 / 2.773.10 / 3.19
二元语法表0.3411.37 / 1.341.43 / 1.461.55 / 1.511.49 / 1.52

(每格:实测 / 预测。)在抽样误差以内一致,有高有低。这个随机初始化的模型里,相邻位置的难度几乎不相关,所以公式成立。真实文本不同:难的位置往往成片出现(一段需要推理的话、一个罕见的专有名词),而一轮要求”前面全部接受”,成片的难位置会让后面整串作废。CS349D 第 5 讲的模拟里,接受成片出现时把实测 α\alpha 代入式 (1) 会高估约 5%。实践中的建议:直接测”每次验证产出几个 token”(EAGLE 叫 τ\tau),不要只测 α\alpha。

论文还算了一个上界:如果有一个”神谕”告诉你每一步该草稿几个(猜对的那一段有多长就草稿多长),期望产出变成 1/(1−α)1/(1-\alpha),比固定 γ\gamma 最多高约 60%(§3.5)。这为后来”动态决定草稿长度 / 树形状”的工作(EAGLE-2 等)留了空间。

8.4 运算量增加,读权重减少

一轮里目标模型算了 γ+1\gamma + 1 个位置,平均只留下式 (1) 那么多,被拒绝之后的都白算了。总运算量的增加倍数(定理 3.11)是

(1−α)(γc^+γ+1)1−αγ+1.\frac{(1 - \alpha)(\gamma \hat c + \gamma + 1)}{1 - \alpha^{\gamma+1}}.

但读权重的次数减少了:一次前向读一遍权重,产出好几个 token(§3.4:“the number of memory accesses … can go down”)。代码 04 演示 4(跳层草稿,α=0.827\alpha = 0.827,只看目标模型的矩阵乘法,不含提示预填充):

做法目标模型运算量定理 3.11(c^=0\hat c = 0)目标模型读权重
普通 decode1.00×–1.00×
投机 γ=2\gamma = 21.21×1.20×0.41×
投机 γ=4\gamma = 41.35×1.41×0.28×
投机 γ=8\gamma = 81.90×1.90×0.22×

这张表是理解投机解码的钥匙:它用更多的运算换更少的读取。decode 受带宽限制、算力闲着,这笔交易就划算——和批处理是同一个道理(提高算术强度),只不过”批”是同一个序列里的几个位置。算力不闲的时候,这笔交易就亏了(§9)。

8.5 真实模型上的 α

  • Leviathan 表 3:草稿比目标小约两个数量级时,α\alpha 一般在 0.5–0.9;连一元、二元语法表都有非零的 α\alpha(T5-XXL 英德翻译、二元语法表 α≈0.2\alpha \approx 0.2,γ=3\gamma = 3 时快 1.25 倍)。
  • Leviathan 表 2(T5-XXL 11B,TPU v4,批大小 1):T5-small(77M)当草稿最好,翻译任务温度 0 时快 3.4 倍、温度 1 时 2.6 倍;摘要任务 3.1 / 2.3 倍。比 T5-large 当草稿更快——更准的草稿太贵了。
  • EAGLE 表 2(MT-bench):每个位置的接受率约 0.64–0.79,平均接受长度 τ\tau 在温度 0 时 3.6–4.0、温度 1 时 3.2–3.5。

一个温度上的反例:真实模型上 α\alpha 在温度 0 时更高(Leviathan:“the sharper the adjusted distribution, the higher the α values”;EAGLE 表 2 也是)。代码 03 演示 3 在随机初始化的模型上看到的却相反:

草稿T=1T = 1T=0.5T = 0.5T=0T = 0
INT4 量化的自己0.9000.8410.783
只用前 3 层0.8270.7280.643
二元语法表0.3410.2180.201

原因:α(T=0)\alpha(T{=}0) 是”第一名相同”的比例,α(T=1)\alpha(T{=}1) 是 1−TV1 - \mathrm{TV}。随机模型的分布很平(第一名的平均概率只有 0.107),小扰动很容易换掉第一名,整体形状却变化不大。真实语言模型相反:大部分位置有一个明显”容易”的答案,大小模型都会选它,分歧主要在尾部低概率的词——第一名的一致率高于 1−TV1 - \mathrm{TV}。这是玩具模型和真实模型的差别,提醒我们:机制可以在玩具上验证,数字不能搬。


9. 硬件视角:什么时候划算 · The hardware view

代码:05_spec_decode_on_hardware.py

Leviathan 的分析有两个关于硬件的假设:(1) 验证 γ+1\gamma + 1 个 token 和普通 decode 一步一样快;(2) cc 是已知常数。用 roofline 看看它们什么时候成立。

9.1 批大小 1:验证几乎免费,但 c 不小

代码 05 演示 1:Llama-3.1-8B 做目标、Llama-3.2-1B 做草稿(同一个分词器,常见的搭配),一块 H100,批大小 1,上下文 1024,roofline 时间 + 固定开销(目标 1 ms、草稿 0.3 ms,假设值):

  • 目标模型普通 decode 一步 6.65 ms;草稿一步 1.23 ms,c=0.186c = 0.186。
  • 目标模型一次送进 nn 个 token:n=1n = 1 到 n=129n = 129 都是 6.65 ms(前 129 个 token 都在”读权重”的阴影里),n=257n = 257 才涨到 7.76 ms。

所以假设 (1) 在批大小 1 时完全成立;最优 γ\gamma 和加速比由 α\alpha 和 cc 决定:

α\alpha最优 γ\gamma每轮 token加速比
0.621.961.43×
0.732.531.63×
0.843.361.93×
0.975.702.48×

c≈0.19c \approx 0.19 把加速比压在 2 倍左右。想更快,要么草稿更便宜(单层草稿、n-gram、放到 SRAM 芯片上),要么更准,要么一次验证更多候选(§10.3 的树)。

9.2 批变大:验证不再免费

代码 05 演示 2(α=0.8\alpha = 0.8;每格:最优 γ\gamma、加速比、括号里是”验证的时间 / 普通一步”):

批大小上下文 512上下文 4096上下文 32768
14, 1.93×(1.00)4, 1.92×(1.00)4, 1.88×(1.00)
164, 1.91×(1.00)4, 1.84×(1.00)放不下
643, 1.85×(1.00)4, 1.76×(1.00)放不下
1282, 1.60×(1.11)放不下放不下
2561, 1.38×(1.09)放不下放不下
5121, 1.10×(1.41)放不下放不下

批大小 512、上下文 512 时,普通一步 19.45 ms,一次验证 5 个 token 要 66.92 ms(3.4 倍):512×5=2560512 \times 5 = 2560 个 token 让矩阵乘法变成算力受限(H100 按 60% / 80% 的效率,临界约 220 个 token,第 1 讲 §3.6)。验证的时间开始按 γ+1\gamma + 1 增长,最优 γ\gamma 缩到 1,加速比缩到 1.1 倍。

长上下文时,显存先装不下大批(32K 上下文最多十几个序列),decode 一直受带宽限制——而且同一个序列的 γ+1\gamma + 1 个 query 共享一次 KV 读取(融合注意力内核把它们放在同一块里)——验证始终几乎免费。所以投机解码在长上下文、小批、交互式的场景里最值钱;在离线批处理、大批量的场景里收益有限,甚至可能变慢。

9.3 批处理和投机抢同一种资源

把 §1 的话再说一遍:连续批处理和投机解码都在利用 decode 时闲着的算力。连续批处理把批做大以后,闲着的算力少了,留给投机的空间就少了。服务系统因此要按负载决定:低负载时用投机换延迟,高负载时减小 γ\gamma 或关掉投机换吞吐。vLLM、SGLang、TensorRT-LLM 都支持这类调整;有研究专门按 goodput 动态选择每个请求的草稿长度。

另外两个容易忽略的代价:

  • 草稿模型占显存:它的权重和它自己的 KV cache 都要放在 GPU 上,减少了目标模型能放的序列数(本讲的模拟没有计入)。
  • MoE 的陷阱:验证 γ+1\gamma + 1 个 token 时,每个 token 各选几个专家,要读的专家变多(第 5 讲 §8.3)。EAGLE 在 Mixtral 8x7B 上只快 1.5 倍(表 3),论文给的解释正是这个。

10. 草稿从哪里来 · Where drafts come from

10.1 一个独立的小模型

最直接的做法(Leviathan、Chen et al.):同一系列、同一个分词器的小模型,比如 Llama-3.2-1B 配 Llama-3.1-8B / 70B。优点是现成;缺点是 cc 不小(§9.1),而且要单独部署、占显存。可以在目标模型的输出上蒸馏草稿以提高 α\alpha(DistillSpec 等)。

10.2 不需要训练的草稿

  • n-gram / 查表:c≈0c \approx 0。Leviathan 发现二元语法表都有 α≈0.2\alpha \approx 0.2。
  • 提示查找(prompt lookup decoding):在已有的上下文里找和最近几个 token 匹配的片段,把它后面的内容当草稿。对摘要、改写代码、多轮对话里”重复已有内容”的输出特别有效(Leviathan §3.6 已经提到这个思路)。
  • 自草稿(self-speculative):用目标模型自己的一部分当草稿——跳过一些层(Draft & Verify、LayerSkip),或者用量化版的自己。代码 03 演示 3 的数字:INT4 量化的自己 α=0.90\alpha = 0.90,只用前 3 层 0.83;但代码 04 演示 4 指出,跳层草稿的 c^=0.77\hat c = 0.77,太贵。自草稿的优点是不用额外的模型和显存。

10.3 额外的预测头和树形验证:Medusa、EAGLE、SpecInfer

Medusa(Cai et al. 2024)在目标模型最后一层的隐状态上加 KK 个额外的”头”,第 kk 个头直接预测第 t+k+1t + k + 1 个 token。草稿几乎免费(几个小矩阵),但越远的头越不准。为了弥补,它在每个位置取头的前几名,组合成一棵候选树,用一次前向验证整棵树——这需要树形注意力掩码:每个节点只看得见自己的祖先。Medusa-1(冻结主干,只训练头)报告 2.2 倍以上,Medusa-2(和主干一起训练)2.3–2.8 倍。注意:Medusa 还提出了”典型接受”(typical acceptance),在温度 > 0 时放宽接受条件——它不再严格保持输出分布,和 §7 的投机采样不是一回事。

EAGLE(Li et al. 2024)观察到:在特征层(目标模型倒数第二层的隐状态)上做自回归,比在 token 上做更容易;但采样带来不确定性(同一个特征后面可能接”am”也可能接”always”),所以把提前一步的 token 也作为输入。草稿只有一个 Transformer 层。EAGLE 报告在 LLaMA2-Chat 70B 上 2.7–3.5 倍,并保持输出分布。EAGLE-2 按草稿的置信度动态决定树的形状;EAGLE-3(2025)不再预测特征,改为直接预测 token,并融合多层特征(“training-time test”),报告最高 6.5 倍,在 SGLang 里批大小 64 时吞吐提高 1.38 倍。

SpecInfer(Miao et al. 2024)最早系统地提出”token 树”验证:用一个或多个小模型生成多个候选,合并成树,一次验证。

代码 05 演示 3 量化了树的利弊。在第 3 讲的模型上,目标模型贪心生成 64 条序列,草稿在这些真实序列上算出”正确答案在草稿里排第几”;贪心时,树在第 dd 层只要正确答案在草稿的前 kdk_d 名里就能继续,所以接受长度可以精确算出来。再把它换算成 H100 上 Llama-3.1-8B 的加速比(草稿每层的代价假设为目标一步的 5%):

草稿形状每轮验证的草稿 token弱草稿 τ\tau批 1批 32强草稿 τ\tau批 1批 32
链 γ=4\gamma = 441.831.52×1.52×3.332.77×2.77×
链 γ=6\gamma = 661.871.44×1.43×3.933.03×3.02×
树 2×2×2142.372.06×1.32×3.643.16×2.02×
树 4×2×2×1442.822.35×0.57×4.463.71×0.90×
树 8×4×2×11683.322.76×0.19×4.633.85×0.26×

(弱草稿 = 只用第 1 层,正确答案在前 1/2/4/8 名里的比例 0.44 / 0.64 / 0.79 / 0.91;强草稿 = INT4 量化的自己,0.79 / 0.94 / 0.98 / 1.00。)

  • 树让 τ\tau 明显变大,弱草稿受益更多:它的第一名常错,但前几名常对。
  • 代价是每轮验证几十到几百个 token。批 1 时几乎免费,大树加速最多;批 32 时,32×32 \times 几百个 token 让验证变成算力受限,大树比链还慢得多。
  • EAGLE 的消融(表 5、图 7):树让平均接受长度多 0.6–0.8、加速比只多 0.3–0.5——接受长度涨得多、加速比涨得少,原因就是每次前向的 token 变多了。

10.4 训练时就为投机做准备:多 token 预测(MTP)

DeepSeek-V3 在训练时加了多 token 预测(MTP)模块:主模型预测下一个 token 的同时,一个小模块预测再下一个。推理时这个模块当草稿用。技术报告说第二个 token 的接受率在 85%–90%,解码速度(TPS)提高到 1.8 倍。此后不少开源模型(如 Qwen3-Next、GLM-4.5)也自带 MTP 层。这代表一个趋势:草稿不再是事后拼上去的,而是模型设计的一部分——又一个”协同设计”的例子。


11. 两者放在一起:批处理里的投机解码 · Speculation inside a continuous batch

真实引擎里,投机解码是在连续批处理的框架里做的,这带来几个工程问题:

  • 批里每个序列接受的个数不同:下一轮各序列的长度又不一样了。这正好是选择性批处理 / 变长注意力内核要解决的问题——它天然支持”每个请求这次喂几个 token”不同。
  • KV 回滚:被拒绝的位置写过的 KV 要作废。分页的 KV(第 7 讲)里,回滚 = 改块表里的长度、释放多出来的块。
  • 调度器要算新的账:一次迭代的 token 数 = 各序列的 (γ+1)(\gamma + 1) 之和(加上 prefill)。批大时,§9.2 说验证不再免费,调度器要减小 γ\gamma 或关掉投机。
  • 草稿和目标的调度:草稿模型可以和目标模型共用 GPU(分时),也可以放在别的设备上(第 5 讲 §8 的异构方案,此时 cc 里还要加通信延迟)。

12. 现状(2025–2026)· State of the field

  • 连续批处理是默认:vLLM、SGLang、TensorRT-LLM、TGI 都是迭代级调度 + 变长 / 分页注意力内核;在此之上叠加分块 prefill(第 5 讲)、前缀缓存(第 7 讲)、解耦(第 5 讲)。
  • 投机解码是标配:主流引擎都支持独立草稿模型、n-gram / 提示查找、EAGLE 系列、MTP。EAGLE-3 一类的单层草稿在小批时常见 2–4 倍的加速;生产系统按负载自动调整或关闭投机。
  • 草稿成为模型的一部分:DeepSeek-V3 的 MTP 之后,越来越多的模型在训练时就带上 MTP 层,推理时直接用于投机。
  • 硬件上:投机解码让 decode 更像”小 prefill”(每步处理几个 token),提高了算术强度;异构方案把草稿放到 SRAM 芯片上,让 cc 接近 0(第 5 讲的 Gimlet 博客)。
  • 新的方向:并行 / 非自回归的草稿(Jacobi 解码、Lookahead decoding)、扩散语言模型(一次生成一块 token)都在尝试打破”一步一个 token”。

13. 小结 · Summary

  1. decode 的问题是算术强度太低:每步每个序列 1 个 token,却要读全部权重。批处理(多放序列)和投机解码(同一个序列多放 token)都是提高强度,抢的是同一种资源——闲着的算力。
  2. Orca 的迭代级调度让结束的请求立刻返回、新请求最多等一次迭代;在 GPT-3 13B / A100 的模拟里,同样的延迟要求下吞吐约是请求级的 13 倍(论文 175B 上 36.9 倍)。
  3. 选择性批处理把 token 拍平做线性层、按请求拆开做注意力;注意力没有权重,拆开不多读一个字节,但要把各请求的注意力内核合成一次启动。
  4. KV 预留是调度的约束:按最大长度预留浪费显存;按声明的上限预留仍是实际的约 2.5 倍;不预留又会死锁——第 7 讲用分页 + 抢占解决。
  5. 投机采样严格保持输出分布:输出 xx 的概率 = min⁡(p,q)+max⁡(0,p−q)=p\min(p, q) + \max(0, p - q) = p;接受概率 β=1−TV(p,q)\beta = 1 - \mathrm{TV}(p, q)。被拒绝后必须从残差抽样。
  6. 每轮期望产出 1−αγ+11−α\frac{1 - \alpha^{\gamma+1}}{1 - \alpha} 个 token,加速比还要除以 γc+1\gamma c + 1;运算量增加、读权重减少。公式假设各位置独立,真实文本里难的位置成片出现时会高估。
  7. 硬件决定划不划算:批小时验证免费,但 cc 常常不小(1B 配 8B 约 0.19);批大到算力受限时验证变贵,投机收益缩水;长上下文、小批时最值钱。树形验证在小批时很划算,大批时很亏。

14. 常见误区 · Common pitfalls

  1. “投机解码会降低输出质量”——正确实现的投机采样输出分布和目标模型完全一样(§7)。降低质量的是不严格的变体(如 Medusa 的典型接受),要分清。
  2. “草稿越准越好”——还要看它多贵。Leviathan 的 T5-XXL 实验里,T5-small 当草稿比更准的 T5-large 快(§8.5)。
  3. “被拒绝后从目标分布重新采样就行”——必须从 max⁡(0,p−q)\max(0, p - q) 的残差采样,否则分布会偏(代码 03 演示 2,TV 从 0.065 涨到 0.18)。
  4. ”cc 等于参数量之比”——cc 是时间之比,取决于硬件和实现:固定开销、草稿的 KV、跑在哪种芯片上都会改变它(§8.2、§9.1)。
  5. “投机解码总是能提速”——批大时验证变成算力受限,加速比可以降到 1 附近甚至变慢(§9.2)。
  6. “选择性批处理放弃了注意力的批处理,所以慢”——注意力没有权重,批处理它不省读取;真正要注意的是内核启动次数(§4.3–4.4)。
  7. “连续批处理只是把批做大”——它解决的是早结束、晚加入和补齐;同样的批大小上限下,请求一样长时它的收益很小(§3.4)。
  8. “不预留 KV 最省显存,所以最好”——没有抢占就可能死锁(§3.5)。
  9. “式 (1) 要求每个位置的接受率一样”——它要求的是各位置独立;接受成片出现时公式会高估(§8.3)。
  10. “树越大越好”——树越大,每次验证的 token 越多;批 32 时 168 个节点的树比链慢 5 倍以上(§10.3)。

15. 练习 · Exercises

练习 1(请求级批处理的浪费) 一批 3 个请求,输入长度 100、300、200,输出长度 10、50、30。请求级批处理补齐输入、所有行跑到最长的输出结束。算过的 token 位置有多少?真正需要的有多少?白算的比例?

答案:算过的 = B×Lmax⁡+B×(Omax⁡−1)=3×300+3×49=1047B \times L_{\max} + B \times (O_{\max} - 1) = 3 \times 300 + 3 \times 49 = 1047;需要的 = ∑(输入+输出−1)=109+349+229=687\sum (\text{输入} + \text{输出} - 1) = 109 + 349 + 229 = 687;白算的比例 1−687/1047=34.4%1 - 687/1047 = 34.4\%。

练习 2(预留决定批大小) GPT-3 13B 在 A100 40GB 上能放 12,596 个 token 的 KV。(a) 按 2048 预留,最多几个请求?(b) Orca 的负载里,平均输入 272、平均输出(= max_gen)64.5,按 prompt + max_gen 预留,平均能放几个请求?(c) 如果请求声明 max_gen = 512 但平均只生成 64.5 个,按 Orca 的方式预留呢?

答案:(a) ⌊12596/2048⌋=6\lfloor 12596 / 2048 \rfloor = 6。(b) 12596/(272+64.5)≈3712596 / (272 + 64.5) \approx 37。(c) 12596/(272+512)≈1612596 / (272 + 512) \approx 16——只有按需分配时的一小半,这就是演示 4 里预留量约是实际写入的 2.5 倍的原因(还有一部分是输入长度不同带来的差别)。

练习 3(手算投机采样) 词表只有 3 个 token,p=(0.5,0.3,0.2)p = (0.5, 0.3, 0.2),q=(0.2,0.6,0.2)q = (0.2, 0.6, 0.2)。(a) 接受概率 β\beta?(b) 残差分布 p′p'?(c) 验证输出分布等于 pp。

答案:(a) β=min⁡(0.5,0.2)+min⁡(0.3,0.6)+min⁡(0.2,0.2)=0.2+0.3+0.2=0.7\beta = \min(0.5, 0.2) + \min(0.3, 0.6) + \min(0.2, 0.2) = 0.2 + 0.3 + 0.2 = 0.7;也等于 1−TV=1−12(0.3+0.3+0)=0.71 - \mathrm{TV} = 1 - \frac{1}{2}(0.3 + 0.3 + 0) = 0.7。(b) max⁡(0,p−q)=(0.3,0,0)\max(0, p - q) = (0.3, 0, 0),归一化后 p′=(1,0,0)p' = (1, 0, 0)。(c) token a:0.2×1+0.3×1=0.50.2 \times 1 + 0.3 \times 1 = 0.5;token b:0.6×0.30.6=0.30.6 \times \frac{0.3}{0.6} = 0.3;token c:0.2×1=0.20.2 \times 1 = 0.2。✓

练习 4(错误变体偏多少) 同上的 pp、qq,如果被拒绝后直接从 pp 重抽,输出分布是什么?和 pp 的 TV 距离?

答案:被拒绝的概率仍是 0.3。a:0.2+0.3×0.5=0.350.2 + 0.3 \times 0.5 = 0.35;b:0.3+0.3×0.3=0.390.3 + 0.3 \times 0.3 = 0.39;c:0.2+0.3×0.2=0.260.2 + 0.3 \times 0.2 = 0.26。TV =12(0.15+0.09+0.06)=0.15= \frac{1}{2}(0.15 + 0.09 + 0.06) = 0.15。qq 偏爱的 b 被多给了。

练习 5(复现论文的加速比) Leviathan 表 2:T5-small 当草稿,英德翻译、温度 0、γ=7\gamma = 7、α=0.75\alpha = 0.75,实测快 3.4 倍。(a) 式 (1) 预测每轮产出几个 token?(b) 如果 c=0c = 0,加速比的预测是多少?(c) 反推实测对应的 cc。

答案:(a) (1−0.758)/0.25=(1−0.1001)/0.25≈3.60(1 - 0.75^8)/0.25 = (1 - 0.1001)/0.25 \approx 3.60。(b) 3.60 倍。(c) 3.60/(7c+1)=3.4⇒c≈0.0083.60 / (7c + 1) = 3.4 \Rightarrow c \approx 0.008——和论文说的”cc 常常接近 0”一致(实测还有实现细节的误差,论文附录 A.3)。

练习 6(最优 γ 附近很平) α=0.8\alpha = 0.8、c=0.05c = 0.05。分别算 γ=4,8,12\gamma = 4, 8, 12 的加速比。最优 γ\gamma 选错一些代价大吗?

答案:γ=4\gamma = 4:(1−0.85)/(0.2×1.2)=0.672/0.24=2.80(1 - 0.8^5)/(0.2 \times 1.2) = 0.672/0.24 = 2.80;γ=8\gamma = 8:(1−0.89)/(0.2×1.4)=0.866/0.28=3.09(1 - 0.8^9)/(0.2 \times 1.4) = 0.866/0.28 = 3.09(最优,和演示 2 的表一致);γ=12\gamma = 12:(1−0.813)/(0.2×1.6)=0.945/0.32=2.95(1 - 0.8^{13})/(0.2 \times 1.6) = 0.945/0.32 = 2.95。最优附近很平,选错几个只损失几个百分点;但 γ\gamma 太小损失更多(从 8 降到 4 损失 9%)。

练习 7(运算量) α=0.7\alpha = 0.7、γ=3\gamma = 3。(a) c^=0\hat c = 0 时总运算量增加几倍?(b) 草稿每 token 的运算量是目标的 10%(c^=0.1\hat c = 0.1)呢?

答案:每轮期望 token =(1−0.74)/0.3=2.533= (1 - 0.7^4)/0.3 = 2.533。(a) (γ+1)/2.533=4/2.533=1.58(\gamma + 1)/2.533 = 4/2.533 = 1.58 倍(论文表 1)。(b) (0.3+4)/2.533=1.70(0.3 + 4)/2.533 = 1.70 倍。

练习 8(批大时为什么变慢) H100 上 Llama-3.1-8B、批大小 128、上下文 512、γ=2\gamma = 2,代码 05 算出验证 3 个 token 的时间是普通一步的 1.11 倍。用”临界 token 数约 220”解释为什么不再是 1;批大小 16 时为什么还是 1?

答案:验证一次送进 128×3=384128 \times 3 = 384 个 token,超过约 220 的临界值,矩阵乘法的时间由算力决定,随 token 数增长。批 16 时 16×5=8016 \times 5 = 80 个 token(γ=4\gamma = 4),远低于临界值,时间仍由读权重和 KV 决定,和普通一步一样。

练习 9(树的接受长度) 弱草稿的正确答案在前 1、2、4 名里的比例是 0.44、0.64、0.79。树 4×2×2×1:(a) 每轮验证几个草稿 token?(b) 假设各层独立,估算 τ\tau,和代码 05 实测的 2.82 比较。

答案:(a) 4+4×2+4×2×2+16×1=4+8+16+16=444 + 4 \times 2 + 4 \times 2 \times 2 + 16 \times 1 = 4 + 8 + 16 + 16 = 44。(b) E[接受]=0.79+0.79×0.64+0.79×0.642+0.79×0.642×0.44=0.79+0.506+0.324+0.142=1.76E[\text{接受}] = 0.79 + 0.79 \times 0.64 + 0.79 \times 0.64^2 + 0.79 \times 0.64^2 \times 0.44 = 0.79 + 0.506 + 0.324 + 0.142 = 1.76,τ≈2.76\tau \approx 2.76,和实测 2.82 接近(差别来自各层并不完全独立)。

练习 10(死锁的最小例子) KV 容量 10 个 token,不预留、没有抢占。两个请求同时到达,各有 4 个输入 token,都要生成 5 个 token。按”放得下输入就接纳”的规则,会发生什么?Orca 的预留规则会怎样处理?

答案:两个都被接纳,prefill 后占 8 个(产出各自的第 1 个 token)。下一次迭代各写 1 个 → 10 个(各 2 个 token)。再下一次每个都要再写 1 个,没有空位,两个请求都还差 3 个 token,谁也完不成、谁也不释放——死锁。Orca 按 prompt + max_gen = 4 + 5 = 9 预留:第一个请求预留 9 个,第二个预留不下就等着;第一个完成后释放,第二个再开始。慢一些,但一定能完成。(严格说最后一个 token 不需要写 KV,所以每个请求实际最多用 8 个,预留 9 是保守的。)

练习 11(读权重减少多少) 代码 04 演示 3 里,跳层草稿 γ=8\gamma = 8 时每次目标前向平均产出 4.74 个 token。目标模型读权重的总量应该降到普通 decode 的多少?演示 4 测出 0.22,为什么略大于你的估计?

答案:1/4.74≈0.211/4.74 \approx 0.21。演示 4 的读权重里还包括提示预填充的那一次前向(两种做法都有),它让比值往 1 靠了一点;另外最后一轮可能多生成几个 token(被截掉),也多算一次。


16. 延伸阅读 · Further reading

阅读材料

投机解码的其他工作

调度

  • W. Kwon et al., Efficient Memory Management for Large Language Model Serving with PagedAttention, SOSP 2023(第 7 讲的阅读材料)。
  • A. Agrawal et al., Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve, OSDI 2024(第 5 讲讨论过的分块 prefill)。

本仓库

  • CS349D 第 2 讲:连续批处理的调度策略(FCFS / SJF、预留策略、抢占)。
  • CS349D 第 5 讲:投机解码的更多变体(树形掩码的实现、SpecInfer 的多草稿)。

17. 下一讲预告 · Next lecture

本讲两次碰到同一堵墙:迭代级调度要为 KV 预留空间,预留浪费显存、不预留会死锁(§3.5);投机解码要回滚 KV(§6.2)。第 7 讲专门讲 KV cache 的管理:

  • PagedAttention(vLLM,SOSP 2023):借用操作系统的虚拟内存——KV 按固定大小的块按需分配,用块表把”逻辑上连续”映射到”物理上分散”;块可以共享(并行采样、束搜索、共同前缀),写时复制;显存不够时抢占(换出或重算)。
  • Mooncake(Kimi,FAST 2025):把 KV cache 当成整个集群的一等公民——解耦的 prefill / decode 之外,再加一个分布在 CPU 内存和 SSD 上的 KV 存储池,按 KV 的复用来调度请求,过载时提前拒绝。

→ 第 7 讲:KV cache 管理

← 返回课程目录