为什么讲这个案例:上一篇(pr11)我们"压"模型——量化把权重变小;这一篇我们"跑"模型——同样的权重,怎样让它出词更快。它把「机制理解 → 瓶颈归因 → 度量方法 → 可复现实验」串成一条链,且不需要写训练代码就能跑起来做实证,是讲"资源效率度量 + 运行性能运维"的天然载体。

阅读路径:§1 是瓶颈本质(先分清 prefill / decode),§2 是全篇基石(投机解码 + 无损证明),§3 是把三种手段放进同一张"三驾马车"对照表,§4–§6 是示例、度量与"何时不值得",§7 统一 traces.csv schema(为 pr14 的 2×2 因子设计预留列),§8–§10 是可复现记录、动手代码与接口。


1. 为什么自回归解码慢:memory-bound 的本质

大模型推理分两个阶段,二者的瓶颈完全不同,混为一谈是所有"提速玄学"的源头:

  • Prefill(预填充):把整段 prompt 一次性喂进去,并行算出所有输入 token 的 KV。它是 compute-bound(算力受限)——GPU 的矩阵乘单元被吃满。
  • Decode(解码):一个 token 一个 token 往外蹦。每生成 1 个词,都要把全部权重和已累积的 KV Cache 从显存搬到计算单元一次。它是 memory-bound(带宽受限)——算力大量闲置,时间都花在"搬数据"上。

关键算术强度直觉:decode 每步的有效算力利用率极低,因为"读一次权重"只换来"算一个 token"。这就是为什么单请求逐 token 生成时,即便顶级显卡也只跑出几十 tokens/s——慢不在乘加,在读权重 + 串行依赖。逐 token 串行还带来第二个诅咒:第 t 个词没算出来,第 t+1 个词的输入就不存在,天生无法并行。

把两个瓶颈记清楚,全篇就顺了:prefill 缺算力,decode 缺带宽 + 缺并行度。量化、并行 / 批处理、投机解码分别对准其中不同的一个——这正是 §3 "三驾马车"分工的基础。


2. 投机解码原理(本篇基石)

投机解码(Speculative Decoding)的目标很纯粹:用一次"并行验证"替代多次"串行生成",把 decode 的串行步数压下来。它不改变模型、不改变输出分布——这是它的生命线。

2.1 草稿—验证—接受/回退循环

流程是三步循环:先用一个便宜的草稿模型(draft)快速连出 k 个候选 token;再让目标模型(target)把这 k 个候选一次前向并行算出各自的概率分布;然后从头比对,接受"最长一致前缀",在第一个被拒绝的位置回退并按修正分布重新采样一个 token。命中越多,一轮吐出的 token 越多;一旦拒绝,后面全部作废——但不会比不投机更差,因为拒绝点会重采。

① 草稿模型 draft快、便宜,连出 k 个候选 ② 目标模型 verify一次前向并行打分 k+1 位 ③ 接受前缀 ✓逐位比对取最长一致 ④ 回退重采 ✗拒绝处按修正分布采样 输出 token与不投机同分布 循环:接受/重采得到的新 token 重新作为草稿起点 a₁✓ a₂✓ a₃✗ a₄✗ 一轮净得:2 个接受 + 1 个重采 = 3 token
图 2-1 · 投机解码的"草稿 → 一次并行验证 → 接受最长前缀 / 拒绝处回退重采"循环(示意)

2.2 无损的数学保证(为什么它"骗"不了人)

投机解码不是"用小草稿凑数"——草稿猜错时目标模型有严格的拒绝采样(rejection sampling)修正。设草稿分布为 q(x)、目标分布为 p(x),对草稿采出的 token x 以概率 min(1, p(x)/q(x)) 接受;若被拒绝,则从修正分布 norm(max(0, p(x) - q(x))) 重采。可以证明:这样得到的 token 边缘分布恰好等于 p(x)——与老老实实一步一采完全相同。

$$\Pr[\text{输出}=x]=\underbrace{q(x)\min\!\left(1,\tfrac{p(x)}{q(x)}\right)}_{\text{接受}}+\underbrace{\left(\sum_{x'}\max(0,\,q(x')-p(x'))\right)\cdot \tfrac{\max(0,\,p(x)-q(x))}{\sum_{x'}\max(0,\,q(x')-p(x'))}}_{\text{拒绝后按修正分布重采}}=p(x)$$

换句话说:投机解码只改变"算多快",不改变"算什么"。这条无损性是它区别于一切近似加速(如激进的剪枝 / 早退)的根本,也是 §5 里"配置有误判据"的理论依据——一旦同 seed 同 prompt 下输出变了,就是哪里配错了,而不是"投机本来就会变"。

2.3 加速比:接受率 α 与验证开销 c(示意推导)

下面用简化符号给一个示意推导(假设每步独立、接受率恒定为 α),说明收益从哪来、又被什么吃掉。草稿一次出 k 个 token,目标模型一次并行验证(含重采位)的相对成本记为 c(c > 0,草稿越接近目标模型 c 越大)。一轮期望净产出 (1-α^{k+1})/(1-α) 个 token,耗时约 c + 1 个单位,于是期望加速比:

$$\text{speedup} \;\approx\; \frac{\mathbb{E}[\text{一轮净产出 token}]}{\text{一轮相对耗时}} \;=\; \frac{1-\alpha^{\,k+1}}{(1-\alpha)\,(c+1)}$$

这张"公式地图"读出三条工程结论:

  • α → 1 才划算:接受率低时分子迅速缩水,加速比可能掉到 1 以下(比不投机还慢,因为白验证了一轮)。
  • c 是隐形杀手:草稿模型越"重"、验证成本越高,分母越大。draft 与 target 差距小则 α 高但 c 也高——存在最优 draft 规模。
  • k 有边际递减:加大 k 提升分子但抬高 c 与拒绝概率,收益先增后减。

诚实声明:上述为示意的闭式近似,真实系统的接受率随位置衰减、KV 与调度还会引入额外项;量级趋势可信,具体数值以实测(§5)为准。


3. "三驾马车":量化 · 并行 / 前缀缓存 · 投机解码

提速的三条路径打的是不同瓶颈,所以收益不能相加:同一段显存带宽,量化省下来的,未必能等量兑现成 tokens/s;投机减下来的串行步数,在高并发下又可能被批处理摊平。下表是这张"分工地图":

手段主要打击的瓶颈典型收益场景会互相抵消 / 失效时
量化(承 pr11)decode 读权重的显存带宽单请求、大模型、带宽受限——权重越小搬得越快并发打满算力后收益递减;过低位宽掉质量
并行 / 批处理 + 前缀缓存GPU 算力利用率与重复 prefill高吞吐、多请求、共享 system / 长前缀单请求延迟不降;显存被 KV 挤占反限并发
投机解码decode 的串行步数低并发、短上下文、草稿命中高高并发时算力本已饱和,验证挤占 batch 算力 → 可能负收益

核心论点(原则驱动):三者作用于带宽 / 算力 / 串行度三个正交维度。"量化 + 批处理 + 投机全都开 = 三倍快"是错误的加法直觉——它们抢同一块 GPU 的有限资源,净收益取决于工况。这正是 §4 用同一份负载在"并发 × 上下文长度"两轴上跑对照的动机。


4. 当前示例:DFlash / DFlash2 型 llama.cpp 投机方案

本节给一个可跑的示例,但必须先立好边界:

⚠ 示例声明:投机解码工具生态迭代极快。此处 DFlash / DFlash2 型 llama.cpp 方案仅作为 一类 draft-based 投机解码的示例,命令行开关与模型格式以官方仓库 / 文档为准;下文出现的一切 tokens/s 与加速比,凡标注"社区"者均为社区测得的参考值、非性能承诺,凡标注"自测"者依赖特定硬件,换机不复现。

基线设计成两两对照:投机 off vs on;单请求 vs 并发;上下文深度取 0 / 8k / 32k 三档。预期趋势(供实验对照,非承诺):

工况投机 off 基线投机 on(预期趋势)说明
单请求 · 短上下文串行逐 token,带宽受限明显加速(社区值 ~1.5–2×+)α 高、算力有闲置 → 收益最大
单请求 · 32k 长上下文KV 搬运占比升高加速比缩水长 KV 抬高每步成本,c 变大
高并发 · 中上下文算力接近饱和收益微弱验证算力与批内其它请求争抢
高并发 · 长上下文显存 / 算力双紧可能 负收益草稿+验证挤占 KV 显存,反压并发

把这张表交给读者先做预测、再实测打脸,是讲"外部效度 + 工况依赖"的好素材:同一份配置,加速比可以从 2× 翻到负值。


5. 度量方法:加速比曲线 + 无损一致性检查

5.1 加速比曲线

定义逐点加速比为同工况下"投机 tokens/s ÷ 基线 tokens/s"。横轴取并发数或上下文长度,纵轴取加速比,画一条会随工况起伏的曲线——它不是常数,这是本篇最重要的可视化结论。记录方式:预热丢弃、固定 prompt、取多次重复的均值 tokens/s(对齐 pr08 的度量规范)。

$$\text{speedup}(\text{工况}) = \frac{\text{tps}_{\text{spec=on}}(\text{工况})}{\text{tps}_{\text{spec=off}}(\text{工况})}$$

5.2 无损一致性检查(配置对不对,先过这关)

投机解码必须无损:同 seed、同 prompt,投机前后输出的 token 序列 / 分布必须逐位一致。若不一致,是配置有误(如 greedy 采样未对齐、采样温度不同、draft 实现 bug),而非"投机本该不同"。这一步应在测速之前先通过,否则你加速的是一个错误系统:

  • 贪心(temp=0)下最直观:两配置输出 token 序列应逐字符相等。
  • 采样下:用同 seed 比对 logits / 采样结果,或用足够大样本比对经验分布。

一句话判据:无损检查 = 投机解码的"回归测试"。没通过就别谈加速比——先把正确性找回来。


6. 何时不值得:判定流程

把 §2.3 的公式与 §4 的对照表合成一条可执行的决策流程(供运维 / 选型用):

  • 并发是否已把算力打满? 是 → 验证挤占 batch 算力,投机大概率负收益,关闭。
  • 上下文是否超长? 是 → KV 抬高每步成本、c 变大,收益缩水,实测后再定。
  • 草稿接受率 α 是否够高? 记录 draft_accept_rate(见 §7),α 偏低(如 < 0.5)时分子缩水,不值得。
  • draft 是否太重? 若 draft 与 target 规模接近,α 高但 c 也高,净收益可能不升反降。

结论段的核心:投机解码是"低并发、短/中上下文、高命中、轻草稿"工况下的利器;一旦走进"高并发 / 超长上下文 / 低接受率"象限,收益消失甚至转负。开关与否应由实测曲线决定,而不是由宣传的"最高 N×"决定。


7. 统一 traces.csv schema(为 pr14 的 2×2 因子设计预留列)

三部曲不是三篇孤立文章,而是一份共享数据集。本节确立的 traces.csv 列,须与 pr11 的量化维度正交组合,构成 pr14 的 2×2 因子设计(量化位宽 × 投机开关)。必须预留 spec_decode(=on/off)这一列:

列名含义来源 / 取值
quant_bits量化位宽(承 pr11)因子,如 4 / 8;与 spec_decode 组成 2×2
spec_decode投机解码开关on / off(本篇主因子)
draft_accept_rate草稿接受率 α运行时统计,衡量 §2.3 分子
verify_cost_c验证相对开销 c估算或实测,衡量 §2.3 分母
concurrency并发请求数工况轴,画加速比曲线用
ctx_len上下文长度工况轴:0 / 8k / 32k
tps吞吐 tokens/s预热后多次均值
lossless_ok无损校验是否通过bool;false 时该行作废
seed随机种子复现与无损比对锚点

与 pr11 / pr14 的衔接:pr11 负责填 quant_bits,本篇负责填 spec_decode / draft_accept_rate / verify_cost_c;两者笛卡尔积 {4,8}×{on,off} 恰为 pr14 的 2×2 实证格点。列名现在就定死,pr14 直接 join,不用回头补数据。


8. 可复现记录(诚实原则)

速度数字最容易"过两天就不复现"。逐条留档,报告里对每个速率标注来源:

  • 运行时版本 + commit 哈希(llama.cpp / llama-server 具体 build)。
  • 硬件:GPU 型号、显存、驱动;并发与是否独占(外部效度声明)。
  • 模型与 draft 的具体文件及哈希、量化格式。
  • 启动命令原样贴出(见 §9);预热轮数、重复次数、取值方式。
  • 每个速率数字标 [自测] 或 [社区];社区值非承诺、自测值限硬件。

诚实原则:不写"实测最高 3.8×"这种脱context 的头条数字。写"在 RTX 4070 / 单请求 / 8k 上下文 / α≈0.7 下,自测 tps 从 A→B",可复现、可证伪,才是工程记录。


9. 动手实验(可直接改跑)

思路:两条 llama-server 启动命令造基线 / 投机两配置 → Python 采 tps + 无损校验 → 写 traces.csv → 画加速比曲线。参数以官方为准;下面注释只写功能/参数含义,不写机器专属信息。

9.1 基线 vs 投机:两条 llama-server 启动

Bash
# 基线:不投机(spec_decode=off),仅目标模型
llama-server \
  -m /models/target.Q4_K_M.gguf \      # 目标模型(quant_bits 承 pr11)
  --ctx-size 32768 \                   # 上下文窗口,覆盖 0/8k/32k 三档
  --host 127.0.0.1 --port 8080 \
  --temp 0                             # 贪心,便于 §9.2 无损逐字符比对

# 投机:加 --model-draft 指定草稿模型(spec_decode=on)
llama-server \
  -m /models/target.Q4_K_M.gguf \
  --model-draft /models/draft.gguf \   # 更小/更快的 draft 模型
  --ctx-size 32768 \
  --draft 8 \                          # k:每轮草稿 token 数
  --n-cpu-moe 0 \                      # 视实现:draft 放 GPU/CPU
  --host 127.0.0.1 --port 8081 \
  --temp 0
# 注:--model-draft / --draft / 采样相关开关的确切名称与语义,请以官方 --help 为准。

9.2 采集 tps + 无损校验并写 traces.csv

Python
import json, time, requests, csv

PROMPT = "用一句话解释什么是投机解码,要求无损。"
SEED, MAXTOK = 42, 256
BASE, SPEC = "http://127.0.0.1:8080", "http://127.0.0.1:8081"

def gen(base, spec_on, ctx_len, concurrency):
    # 单请求采样;ctx_len / concurrency 由外层因子循环控制
    url = f"{base}/completion"
    payload = dict(prompt=PROMPT, seed=SEED, n_predict=MAXTOK,
                   temperature=0.0, cache_prompt=(ctx_len == 0))
    t0 = time.perf_counter
    r = requests.post(url, json=payload, timeout=600).json
    dt = time.perf_counter - t0
    text = r.get("content", "")
    ntok = r.get("tokens_evaluated", 0) + len(r.get("generation_settings", {}) and []) or MAXTOK
    ntok = r.get("timings", {}).get("predicted_n", MAXTOK)  # 以官方 timings 字段为准
    return text, ntok / dt if dt > 0 else 0.0, r

def lossless_check(ctx_len, concurrency):
    # 无损判据:同 seed 同 prompt,两配置输出 token 序列必须逐字符一致
    tb, _, _ = gen(BASE, False, ctx_len, concurrency)
    ts, _, _ = gen(SPEC, True,  ctx_len, concurrency)
    return tb == ts, tb, ts

rows = []
for quant in (4, 8):                       # 承 pr11 的 quant_bits 维度
    for ctx_len in (0, 8192, 32768):       # 上下文长度 三档
        for conc in (1, 4, 8):             # 并发 三档
            ok, _, _ = lossless_check(ctx_len, conc)
            if not ok:
                print(f"[!] 无损未通过 q={quant} ctx={ctx_len} c={conc} —— 配置有误,跳过测速")
            # 预热一次丢弃,再重复取均值
            for _ in range(1): gen(BASE, False, ctx_len, conc)
            tps_b = sum(gen(BASE, False, ctx_len, conc)[1] for _ in range(3)) / 3
            tps_s = sum(gen(SPEC,  True,  ctx_len, conc)[1] for _ in range(3)) / 3
            rows.append(dict(quant_bits=quant, spec_decode="on",
                             ctx_len=ctx_len, concurrency=conc, seed=SEED,
                             lossless_ok=ok, tps=round(tps_s, 1),
                             tps_baseline=round(tps_b, 1),
                             speedup=round(tps_s / tps_b, 2) if tps_b else None,
                             draft_accept_rate=None, verify_cost_c=None))
            print(f"q={quant} ctx={ctx_len} conc={conc} speedup={tps_s/tps_b:.2f}")

fields = ["quant_bits","spec_decode","ctx_len","concurrency","seed",
          "lossless_ok","tps","tps_baseline","speedup",
          "draft_accept_rate","verify_cost_c"]
with open("traces.csv", "w", newline="", encoding="utf-8") as f:
    w = csv.DictWriter(f, fieldnames=fields)
    w.writeheader; w.writerows(rows)
print("已写 traces.csv —— draft_accept_rate / verify_cost_c 需从 server 日志/统计回填")

说明:draft_accept_rate、verify_cost_c 一般从 llama-server 的统计日志里回填(字段名以官方为准);timings.predicted_n 等 JSON 路径也随版本变化,跑前用 r.keys 核对。无损未通过的格点应作废而非硬凑数字。

9.3 画加速比曲线(scipy / matplotlib)

Python
import pandas as pd, matplotlib.pyplot as plt
from scipy import stats

df = pd.read_csv("traces.csv")
df = df[df.lossless_ok == True]                 # 只保留通过无损校验的行

fig, ax = plt.subplots(figsize=(6.5, 4))
for ctx, g in df.groupby("ctx_len"):
    c = g.groupby("concurrency").speedup.mean
    ax.plot(c.index, c.values, marker="o", label=f"ctx={ctx}")

ax.axhline(1.0, color="gray", ls="--", lw=1)    # 加速比=1 的盈亏线
ax.set_xscale("log", base=2)
ax.set_xlabel("并发数(log2)")
ax.set_ylabel("加速比 tps_on / tps_off")
ax.set_title("投机解码加速比随并发/上下文变化")
ax.legend; ax.grid(alpha=.3); fig.tight_layout
fig.savefig("spec_speedup.pdf")                 # 矢量图直出

# 高并发段斜率显著为负 → 佐证"并发高时收益转负"(趋势检验)
hi = df[df.concurrency >= 4]
slope, _, p, *_ = stats.linregress(hi.concurrency, hi.speedup)
print(f"高并发段斜率={slope:.3f}, p={p:.3f} (负斜率→并发侵蚀收益)")
盈亏线 speedup=1 并发数 →(log 轴) 加速比 ↑ ctx=0(短) ctx=32k(长) 高并发 → 跌破盈亏线(负收益)
图 9-1 · 加速比随并发升高而下滑、长上下文更早跌破 1(示意,非实测值;实测见 §9.3 曲线)

10. 与其它案例的接口

本篇是"模型工程三部曲·中",前后各有对接:

↑
pr11 · 量化前提

量化位宽 quant_bits 是本篇对照组的一维;decode 省带宽的收益在 §1 瓶颈里承接。

⚙
pr08 · 度量规范

tps 均值 / 预热 / 置信区间沿用 pr08,§5 加速比曲线是其方法在提速场景的具体化。

▦
pr14 · 2×2 实证

spec_decode × quant_bits 的格点由 §7 schema 直接喂给 pr14 的因子设计与统计检验。

◎
pr02 / pr18 · 真实负载

多模态 / 长上下文真实负载下,投机解码的"工况依赖"直接决定它值不值得开。

三部曲的一句话主线:pr11"压"(量化减带宽)→ pr12"跑"(投机减串行、并行提吞吐)→ pr14"证"(把两维正交因子放进同一份 traces.csv 做实证)。本篇居中,是把"能压"接上"能证"的机制与度量环节。


一页纸总结:decode 是 memory-bound + 逐 token 串行 → 投机解码用"草稿+一次并行验证+接受最长前缀+回退重采"减串行步数,且数学上无损;量化 / 并行 / 投机三驾马车打不同瓶颈、收益不可相加;加速比 ≈ (1−α^{k+1})/((1−α)(c+1)),α 低 / c 高 / 并发高 / 上下文超长都会把收益吃到甚至吃负。开不开,用无损校验 + 加速比曲线说话,不用宣传头条数字说话。