Kestrel_VLLM - 35 · 把 m 循环搬进内核:一次「先量发射、再合并发射」的完整回合

35 · 把 m 循环搬进内核:一次「先量发射、再合并发射」的完整回合

一句话 :第 34 篇把批量 M 行建出来了,但只证明算术是对的 。第一次真正量它的性能时,实测比模型预测高约 35% ------本篇把这笔账从对不上 走到结清 :逐相分解 → 定位到 GDN 子层 → 造两个诊断档把「发射」与「工作」分开 → 坐实「每行成本 = 发射主导」→ 把 6 次/行的发射合并成 1 次 → 默认化 。拿到了阶段五单笔最大的一刀(M=4 的 verify 段 2.130× → 2.495× ,默认化后整步 2.521× )。 前置 :先读 第 34 篇(批量 M 行的五个块);对照 第 29 篇(「发射次数多 ≠ 发射开销大」)。 环境:x86-64 · NVIDIA RTX 5090 · Qwen3.5-27B(q4 打包)· CUDA 后端;同会话交错 3 轮。

一、问题与结论

指标 数值 / 结果 判定
「高了 35%」先变成分相账 缺口落在 ② 子层 ,病态的是 GDN 子层(attn-64 0.930 / gdn-64 3.260 / mixed 3.501 ms/行) 实测
门禁:LF2 每行增量 0.38--0.54 ms/行,低于 attn 档的 0.930,远低于 real 的 3.26--3.50 实测
落地:每层发射数 M=4:27 → 9 ;M=8:51 → 9(与 M 无关) 实测
M=4 verify 段 28.182 → 24.063 ms ,省 4.119 ms(−14.6%) ;提速 2.130× → 2.495× 实测
M=8 省 9.34 ms (2.460× → 3.207×) 实测
默认化后整步 M=4 2.521× / M=8 3.174× 实测

一句话概括:「发射是不是瓶颈,只能实测,不能数数」------这一轮把它从推测做成了实测,再动手。

二、背景:先量------把「35%」拆成五相

**「35% 是拿谁比谁」**必须先说清:不是「整步 vs 单 token」,而是「批量步进的 ② 子层 vs 模型按形状预测的 ② 子层」。

把整步拆成 ①norm ②子层 ③残差 ④norm ⑤MLP 五相,逐相分解的结果:缺口不在 MLP ,在 ② 子层;再按层型拆一刀:

档 ② 子层超出量
attn-64(全 full-attn) 0.930 ms/行
gdn-64(全 GDN) 3.260 ms/行
mixed(真实 48+16) 3.501 ms/行

⇒ 病态的是 GDN 的子层 。3.655 ms/行 ÷ 288 次发射 ≈ 13 µs/次 ⇒ 推测「发射受限」。

注意这一步仍是推测 。本项目吃过多次「把发射次数多当成发射开销大」的亏(第 29 篇那次最典型),所以下一节必须实测。

三、核心机制 / 做法

3.1 门禁:把「发射」与「工作」分开

造两个诊断档(输出故意错,只用于计时):

档 定义 量到的是
-DVC_DIAG_GDN_LF=1 6 个逐 m 内核照旧发射,只把 grid 缩到 1 块 发射 + 流同步本身(工作≈0)
-DVC_DIAG_GDN_LF2=1 这 6 次发射整体不发射 「合并 / m 循环搬进内核」的绝对上限
c 复制代码
/* 文件名:GitHupSRC/src/npu/cuda/vllm_cuda_kernels.cu(节选)
 * 决策 D 的前置门禁:**发射地板**(诊断档)。待验命题:GDN 子层的「每行增量」
 *   3.655 ms/行 ÷(6 内核 × 48 层 = 288 次发射/行)≈ 13 µs/次 ------「发射受限」是**推测**。
 *   做法:保持发射次数、顺序、流依赖完全不变,只把非递推内核的 grid 缩到 1 块、
 *   把递推内核换成 1 块空核 ⇒ 量到的是「这批发射本身值多少」。
 *   ★ 输出**故意错**;只用于界定上界,不产出任何精度结论。
 *   ★ 出厂档(不带该宏)VC_LFG(g) 逐字展开成 (g) ⇒ 代码等价。 */
#if defined(VC_DIAG_GDN_LF)
__global__ void k_gdn_lf_noop(void) {}
#define VC_LFG(g) 1
#else
#define VC_LFG(g) (g)
#endif

★ 两档都不动 ② 里的 3 次批量 GEMV(qkv / z / out)⇒ 差异只来自那 6 个逐 m 内核。

把三档写成 real = F + W + L、LF1 = F + W′ + L、LF2 = F(F = 固定项、W = 那 6 个小内核的工作量、L = 发射 / 流依赖气泡),实测(mixed,M=4):real = 16.082、LF1 = 13.819、LF2 = 7.199 ms。

三个结论(都带审计订正后的限定):

  1. 那 6 个内核的工作量 W 落在噪声内、不可分辨 :real − LF1 在两档符号相反 (gdn 档 −0.35、mixed 档 +2.26),而 real 自身离散就有 3.7 ms 宽 ⇒ 既不能说「免费」,也不能说「有 2.26 ms」。 ★ 订正:原先写「工作基本免费」过强(读数少 + 符号相反)。
  2. real − LF2 在 W ≈ 0 的前提下主要由 L 贡献 :8.64 ms(gdn)/ 8.88 ms(mixed) 。⇒ 两步互证成立,但残差不是零 。 ★ 订正:原先写「全压在发射上」过强。
  3. ★ 每行增量(这是判定的关键):
档 gdn-64 mixed 对照 attn-64
real 3.260 3.501 0.930
LF1 3.375 2.747 ---
LF2 0.379 0.540 0.930

⇒ LF2 的每行增量(0.38--0.54)低于 attn 的 0.930------因为它连工作都抽掉了;而 real 是它的 6--9 倍 ⇒ 「GDN 子层的每行成本 = 发射」被实测坐实,第二节那个推测成立。

3.2 落地:6 个内核,发射数与 M 解耦

新增 6 个「m 循环在内核里」的内核,②--⑥ 由「逐 m 发射」改成「每个内核内部循环 m」:

M=4 每层发射 M=8 每层发射
改前(逐 m) 6 + 3 GEMV = 27 6 + 3 = 51
改后(合并) 6 + 3 = 9 6 + 3 = 9 (与 M 无关)

改动落在 GDN 层步进的分派处:默认走合并路,只保留一条显式逃生舱:

c 复制代码
/* 文件名:GitHupSRC/src/npu/cuda/vllm_cuda_kernels.cu(节选:vc_gdn_step_dev_m 的 ②--⑥ 分派) */
    /* ②--⑥ **两条路**:
     *   - **默认**:把 m 循环搬进内核 ⇒ **每层 6 次发射**(与 M 无关);
     *   - VLLM_CUDA_Q35_GDN_MERGE=0:逐 m 发射(逃生舱;与本改动前**逐字节相同**)。
     * ★ 只在 kd==vd==128(真机形状)走 merge;其它形状显式回退,不做静默降级。 */
    const int use_merge = (gdn_merge_env && kd == 128 && vd == 128);
    if (use_merge) {
        k_gdn_ab_m<<<...>>>(...);                 /* a/b 投影 */
        k_gdn_beta_g_m<<<...>>>(...);             /* beta / g */
        k_gdn_conv_silu_m<<<dim3((conv_dim + 255) / 256, M), 256, ...>>>(...);  /* conv 按 m 并行 */
        k_gdn_conv_hist_m<<<...>>>(...);          /* 末态 hist 微核 */
        k_gdn_qk_norm_m<<<...>>>(...);
        k_gdn_delta_m<<<vh, vd, (size_t)2 * kd * 4, ...>>>(...);   /* 递推:寄存器跨 m 接力 */
        k_gdn_gate_norm_m<<<dim3(vh, M), vd, ...>>>(...);
    } else {
        for (int m = 0; m < M; m++) { /* 逐 m 发射(逃生舱)*/ ... }
    }

位级(直接 memcmp,不靠推理) :真实混合 M=2..8 全 7 档 h_out/logits 全 memcmp=0。依据是:每个 (m, ...) 的算式、列步长、归约树与逐 m 版逐字未改 ;串行状态(conv 的 hist、递推的 S)在同一线程内跨 m 接力 (原来是每 m 从 global 读回再写回,不做任何舍入 ⇒ 值相同)。

递推内核 k_gdn_delta_m 的「寄存器接力」是这条等价的关键------S 的行用寄存器 Sr[] 跨 m 常驻,省掉 M−1 次 global 往返,值不变:

c 复制代码
/* 文件名:GitHupSRC/src/npu/cuda/vllm_cuda_kernels.cu(节选:k_gdn_delta_m) */
/* ⑤ 递推的 M 行版:`Sr[]` 常驻寄存器**跨 m 接力**(省掉 M−1 次 S 的 global 往返)。 */
__global__ void k_gdn_delta_m(float *__restrict S, const float *__restrict xc,
                              const float *__restrict g, const float *__restrict beta,
                              float *__restrict on, int M, ...) {
    ...
    float Sr[KDT];
    #pragma unroll
    for (int dk = 0; dk < KDT; dk++) Sr[dk] = Sm[(size_t)dk * KDT + dv];
    for (int m = 0; m < M; m++) {
        if (m) __syncthreads();               /* 上一轮读者退场后,才允许改写 smem */
        const float alpha = expf(g[(size_t)m * (size_t)vh + vi]);
        const float bt    = beta[(size_t)m * (size_t)vh + vi];
        for (int dk = 0; dk < KDT; dk++) { ... Sr[dk] = ...; }   /* 逐 m 就地推进,舍入与逐 m 版一致 */
        on[((size_t)m * (size_t)vh + (size_t)vi) * KDT + dv] = oa;
    }
    #pragma unroll
    for (int dk = 0; dk < KDT; dk++) Sm[(size_t)dk * KDT + dv] = Sr[dk];
}

同会话交错 A/B(真实混合,M=4,3 轮):

轮 逐 m(ms) 合并(ms) 省
1 28.228 24.271 3.957
2 27.919 23.985 3.934
3 28.398 23.933 4.465
均值 28.182 24.063 4.119(−14.6%)

提速 2.130× → 2.495× ;M=8 省 9.34 ms (2.460× → 3.207×)。

3.3 默认化:一次被公理拦下,一次项目裁定

按项目纪律,默认化要过「默认化门禁公理 」的四条(perf_axiom_default_enablement_gate_v1)。第一次没过:

  • 第 ② 条要求「性能全面领先且极差稳定 <1%」;
  • 而本机测量重复性达不到 1% ------连基线 A 侧 (与本改动无关)极差就有 2.17%--2.93%;
  • ⇒ <1% 在噪声本底 2--3% 的机器上会把任何 改动都判不通过(包括零改动 ),它作为「测量重复性」门槛失去了鉴别力。

⇒ 项目裁定 :把该条替换为 「效应 ≥ 5× 配对极差」 。理由:真正要拦的是「在噪声里找出来的收益 」------新判据要求效应显著大于配对噪声,而不要求仪器绝对安静。

修订后的四条对账(本轮):

# 公理条目(② 已按裁定改写) 本轮实测 判定
① 位级对拍通过 真实混合 M=2..8 全 7 档 + gdn M=3/4/5/7/8 + attn M=4,memcmp 全 0 ✅
② 性能领先且效应 ≥ 5× 配对极差 配对省 4.504 ms 、配对极差 0.487 ms ⇒ 9.2× ;符号 6/6 ✅
③ =0 逃生舱关闭后输出与基线一致 memcmp=0 ✅
④ 不满足启用条件自动回退 非 kd==vd==128 回退逐 m 路 ✅

⇒ 四条件齐备 ⇒ 默认化 :env 只有显式 =0 才回逐 m,不设 = 合并路 。默认化后整步:M=4 2.521× / M=8 3.174×。

★ 一条前瞻登记要求 (审计提出):本条是事后 从既有数据重分析出来的判据(那 6 轮原始数据当初是按「把极差压到 <1%」的目的采集的)。本轮由裁定追认 ,但下一项默认化必须先登记判据、再采数据。

3.4 上界结算:三个方向不可相加

「把缺口填掉」看起来有几个方向,但它们互相重叠:

方向 上界可省(混合档 M=4 整步) 证据等级
x 复用落到批量 GEMV ≤ 7.10 ms 作废 :这一刀早已落地 (第 34 篇 block b/c)。那 7.10 ms 是残差桶,不是待做项
D:压 GDN ②--⑥ 的 M 倍发射 ≤ 5.31 ms 斜率外推 (未实测);后来实拿 4.119 ms
A1:MLP 的 gate+up 批量融合 ≤ 3.63 ms 实测上界(地板是下界)
A2:attn 的 qkv3 批量融合 ≤ 0.13 ms 实测(attn 层已经贴着口径)

★ 三条必须写清:

  1. 不可相加 :各自都受「非投影固定成本」托底,且 D 已实现的 4.119 ms 是从那个 7.10 ms 的残差桶 里拿的------残差桶本身是混合量。
  2. D 是外推 :5.31 ms 是「② 段超出量」,全归给「M 倍发射」是归因。
  3. A2 基本是零:attn 层已经贴口径(64 层只超 0.52 ms)。

⇒ 订正后的结论:D 才是大头,而且它已经落地 ;剩下的是 A1(≤3.63 ms)。

3.5 两处自证(都是先自相矛盾才被抓出来)

① 首版逐相插桩虚报 ① 为 12.0 ms :每层「起点」事件只在循环外记一次 ⇒ ① 的区间变成「整步开始 → 本层 norm 结束」的累积值 。同一个内核的 ④ 只有 0.4 ms ------正是这个自相矛盾暴露了它。

c 复制代码
/* 文件名:GitHupSRC/src/npu/cuda/vllm_cuda_kernels.cu(节选:vc_model_step_body_m 的逐层循环) */
    for (int L = 0; L < nl; L++) {
        /* ★ 每层自己的起点事件。**首版漏了这一行** ⇒ 所有层共用循环外那一个事件,
         *   ① 的区间变成「整步开始 → 本层 norm 结束」的**累积值**
         *   (实测 ① 被虚报成 12.0 ms,而同一个内核的 ④ 只有 0.4 ms ------ 正是这个
         *   自相矛盾把 bug 暴露出来的)。诊断计时也必须先自证。 */
        VC_EVM_MARK(L, 0);
        k_rms_norm_rows<<<M, DC_BLK, 0, st>>>(nrm, h, inn, dim, s->mdl_eps);   /* ① 输入 RMSNorm */
        ...

修法:循环内补记每层起点。⇒ 诊断计时也必须先自证。

② 一处字段名少写 d_ 前缀 (s->mdl_in_norm vs 正确 s->d_mdl_in_norm),加插桩后编译才报错。

★ 这里有一个被撤回的错误结论 :当时写「此前不可能编译通过」,但审计指出------那两个引用不在任何诊断门内 ⇒ 源码真含裸名的话出厂档也编不过 ;而归档里的 DLL 实存且 SHA 与记录吻合 ⇒ 产生那批读数的那一版源码必然编译通过 。⇒ 该结论撤回 。仓库不是 git 仓库,谁在何时引入无法回溯,不作断言。 顺带一条根治建议 :给仓库初始化本地 git (可无 remote)------这类「这一版到底编没编过」的问题已撞第二次,靠 mtime 与归档 SHA 兜底不是长久之计。

③ 诊断标签改全 ASCII :原中文标签经 PowerShell 捕获会变乱码,而这一行的数是要被抄进规格的 ⇒ 抄错比看不到更糟。

四、实测数据

读数 数值 口径
② 子层超出量:attn-64 / gdn-64 / mixed 0.930 / 3.260 / 3.501 ms/行 实测
门禁(mixed M=4):real / LF1 / LF2 16.082 / 13.819 / 7.199 ms 实测
LF2 每行增量:gdn / mixed / attn 0.379 / 0.540 / 0.930 实测
同会话交错 3 轮均值(M=4) 逐 m 28.182 → 合并 24.063,省 4.119(−14.6%) 实测
verify 段提速 2.130× → 2.495× 实测
M=8 省 9.34 ms(2.460× → 3.207×) 实测
默认化后整步 M=4 2.521× / M=8 3.174× 实测
配对省 / 配对极差 4.504 ms / 0.487 ms ⇒ 9.2×,符号 6/6 实测

五、边界与已知限制

  • 全部在同一会话 、同一台机、真权重上取;非合成权重。
  • M=2/3/5/6/7 未标定 (只用 M ∈ {1,4,8});斜率是 M=4→M=8 的割线,不是 M=1 处的导数。
  • 两个 DLL 分开 :诊断档落 build-x64-diag\(不落出厂目录 ),出厂档另编一份;已做二进制核验(出厂档无诊断字符串)。
  • ⚠ 手工 nvcc 是应急路径 :build_x64.ps1 -Cuda 因为找不到 cl.exe 不可用,于是构建脚本自带的两道护栏(诊断宏禁止落出厂目录 / BOM 断言)本轮未被执行 。本轮结果正确(已核验),但这条记为未闭环项。
  • ≤ 5.31 ms 是外推 (未实测),只有 4.119 ms 是实测;两者不可混用(见 3.4)。

CPU 对照(迁移前基线)

  • CPU 参考 :model/vllm_q35_gdn.c:q35_gdn_step ------ GDN 的 ②--⑥ 串行语义与归约序参考;gdn_delta_twin.inc/gdn_ab_twin.inc 声明与 CPU 同序。
  • 迁移要点 :把「逐 m 发射」的 6 个小内核改成「内核内循环 m」,每层发射数与 M 解耦;递推 S 用寄存器跨 m 接力(省 M−1 次 global 往返、值不变 ⇒ 位级等价);默认走合并路,保留显式逃生舱 =0。
  • 真机验证 :命中 E5(q35_gdn_check PASS:5/5 OK、跨层位级相同 0/25600、host↔设备位级一致、每层往返 ≈0.010 ms)。具体性能 A/B(2.130×→2.495×、默认化门禁)属历史测量,未在本机清单复现。

六、小结

  1. 「发射次数多」必须转成「发射开销大」的实测,才能立项 。这次做对了:先造 LF/LF2 两个诊断档 把「发射」从「工作」里剥出来,坐实了才动手。 对照第 29 篇(graph 那一刀):那次是反例 ------发射次数多不必然 意味着主机侧开销大,只要每次发射背后有足够工作,驱动会流水起来。⇒ 两条合起来才是完整的纪律:发射是不是瓶颈,只能实测,不能数数。
  2. 诊断档要「输出故意错、只量时间」 ,并落独立目录。这是它敢做的前提(否则一个错输出混进出厂档就是灾难)。
  3. 默认化门槛必须「可鉴别」 。<1% 在噪声本底 2--3% 的机器上鉴别力为零 (连零改动都过不了)------门槛把噪声当信号拒,等于没有门槛。改成「效应 ≥ 5× 配对极差」之后,它拦的正是「在噪声里找出来的收益」。
  4. 归因要分级 :≤5.31 ms 是外推 、4.119 ms 是实测 、「x 复用 ≤7.10 ms」是已作废的残差桶读数 。三者在表里必须分开标注,否则「不可相加」的三栏会被读成「加起来能省 16 ms」。
  5. 诊断插桩的错会伪装成结论 。① 被虚报成 12.0 ms,如果直接拿去归因,就会得出「norm 是瓶颈」这个完全错误 的方向。⇒ 自证不只对外,也要对诊断工具本身。

相关篇目:第 34 篇(批量 M 行的五个块,本篇在它上面继续);第 29 篇(「发射次数多 ≠ 发射开销大」);第 26 篇(发射空档三步曲);第 32 篇(用这条批量路径跑投机)。 逐相分解 / 发射地板门禁 / 默认化裁定与口径改写的细则见仓库 GitHupSRC/docs/。 源码与配套资源:本仓库 gitee.com/pei-xiaogua...; CPU 推理源码 gitee.com/pei-xiaogua...

相关推荐
Linye5 分钟前
为什么让 AI 修个小 Bug 却把底层全重写了?聊聊 Coding Agent 的最小修改边界
人工智能·架构
深度开源12 分钟前
我扫了自己电脑里的 402 个 AI 技能,光目录就吃掉 4.5 万 token
人工智能
skywalk816312 分钟前
hive 常驻集群:像 OSPF 一样在线,像 git 一样可信
运维·人工智能·hive
王中阳Go12 分钟前
你那条三跳链路的单跳超时,是按压测的 p95 定的,还是拍出来的?
人工智能·后端·架构
方方洛14 分钟前
ray教程-27-附录F-RayClient与runtime_env与多语言
人工智能·分布式·机器学习
神鹰手哥_起飞版15 分钟前
用 Agent 越多,硬盘越满?我在隐藏目录里发现了 334 GB 的 .mimosa
人工智能
相玉17 分钟前
AI 开发工作流做成 Claude Code 插件:原理、集成与一致性保障
人工智能
一条鱼的星辰大海29 分钟前
大模型提示词工程:从提示词到提示词系统
人工智能
用户29914221968039 分钟前
GitHub 热榜项目:日榜(2026-11-07)
人工智能·开源·github