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 后端。
一、问题与结论
一句话概括:这一步不产出任何加速数字,它是一条前置基建——让草稿头在设备上能跑、而且跑得对、还保证「关了就等于不存在」。
二、背景
第 30 篇把 MTP 头当成一个模块讲清楚了:15 个张量、stem + tail、四个「写反了也跑得动」的口径点,以及它没有官方 oracle。但那一篇的落点在 CPU 侧——模块级对拍(11 组 × 5 token)与接受率测量(α₁ = 0.657 / E[len] = 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 会先跑在主模型前面(草稿链要把游标推到 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]…)必须在算预算的那一刻就被算进去:
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:
/* 文件名: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,写入槽位。任一失败都要收干净(转入「未就绪」),绝不留下半绑——半绑的槽位是「跑得动但结果错」的温床。
/* 文件名: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` 收干净。 */
对应的负向自检(纯自检、默认不跑):
3.4 复用 = 把「MTP 抄错了」这个失败模式结构性消除
第 30 篇的原话是:MTP 在 CPU 侧直接调 q35_attn_step,「把『MTP 的 attention 抄错了』这个失败模式结构性消除」。设备侧要做完全一样的事,但多一道障碍:主模型的 attention 核心是和层位绑死的(它从 s->mdl_aidx[L] 取 compact index、从 s->attn_len[ai] 取游标)。
⇒ 动手是把这段核心拆成两层:
/* 文件名: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, ...)——启动点就是这一句:
/* 文件名: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 参考:
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)读回来的入口就在这里:
/* 文件名: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 张量名,不是按名字长短或出现顺序猜):
这和第 30 篇那「四个口径点」是同一类错误(形状合法、前向跑完、断言不炸),唯一的暴露方式就是逐级对拍。⇒ 记入头条,写死在头文件/挂载函数的注释里:
/* 文件名: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)*/
四、实测数据
对拍用的是同一份 .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-selftest 6/6 PASS)、E4(q35_attn_check PASS:共享 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),E[len]=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…
本文由 AI图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。