为什么讲这个案例:上一篇(pr11)我们"压"模型——量化把权重变小;这一篇我们"跑"模型——同样的权重,怎样让它出词更快。它把「机制理解 → 瓶颈归因 → 度量方法 → 可复现实验」串成一条链,且不需要写训练代码就能跑起来做实证,是讲"资源效率度量 + 运行性能运维"的天然载体。
阅读路径:§1 是瓶颈本质(先分清 prefill / decode),§2 是全篇基石(投机解码 + 无损证明),§3 是把三种手段放进同一张"三驾马车"对照表,§4–§6 是示例、度量与"何时不值得",§7 统一
traces.csvschema(为 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 越多;一旦拒绝,后面全部作废——但不会比不投机更差,因为拒绝点会重采。
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)——与老老实实一步一采完全相同。
换句话说:投机解码只改变"算多快",不改变"算什么"。这条无损性是它区别于一切近似加速(如激进的剪枝 / 早退)的根本,也是 §5 里"配置有误判据"的理论依据——一旦同 seed 同 prompt 下输出变了,就是哪里配错了,而不是"投机本来就会变"。
2.3 加速比:接受率 α 与验证开销 c(示意推导)
下面用简化符号给一个示意推导(假设每步独立、接受率恒定为 α),说明收益从哪来、又被什么吃掉。草稿一次出 k 个 token,目标模型一次并行验证(含重采位)的相对成本记为 c(c > 0,草稿越接近目标模型 c 越大)。一轮期望净产出 (1-α^{k+1})/(1-α) 个 token,耗时约 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 的度量规范)。
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 启动
# 基线:不投机(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
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)
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} (负斜率→并发侵蚀收益)")
10. 与其它案例的接口
本篇是"模型工程三部曲·中",前后各有对接:
量化位宽 quant_bits 是本篇对照组的一维;decode 省带宽的收益在 §1 瓶颈里承接。
tps 均值 / 预热 / 置信区间沿用 pr08,§5 加速比曲线是其方法在提速场景的具体化。
spec_decode × quant_bits 的格点由 §7 schema 直接喂给 pr14 的因子设计与统计检验。
多模态 / 长上下文真实负载下,投机解码的"工况依赖"直接决定它值不值得开。
三部曲的一句话主线:pr11"压"(量化减带宽)→ pr12"跑"(投机减串行、并行提吞吐)→ pr14"证"(把两维正交因子放进同一份 traces.csv 做实证)。本篇居中,是把"能压"接上"能证"的机制与度量环节。
一页纸总结:decode 是 memory-bound + 逐 token 串行 → 投机解码用"草稿+一次并行验证+接受最长前缀+回退重采"减串行步数,且数学上无损;量化 / 并行 / 投机三驾马车打不同瓶颈、收益不可相加;加速比 ≈ (1−α^{k+1})/((1−α)(c+1)),α 低 / c 高 / 并发高 / 上下文超长都会把收益吃到甚至吃负。开不开,用无损校验 + 加速比曲线说话,不用宣传头条数字说话。