ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Kestrel_VLLM - 30 · MTP 头适配:十五个张量与一个不存在的 oracle

Kestrel_VLLM - 30 · MTP 头适配:十五个张量与一个不存在的 oracle

30 · MTP 头适配:十五个张量与一个不存在的 oracle

一句话:Qwen3.5-27B 除 64 层主干外还自带一个模型内投机草稿头MTP(Multi-Token Prediction,多 token 预测)——它不在主干里、没有官方实现可对照,却决定了投机解码那条线能不能成立;本篇把这个头的适配讲清楚:它长什么样、哪三个口径点「写反了也跑得动」、以及在没有 oracle时怎么证明自己没抄错。
前置:建议先读 第 29 篇(hybrid 设备路径,以及接受率那一道门禁)。
环境:x86-64 · Qwen3.5-27B(q4 打包)· CUDA 后端;本篇模块级对拍的基准是 f32 / torch 转录参考。

一、问题与结论

先给结论表(口径见后文各节):

指标数值判定
MTP 头张量数15 个(另 2 个与主模型共享)实测(权重清单)
MTP 结构一次 stem 投影 + 1 层 full attention + 1 层 dense FFN实测(对源码)
模块级对拍(T=5)11 阶段 × 5 token全 PASS,expectmax_rel1.5e-6 ~ 3.4e-6实测
首草稿命中率 α₁0.657(169/169)实测
平均接受长度E[len]2.107 token/轮实测
顺序 verify 成本≈24.4 ms/token,比基线 14.4 ms慢 1.7×实测
草稿成本里的 lm_head682 MBvs MTP 层229 MB(约 3 倍)实测

一句话概括:这个头能跑通、也能对上,但它到底「有没有用」是另一件事——而那件事无法从结构外推,只能实测。

二、背景:为什么这个头要单独讲

MTP 不在主干里、不参与逐层串接,草稿由它自回归生成,verify 交给主模型批量验证。它和前面 29 篇都不一样,有两个特点:

  1. 它是一个模块,不是一条流水线。15 个张量、一次 stem、一次 tail,与主模型的 full attention 层同构——于是实现上最省力的做法是直接复用q35_attn_step,一行都没重写。
  2. 它没有官方 oracle。transformers不实现 MTP 头(Qwen3_5ForCausalLM._keys_to_ignore_on_load_unexpected = [r"^mtp.*"]),模型目录里也没有auto_map/ remote code,本机也没装 vllm / sglang。⇒ 这是整个适配里唯一一个「基准要自己造」的模块,而「自己造的基准」能证什么、不能证什么,必须先写清楚。

本篇不重复第 29 篇里接受率 / 形状门禁 / R 行(调参面)那一刀——那些属于投机线的门禁链。本篇讲的是这个头本身:长什么样、抄哪里最容易错、怎么在没有 oracle 的情况下证明没抄错。

三、核心机制 / 做法

3.1 张量清单(15 个 + 2 个共享)

张量形状语义
mtp.fc.weight[5120, 10240]输入投影[e ‖ h] → hidden
mtp.pre_fc_norm_embedding.weight[5120]embed 侧 stem norm
mtp.pre_fc_norm_hidden.weight[5120]hidden 侧 stem norm
mtp.norm.weight[5120]输出final norm
mtp.layers.0.input_layernorm.weight[5120]注意力前置 norm
mtp.layers.0.post_attention_layernorm.weight[5120]FFN 前置 norm
mtp.layers.0.self_attn.q_proj.weight[12288, 5120]q+ gate(同主模型)
mtp.layers.0.self_attn.k_proj.weight[1024, 5120]
mtp.layers.0.self_attn.v_proj.weight[1024, 5120]
mtp.layers.0.self_attn.o_proj.weight[5120, 6144]
mtp.layers.0.self_attn.q_norm.weight[256]逐 head RMSNorm
mtp.layers.0.self_attn.k_norm.weight[256]逐 head RMSNorm
mtp.layers.0.mlp.gate_proj.weight[17408, 5120]
mtp.layers.0.mlp.up_proj.weight[17408, 5120]
mtp.layers.0.mlp.down_proj.weight[5120, 17408]

外加上共享的两个(转换器不重复打包):model.language_model.embed_tokens.weight、lm_head.weight。

q_proj的12288 = 2 × 24 × 256——前一半是 q、后一半是 gate,且切分是逐 head 交织的(见 3.4)。
MTP 只有1 层,所以layers.0.是它唯一的编号。它有自己的一层 KV cache,与主干那 64 层互不相干。

这条「共享 not 复制」在代码里的落点是:embed_tokens与lm_head与主模型共用,MTP不带走自己的 embed 与 lm_head。头文件把输入口径写死在注释里:

/* 文件名:GitHupSRC/include/model/vllm_q35_mtp.h(节选) * 关键输入口径(易错): * `hidden` 是主模型 **final-norm 之后**的 hidden,即喂 lm_head 的那一份 * (ninfer text.cpp:706 `rmsnorm(x, final_norm) → project(hidden, lm_head)` * 与 :1265 `mtp_prefill_chunk(..., xf, ...)` 同源);**不是**最后一层原始输出。 */

3.2 前向:stem + tail

# ---- stem ---- e = rmsnorm(embed_row, pre_fc_norm_embedding) # (1+w) h = rmsnorm(hidden, pre_fc_norm_hidden) # (1+w) x = fc( concat(e, h) ) # [10240] → [5120] # ---- tail:与主模型 full attention 层同构 ---- ah = rmsnorm(x, input_layernorm) a = full_attention(ah) # 用自己的那 1 层 KV x = x + o_proj(a) # res1 x = x + ffn(rmsnorm(x, post_attention_norm)) # res2 out = rmsnorm(x, mtp.norm) # → 直接喂主模型 lm_head # ---- proposal ---- draft = argmax( lm_head(out) )

主干那一侧则是:

bridge(首个草稿): MTP( 主模型上一 token 的 embed, 主模型上一 token 的 hidden ) AR (后续草稿): MTP( 上一个草稿 token 的 embed, MTP 自己上一步的 out )

⇒同一个q35_mtp_step被调两次、口径不同的地方,差别只在「喂进去的embed_row/hidden是自己的还是主模型的」。

embed_row

rmsnorm(1+w)

hidden(final-norm 之后)

rmsnorm(1+w)

concat(e ‖ h):embed 在前

fc:10240→5120

input_layernorm

full_attention(复用 q35_attn_step)

res1 = fc + o_proj(attn)

post_attention_layernorm

dense FFN

res2 = res1 + mlp

mtp.norm

主模型 lm_head → argmax 草稿

图 1:MTP 头的前向数据流(stem + tail)。读法:左侧两路 feed——embed_row与主模型final-norm 之后的hidden——各自过一个(1+w)的 RMSNorm 后进concat(e ‖ h)(embed 在前)→fc投影成dim。tail 与主模型 full attention 层同构,直接复用q35_attn_step(一行未重写),再走两次残差 +mtp.norm,最后喂主模型共享的lm_head。整块15 个张量(另 2 个embed_tokens/lm_head与主模型共享)。四个「写反了也跑得动」的口径点就藏在图上:concat 顺序、吃哪一份 hidden、(1+w)、q/gate 逐 head 交织。因无官方 oracle(transformers忽略mtp.*),基准是自造的(算子层用官方类、只有 wiring 层手工转录)⇒ 只能证单侧笔误,证不了「两侧共同误读」。

3.3 实现:一行 attention 都没重写

MTP 的 attention 与主模型full_attention同构 ⇒q35_mtp_step里直接调q35_attn_step:

/* 文件名:GitHupSRC/src/model/vllm_q35_mtp.c *//* ---- tail:与主模型 full_attention 同构(复用 q35_attn_step) ---- */if(q35_attn_step(&c->attn,&w->attn,&st->attn,pos,ah,mo,ascr)!=0){free(own);return-1;}

这一条不只是省代码:它把「MTP 的 attention 抄错了」这个失败模式结构性消除——主模型那条路径已经在第 25 篇里对拍收口过,MTP 用的是同一份实现,于是差异只可能出在stem 的 wiring上,而 stem 恰好是唯一「没有官方实现可对照」的那一段(见 3.6)。

stem 的两半按「embed 在前、hidden 在后」写进fc_in,这一步也在同一份源码里:

/* 文件名:GitHupSRC/src/model/vllm_q35_mtp.c *//* ---- stem ---- */q35_rmsnorm_unit(embed_row,w->emb_norm,D,c->eps,e);q35_rmsnorm_unit(hidden,w->hid_norm,D,c->eps,h);memcpy(fc_in,e,(size_t)D*sizeof(float));/* ★ embed 在前 */memcpy(fc_in+D,h,(size_t)D*sizeof(float));/* ★ hidden 在后 */q35_gemv(w->fc_w,D,2*D,fc_in,x);

模块其余部分是一个平坦的 scratch 池,按8D + FF + attn 内部 scratch切块:

/* 文件名:GitHupSRC/src/model/vllm_q35_mtp.c */size_tq35_mtp_scratch_floats(constq35_mtp_cfg*c){/* e | h | fc_in(2D) | x | ah | mh | mo = 8D,+ gg(ff),+ 注意力内部 scratch */return8*(size_t)c->dim+(size_t)c->ff+q35_attn_scratch_floats(&c->attn);}

各段用途:e/h(两个 stem norm 输出,各D)、fc_in(2D,[e‖h],embed 在前)、x(D,fc 输出 → 残差主干)、ah(D,注意力输入)、mh(D,post_norm 输出)、mo(D,注意力输出 / FFN 输出复用)、gg(FF,swiglu 中间量)、ascr(q35_attn_scratch_floats())。传scratch == NULL时内部malloc(慢,只为方便调用);调用方(测量器/探针)自己分配,热路径上没有分配。

3.4 一个只在探针里打开的开关:逐级捕获

q35_mtp_step的最后一个参数是const q35_mtp_trace *tr,传 NULL 时零开销(运行期一次指针判空)。打开后它把 10 个中间量各拷一份出来:

s_e → s_h → s_fc_in → s_fc → s_ah → s_attn → s_res1 → s_mh → s_mlp → s_res2 → expect

为什么要这个:MTP 一旦对不上,如果只有一个「最终输出」可比,你只知道「这个头不对」;有了逐级快照,差异第一次出现的地方就是病灶所在——stem?注意力?还是最后的 norm?这条设计在第 25 篇(整层对拍)里已经用过一次,这里是同一套方法论搬到模块上。

/* 文件名:GitHupSRC/include/model/vllm_q35_mtp.h */typedefstruct{float*e;/* [dim] norm 后的 embedding */float*h;/* [dim] norm 后的 hidden */float*fc_in;/* [2*dim] [e ‖ h] */float*fc;/* [dim] fc 输出 */float*ah;/* [dim] input_layernorm 输出(= 注意力输入) */float*attn;/* [dim] 注意力输出(o_proj 之后,未加残差) */float*res1;/* [dim] fc + attn */float*mh;/* [dim] post_attention_layernorm 输出 */float*mlp;/* [dim] ffn 输出 */float*res2;/* [dim] res1 + mlp */}q35_mtp_trace;/* 各指针可为 NULL(不捕获该项) */

3.5 四个口径点:错了会「跑得动但全错」

这四条全部逐条核对到了 ninfer 源码,不是推测。

#口径点结论证据
1concat 顺序embed 在前、hidden 在后ninfer/include/ninfer/ops/mtp_pack.h:out[0:D]=embedding_norm, out[D:2D]=hidden_norm
2hidden是哪一份主模型final-norm 之后的 hidden(与 lm_head 同源),不是最后一层原始输出execution/text.cpp:706(rmsnorm(x, final_norm) → lm_head)与:1265(mtp_prefill_chunk(..., xf, ...))同源
3norm 形式全部(1 + w)ops/rmsnorm.h的unit_offset;MTP 各处全传true
4q/gate 切分逐 head 交织(head i 的前 256 维是 q_i、后 256 维是 gate_i),不是全局两段tools/convert/qwen3_5.py:499-512;MTP 层由同一个attention()构建

① 顺序、④ 切分这两条最阴:写反了以后张量形状完全合法,前向能一路跑完、输出是有限值、loss 也不炸——只是「意思不对」。它们不会触发任何断言,唯一的暴露方式是逐级对拍。

② hidden 口径是一条已经在主干上踩过的坑:hidden_states[-1]是 final-norm之后的值,MTP 的 stem 吃的正是这一份。写错成「第 63 层输出」的话,量纲一样、数值接近、永远不会报错。

③(1+w)是同一个「关键发现」的延续:Qwen3_5RMSNorm用(1 + weight),且权重初始化为 0。⇒ 如果误写成w,那么「初始化 0」的层会变成恒等擦除(输出全 0),这倒会炸;但一拍脑袋改成「加个 epsilon」之类的「修法」就会把错误藏起来。MTP 里5 处 norm(emb_norm/hid_norm/in_norm/post_norm/final_norm)全部是(1+w),一处不同就前功尽弃。

3.6 与主干的关系:只在两处「接线」

MTP 头在引擎里是一个独立的模块,与 64 层主干只有两个接触点:

  1. 输入 embed:查同一张embed_tokens(主干 embed 表);
  2. 输出 lm_head:mtp_hidden直接喂主模型的lm_head,MTP 自己没有output head。

⇒ 转换器必须不重复打包这两个张量。共享而不是复制,既省显存(lm_head是248320 × 5120,本系列里它单独就有 682 MB/次草稿的读量),也保证「草稿头与主模型看到同一个词表」。

状态侧:MTP 有自己的 1 层 KV(q35_attn_state {kc, vc, len, max_seq}),生命周期与主干那 64 层的 KV 完全独立——AR 链每前进一步,就往这层 KV 里追加一行(追加行号 =st->len,RoPE 位置 = 入参pos)。

3.7 验证边界:没有 oracle 的时候,能证什么

这是整个适配里唯一需要先声明「基准是哪来的」的地方。

为什么没有官方基准:transformers的Qwen3_5ForCausalLM明确忽略 MTP 权重;模型目录里没有auto_map/ remote code;本机未安装 vllm / sglang ⇒官方路径上不存在 MTP 头。

于是基准是这么造的:tools/q35_oracle.py --export-mtp <dir>导出——

  • 算子层:Attention / MLP / RMSNorm / RotaryEmbedding全部实例化transformers官方类,并装上mtp.*的权重;
  • wiring 层:只有 stem 的接线(见 3.2)是手工转录的,其四个关键点逐条核到 ninfer 源码(见 3.5 的表)。

⇒ 算子级与「已经位级对拍收口过的主模型路径」用的是同一份代码;手写的只有「谁接谁」这一层。

内容
能证C 侧单侧转录笔误:concat 顺序、吃哪一份 hidden、哪几处 norm 是(1+w)、残差接法、逐 head 切分
不能证「两侧共同误读 ninfer」——如果我和转录参考都把out[D:2D]读成了 embed,两边一致地错,这个基准不会报

⇒ 这正是「收益必须在真实引擎内实测」这条公理的用法:功能要在引擎内用真实 hidden 实测(接受率门禁),不能拿「模块级对拍 PASS」当功能证明。

四、实测数据

4.1 模块级对拍:11 组 × 5 token

tools/q35_mtp_probe.c驱动。逐级比对,差异一出现即可定位阶段。

结果:T=5,全部 PASS(expectmax_rel1.5e-6 ~ 3.4e-6),判据是max_rel < 1e-4或max_abs < 1e-5(与主干路径同一套容差),实测余量在30~60 倍量级。

阶段内容
s_e/s_h两个 stem norm(embed / hidden)
s_fc_in/s_fc[e‖h]与 fc 输出
s_ahinput_layernorm 输出(= 注意力输入)
s_attn注意力输出(o_proj 之后,未加残差)
s_res1fc + attn
s_mh/s_mlppost_norm 与 FFN
s_res2res1 + mlp
expectmtp.norm之后的mtp_hidden(→ 主模型 lm_head)

4.2 接受率:头能跑通 ≠ 头有用

模块级对拍只能证「抄写正确」。MTP 头的存在价值是投机解码的接受率——而接受率无法从结构外推:MTP 层与主模型 full attention 同构,这件事对 α 该是多少没有任何约束力。

⇒ 所以投机线的第一步不是接线,而是量接受率:新增tools/q35_mtp_accept.c,MTP 15 个张量 + embed 表 + lm_head全部从.vqf读(与引擎同一份量化权重,不是 f32 原件那种乐观口径),主模型 hidden 用引擎--dump的hfinal.bin,判据链一断就停(部署口径)。结果(一段真实中文文本,169 个位置,k=4):

深度 j活到本层α_j(条件)口径
1169/169 = 100.0%0.657实测
2111/169 = 65.7%0.450实测
349/169 = 29.0%0.388实测
419/169 = 11.2%0.368实测

⇒E[接受长度] = 2.107token/轮。

这次测量的口径自查也值得单记一笔:第一版把无条件接受率直接相加(1 + Σα_j = 2.31),那是上界——它忽略了合取(d1 错的时候,d2 在部署里根本不会发出)。改成「链一断就停」后条件 α_j 在 0.37–0.45 走平,E[len]落到 2.107。同一批数据,两个口径给出 2.31 vs 2.11,只有后者能拿去算收益。

4.3 拿到 α 之后暴露的两笔账

拿到 α 之后暴露出来的,是一个结构性的墙:hybrid 设备路径是纯M=1的,没有任何M>1的批量前向——于是「一次前向验 k+1 个位置」这件事根本不存在。顺带量出来的两条:

项数值判定
顺序 verify(成本 ≈(k+1)·c_m + k·c_d)≈24.4 ms/token,比基线 14.4 ms 慢1.7×实测
草稿成本c_d中 lm_head 与 MTP 层之比682 MBvs229 MB(约 3 倍)实测

漏算 lm_head 会把c_d低估约 4 倍——它比整个 MTP 层还大 3 倍。

五、调试记录:探针先抓到了自己

这是本篇最该记的一段。

现象:首轮跑,token 0的 11 组全过;token ≥ 1的中间量全部是got = 0。第一反应当然是「实现错了」。但有个矛盾:expect五个 token 全过——如果中间量真的算成了 0,最终输出不可能对。

根因:探针自己把 trace 缓冲固定指向了第 0 行。q35_mtp_probe里每个阶段一块[T, width]缓冲,而 trace 指针是tb[i] + t * width——首版漏了t * width这一项,于是每个 token 都把结果写回第 0 行的偏移上、逐步被覆写;到了比对时,token ≥ 1去读自己那一行,读到的是从没被写过的 0。token 0恰好就是第 0 行,所以它「看起来是对的」。

教训:探针报「通过」之前,必须先自证它在每个样本上都真的读到了值。「T=1 通过」不是「T=5 通过」的充分条件——多 token 的探针要单独证明「它的多 token 通路是通的」。

顺带一条容易忽略的:t = 0时npos = 1,softmax只有一个元素 ⇒恒等于 1。此时 attention 输出 =v,与 q/k 完全无关(Rotary、q_norm、scale 全都不起作用)。⇒ 「t=0的s_attn对上了」这件事,不构成 q/k 正确性的任何证据;真正的 q/k 证据来自t ≥ 1。

这两条合起来说明一件事:「11 组 × 5 token 全过」这个结论,是在修掉探针自身的 bug 之后才成立的。首轮那个「token 0 全过」的绿,是被 bug 伪造出来的。

六、边界与已知限制

  1. 模块级对拍只能证「单侧转录笔误」,证不了「两侧共同误读 ninfer」——因为基准本身是手工转录的(见 3.7)。
  2. 「能跑通」不等于「有用」:接受率E[len] = 2.107是在一段真实中文文本、169 个位置、k=4下测的,α 依赖上下文,不能当成模型常数外推。
  3. t=0的通过不算数:npos=1时 attention 与 q/k 无关,不构成 q/k 正确性证据。
  4. 本模块尚未接入设备端批量路径——本篇的 4.2/4.3 都在 CPU/离线口径给出,端到端收益要等把批量 verify 建出来后才谈(见 第 32 篇)。
  5. 权重是从.vqf读的量化权重,不是 f32 原件;这与引擎实际跑的口径一致,但不是乐观的 f32 上界。

CPU 对照(迁移前基线)

  • CPU 参考:src/model/vllm_q35_mtp.c:q35_mtp_step(复用q35_attn_step;口径见include/model/vllm_q35_mtp.h)—— MTP 头单 token 前向(本篇为 CPU 侧适配,头尚未上 GPU)。
  • 迁移要点:无 CPU→CUDA 迁移:讲清 15 张量结构、四个「写反了也跑得动」的口径点(concat 顺序 / hidden 口径 / (1+w) / q-gate 逐 head 交织)。
  • 真机验证:命中E16:q35_mtp_accept ... --k 4(T=63,teacher-forced 上界口径)→ α₁=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/轮。基准仍为自造(transformers忽略mtp.*,无官方 oracle);E16 为 teacher-forced 上界口径(非线上实测),tools/q35_mtp_probe(11 组×5 token)与真实 hidden dump 未在证据内。

七、小结

  1. 「与主模型同构」这句话的真正价值在实现上,不在性能上。它让 MTP 的 attention一行都不用重写(复用q35_attn_step),把「MTP 抄错了」这个失败模式压缩到只剩stem 的 wiring;但它对接受率没有任何保证——α 必须单独实测。同构省的是工程量,不是验证量。
  2. 验证基准的「来源」必须写在结论旁边。transformers不实现 MTP 头 ⇒ 这个模块的基准是自造的。「11 组 × 5 token PASS」这句话,脱离「基准是 torch 转录参考」这个前提就是误导:它能证单侧笔误,证不了两侧共同误读。
  3. 探针会伪造绿。首轮「token 0 全过」是被探针自身的缓冲 bug 造出来的。⇒ 探针的通过必须先自证通路;「样本少的时候通过」不是「样本多的时候通过」的充分条件。
  4. npos=1 ⇒ softmax≡1 ⇒ 输出与 q/k 无关:这是「小样本给出假绿」的又一个具体形态,和探针 bug 一起写,是因为它们都会让第一轮看起来是对的。
  5. 「能跑通」与「有用」是两件事:模块级对拍(正确性)与接受率(价值)用的是两套工具、两个口径,中间隔着一个不能外推的鸿沟。

相关篇目:第 29 篇(hybrid 设备路径与投机门禁);第 25 篇(全注意力层设备化,MTP 复用它的 attention 路径);第 31 篇(把这个头搬上 GPU)。
前向公式与验证边界见仓库GitHupSRC/docs/与GitHupSRC/include/model/vllm_q35_mtp.h顶部注释。
源码与配套资源:本仓库 https://gitee.com/pei-xiaoguang/kestrel-llm-cuda.git;
CPU 推理源码 https://gitee.com/pei-xiaoguang/kestrel-llm

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进