31 · MTP 头上 GPU:一个独立槽位,与一条抽出来共用的注意力
一句话 :把 MTP 草稿头从 CPU 搬到 CUDA------它必须是主模型之外的一个独立槽位 (自带一层 KV、自带游标),接入必须走非致命绑定 + 默认关 + fail-closed ,attention 核心从「主模型的第 L 层」里抽出来两边共用;实测设备级逐级对拍 T=5、11 阶段 × 5 token 共 55 行全 OK(maxrel 6.2e-7 ~ 1.5e-5)。 前置 :先读 第 30 篇(MTP 模块、15 张量、四个口径点、无 oracle)。 环境:x86-64 · NVIDIA RTX 5090 · Qwen3.5-27B(q4 打包)· CUDA 后端。
一、问题与结论
| 指标 | 数值 | 判定 |
|---|---|---|
| 槽位形态 | 主模型之外的独立槽位 ,自带 1 层 KV(kv_width = 1024) |
实测 |
| 键落位 | 8 个打包 q4 大矩阵走同一口 rq 池;7 个 f32 小张量单独上载 | 实测 |
| 池增量(开档 vs 关档) | +8 entry / +227.8 MB (401 → 409) |
实测 |
| 设备级逐级对拍 | T=5,11 阶段 × 5 token = 55 行全 OK ,maxrel 6.2e-7 ~ 1.5e-5(容差 1e-4) | 实测 |
| 缺键 / 错形状自检 | 均 rc=-1;两次失败都没破坏已挂好的槽位 |
实测 |
一句话概括:这一步不产出任何加速数字 ,它是一条前置基建------让草稿头在设备上能跑、而且跑得对、还保证「关了就等于不存在」。
二、背景
第 30 篇把 MTP 头当成一个模块 讲清楚了:15 个张量、stem + tail、四个「写反了也跑得动」的口径点,以及它没有官方 oracle 。但那一篇的落点在 CPU 侧------模块级对拍(11 组 × 5 token)与接受率测量(α₁ = 0.657 / Elen = 2.107)。
本篇讲把它搬到 CUDA 上 这一步。第 30 篇末尾那道「结构性的墙」说的是:hybrid 设备路径是纯 M=1 的,没有批量 verify;要谈端到端投机,先得让草稿头在设备上能跑。
本篇只认三件事:它在设备上长什么样 、怎么证明它没抄错 、怎么保证它关了就等于不存在。
三、核心机制 / 做法
3.1 为什么是「独立槽位」,不是「一层」
主模型的 64 层共享一个 vc_attn_w_t 数组(下标 = compact index);MTP 只有 1 层,形状上与它们同构 (24 heads / 4 kv / head_dim 256 / 带 output gate / partial rotary 0.25),但那 64 个槽位不能塞它:
| 维度 | 主干层 | MTP 头 |
|---|---|---|
| 归属 | 64 个 compact index 之一 | 不属于主干 |
| KV | 与其它 63 层同池、按 attn_len[ai] 推进 |
自己一层 ,kv_width = 1024 |
| 游标 | 与主模型位置同步 | 独立(草稿链推进它,主模型不动它) |
| 打包键 | `(layer<<32) | (proj+1)`,layer ∈ [0,64) |
| 权重源 | 主模型对应层 | .vqf 里 mtp.* 的 15 个张量 |
「独立」这件事不是审美,是状态机的需要。 投机轮次里 MTP 会先跑在主模型前面 (草稿链要把游标推到 p+k),然后主模型 verify,再按接受长度把 MTP 的游标截回去。如果两者共用一个游标,这条「先跑、再回退」的节奏根本写不出来。

图 1 :MTP 头在设备上是一个独立槽位 ,不是主干 64 层里的一层。读法:它自带 1 层 KV(
kv_width = 1024)与独立游标 ------投机轮次里草稿链要把游标推到p+k、verify 后按接受长度截回,共用游标这条节奏根本写不出来;打包键用VLLM_CUDA_LAYER_MTP(0x7FFFFFF1) 与VLLM_CUDA_PROJ_MTP_FC(11),权重源是.vqf里mtp.*的 15 个张量。池增量只有 +8 entry / +227.8 MB (401 → 409)。
3.2 池预算必须先算
rq 池是一块固定 slab (一次分配,之后只放不放)。⇒ 「先上载、发现池满再补」这条路不通:MTP 的 8 张大矩阵(fc 是 [5120,10240]、gate/up 各 [17408,5120]、down 是 [5120,17408]、q 是 [12288,5120]...)必须在算预算的那一刻就被算进去:
ini
64 层主干 + lm_head = 401 entries / 13732.0 MB
再 + MTP 的 8 张 = 409 entries / 13959.8 MB (+227.8 MB)
这 227.8 MB 与「8 张张量按 4×4 tile(72 B/格)算出来的字节数」吻合------也就是说,开 MTP = 多读 8 张矩阵,此外没有别的常驻开销。
3.3 三个工程决定(都是被「结构而不是纪律」逼出来的)
3.3.1 非致命绑定:VC_BIND 是致命的
设备侧有一张 API 表(函数指针),连接器里有一段宏 VC_BIND。它的语义是:一个符号取不到 ⇒ 整张表判为失效 。这在单模块内是对的(主模型少一个内核都不该继续跑),但对后加的可选入口 是灾难:旧 DLL(没有 MTP 符号)+ 新引擎(连接器里用 VC_BIND 取 MTP 符号)⇒ 整个 CUDA 后端失效,连不涉及 MTP 的主模型路径也跟着陪葬。
⇒ 新增的每一个 MTP 入口都写成单独取、缺失只置 NULL:
c
/* 文件名:GitHupSRC/src/npu/cuda/vllm_cuda.c(节选)
* 上面 VC_BIND 的语义:任一符号缺失就把整个 API 表判为失效(missing=1)。
* 凡新增设备入口都必须是非致命绑定(vc_lib_sym 单独取、缺失只置 NULL)。 */
/* ★ MTP 头的公开入口同样是**可选**的 ------ 用同一套「非致命绑定」,
* 缺了就保持 NULL(wrapper 判 NULL 拒绝),**不**把整个 API 表判失效。 */
*(void **)(&c->api.dev_mtp_attach) = vc_lib_sym(c->lib, "vcuda_dev_mtp_attach");
*(void **)(&c->api.dev_mtp_ready) = vc_lib_sym(c->lib, "vcuda_dev_mtp_ready");
*(void **)(&c->api.dev_mtp_reset) = vc_lib_sym(c->lib, "vcuda_dev_mtp_reset");
*(void **)(&c->api.dev_mtp_step) = vc_lib_sym(c->lib, "vcuda_dev_mtp_step");
*(void **)(&c->api.dev_mtp_get) = vc_lib_sym(c->lib, "vcuda_dev_mtp_get");
*(void **)(&c->api.dev_mtp_truncate) = vc_lib_sym(c->lib, "vcuda_dev_mtp_truncate");
调用侧再补一道 if (!c->api.dev_mtp_step) return -1;(wrapper 判 NULL 即拒)。于是「引擎比 DLL 新」这件事从致命 降级成可诊断。
这条是结构性的:以后凡给设备层加可选入口,都得走这个口。
3.3.2 默认关 + fail-closed
VLLM_CUDA_Q35_MTP 默认不设:
- 不设 ⇒ 既不分配 MTP 槽位、也不调
attach,主模型路径一个字节都不变; - 设了 ⇒ 要求 DLL 里确实有 MTP 符号,否则
attach失败并打印。不静默降级(那会让人以为开了、其实没开)。
这是第 12/13 篇判否项的同一条纪律:判否也要留一个「能重开」的开关 ,但默认必须是关 、且开不了时要喊。
3.3.3 组装顺序与「全有或全无」
挂载分两相 :① 校验相 :15 个键全部解析、形状逐个核对(fc 是 [dim,2dim]、q 是 [2·qw,dim]...);② 分配相 :分配 KV、workspace,写入槽位。任一失败都要收干净 (转入「未就绪」),绝不留下半绑------半绑的槽位是「跑得动但结果错」的温床。
c
/* 文件名:GitHupSRC/src/npu/cuda/vllm_cuda_kernels.cu(节选) */
/* ① **全量校验**(8 键非零 + 8 个形状精确匹配)------ 这一遍**不写任何状态**。 */
if (!vc_rq_expect(s, key_fc, dim, 2 * dim) ||
!vc_rq_expect(s, key_gate, ff, dim) ||
!vc_rq_expect(s, key_up, ff, dim) ||
!vc_rq_expect(s, key_down, dim, ff) ||
!vc_rq_expect(s, key_q, 2 * q_width, dim) ||
!vc_rq_expect(s, key_k, kv_width, dim) ||
!vc_rq_expect(s, key_v, kv_width, dim) ||
!vc_rq_expect(s, key_o, dim, q_width)) {
vc_vlog(s, "[CUDA] mtp attach: rq 键缺失或形状不符(8 键全查)\n");
return -1;
}
/* ② 校验通过 ⇒ 清旧槽位、建新槽位;从这里起任何失败都 `vc_mtp_free` 收干净。 */
对应的负向自检(纯自检、默认不跑):
| 判据 | 实测 |
|---|---|
| 15 键全部可绑 | ✅ ready=1,KV 1024 行 |
| 缺键必须报错 | ✅ 缺键 rc=-1 |
| 形状不符必须报错 | ✅ 把 fc 的键换成 q 的键 ⇒ [2qw,dim] ≠ [dim,2dim] ⇒ rc=-1 |
| 不做部分成功 | ✅ ready(前)=1 ... ready(后)=1(两次失败都没破坏已挂好的槽位) |
3.4 复用 = 把「MTP 抄错了」这个失败模式结构性消除
第 30 篇的原话是:MTP 在 CPU 侧直接调 q35_attn_step ,「把『MTP 的 attention 抄错了』这个失败模式结构性消除」。设备侧要做完全一样 的事,但多一道障碍:主模型的 attention 核心是和层位绑死 的(它从 s->mdl_aidx[L] 取 compact index、从 s->attn_len[ai] 取游标)。
⇒ 动手是把这段核心拆成两层:
| 抽出来的函数 | 职责 |
|---|---|
vc_attn_w_ready(w) |
权重就绪判定(q/k/v/o + q_norm/k_norm 齐备)------与层位无关 |
vc_attn_step_w(w, ...) |
一次 decode 步(RoPE → KV 追加 → attention)------与层位无关,游标/位置全从参数来 |
c
/* 文件名:GitHupSRC/src/npu/cuda/vllm_cuda_kernels.cu(节选)
* vc_attn_w_ready:与层位无关的权重预检 ------ 主模型 attn_w[] 与 MTP 独立槽位共用。 */
static int vc_attn_w_ready(vc_dev_t *s, const vc_attn_w_t *w) {
if (!s || !s->ok || !w) return 0;
if (!w->d_q_norm || !w->d_k_norm) return 0;
const int dim = s->attn_dim, hd = s->attn_hd;
const int kv_width = s->attn_nkv * hd, q_width = s->attn_nh * hd;
if (w->rq_q || w->rq_k || w->rq_v || w->rq_o) {
if (!vc_rq_expect(s, w->rq_q, 2 * q_width, dim) ||
!vc_rq_expect(s, w->rq_k, kv_width, dim) ||
!vc_rq_expect(s, w->rq_v, kv_width, dim) ||
!vc_rq_expect(s, w->rq_o, dim, q_width)) return 0;
} else if (!w->d_q_w || !w->d_k_w || !w->d_v_w || !w->d_o_w) {
return 0;
}
return 1;
}
/* vc_attn_step_w:把「一层全注意力」抽成与层位无关的核心,
* 权重、KV 基址、游标、定尺上限全部由调用方给出。 */
static int vc_attn_step_w(vc_dev_t *s, const vc_attn_w_t *w, const int *len_io,
int maxseq, int graph_mode, float *kc, float *vc,
const float *d_h, float *d_out, cudaStream_t stream);
主模型调 vc_attn_step_w(s->attn_w[ai], ...);MTP 调 vc_attn_step_w(&s->mtp_w, ...)------启动点就是这一句:
c
/* 文件名:GitHupSRC/src/npu/cuda/vllm_cuda_kernels.cu(节选:MTP 单 token 装配第 ④ 步) */
if (vc_attn_step_w(s, &s->mtp_w, &s->mtp_len, s->mtp_maxseq, 0,
s->d_mtp_kc, s->d_mtp_vc,
s->d_mtp_ah, s->d_mtp_attn, st) != 0) return -1;
同一份实现、两个权重对象 ------差异只可能出在两处:喂进去的输入 、权重对象里的东西 。于是「MTP 的设备实现对不对」这个问题被缩小 到:那 15 个张量装对了没有 。而第 30 篇已经告诉我们:这 15 个张量里最容易错的不是 attention 部分(它同构、已对拍收口过),而是 stem 的 wiring------恰恰是「没有官方实现可对照」的那一段。
3.5 逐级对拍:11 个阶段,错在哪一级就停在哪一级
设备档探针(q35_mtp_probe --cuda)把 CPU 侧那套逐级快照原样搬过来,喂同一份 .vqf 反量化出来的 f32 参考:
csharp
s_e → s_h → s_fc_in → s_fc → s_ah → s_attn → s_res1 → s_mh → s_mlp → s_res2 → out
结果:T=5,55 行全 OK ,实测 maxrel 6.2e-7 ~ 1.5e-5(容差 1e-4,余量 6~160 倍)。

图 2 :设备级逐级对拍链(
q35_mtp_probe --cuda)。把 CPU 侧那套快照原样搬到设备,喂同一份.vqf反量化出的 f32 参考:s_e(norm 后 embedding)→s_h(norm 后 hidden)→s_fc_in([e‖h])→s_fc→s_ah(注意力输入)→s_attn(o_proj 后、未加残差)→s_res1→s_mh(post_norm)→s_mlp→s_res2→out(→ 主模型 lm_head)。读法:只看out,对不上时你只知道「这个头不对」;有逐级,差异第一次出现的那一级 就是病灶。实测 T=5、11 阶段 × 5 token = 55 行全 OK ,maxrel 6.2e-7 ~ 1.5e-5(容差 1e-4);正因此才抓到「三个 tail norm 角色互换」这类形状合法却跑错的错。
为什么不只看最终输出:只看out,对不上时你只知道「这个头不对」;有了逐级,差异第一次出现的地方 就是病灶。这条方法论在第 25 篇(整层对拍)用过,第 30 篇(模块对拍)用过,这里是第三次------同一套办法从「一层」用到「一个模块」再到「设备侧的同一个模块」。
设备侧把 11 个阶段(10 个 trace + out)读回来的入口就在这里:
c
/* 文件名:GitHupSRC/src/npu/cuda/vllm_cuda_kernels.cu(节选:vcuda_dev_mtp_get) */
switch (what) {
case VLLM_CUDA_MTP_E: src = s->d_mtp_fcin; n = dim; break;
case VLLM_CUDA_MTP_H: src = s->d_mtp_fcin + dim; n = dim; break;
case VLLM_CUDA_MTP_FCIN: src = s->d_mtp_fcin; n = 2 * dim; break;
case VLLM_CUDA_MTP_FC: src = s->d_mtp_snap_fc; n = dim; break;
case VLLM_CUDA_MTP_AH: src = s->d_mtp_ah; n = dim; break;
case VLLM_CUDA_MTP_ATTN: src = s->d_mtp_attn; n = dim; break;
case VLLM_CUDA_MTP_RES1: src = s->d_mtp_snap_res1; n = dim; break;
case VLLM_CUDA_MTP_MH: src = s->d_mtp_mh; n = dim; break;
case VLLM_CUDA_MTP_MLP: src = s->d_mtp_mlp; n = dim; break;
case VLLM_CUDA_MTP_RES2: src = s->d_mtp_snap_res2; n = dim; break;
case VLLM_CUDA_MTP_OUT: src = s->d_mtp_out; n = dim; break;
}
3.5.1 一个设备档才暴露的坑:三个 tail norm 的角色
第一版把三个 tail norm 转着用 了。它们形状完全一样 (都是 [5120]),所以:跑得动、数值有限、不报任何错------只是「意思不对」。
正确对位(按 HF 张量名,不是按名字长短或出现顺序猜):
| 权重名 | 角色 |
|---|---|
mtp.layers.0.input_layernorm.weight |
注意力前置 norm |
mtp.layers.0.post_attention_layernorm.weight |
FFN 前置 norm |
mtp.norm.weight |
头输出 norm(→ 主模型 lm_head) |
这和第 30 篇那「四个口径点」是同一类 错误(形状合法、前向跑完、断言不炸),唯一的暴露方式就是逐级对拍。⇒ 记入头条,写死在头文件/挂载函数的注释里:
c
/* 文件名:GitHupSRC/src/npu/cuda/vllm_cuda_kernels.cu(节选:vcuda_dev_mtp_attach)
* ★ 三个 tail norm 的**角色**必须按名字对(首轮就跑反过):
* `in_norm` = `mtp.layers.0.input_layernorm.weight` → 注意力**输入**
* `post_norm` = `mtp.layers.0.post_attention_layernorm.weight`→ MLP 输入
* `final_norm`= `mtp.norm.weight` → 最末(喂 lm_head)*/
四、实测数据
| 项 | 数值 / 结果 | 口径 |
|---|---|---|
| 池增量 | +8 entry / +227.8 MB (401 → 409) |
实测 |
| 设备级逐级对拍(T=5) | 11 阶段 × 5 token = 55 行全 OK | 实测 |
| 对拍 maxrel | 6.2e-7 ~ 1.5e-5(容差 1e-4) | 实测 |
| 负向自检(缺键 / 错形状 / 部分成功) | 全部按预期 rc=-1 / ready 前后不变 |
实测 |
对拍用的是同一份
.vqf权重(不是 f32 原件那种乐观口径)。
五、边界与已知限制
- 本篇不产出任何加速数字:它只证明「设备上的头算得对」,没证明「投机更快」。
- 对的只是 M=1 的单 token 装配:MTP 天然串行,不给它上 M 行(那是把投机回路并行化,语义不同)。
- 对拍的一致性来源是「同一个被调用的头」 :探针证明的是被调用者 对,不覆盖调用者喂进来的输入 (这一点在 第 33 篇 会翻车)。
- 三个 tail norm 角色互换在 f32 参考上也是错的 ,但它在 CPU 侧的对拍里没暴露(那边是另一套 wiring),在设备档第一次跑才现形 ⇒ 同一个模块的两条实现路径,要各自对拍。
- 非致命绑定把「引擎比 DLL 新」从致命降级为可诊断,但要求调用侧逐处判 NULL;漏判会变成空指针调用。
CPU 对照(迁移前基线)
- CPU 参考 :
src/model/vllm_q35_mtp.c:q35_mtp_step(MTP stem+tail)、src/model/vllm_q35_attn.c:q35_attn_step(被 MTP 复用的 attention 核心)。 - 迁移要点 :把与层位绑死的 attention 核心抽成与层位无关的
vc_attn_step_w/vc_attn_w_ready(权重/KV 基址/游标/上限全由调用方给);MTP 做成主模型之外的独立槽位(自带 1 层 KV、独立游标、独立打包键);新入口非致命绑定+默认关+fail-closed。 - 真机验证 :命中 E1(
--cuda-selftest6/6 PASS)、E4(q35_attn_checkPASS:共享 attention 核心 5/5 OK)、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),Elen=2.190 token/轮 ;T=63、teacher-forced 上界口径);MTP 专用逐级探针(11 阶段×5 token=55 行)未独立复现:MTP 门VLLM_CUDA_Q35_MTP默认关,q35_mtp_probe --cuda不在本机复跑清单。
六、小结
- 「独立槽位」是投机轮次能写出来的前提 。MTP 的 KV 游标必须能独立于主模型 先跑、后退------共用游标就没有「草稿先跑
k步、再截回去」这条路径(见 第 32 篇)。 - 非致命绑定是一条该固化的接口纪律 。
VC_BIND的「一个都不能少」对后加的可选入口 是错的;新入口一律单独取符号。这条与「默认关 + fail-closed」一起,构成可选功能的接入规范 :加进来不能变成别人的负担,关掉要等于不存在。 - 复用比重写更值钱的地方在「验证面」上 :把 attention 核心抽出来共用之后,「MTP 抄错了」的怀疑面从「整层」缩到「15 个张量 + 一段 stem」。同构省的是工程量,但共用省的是验证量------第 30 篇说的是前者,本篇补上后者。
- 设备级逐级对拍抓到的是「设备档才有的错」:三个 tail norm 的角色互换在 CPU 侧对拍里没暴露,在设备档第一次跑才现形 ⇒ 同一个模块的两条实现路径要各自对拍,不能拿一条的绿去担保另一条。
相关篇目:第 30 篇(MTP 模块与四个口径点);第 25 篇(逐级下钻的方法论来源);第 29 篇(hybrid 设备路径);第 32 篇(把它接进投机轮次,以及为什么判否)。 挂载 / 步进 / 门表的细则见仓库
GitHupSRC/docs/。 源码与配套资源:本仓库 gitee.com/pei-xiaogua...; CPU 推理源码 gitee.com/pei-xiaogua...