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。
三个结论(都带审计订正后的限定):
- 那 6 个内核的工作量 W 落在噪声内、不可分辨 :
real − LF1在两档符号相反 (gdn 档 −0.35、mixed 档 +2.26),而 real 自身离散就有 3.7 ms 宽 ⇒ 既不能说「免费」,也不能说「有 2.26 ms」。 ★ 订正:原先写「工作基本免费」过强(读数少 + 符号相反)。 real − LF2在W ≈ 0的前提下主要由 L 贡献 :8.64 ms(gdn)/ 8.88 ms(mixed) 。⇒ 两步互证成立,但残差不是零 。 ★ 订正:原先写「全压在发射上」过强。- ★ 每行增量(这是判定的关键):
| 档 | 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 整步) | 证据等级 |
|---|---|---|
| 作废 :这一刀早已落地 (第 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 层已经贴着口径) |
★ 三条必须写清:
- 不可相加 :各自都受「非投影固定成本」托底,且 D 已实现的 4.119 ms 是从那个 7.10 ms 的残差桶 里拿的------残差桶本身是混合量。
- D 是外推 :5.31 ms 是「② 段超出量」,全归给「M 倍发射」是归因。
- 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_checkPASS:5/5 OK、跨层位级相同 0/25600、host↔设备位级一致、每层往返 ≈0.010 ms)。具体性能 A/B(2.130×→2.495×、默认化门禁)属历史测量,未在本机清单复现。
六、小结
- 「发射次数多」必须转成「发射开销大」的实测,才能立项 。这次做对了:先造 LF/LF2 两个诊断档 把「发射」从「工作」里剥出来,坐实了才动手。 对照第 29 篇(graph 那一刀):那次是反例 ------发射次数多不必然 意味着主机侧开销大,只要每次发射背后有足够工作,驱动会流水起来。⇒ 两条合起来才是完整的纪律:发射是不是瓶颈,只能实测,不能数数。
- 诊断档要「输出故意错、只量时间」 ,并落独立目录。这是它敢做的前提(否则一个错输出混进出厂档就是灾难)。
- 默认化门槛必须「可鉴别」 。
<1%在噪声本底 2--3% 的机器上鉴别力为零 (连零改动都过不了)------门槛把噪声当信号拒,等于没有门槛。改成「效应 ≥ 5× 配对极差」之后,它拦的正是「在噪声里找出来的收益」。 - 归因要分级 :
≤5.31 ms是外推 、4.119 ms是实测 、「x 复用 ≤7.10 ms」是已作废的残差桶读数 。三者在表里必须分开标注,否则「不可相加」的三栏会被读成「加起来能省 16 ms」。 - 诊断插桩的错会伪装成结论 。① 被虚报成 12.0 ms,如果直接拿去归因,就会得出「norm 是瓶颈」这个完全错误 的方向。⇒ 自证不只对外,也要对诊断工具本身。
相关篇目:第 34 篇(批量 M 行的五个块,本篇在它上面继续);第 29 篇(「发射次数多 ≠ 发射开销大」);第 26 篇(发射空档三步曲);第 32 篇(用这条批量路径跑投机)。 逐相分解 / 发射地板门禁 / 默认化裁定与口径改写的细则见仓库
GitHupSRC/docs/。 源码与配套资源:本仓库 gitee.com/pei-xiaogua...; CPU 推理源码 gitee.com/pei-xiaogua...