Kestrel_VLLM - 32 · 投机轮的锚定口径:位级同流,却输在一堵退不回去的墙上
32 · 投机轮的锚定口径:位级同流,却输在一堵退不回去的墙上
一句话:把 MTP 接进投机轮次跑端到端 A/B——输出与纯自回归逐字节相同(位级通过),但加速比只有0.531×–0.737×(全部 <1×,判否);否因是结构性的:被拒草稿行的 GDN 递推状态无法就地截断,只能回滚后用M=1 单步逐行重放,而批量入口又硬要求批宽与
mdl_mmax严格相等。「输出完全正确」与「能不能赚钱」之间,隔着一堵结构性的墙。
前置:先读 第 31 篇(MTP 上 GPU:独立槽位、非致命绑定、逐级对拍)。
环境:x86-64 · NVIDIA RTX 5090 · Qwen3.5-27B(q4 打包)· CUDA 后端;T=64、k=3、单会话。
一、问题与结论
第 31 篇把 MTP 头搬上了设备,并且证明了它「抄对了」(55 行逐级全过)。但第 30 篇早就留了一句话:「头能跑通 ≠ 头有用」。本篇就是去回答「有没有用」——结局是否定的,而且否得很难看。
| 指标 | 数值 | 判定 |
|---|---|---|
| 判据 2:与纯自回归同流 | T=64 生成流memcmp = 0,六个起点全部成立 | ✅ 位级通过(实测) |
| 判据 3:端到端 ≥1.05× | 六个起点区间0.531×–0.737× | ❌ 判否(实测) |
| 每轮推进 | ≈1.4 token/轮(最好 1.730) | 实测 |
| 一轮成本 | ≈35–40 ms | 实测 |
理想草稿下(E[len]=2.107)的推算上界 | ≈0.76× | 推算 |
一句话概括:「输出完全正确」与「能不能赚钱」之间,隔着一堵结构性的墙。
二、背景:先订正口径——什么叫「一轮」
最初的写法是verify : model_step_m(4, pos, [t0,t1,t2,t3])——把「位置pos的 tokent0」和「位置pos的 hidden」配成一对。
但按第 30 篇那条口径:MTP 的输入是(embed(位置 i 的 token), 位置 i 的 hidden),两者同位置配对。而一轮投机跑完之后,主模型手里只有**「最后被接受的那个位置」的 hidden——t0那一行(位置pos)早在上一步就被消费过了,再放进 verify 批就是同一个位置消费两次**。
订正后的锚定口径:
不变量:主模型已消费到位置 p;手上有 h_p、t_p、logits_p(= 位置 p 之后那一 token 的分布) MTP 自己的 KV 游标也在 p 1) 草稿(k=3,全从 p 出发、链式): e = embed(t_p), h = h_p, q = p for j in 1..k: mtp_step(q, e, h, logits_mtp) ← 位置参数 = q d_j = argmax(logits_mtp) if j < k: e = embed(d_j); h = mtp_get(OUT) ← 链式喂**自己的输出** q += 1 2) verify(M = k,批的位置从 p+1 起): mark() model_step_m(k, p+1, [d_1..d_k]) → h_out[k], logits[k] 3) 接受(★ d_1 用**已有的** logits_p,不占 verify 行): n_ok = 1 若 argmax(logits_p) == d_1,否则 0 再逐位比 argmax(logits[i-2]) == d_i(i ≥ 2)直到第一次不等 4) 发出(最容易做错的一处): n_ok ≥ 1 → 发出 d_1..d_{n_ok}(**不**发出自产 token) n_ok == 0 → 发出 argmax(logits_p) 5) 状态收尾(消费量必须与本轮发出量**相等**): n_ok == k → 原地保留 n_ok ≥ 1 → rollback(),再 model_step_m(n_ok, p+1, [d_1..d_{n_ok}]) 重放 n_ok == 0 → rollback(),再 model_step(1, p+1, embed(自产 token))「不发出自产 token」那一条值得单独记:它就是「位置p+n_ok+1的 token」,而下一轮的d'_1正是同一个位置的预测(下一轮从p+n_ok起锚)。若这一轮先发,下一轮接受d'_1时会再发一次⇒ token 流出现重影。
三、核心机制 / 做法
3.1 要三个原语,不是两个
mark/rollback是一次性快照:mark记下 GDN 的S/hist与各 attn 层的 KV 游标,rollback把它们逐位恢复到mark那一刻,然后快照失效(必须重新mark)。
但投机轮次要的是一个中间值:
mark 在 p (快照点 = 消费到 p) verify 跑到 p+k (批已把游标推到 p+k) rollback → 回到 p 重放 n_ok 行 → 消费到 p + n_ok ★ MTP 游标要停在 p + n_ok —— 而快照里只有 p,没有 p+n_ok⇒ 必须新增vcuda_dev_mtp_truncate(dev, len):把 MTP 的写游标截到len,不拷、不清 KV。
/* 文件名:GitHupSRC/src/npu/cuda/vllm_cuda_kernels.cu(节选) *//* 把 MTP 那一层的**写游标截到 `len`**(不拷 KV、不清 KV)。 * 为什么需要它:草稿链会把游标推到 `p+k`,而一轮只接受 `n_ok` 个 ⇒ 下一轮要从 * `p+n_ok` 起锚。`mark`/`rollback` 只能把它退回**快照那一格**,退不到中间值。 * ★ 为什么「只截游标」是安全的:保留下来的 [0, len) 全是**已确认**的 token * (可见长度/追加行号都取自游标,越界行永远读不到,下一轮 append 就地覆盖)。 */VCUDA_APIintvcuda_dev_mtp_truncate(void*dev,intlen){vc_dev_t*s=(vc_dev_t*)dev;if(!s||!s->ok||!s->mtp_ok)return-1;if(len<0||len>s->mtp_maxseq)return-1;s->mtp_len=len;s->mtp_trace_done=0;/* 截断后快照不再配套 */if(s->mdl_mk_ok)s->mdl_mk_ok=0;/* 在册快照一并作废 */return0;}为什么可以不拷:MTP 那层 KV 的可见长度是由它自己的游标派生的⇒ 游标一改小,多写的那几行就永远读不到(下次 append 会就地覆盖)。所以「截断」= 退游标,零拷贝。这条论证与第 19/20 篇(KV 镜像的「游标即可见长度」)是同一条,只是这次它作用在一个自己的槽位上——这只有在第 31 篇「MTP 是独立槽位」成立时才成立。
3.2mark/rollback:两种状态,两种退法
同样的零拷贝论证也用在 attn 的 KV 上;但 GDN 的递推状态S/hist是就地覆写的,只能快照/恢复:
/* 文件名:GitHupSRC/src/npu/cuda/vllm_cuda_kernels.cu(节选:vcuda_dev_model_rollback) */if(cudaMemcpy(s->d_gdn_S,s->d_mb_ss,ssz*4,cudaMemcpyDeviceToDevice)!=cudaSuccess||cudaMemcpy(s->d_gdn_hist,s->d_mb_hs,hsz*4,cudaMemcpyDeviceToDevice)!=cudaSuccess){cudaGetLastError();return-1;}memcpy(s->attn_len,s->mdl_len_snap,(size_t)s->attn_nl*sizeof(int));/* 游标回退,零拷贝 *//* MTP 游标同样回退(`-1` = mark 时就没有 MTP 槽位)。 */if(s->mtp_len_snap>=0){s->mtp_len=s->mtp_len_snap;s->mtp_trace_done=0;}s->mdl_mk_ok=0;/* 一次性:回滚后快照失效,必须重新 mark 才能再回滚 */GDN 那两笔cudaMemcpy(S= 149.6 MB +hist)就是本轮的贵处——见 3.5。
3.2.1 回滚面要单独验:一个负向对照
专门验「MTP 游标是否真的进了快照面」:
| 臂 | 做法 | 结果 |
|---|---|---|
| 正向 | reset → mark → 3 草稿 → rollback → 再跑同样 3 草稿 | out memcmp = 0✅ |
| 负向对照 | 短路掉回退(不动游标)后重跑 | memcmp ≠ 0✅(证明该面确实在起作用) |
只跑正向是不够的:如果回退本来就是空操作,正向也会过。真正能证明「这一段在做事」的,是把它拿掉以后结果变了。
3.3 判据 2 位级通过:为什么它是「构造保证」
判据 2 要的是「端到端生成流 == 纯自回归参考流」。它成立的原因是构造性的:
n_ok ≥ 1时,d_1..d_{n_ok}按定义就是逐位等于argmax(logits)(否则不会被接受);n_ok == 0时,发出的是argmax(logits_p)本身。
⇒ 每一轮发出的 token都是主模型自己那条 argmax 链上的值,与草稿质量无关。代码里这条「接受 + 重放」是这么落的:
/* 文件名:GitHupSRC/tools/q35_gpu_model.c(节选:spec_run 的接受与状态收尾) */intn_ok=(amax_v(lg,V)==d[1])?1:0;/* ★ `n_ok >= 1` 这一项不能少:`(size_t)(n_ok - 1)` 在 n_ok==0 时是 SIZE_MAX, * `lgM + SIZE_MAX*V` 会回卷成 `lgM - V` ⇒ 在缓冲**前面**扫 993 KB,访问违例。 * 而且语义上也对:d_1 没被接受时 d_2 不可能被接受。 */while(n_ok>=1&&n_ok<kv&&amax_v(lgM+(size_t)(n_ok-1)*V,V)==d[n_ok+1])n_ok++;...if(n_ok>=kv){/* 整批保留,无需回滚 */memcpy(h_out,hoM+(size_t)(kv-1)*D,(size_t)D*4);memcpy(lg,lgM+(size_t)(kv-1)*V,(size_t)V*4);for(intj=1;j<=kv;j++)seqB[emit++]=d[j];p+=kv;tk=d[kv];}elseif(n_ok==0){/* 只回滚,不重放 */constintnx=amax_v(lg,V);/* lg 未被动过 = dist at p */if(vllm_cuda_model_rollback(cu)!=0)gotodone;seqB[emit++]=nx;...}else{/* 重放 n_ok 行(1..k-1)。★ 只能用 M=1 单步,不能批量(见 3.5)。 */if(vllm_cuda_model_rollback(cu)!=0)gotodone;for(intj=1;j<=n_ok;j++){if(vllm_cuda_model_step(cu,p+j,hM+(size_t)(j-1)*D,h_out,lg)!=0)gotodone;seqB[emit++]=d[j];}p+=n_ok;tk=d[n_ok];}3.4 拿到读数的实测日志
[spec] 档A(纯自回归,MTP=0)64 token:933.8 ms(14.590 ms/token) [spec] 档B(投机,k=3,force=0)64 token:1757.2 ms(27.456 ms/token) [spec] 轮数=45 接受总数=40 平均每轮接受=0.889 ⇒ 每轮推进=1.422 token(n_ok=0 的轮=24) [spec] n_ok 直方图: n_ok=0:24 n_ok=1:6 n_ok=2:11 n_ok=3:4 [spec] token 流 memcmp=0 ⇒ PASS(与纯自回归**逐字节**相同)★ 这条「构造保证」有一个代价,第 33 篇会讲:正因为发出流由主模型 argmax 兜底,判据 2 对「喂给 MTP 的输入对不对」完全不敏感——它会在驱动的输入口径错得很离谱时,照样报绿。
3.5 那堵墙:重放躲不掉,因为批宽被钉死
成本账:
一轮 ≈ 3 × c_d(≈3.4 ms) ← 三次设备 MTP 草稿步 + 3 行批(≈21 ms) ← verify(M=3) + n_ok × 14.6 ms ← ★ 重放:被拒/接受前缀用 M=1 逐步重放 ≈ 35–40 ms 一轮产出 ≈ 1.4 个 token ⇒ ≈ 23–27 ms/token vs 纯自回归 14.6那 21 ms 的 verify 批并不贵(它是一次 M=3 的批前向,摊薄了权重读)。贵的是重放:n_ok < k时每轮都要rollback+M=1 逐行重放max(n_ok,1)行。
为什么重放躲不掉:批量入口有一条硬预检——批宽M必须与mdl_mmax严格相等:
/* 文件名:GitHupSRC/src/npu/cuda/vllm_cuda_kernels.cu(节选:vc_mb_ensure) */staticintvc_mb_ensure(vc_dev_t*s,intM){if(!s||M<2||M>s->mdl_mmax)return-1;/* ★ 硬约束:M 必须在 [2, mdl_mmax] */if(s->d_mb)return0;.../* ★ 按 mdl_mmax(上限)分配,而不是本次的 M —— 行距是 mdl_mmax,必须容纳全部行。 */if(cudaMalloc((void**)&s->d_mb,per*(size_t)s->mdl_mmax*4)!=cudaSuccess){...}return0;}mdl_mmax是固定一格(3)——而重放的行数n_ok是1或2,凑不出「非满格批」⇒ 直接拒。
顺带一个真实踩到的 bug:尾轮
kv = min(k, T-emit) = 2也会撞上同一条 ⇒「非满格批」被拒。修法是尾段不投机,走普通单步(只影响最后 ≤k−1 个 token)。
3.6 结构性判断:换掉草稿也救不回来
把「首草稿命中率」修到离线水平(E[len] → 2.107)呢?——重放那一项线性随n_ok增长,仍然把加速压在≈0.76×(推算)。
⇒这不是 MTP 质量问题,是「GDN 递推状态退不回去」问题。attn 层的 KV 可以「退游标」(第 19/20 篇已收口);但GDN 的递推状态S/hist是逐 token 用S ← f(S, x)叠出来的,退到中间某一步必须重新逐 token 叠——没有位级捷径。加上「批宽必须严格相等」,这条退路就彻底封死了。
一句话概括这一轮:在混合架构上,投机解码的拦路虎从「草稿准不准」换成了「状态退不退得回去」。
四、实测数据
4.1 六个起点的完整读数(判据 3 判否)
| 起点 | 首草稿命中率 | 每轮推进 | 档A | 档B | 加速 |
|---|---|---|---|---|---|
| 0 | 46.7% | 1.422 | 933.8 ms | 1757.2 ms | 0.531× |
| 20000 | 52.4% | 1.524 | 937.5 ms | 1571.1 ms | 0.597× |
| 50000 | 43.9% | 1.561 | 934.2 ms | 1495.3 ms | 0.625× |
| 100000 | 56.8% | 1.730 | 947.0 ms | 1284.6 ms | 0.737×(最好) |
| 150000 | 43.2% | 1.455 | 938.5 ms | 1665.1 ms | 0.564× |
| 248045 | 63.2% | 1.684 | 935.3 ms | 1520.1 ms | 0.615× |
全部 <1×,最好的数据也只有 0.737×(离门槛 1.05× 差 1.4 倍)。
4.2 判据 2 位级通过:T=64 六个起点全memcmp=0
六起点与纯自回归的 token 流逐字节相同(实测)。它是构造保证(见 3.3)+ 实测坐实。
4.3 归因订正:一次「把推断当实测」的现形
第一版的归因写的是:
「平均每轮接受 = 0.667≈ α₁(0.657)⇒第 1 个草稿之外几乎全落空(
n_ok=0的轮占 62.5%)」
这句话有两个毛病:①没实测:0.667 ≈ 0.657是看着像,不是量出来的;②与工具自己的输出打架:同一轮的工具自印轮数=48 / 接受总数=32 / n_ok=0 的轮=30⇒非零轮只有 18 个、平均接受 = 32/18 = 1.78——与「几乎全落空」直接矛盾。
审计抓到了这条。补上n_ok直方图后重跑,真相是:
n_ok | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| 轮数(T=64) | 30 | 7 | 8 | 3 |
- 首草稿命中率 = 18/48 = 37.5%(这才是低的那个量);
- 链尾并不落空:条件命中
d₂|d₁ = 11/18 = 61.1%、d₃|d₁d₂ = 3/11 = 27.3%; - 平均
0.667 = 32/48之所以低,纯粹是被 30 个零接受轮拉下去的(30 轮贡献 0,18 轮贡献 32)。
⇒与第一版归因正好相反。这条改写的价值不在于数字变了(结论方向没变,还是判否),而在于它证明了这条纪律的必要性:
「先量后做」不是一句口号:
0.667 ≈ 0.657这种「数字碰巧接近」的推断,一旦写进结论,就会把「链尾失效」这个错误的机制解释固化下来,误导后面的所有优化方向(会去查「为什么d₂命中率低」,而真问题在首草稿)。
五、边界与已知限制
5.1 预登记的四条判据与处置
开工前就冻结了判据与「判否后怎么办」:
| # | 判据 | 结果 |
|---|---|---|
| 1 | 位级·两端点(强制全接受 / 全拒绝) | ❌未单独跑——按原文字面产不出来(见下) |
| 2 | 与纯自回归同流 | ✅位级通过(六起点memcmp=0) |
| 3 | 端到端 ≥1.05× | ❌判否(0.531×–0.737×) |
| 4 | =0逐字节 | ❌ 未单独跑 |
判据 1 为什么「字面产不出来」:它要求「强制接受全部」这个端点。但实现里不能把n_ok硬置成k——若被接受的草稿本来就错,硬置后整批的后续行是按错 token算的,发出去的流就不再是主模型 argmax 链 ⇒ 判据 2(与纯自回归同流)会假红。所以--spec-force 1只做「观测 + 走状态路径」:自然全接受时记成全接受并发往「整批保留、不回滚」那条状态路径,没命中就按自然值走。⇒ 判据 1 记为待裁定项,不事后改判据文字。
5.2 判否后的动作(预登记,照办)
- 默认关保持(
VLLM_CUDA_Q35_MTP仍默认关,主模型零接触); - 不删代码:
--spec保留为已实现、可复跑的正确性载体(它是目前唯一能在设备上位级证明锚定口径的装置),记为「已实现但收益不足、不默认」。
5.3 其余边界
- 读数后来还被推翻过一次:本篇的六起点读数是在「喂错 hidden」的真 bug 下测的,修正后的幅度以 第 33 篇 为准(判否方向不变)。
- 全部读数在同一会话、同一台机、真权重上取;
k=3、T=64。 E[len] → 0.76×是推算,不是实测:它按离线E[len]=2.107粗算重放项。- 判据 1/4 未单独跑,是流程内的已知缺口。
CPU 对照(迁移前基线)
- CPU 参考:
src/model/vllm_q35_mtp.c:q35_mtp_step、src/model/vllm_q35_model.c:q35_model_step / q35_model_prefill—— 锚定口径的参考语义。 - 迁移要点:设备侧新增第三原语
truncate(只退 MTP 写游标、不拷不清 KV,依据「游标即可见长度」);rollback对 GDNS/hist做 D2D 快照恢复、对 attn KV 只退游标零拷贝;批量入口硬要求M==mdl_mmax。 - 真机验证:命中E16(MTP 接受率
q35_mtp_accept ... --k 4:α₁=0.714(p₁=0.714)、α₂=0.364(p₂=0.254)、α₃=0.500(p₃=0.127)、α₄=0.750(p₄=0.095),E[len]=2.190 token/轮;T=63、teacher-forced 上界口径)。锚定口径的投机--spec路径未独立复现:读数后被第 33 篇推翻;投机默认关;仓内无 per-start 直方图落盘。
六、小结
- 「输出位级正确」与「有收益」是两条独立轴。这一轮把前者做到了逐字节,后者仍然是 0.5–0.7×。任何「正确性通过 ⇒ 性能必然可期」的暗示都是错的。
- 状态能否「退回去」是混合架构上投机的第一性约束。attn 的 KV 退游标是免费的,GDN 的递推状态退回去要重算——而「重算」在批宽被钉死成一格时,只能逐行来。⇒凡是「有递推状态」的层,投机解码的收益都要先减掉「重放」这一项再谈。
- 第三个原语的必要性来自「独立槽位」:如果 MTP 的游标不是它自己的,
truncate这个操作根本没有定义(第 31 篇的伏笔在这里收)。 - 构造保证会掩盖输入错误:判据 2 之所以能位级通过,恰恰因为它由主模型 argmax 兜底——这条性质让它对驱动的输入口径完全不敏感。第 33 篇就是踩在这上面。
- 归因必须建立在分布上,不是均值上。
0.667这个均值同时兼容「每轮稳定 0.67」与「很多 0 + 少数 2」两种完全不同的机制;只有直方图能分开它们。
相关篇目:第 31 篇(MTP 上 GPU,独立槽位 / 非致命绑定 / 逐级对拍);第 30 篇(MTP 模块与四个口径点);第 19 篇 / 第 20 篇(「游标即可见长度」的来源);第 33 篇(这一轮的读数为什么还不能作数)。
工单冻结、首试、验收与订正复测的细则见仓库GitHupSRC/docs/。
源码与配套资源:本仓库 https://gitee.com/pei-xiaoguang/kestrel-llm-cuda.git;
CPU 推理源码 https://gitee.com/pei-xiaoguang/kestrel-llm