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 秒,所以一个下午能扫完六档。
--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.7 | 73.7 | — |
| 1 | 123.9 | 122.2 | 80.2% |
| 2 | 157.6 | 154.2 | 74.8% |
| 3 | 188.1 | 183.2 | 66.3% |
| 4 | 194.5 | 181.1 | 55.7% |
| 5 | 191.0 | 184.8 | 47.1% |
看中位数,峰在第 4 档,相对不投机的地板是 ×2.64,第 5 档回落。这个结论我第一版就是这么写的。 但把均值那一列读完就发现问题:第 4 档是 3 / 4 / 5 三档里均值最低的一档(181.1), 按均值峰在第 5 档(184.8)。同一批数据、同一个引擎、同一份脚本, 换一种集中趋势就换了一个"最优档"。这两句话都成立,所以两句都不能单独拿去当结论。
原因很简单:8 次运行是 4 类内容 × 2 次复测,不是 8 次独立重复。按类别拆开(每格是两次复测的均值):
| 类别 | 1 | 3 | 4 | 5 | 峰 | 接受率 1→5 |
|---|---|---|---|---|---|---|
| code | 120.8 | 180.9 | 200.2 | 178.8 | 4 | 76.1%→42.7% |
| story | 114.7 | 147.0 | 134.7 | 125.7 | 3 | 67.9%→24.3% |
| translation | 126.8 | 195.4 | 188.8 | 202.9 | 5 | 85.5%→51.5% |
| structured | 126.6 | 209.4 | 200.8 | 232.0 | 5 | 84.2%→61.5% |
列名是 --draft-tokens 的档位;"峰"是该类自己四档里最快的那一档。
四件事一下清楚了:
按类别重算相对档 0 的加速,是 1.99×(story)到 3.15×(structured)之间四个不同的数:
1.99 / 2.72 / 2.75 / 3.15。×2.64 落在里面,但不代表其中任何一个。
如果只能报一个数我会报地板到峰,但那等于把最重要的一条信息(收益是内容的函数)报丢了。
固定一档、不知道来的是散文还是 JSON 时,我关心的是每一类相对它自己最优档亏多少(regret):
| 固定档位 | code | story | translation | structured | 最坏 |
|---|---|---|---|---|---|
| 3 | −9.6% | 0 | −3.7% | −9.7% | 9.7% |
| 4 | 0 | −8.3% | −6.9% | −13.4% | 13.4% |
| 5 | −10.7% | −14.5% | 0 | 0 | 14.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_groups 和 kv_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
两档都"严丝合缝",我就下结论说前一个数是每槽配额 = 总池 ÷ 槽数。 这一版比上一版看着靠谱得多,其实还是错的,而且错法一模一样:把巧合当机制。真正的定义是——
src/core/paged_kv_cache.h:18,kPagedKVPageSize = 64),
所以前一个数就是整个已分配池:224,000 ÷ 64 = 3,500;262,144 ÷ 64 = 4,096。槽数 × 单序列上限页数(planning/startup.cpp:903-918),
也就是 --kv-capacity auto 最多能要到多大:3 × 3,500 = 10,500;4 × 4,096 = 16,384。max-context 都恰好等于 kv-capacity,所以"前一个数 ÷ 后一个数"必然等于
1 ÷ 槽数——那是恒等式,不是分配策略。顺带把上一版写的"页 → token 还没钉死"也钉死了:不是 16,是 64。这才是我想弄清楚的。手边另一套引擎是动态那一派:槽位共享一个池,边生成边取页,池子不够就把最老的槽回收掉 (代价是那个槽的上下文丢了)。这套是预约准入 + 不抢占,四条都有出处:
max_tokens 被 max-context
削过的结果(planning/request_plan.cpp:245-262:
effective_output = min(requested, max_context - prompt + 1))。
所以"申请 20 万 token"不会超卖,也解释了为什么 prompt + max_tokens > max_model_len 不报错。paged_kv_cache.h:219-222:
"Grows destination to target_page_count without … a second capacity check")。engine-architecture.md:28),
完成预留"在 terminal resource result 采用前不可被借用或回收";Host 那 8 GiB 只收不活跃的状态。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-slots(startup.cpp:108-110),
所以 3 active + 2 cached device states 里的 3 就是槽数;决定并发的是 lane 数,不是这些 state 槽。
新引擎在入口就校验,老引擎不校验,所以照旧配置写出来的调用会坏在这儿:
model 字段必须和 --model-id 一字不差,否则 model_not_found。
老引擎完全忽略这个字段,所以我那条"随便填"的经验只在新引擎这边失效。[A-Za-z0-9_-]{1,64}。实践中卡住的是那个 64 字符上限:
我另一套工具链把插件工具命名空间拼成 mcp__plugin_<插件>_<插件>__<工具>,
一份 76 个工具的清单里有 15 个是 65~71 字符。只把这 15 个改短,同一个 139 KB 请求体就正常返回
(prompt 35,962 / 首 token 4.7 秒 / 解码 170.7 tok/s)。这条最阴的地方是:这种请求在 1 毫秒内被拒,而且一行日志都不留。 所以"服务端日志里没有它"不能当作"请求没到过"的证据——我在这个上面白找了一轮。
跨引擎的绝对速度倍数,我不写。这台机器上先后跑过两套东西,但两边的权重格式 (NVFP4 自包含 artifact vs 量化 GGUF)、KV 量化、探测脚本全都不一样,那是两份 artifact 的对照,不是引擎的对照。 更麻烦的是:对面的长上下文原始数据还落在磁盘上,这边当时的 prefill 数字只进了笔记没落盘, 所以现在连"补一次同脚本对照"的原始凭据都凑不齐。要真做严格对照得同一份权重两种格式,目前没这条路。
尾延迟也不写。每格 8 个原始值、每类只有 2 次复测,拿这个算 p95 是装样子。 要谈尾延迟得按类各跑二十次以上。
那条"并发跑不满、日志里出现 waiting 1"的观察,这次当场复现了(两段原始输出在上一节),
但 9-16 那一次的日志仍然没留下来——扫描脚本每换一档都用 mode="w" 重开日志文件,把当时的输出覆盖掉了。
教训是:测并发/排队这类现象,日志要按档追加(a),别开 w。