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 ,expect max_rel 1.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_head 682 MB vs 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。头文件把输入口径写死在注释里:

c 复制代码
/* 文件名: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

ini 复制代码
# ---- 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) )

主干那一侧则是:

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

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

图 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:

c 复制代码
/* 文件名: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,这一步也在同一份源码里:

c 复制代码
/* 文件名: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 切块:

c 复制代码
/* 文件名:GitHupSRC/src/model/vllm_q35_mtp.c */
size_t q35_mtp_scratch_floats(const q35_mtp_cfg *c) {
    /* e | h | fc_in(2D) | x | ah | mh | mo = 8D,+ gg(ff),+ 注意力内部 scratch */
    return 8 * (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 个中间量各拷一份出来:

kotlin 复制代码
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 篇(整层对拍)里已经用过一次,这里是同一套方法论搬到模块上。

c 复制代码
/* 文件名:GitHupSRC/include/model/vllm_q35_mtp.h */
typedef struct {
    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 源码,不是推测。

# 口径点 结论 证据
1 concat 顺序 embed 在前、hidden 在后 ninfer/include/ninfer/ops/mtp_pack.h:out[0:D]=embedding_norm, out[D:2D]=hidden_norm
2 hidden 是哪一份 主模型 final-norm 之后 的 hidden(与 lm_head 同源),不是最后一层原始输出 execution/text.cpp:706(rmsnorm(x, final_norm) → lm_head)与 :1265(mtp_prefill_chunk(..., xf, ...))同源
3 norm 形式 全部 (1 + w) ops/rmsnorm.h 的 unit_offset;MTP 各处全传 true
4 q/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 (expect max_rel 1.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_ah input_layernorm 输出(= 注意力输入)
s_attn 注意力输出(o_proj 之后,未加残差)
s_res1 fc + attn
s_mh / s_mlp post_norm 与 FFN
s_res2 res1 + mlp
expect mtp.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(条件) 口径
1 169/169 = 100.0% 0.657 实测
2 111/169 = 65.7% 0.450 实测
3 49/169 = 29.0% 0.388 实测
4 19/169 = 11.2% 0.368 实测

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

这次测量的口径自查 也值得单记一笔:第一版把无条件 接受率直接相加(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 MB vs 229 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),Elen=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 顶部注释。 源码与配套资源:本仓库 gitee.com/pei-xiaogua...; CPU 推理源码 gitee.com/pei-xiaogua...

相关推荐
泡海椒2 小时前
jquick-pdf 漏斗图怎么用:Java 生成带客户转化漏斗的 PDF 信用评级报告
后端
极限实验室2 小时前
Easysearch 布尔查询子句重排序(二)|ConjunctionDISI 按 cost 排序源码解析
后端·搜索引擎·性能优化
废柴程序员_熊熊2 小时前
用 Docker 搭 n8n 私有服务器:2 个容器、382 MB 内存,附可导入的测试工程
后端
杨运交3 小时前
086-对象存储模块-基于Spring Boot的文件存储模块监控设计与实现
java·spring boot·后端
利威尔xu3 小时前
万字详解|Go 进阶(五):sync 包底层:Mutex 自旋 4 次转饥饿,WaitGroup 漏 1 个 Done 挂起 3000 goroutine
后端·golang·并发编程·源码分析·sync
贾公子4 小时前
检测结果怎么落库——表结构、状态机与三类对账
后端·mysql
136096757234 小时前
LLM 当裁判可靠吗:位置偏见、长度偏见、自我偏好
后端
拖孩4 小时前
流量主门槛从 500 降到 100,我的小程序上线 18 小时够到了线
前端·后端·微信小程序
养军博客4 小时前
Spring Security 4.x / 5.x / 6.x 配置全对比:前后端不分离与前后端分离
java·后端·spring·springsecurity