← 返回

换引擎扫草稿深度:中位数选第 4 档,均值选第 5 档,逐类各选各的

2026-09 · 单张 5090 上换了一套推理引擎,把唯一的草稿深度旋钮从 0 扫到 5;顺带记下两处我读错过的东西

为什么又扫一遍

之前那篇《投机解码:草稿长度不是越长越好》扫的是外挂草稿模型的路径, 结论有两条:草稿长度是有峰曲线,而那个"草稿置信度门"单调有害(接受率涨到 96.7%,吞吐掉到 72%)。 这次换了一个自带多 token 预测头(MTP)的引擎(Neroued/ninfer,Apache 2.0,commit 1d8587b), 同一件事要重新问一遍:峰还在不在原来的位置。

先说装它的代价:没有 install 目标、没有预编译包,只能从构建树里直接跑 (cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release && cmake --build build -j12, 三个产物各 177 MB 上下)。官方卡片要求 CUDA ≥ 13.1,我在 13.0.88 上照编照跑。 权重是一份自包含 artifact(22,783,241,220 字节,校验和 5412a0e7 开头),起服快得超出我预期: engine ready | total 5.0s | weights 18.9 GiB,其中权重 2.9s | 6.59 GiB/s。 换一次参数就是 5 秒,所以一个下午能扫完六档。

唯一的旋钮只有 5 档,而且没有门限

--draft-tokens 是这条路径上唯一的投机旋钮。MTP 的合法范围是 1..5 (另一条 DFlash 路径是 1..15),填 6 不是被钳到上限,而是启动期直接报错: --spec mtp requires --draft-tokens in [1,5]。所以"第 6 档会不会更快"这个问题在这条路径上无法回答。

更重要的是没有置信度门这种东西。接受度在这里是一个输出指标(draft_n_accepted / draft_n), 不是一个可以设的输入阈值。也就是说上一篇里"调门限反而改不了输出、只会亏速度"的那条经验,在这套东西上根本无从复现—— 不是它被推翻了,是那个参数不存在。这类事情我只能一条条引擎去记,不能跨引擎照搬默认值。

扫出来的聚合表:两个统计量选了不同的档

测法:四类内容(写代码 / 讲故事 / 翻译 / 结构化 JSON)各两条,温度 0,每条上限 400 token, 所以每档是 8 次运行;换档要重启进程,只能串行,六档跑完 143 秒(结果文件首行创建到末行落盘的时间戳差)。 吞吐取服务端返回的 timings.predicted_per_second

草稿深度中位数 tok/s均值 tok/s接受率(中位)
0(不投机)73.773.7
1123.9122.280.2%
2157.6154.274.8%
3188.1183.266.3%
4194.5181.155.7%
5191.0184.847.1%

看中位数,峰在第 4 档,相对不投机的地板是 ×2.64,第 5 档回落。这个结论我第一版就是这么写的。 但把均值那一列读完就发现问题:第 4 档是 3 / 4 / 5 三档里均值最低的一档(181.1), 按均值峰在第 5 档(184.8)。同一批数据、同一个引擎、同一份脚本, 换一种集中趋势就换了一个"最优档"。这两句话都成立,所以两句都不能单独拿去当结论。

拆开看:那 8 个数不是同分布的 8 次抽样

原因很简单:8 次运行是 4 类内容 × 2 次复测,不是 8 次独立重复。按类别拆开(每格是两次复测的均值):

类别1345接受率 1→5
code120.8180.9200.2178.8476.1%→42.7%
story114.7147.0134.7125.7367.9%→24.3%
translation126.8195.4188.8202.9585.5%→51.5%
structured126.6209.4200.8232.0584.2%→61.5%

列名是 --draft-tokens 的档位;"峰"是该类自己四档里最快的那一档。

四件事一下清楚了:

  1. 复测极稳,类别差极大。同一格两次复测最多差 0.1%(档 4 的八次原始值是 200.1 / 200.2、134.8 / 134.7、188.9 / 188.7、200.7 / 200.9),而同档跨类别差 66 tok/s。 也就是说那条曲线的宽度全是内容,不是噪声。
  2. 不跑投机时四类几乎同速:73.63 ~ 73.74,跨度 0.15%。 差异 100% 来自投机这条路,而不是"某类内容本来就更吃算力"。这条只有把档 0 一起扫才能看出来。
  3. 中位数 194.5 这个数,八次运行里没有一次落在它上面——它是 translation(188.9) 和 code(200.2)之间插出来的。在多峰分布上,"代表典型一次运行"这个说法对不上了。
  4. 翻译和结构化两档在档 4 上比档 3 还慢(195.4→188.8、209.4→200.8),到档 5 才抬上去。 聚合曲线把这一点抹平了:中位数看到的"峰在 4",其实是"story 在 4 崩得没那么快"加"其它类在 4 还没起来"。

按类别重算相对档 0 的加速,是 1.99×(story)到 3.15×(structured)之间四个不同的数: 1.99 / 2.72 / 2.75 / 3.15。×2.64 落在里面,但不代表其中任何一个。 如果只能报一个数我会报地板到峰,但那等于把最重要的一条信息(收益是内容的函数)报丢了。

真要定一个默认档,我按"最坏情况亏得最少"选

固定一档、不知道来的是散文还是 JSON 时,我关心的是每一类相对它自己最优档亏多少(regret):

固定档位codestorytranslationstructured最坏
3−9.6%0−3.7%−9.7%9.7%
40−8.3%−6.9%−13.4%13.4%
5−10.7%−14.5%0014.5%

最小最大 regret 是档 3,不是中位数峰上的档 4。这和上一篇的结论对上了: story 的接受率在档 4 只剩 30.6%、档 5 只剩 24.3%,长草稿对自然语言叙述基本是白猜。

但我现在常驻挂的是档 4(脚本里写死的默认值,我没改)。按这张表应该改到 3—— 代价是聚合中位数从 194.5 掉到 188.1,看着像退步。写在这里当给自己留的提醒。

第二处读错:容量日志的斜杠,我连着读错了两次

起服那行是 capacity | KV 224,000 tokens, int8, explicit | pages 3,500/10,500 | runtime 8.64 GiB | free 3.26 GiB。 我第一遍把它读成"224K 的池子在第一条请求进来之前就被吃掉 25%",还据此算了一版并发上限。 去源码看那行的构造(src/serve/operational_log.cpp:447),两个数是 kv_capacity_page_groupskv_capacity_max_page_groups——都不是"已用",第一条读错作废。

然后我第二次也读错了:拿两档配置一凑——

224,000 tokens / 3 槽 →  pages 3,500 / 10,500   10,500 ÷ 3 = 3,500
262,144 tokens / 4 槽 →  pages 4,096 / 16,384   16,384 ÷ 4 = 4,096

两档都"严丝合缝",我就下结论说前一个数是每槽配额 = 总池 ÷ 槽数。 这一版比上一版看着靠谱得多,其实还是错的,而且错法一模一样:把巧合当机制。真正的定义是——

那它到底是不是"完全动态"的多并发分配?不是

这才是我想弄清楚的。手边另一套引擎是动态那一派:槽位共享一个池,边生成边取页,池子不够就把最老的槽回收掉 (代价是那个槽的上下文丢了)。这套是预约准入 + 不抢占,四条都有出处:

  1. 预留量 = prompt + 有效输出,而有效输出是 max_tokensmax-context 削过的结果(planning/request_plan.cpp:245-262effective_output = min(requested, max_context - prompt + 1))。 所以"申请 20 万 token"不会超卖,也解释了为什么 prompt + max_tokens > max_model_len 不报错。
  2. 解码期只从自己的预留里长,不再查第二次容量paged_kv_cache.h:219-222: "Grows destination to target_page_count without … a second capacity check")。
  3. 不抢占:文档原话"不抢占已经激活的请求"(engine-architecture.md:28), 完成预留"在 terminal resource result 采用前不可被借用或回收";Host 那 8 GiB 只收不活跃的状态。
  4. 池子不切给槽engine-architecture.md:41-43)。这条我用行为验的:3 槽配置下 单发一条 max_tokens=200,000 照样准入(预留 200,055 < 224,000), 说明单请求最多吃掉整个池,而不是 1/3。

但它也不是自由进出:批处理只在轮次边界补位(engine-architecture.md:346-358,366-367, "bounded continuous batching",文档还专门声明这不是"大规模 continuous batching")。 所以准确说法是预约制的有限动态——进来之前把账算死,进来之后各花各的,谁也不挤谁。

后果是有两个互不相干的天花板,日志里长得一模一样(都是 waiting 1),成因完全不同。当场复现:

3 路并发 × 各要 100,000 输出(3 个槽够,配额不够:3 × 101,272 > 224,000)
  throughput | running 2 (decode-ready 2) | waiting 1 | batch 2.00
  → 第 3 条 queue 14.0s、output 0,客户端先超时

4 路并发 × 各要 1,200 输出(配额绰绰有余,槽不够:max-concurrency = 3)
  throughput | running 3 (decode-ready 3) | waiting 1 | batch 2.76

前者是 KV 池天花板,后者是 lane 天花板;两种都表现成"排队", 但只有前者会因为你把 max_tokens 调大而恶化。错误码也分两种: 真没容量是 429 server_overloaded(队列长度超上限), 等准入等超时是 503 request_queue_timeout(默认时限只有 30 秒)。 把 503 当成"服务挂了"去重试,会把一整轮批量任务跑成一串假零分——这是我踩过的最贵的一条。 另外 Device StateImages = max-concurrency + device-state-slotsstartup.cpp:108-110), 所以 3 active + 2 cached device states 里的 3 就是槽数;决定并发的是 lane 数,不是这些 state 槽。

顺带:严格校验把老配置打回来

新引擎在入口就校验,老引擎不校验,所以照旧配置写出来的调用会坏在这儿:

这条最阴的地方是:这种请求在 1 毫秒内被拒,而且一行日志都不留。 所以"服务端日志里没有它"不能当作"请求没到过"的证据——我在这个上面白找了一轮。

没测的、不敢写的

跨引擎的绝对速度倍数,我不写。这台机器上先后跑过两套东西,但两边的权重格式 (NVFP4 自包含 artifact vs 量化 GGUF)、KV 量化、探测脚本全都不一样,那是两份 artifact 的对照,不是引擎的对照。 更麻烦的是:对面的长上下文原始数据还落在磁盘上,这边当时的 prefill 数字只进了笔记没落盘, 所以现在连"补一次同脚本对照"的原始凭据都凑不齐。要真做严格对照得同一份权重两种格式,目前没这条路。

尾延迟也不写。每格 8 个原始值、每类只有 2 次复测,拿这个算 p95 是装样子。 要谈尾延迟得按类各跑二十次以上。

那条"并发跑不满、日志里出现 waiting 1"的观察,这次当场复现了(两段原始输出在上一节), 但 9-16 那一次的日志仍然没留下来——扫描脚本每换一档都用 mode="w" 重开日志文件,把当时的输出覆盖掉了。 教训是:测并发/排队这类现象,日志要按档追加(a),别开 w