第 10 章 投机解码与批槽
问题清单 #3 与路线图「批槽 drafter 批量化」的现场。本章讲清:一台 4090D 上怎么同时跑
4 路请求、MTP 草稿链如何把解码提速、链式(BCHAIN)在批槽上的得与失、
以及为什么并发数字有时不可信。
10.1 批槽:一张卡的 4 个座位
生产命令行(A 道配置,deploy/engines/profiles/ssebed/strata-a.json)里的三个参数定义了座位:
--batch 4 每引擎 4 个批槽(同时驻留 4 路请求的 KV/状态)
--batch-groups 1|2 4 槽分几组轮转(写书时 A=1、B=2;**2026-10-21 实测两套 profile 都已改成 1**)
--batch-mtp 批槽也走 MTP 草稿链
为什么是 4:可观测性实验(2026-10-14)实测 GPU 在 4 路已饱和——总吞吐 67~86 tok/s
与并发数基本无关(1 路 74、4 路 77、12 路 86)。加并发买到的是排队不是吞吐;
批槽数超出显存与带宽的甜点只会摊薄每路速度。/v1/status 的 concurrency.serving 就是批槽数。
批步主循环:generate.cpp 约 :5658 ver.run_slots(...)——一次前向同时验证一批槽
(每槽一步 decode + 各自的草稿验证)。src/core/verify.cpp 是验证与前向主流程。
10.2 MTP:一次猜 4 个,验证一次算
模型自带一个轻量草稿头(--mtp <path>/rt,运行时目录)。serve 模式强制启用 MTP
(约束 11:缺 --spec/--mtp/--prefill 直接 exit 2,不存在"关掉 MTP 跑基线")。
关键参数(generate.cpp :1420-1432):
| 参数 | 值(生产) | 语义 |
|---|---|---|
--spec |
4 | 草稿链长 T(一次猜 4 个 token,T≥2 才启用) |
--spec-min-p |
0.5 | 草稿 token 的概率下限:太没把握的不进链 |
--spec 窗口 |
— | 每窗验证 T+1 行:接受 k 个就一步产出 k+1 个 |
实测接受率(实验报告,2026-10-14):独跑 ~53%,多路稳态 ~60%(MTP 草稿链全程 ~60%)。
折算:独跑 76 tok/s 的相当一部分来自"一步多 token"——这是 24G 单卡撑住这个模型的关键红利。
草稿器实现:src/core/mtp.cpp / include/strata/core/mtp.hpp。
时序坑(改造时要背的雷,总览 §7 明示):set_prompt_len 可能在 load() 之前跑过
(mtp.cpp:245 注释原话),提示长度要先记后放。
10.3 链式批槽(BCHAIN):4 个座位各挂一条草稿链
--batch-mtp 打开后,批槽不是"4 路各走各的单 token",而是把 S 个槽的整轮草稿合成一张 CUDA 图
(mtp.cpp 约 :775-777 的批链注释:draft_batch,逐行算术与单槽 draft() 相同;
实现 :1142 MtpDrafter::draft_batch(int S, int L, ...))。
窗口调度(generate.cpp):
chain_slots = batch_groups > 1 ? batch / batch_groups : batch(约 :4895;
总览旧行号 :4568 已因注释整理漂移)——每组允许进链的槽数;one_group()(约 :5754):一组批步 =run_slots一步;run_slots_chain(约 :5776):链式批步;L<2(没得链)直接回落单步;- 降级开关
chain_off(约 :5732/:5783/:5849):一次链捕获显存不足或失败 → 永久关链,
该进程余生都用单步批槽。
收益(路线图口径):批槽链式 MTP 聚合吞吐 +10~26%;代价:链失败时整窗回退、
显存峰值更高(4090D 24G 上 --vram-reserve-mib 1600 就是给它留的余量)。
10.3.1 自适应验证窗口:--mtp-varlen(2026-10-21 起两条 lane 都开)
上面说的「每窗验证 T+1 行、固定 L」自 build 5 起变成可选的自适应:
- 怎么选:
plan_uniform(rates, S, L_max, cost)—— 输入是逐位置接受率(AcceptanceTracker的 EMA)
和在线实测的(rows → step_ms)曲线(StepTimeEstimator,分桶中位数 + PAVA 保序 + 插值),
在L = 1..L_max里挑期望 token/毫秒最高的宽度。 - 硬约束:本轮 L 最多比上一轮深一格(
L ≤ 上轮草稿深度 + 1,缩短不受限)。
根因在mtp.cpp:1142-1196:draft_batch(S, L, …)只按本轮 L 造草稿,j ≥ L的位置被清零;
加宽超过上一轮深度 = 校验一串 0 草稿。反过来「按 L_max 多造草稿」也不行:追赶行会写脏草稿链。
两次踩坑的实测证据在报告 §5.4。 - 默认零变化:不带
--mtp-varlen的路径与 build 1 完全一致。 - 实测:与固定 L 持平(c=1 178→180、c=2 53.7→55.4、c=4 28.6→30.6 tok/s),
TTFT 不受影响(冷 prefill 8k/32k/128k:2.55/9.15/36.94 s)。
真正的价值是流量异质时自动收窄与每窗口统计,不是今天的吞吐。 - 统计:
STRATA_STEP_STATS=<path>开 JSONL,一窗一行(slots/rows/drafts/accepted/step_ms/keeps/ctx),
用bench/deepinfer/analyze_steps.py汇总。两条 lane 现在分别写~/logs/step-stats-{a,b}.jsonl。 - 上普道后的冒烟(2026-10-21 14:00):空闲单流 115 tok/s;只要同时有两个槽在跑,
引擎就写{"kind":"batch","slots":2,"drafts":0}、一步 ~36 ms,每请求掉到 15–19 tok/s。
这是 batch 路径不做 MTP(问题 #3)的现场样本,与变长预算无关——改造前后都存在。 - 详细报告与源码存档:
docs/reports/DEEPINFER-VARLEN-2026-10-21.md、
archive/patches/mtp-varlen-2026-10-21/。
10.4 天花板:GMAX = 8
想冲 8 路以上并发之前,先看内核的物理上限:
src/kernels/cuda/s2_expert_grouped.cu:1360 constexpr int GMAX = 8;
// 组条目数被 min(..., GMAX) 截断,更长的窗口会静默丢弃条目(原注释)
src/kernels/cuda/iq_kernels.cu:1804 kVerifyMaxT = 8;MMVQ 模板 ncols<=8 硬编码
static_assert(GMAX >= kVerifyMaxT)
一个批窗口里路由到同一专家的 token 数上限是 8——超过的条目被静默丢弃。
--spec 4 的验证窗(最多 5 行/槽)在 4 槽下刚好贴着上限;
加槽数或加链长都要先动这两个 constexpr 重编内核(这是"换更小量化冲并发"被否决的
深层原因之一:瓶颈不在权重体积,在 kernel 模板,02 章已否决清单)。
聚合吞吐的现实天花板:~145-170 tok/s/机(本机双实例上限口径);官方 4 卡 IQ3_S 最好 360。
要真正达标只能加卡(阶段③,19 章)。
10.5 问题 #3:并发数字什么时候不可信
两条实测瑕疵(状态:未修,上游引擎问题):
timings字段并发下偶发缺失/错挂:核心输出正确,但decode_tok_s会出现
0.1 tok/s 这种假值;engine.last共享导致并发请求只有一路能在/metrics留下完整记录
(实验报告 §8)。监控页的复用率/命中率在并发时段不可信(总览 §6.2 盲区);
测速一律以客户端流式时间戳为准(bench 台口径,memorystrata-bench-harness)。- 惩罚类采样参数在批槽不生效:
verify.cpp:1901原话——
sp.penalty_last_n = 0; // 批量路径不做重复惩罚。采样/惩罚请求的头部会走
host 侧重采样(:951/:1269 注释),但批路径的重复惩罚就是不做。
客户端传repetition_penalty/presence_penalty在并发时可能被静默忽略——
这是"参数生效语义随并发状态变化"的接口级坑。
10.6 改造抓手(按代价排序)
| 方向 | 动什么 | 代价 |
|---|---|---|
| 观测先行 | 每槽进度/链状态出 INFO 行与 /slots 数组 |
改引擎+重启;盲区清单第一优先级 |
| drafter 批量化收尾 | draft_batch 的接受/回滚统一路径(Stage A-D 计划:docs/plans/ENGINE-BATCH-MTP-PLAN.md) |
中;测量纪律要过逐 token 一致性 |
| 抬 GMAX/kVerifyMaxT | 两个 kernel 模板重编(MMVQ ncols 参数化) | 大;显存与图捕获都要重算 |
| 采样态草稿(Stage D) | 链按概率分布接受(当前贪心 v1) | 已列"可选,未做" |
测量纪律(协作约定 3):任何并发改动必须过官方 batch_test 的逐 token 一致性,
A/B 交替顺序防偏——并发数字本身不可信,只有对照着同一份旧二进制跑出来的差值可信。
10.7 本章小结
- 批槽 4 = GPU 饱和点;
--batch-groups/链式把 4 路各自的多 token 合并成整窗一图。 - MTP 是强制项(约束 11),接受率 53~60%,聚合收益 +10~26%;失败永久降级的
chain_off
是显存压力的保险丝。 - 8 是内核里的字面量(GMAX/kVerifyMaxT),不是调优结果;想越过它先重编内核。
- 并发下的 timings/惩罚参数有已知不可信面;测速口径以客户端时间戳为准。
10.8 2026-10-21 复核增补:链式窗口为什么在并发下失效
§10.3 讲了 chain_off 这条"显存保险丝"。逐行读 SSEStraBed 之后,还要补上另外两道闸——
它们和显存无关,却决定了 4 路时每路实际只有多少收益。20 章从"时间去哪了"的角度讲过同一件事,
这里从行预算的角度再讲一遍。
① 行预算被并发除
// src/program/generate.cpp:5781
const int L_max = std::min(chain_rows_per_slot, std::max(1, (int) strata::kernels::kVerifyMaxT / S));
kVerifyMaxT = 8(10.4)是一帧的总行数上限,S 是这一帧有资格进链的槽数。
于是(chain_rows_per_slot = --spec = 4):
| 槽数 S | 1 | 2 | 3 | 4 |
|---|---|---|---|---|
每槽行预算 L_max |
4 | 4 | 2 | 2 |
| 一帧总行数 | 4 | 8 | 6 | 8 |
注意 S=3 时的浪费:8/3 = 2(整除),一帧只用 6 行,剩下 2 行预算没人用。
这就是 §10.4 说的"加槽数或加链长都要先动 constexpr"的具体形态。
② 一个采样槽,整组退化
// src/program/generate.cpp:5821-5824
for (int k = 0; k < S; ++k) { // v1: one sampled slot sends the whole group through the old path
const BSlot& sl = bs[(size_t) k];
if (sl.active && sl.sampled) return one_token();
}
sl.sampled 在请求不是贪心时置位(generate.cpp:7192:sl.sampled = !req_sp.greedy;)。
网页默认 temperature 0.6,所以真实流量里几乎每个槽都是 sampled ⇒ 整组退回 one_token(),
--batch-mtp、--mtp-varlen 全部失效。
实测(lane A,同一模型):
| 路径 | 1 槽 | 2 槽 | 3 槽 | 4 槽 |
|---|---|---|---|---|
| chain(贪心) | 194 | 121 | 82 | 67 tok/s·每请求 |
| batch(采样) | 82 | 71 | 18 | 25 tok/s·每请求 |
同一份二进制,只因为采样参数不同,单请求差 2.4 倍。 这是本项目里性价比最高的一处"改默认值"。
代码里那句注释
// v1: one sampled slot sends the whole group through the old path写得很诚实:
这是已知的 v1 限制,不是设计意图。--mtp-varlen也没能绕过它(它只决定"多宽",不决定"走不走链")。
③ 槽间严格 lockstep
所有槽共用同一帧、同一个 L、同一套门铃(20.3)。没有"这一轮某个槽先不算"的机制:
一个长上下文、低命中的槽会把整组拖慢,而其他槽明明可以继续。
step-stats 里的 keeps 数组是每槽这一帧提交了几行(step_stats.hpp:48 原话:
1 = golden only)——它只会说明"这个槽这一帧拿了几个 token",没有"这轮不算"这个取值。
④ 这两道闸与 §10.3.1 的变长窗口是什么关系
--mtp-varlen(build 5)改的是第 ① 项里 L 的取值(在 1..L_max 里挑期望收益最高的),
不改 L_max 本身,也不改第 ② 项的短路。这解释了 10.3.1 的实测:
“与固定 L 持平”——因为它能调的只有一个已经被并发除过的上限。
天花板不在窗口宽度,在预算的分配方式。
增补后的结论:10.1–10.7 的机制描述仍然成立;但"并发下每路腰斩"的主因不是 MTP 不够好,
而是 ① 行预算被除、② 采样短路、③ lockstep。三者的修法分别是放宽 kernel 上限(M4)、
改默认值(M0-4,零成本)、让槽独立进退(M2-2 的副产品)——见 21 章。