Kestrel_VLLM - 31 · MTP 头上 GPU:一个独立槽位,与一条抽出来共用的注意力

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 原件那种乐观口径)。

五、边界与已知限制

  1. 本篇不产出任何加速数字:它只证明「设备上的头算得对」,没证明「投机更快」。
  2. 对的只是 M=1 的单 token 装配:MTP 天然串行,不给它上 M 行(那是把投机回路并行化,语义不同)。
  3. 对拍的一致性来源是「同一个被调用的头」 :探针证明的是被调用者 对,不覆盖调用者喂进来的输入 (这一点在 第 33 篇 会翻车)。
  4. 三个 tail norm 角色互换在 f32 参考上也是错的 ,但它在 CPU 侧的对拍里没暴露(那边是另一套 wiring),在设备档第一次跑才现形 ⇒ 同一个模块的两条实现路径,要各自对拍。
  5. 非致命绑定把「引擎比 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),Elen=2.190 token/轮 ;T=63、teacher-forced 上界口径);MTP 专用逐级探针(11 阶段×5 token=55 行)未独立复现:MTP 门 VLLM_CUDA_Q35_MTP 默认关,q35_mtp_probe --cuda 不在本机复跑清单。

六、小结

  1. 「独立槽位」是投机轮次能写出来的前提 。MTP 的 KV 游标必须能独立于主模型 先跑、后退------共用游标就没有「草稿先跑 k 步、再截回去」这条路径(见 第 32 篇)。
  2. 非致命绑定是一条该固化的接口纪律 。VC_BIND 的「一个都不能少」对后加的可选入口 是错的;新入口一律单独取符号。这条与「默认关 + fail-closed」一起,构成可选功能的接入规范 :加进来不能变成别人的负担,关掉要等于不存在。
  3. 复用比重写更值钱的地方在「验证面」上 :把 attention 核心抽出来共用之后,「MTP 抄错了」的怀疑面从「整层」缩到「15 个张量 + 一段 stem」。同构省的是工程量,但共用省的是验证量------第 30 篇说的是前者,本篇补上后者。
  4. 设备级逐级对拍抓到的是「设备档才有的错」:三个 tail norm 的角色互换在 CPU 侧对拍里没暴露,在设备档第一次跑才现形 ⇒ 同一个模块的两条实现路径要各自对拍,不能拿一条的绿去担保另一条。

相关篇目:第 30 篇(MTP 模块与四个口径点);第 25 篇(逐级下钻的方法论来源);第 29 篇(hybrid 设备路径);第 32 篇(把它接进投机轮次,以及为什么判否)。 挂载 / 步进 / 门表的细则见仓库 GitHupSRC/docs/。 源码与配套资源:本仓库 gitee.com/pei-xiaogua...; CPU 推理源码 gitee.com/pei-xiaogua...

相关推荐
老金带你玩AI41 分钟前
Codex改了又测、测了又改,我一查Token才发现这个坑
人工智能
IT_陈寒1 小时前
当Java的hashCode遇上Lombok,我的缓存被全清了
前端·人工智能·后端
刘胡子大叔1 小时前
评测驱动优化:失败样本回流 Prompt 的闭环
人工智能·prompt
这个DBA有点耶1 小时前
内核级AI与多模融合:AI数据库在海外场景的落地要点
数据库·人工智能·架构
涛思数据(TDengine)1 小时前
从工业本体到 AI 原生:TDengine 出席 2026 QCon 分享工业数据基础设施路径
大数据·数据库·人工智能·tdengine·工业
yz_aiks1 小时前
WorkBuddy 怎么做网页小游戏:深海钓鱼游戏 43 次对话、301 积分从一句话做到发布(附手机横屏踩坑)
人工智能·html5·ai编程·小游戏·workbuddy
byte轻骑兵2 小时前
【LMP】规范精讲[12]: 链路层的花名册——一张表数完88条指令的门道
人工智能·蓝牙协议·lmp·蓝牙链路管理·通信学习
萤箜2 小时前
Chroma、Pinecone、Milvus 选型对比:不同发展阶段该选哪款向量数据库
人工智能