26 · 发射空档三步曲:事件计时 → nsys → 判别实验
一句话 :上一轮把 raw-q4 GEMV 的访存粒度改成「每 lane 一个 uint32」后,端到端从 27.2 打到 18.9 ms/token(量级 1.44×),却留下一个问题------「为什么是 1.44× 而不是 1.64×」;本篇用三种递进的手段(逐相 CUDA 事件计时 → Nsight 逐核 profile → 不碰引擎的判别实验)去量「kernel 之间 GPU 无事可做的间隙」,结论被推翻并订正了两次。 前置 :建议先读 第 25 篇(访存粒度与 1.44×)。 环境 :x86-64 + NVIDIA RTX 5090;模型 Qwen3.5-27B q4;工具 nsys / ncu;出厂档
build-x64\vllm_cuda.dll。 三个数 :nsyscuda_gpu_trace逐核起止量出发射空档 2.40 ms/token = 12.3% (第 4.3 节);ncu 显示k_gdn_delta只用 DRAM 3.04% / SM 1.19% / 0.02 waves/SM ⇒ 并行度饥饿、降字节数没用(第 4.5 节);判别实验给出每次发射的空档 = 主机侧 CPU ≈5 µs 与 设备地板 ≈1.1 µs 取大者,模型实测平均 2.05 µs (第 5.2 节)。 怎么读:结论看[一、问题与结论]与[六、串起来的教训](必读);机制看[四、第二步 · Nsight 逐核 profile]的 4.3--4.5(空档量化 + ncu 硬证据,想深挖再看);[三、第一步 · 逐相 CUDA 事件计时]与各节末尾的「审计结论与订正」是过程与原始数据(可跳,逐轮原始读数见文末附录)。
第 25 篇留下一个必须回答的问题:为什么是 1.44× 而不是 1.64×? 差额被归给了三条推断 (其中一条直指「逐层发射的空隙」)。
本篇是那条推断的清算:用三种递进的手段 去量「kernel 之间 GPU 无事可做的间隙」------ 先逐相 CUDA 事件计时,再 Nsight 逐核 profile,最后写一个不碰引擎的判别实验。 结论被推翻并订正了两次:第一次「空隙只占 0.2%」,第二次「发射空档 12.3%」, 第三次才回答「这个空档到底是什么」。
它是本系列方法论的集中体现:每一步都要问「这个测量在量什么」。
状态:✅ 已落地(并两次订正)。
一、问题与结论
- 三版测量的口径不同,结论也不同,且后一版推翻了前一版 :
- 第一版(逐相 CUDA 事件计时):空隙只占 0.2%(0.037 ms/token)------错在口径太窄。
- 第二版(nsys 逐核 profile):发射空档 2.40 ms/token = 12.3%------第一版的结论被推翻。
- 第三版(判别实验):「每次发射 ~2 µs 固定成本」不成立 ------空档与 grid / block / smem / 参数个数无关,但与内核时长强相关。
- 第一版为什么看不见空档 :它量的是「相邻两个 event 标记之间的区间」, 而空档被摊进了两侧的相时间里 ------
gap = 层循环 − Σ4 相只覆盖 63 个层间边界 , 0.037 ms 是这条窄口径的上界,不是全 token 的空档总量。 - 空档的两个机制,取大的那个 :主机侧 CPU 发射成本 ≈5 µs/次 (短核时是瓶颈); 设备侧 kernel 间地板 ≈1.1 µs/次 (长核时主机早已领先)。模型实测的平均 2.05 µs 是两者混合。
- 最硬的一条 :ncu 显示
k_gdn_delta只用 DRAM 3.04% / SM 1.19% / 0.02 waves/SM ⇒ GDN 非投影是延迟 / 并行度 瓶颈,不是带宽瓶颈。降字节数没用。 - graph 不是万能的 :它能把「主机侧 5 µs」那一半消掉,但消不掉设备地板那一半 。 每节点成本还随 grid 上升(grid=1 → 525 ns,grid=4096 → 2454 ns)。
- 可拿回的时间只是「乐观(上限)外插」 :CUDA Graph ≈1.8 ms(9.3%) 、 融小核 0.39--0.86 ms 。未在模型路径实测,禁止当收益引用。
二、背景:为什么要专门量「发射空档」
第 25 篇那 1.44× 的账,卡在一句「推断」上。上一轮给出的三条解释里,有一条是:
逐层发射的空隙:每层约 10 次发射,64 层的发射间隙不随内核变快而缩小。
这条推断的麻烦在于------它看不见。它没有对应的探针:
- 反算带宽(761 GB/s)是把 16 GB 权重除以总时间得到的black-box 均值, 管你时间花在算上还是等待上,它都算进去;
- 相级
--skip分解只能给「哪一类算子花得多」,而「发射间隙」不是一个算子; - 微基准(
--sweep)跑的是背靠背的内核,根本没有 GPU 空闲的窗口。
换句话说,「发射空档」是关键路径上的一段负空间 :它既不属于任何一个核, 也不属于任何一次往返。要量它,必须换一套能看见时间线的仪器。
本篇就是这条线的三步走------而且三步的结论互相打架:
- 设备侧
cudaEvent分段计时(逐相)→ 空隙 0.2%; - Nsight Systems 逐核 profile(逐核起止)→ 空档 12.3%;
- 独立判别实验(stream − graph)→ 空档与什么有关。
三、第一步 · 逐相 CUDA 事件计时
上一轮的三条解释都是推断 ,且「跨轮基线不可自证」这一点也已被暴露。 第一步用设备侧 cudaEvent 分段计时把 18.9 ms/token 逐相拆开,把推断换成实测。
3.1 探针本身:可复现的构建线
- 新开关:
-DVC_DIAG_MDL_EVENTS(编译期)+VLLM_CUDA_MDL_EVENTS=1(运行期), 与VC_DIAG_MDL_SKIP同一套门控理由:出厂 DLL 里这条路径打不开。 - 插桩位置(
vcuda_dev_model_step):每层 5 个标记 ⇒ 4 个区间 (in_norm/ 子层+残差 /post_norm/ MLP+残差 ),外加 3 个全局标记 (步开始 / 层循环结束 / final norm+lm_head 结束)。标记只入队,本步末尾一次同步读全部。
这套探针就落在 vcuda_dev_model_step 里,用 6 个累加槽把「4 个相 + 尾段 + 层循环」分开,gap 就是「层循环 − 四相和」:
c
/* 文件名:src/npu/cuda/vllm_cuda_kernels.cu ------ 逐相事件计时的收尾(节选)
* 只在 -DVC_DIAG_MDL_EVENTS 构建里存在;每层 5 个标记 ⇒ 4 个区间 + 3 个全局标记。 */
#if defined(VC_DIAG_MDL_EVENTS)
double ph[4] = {0, 0, 0, 0};
for (int L = 0; L < nl; L++) {
const int base = L * 5;
for (int k = 0; k < 4; k++) { /* in_norm / 子层 / post_norm / MLP */
float d = 0.0f;
if (cudaEventElapsedTime(&d, ev[base + k], ev[base + k + 1]) == cudaSuccess)
ph[k] += (double)d;
}
}
double loop_ms = 0.0, tail_ms = 0.0;
cudaEventElapsedTime(&d, ev[nl*5 + 0], ev[nl*5 + 1]); loop_ms = (double)d; /* 层循环 */
cudaEventElapsedTime(&d, ev[nl*5 + 1], ev[nl*5 + 2]); tail_ms = (double)d; /* final+lmhead */
const double sum = ph[0] + ph[1] + ph[2] + ph[3];
s->mdl_ev_gap_ms += (loop_ms - sum); /* ★ gap = 层循环 − 四相和(只覆盖 63 个层间边界)*/
fprintf(stderr,
"[CUDA] mdl-ev AVG n=%.0f | step=%.3f ms | layer-loop=%.3f "
"(in_norm=%.3f sub=%.3f post_norm=%.3f mlp=%.3f | 4ph-sum=%.3f gap=%.3f %.1f%%) | "
"tail(fnorm+lmhead)=%.3f\n", ...);
#endif
- 口径声明 :探针不改输出 (同 diag DLL、
--ref _q4ref64在探针开/关下三项 maxrel 在打印精度内同值 :2.2536e-06 / 2.0334e-06 / 3.3887e-06,argmax 5/5; ⚠ 这是 5 位有效数字的比对,不是逐位比对 ),但会扰动计时 (每步 64×5+3=323 次 record)。 同构建 A/B 实测:探针开 19.611 ms/token vs 探针关 18.903 ms/token ⇒ 探针本身 0.708 ms(3.7%) 。 ⇒ 所有绝对值只在「探针开」这一组内部互相比较;与上一轮的 18.9 对比时要记住这 3.7%。
构建(新增 -CudaDefines,诊断档放另一个产物目录,绝不覆盖出厂档):
powershell
powershell -File tools\build\build_x64.ps1 -Cuda -OutDir build-x64 # 出厂
powershell -File tools\build\build_x64.ps1 -Cuda -OutDir build-x64-diag `
-CudaDefines VC_DIAG_MDL_EVENTS=1 # 诊断
顺带解决了上一轮记的那个构建坑 :-OutDir 传相对路径 时, MinGW ld 收到的 -o 也是相对路径 ⇒ 不再触发「中文路径 → cannot open output file」 , exe 也能正常链接(本轮 rc=0)。旧写法(省略 -OutDir)会把绝对路径喂给 ld,必然失败。
★ 审计订正(6b/6a):
- 相对
-OutDir原先在Test-Path/New-Item时按 cwd 解析, 而编译器在Push-Location $root之后按 ** root∗∗解析⇒从别的cwd调用时会「目录建错地方」;已把'Push−Locationroot` 提到 OutDir 解析之前,三处基址统一。 -CudaDefines非空却把-OutDir指向出厂目录时,脚本直接拒绝(原先只有注释提醒) ------ 否则会产出「名为出厂、实为诊断」的同名 DLL。
3.2 实测(全 64 层,iters=30,n=32 平均;同会话内四组背靠背)
⚠ 口径分层(审计 5a 订正) :下表前三行是 cudaEvent 口径(step 是事件窗口内), 不包含任何 host wall 时间 ;「探针关」那行是工具 clock() 口径(含 H2D/D2H+同步), 两者不是同一个钟,必须分开读。
| 组 | step(event) | in_norm | 子层 | post_norm | MLP | 4 相和 | 空隙 | tail |
|---|---|---|---|---|---|---|---|---|
| 混合 64 层(48 GDN + 16 attn) | 19.482 | 0.382 | 9.566 | 0.445 | 8.601 | 18.993 | 0.037(0.2%) | 0.453 |
--synthetic gdn(64 GDN) |
20.234 | 0.368 | 10.475 | 0.357 | 8.542 | 19.743 | 0.039 | 0.452 |
--synthetic attn(64 attn) |
17.222 | 0.361 | 7.544 | 0.416 | 8.384 | 16.704 | 0.037 | 0.481 |
探针开的 wall 口径 (工具 clock(),同一构建、同一 harness): 19.611 ms/token ;探针关:18.903 ms/token ⇒ 探针自身 0.708 ms 。 占分母 19.611 的 3.6% 、占 18.903 的 3.75% (文稿取 ≈3.7%,两个分母都写明)。
⚠ step(event)=19.482 与 wall=19.611 不是同一个量(差 0.13 ms ≈ h 的 H2D + 输出 D2H
- 主机侧开销),不可互相代入。
三跑稳定性(gap 是三个数里最稳的一个) :混合档第三次独立运行 step=19.510 / sub=9.459 / post_norm=0.534 / mlp=8.644 / gap=0.037 / tail=0.470 (wall 19.642)⇒ gap 三次都是 0.037--0.039 ,而 sub 在 9.459--9.566、 post_norm 在 0.403--0.534 之间漂 ⇒ 「空隙≈0」这条结论不依赖抖动, 而占比类的数字要按 ±5% 读。
子层的可加性自检 :把 --synthetic 得到的两类各层子层耗时按 48/16 加权 (0.16367×48 + 0.11788×16 = 9.74)对实测混合 9.566 ⇒ 1.8% (上一轮同法为 0.4%)。 ⇒ 「按层型可加」在本轮成立(与上一轮被否掉的整体线性可加不是一回事)。
3.3 归因表:18.9 ms/token 花在哪
⚠ 本表是「探针开」口径,且未扣除 3.7% 探针开销(审计 4d 订正)。
| 项 | ms/token | 占比 | 依据 |
|---|---|---|---|
| q4 投影(MLP 64×3 + GDN 48×3 + attn 16×3 + lm_head) | 12.47 | 64.0% | 字节 × 微基准 cold 速率(预测,全表唯一的预测项) |
| GDN/attn 子层里的非投影 | 5.59 | 28.7% | 子层实测 9.566 − 投影预测 3.98; 按 --synthetic 分层:GDN ≈4.84、attn ≈0.92(两者相加 5.76,与 5.59 差 3%,落在跑间抖动内) |
| 两处 RMSNorm | 0.83 | 4.2% | 实测 |
| swiglu + 残差 + 启动 | 0.56 | 2.9% | MLP 实测 8.601 − 投影预测 8.04 |
| kernel 之间的空隙 | 0.037 | 0.19% | 实测(上界,见下节) |
| 合计 | 19.48 | 100% | ⚠ 这不是独立校验 |
★ 审计订正(4c) :上一版写的「合计 = 实测 step(差 0.02%)⇒ 归因被实测验证」是错的, 那是定义闭合,不是独立校验。把两行「非投影」的定义代进求和即可看出:
text
合计 = P总 + (M子层 − P子层) + M_norm + (M_MLP − P_MLP) + M_gap
= P_lm + (M子层 + M_norm + M_MLP + M_gap) = P_lm + M_loop
投影项在求和里被完全抵消 ,合计恒等于「实测四相和 + 实测空隙 + 预测的 lm_head」。 它与实测 step 的差只等于 P_lm − M_tail。⇒ 0.02% 只说明「预测的 lm_head 0.454 ≈ 实测 tail 0.453」这一件事 (0.2% 吻合),不是「整张表被独立验证」 。 本表唯一的独立对账就是这一条;除此之外「投影 = 字节×速率」始终是外插。
探针开销的量级影响(4d) :GDN 非投影 4.84 恰恰是「探针开的子层实测 − 无探针的微基准预测」 的残差 ⇒ 探针开销会整块计入它 。若 0.708 ms 按四相大小比例摊,子层约吃 0.18 ms ⇒ GDN 非投影 ∈ 4.66, 4.84 (全摊给子层则是 4.13, 4.84)。 排名结论不受影响(GDN 非投影仍是第一),但引用数字时要带上这个区间。
⚠ 另外 0.708 ms 落在哪一相并未定位 (空隙只有 0.037,却总增 0.708)------ 它可能在相内的 device 侧 event 成本,也可能是 host 侧 323 次 record + 每步 256 次 cudaEventElapsedTime 的开销。这一层矛盾本轮没有解释。
(tail 0.453 ms ≈ lm_head 单个投影的预测 0.454 ms ⇒ lm_head 已计入「投影」行, final norm 本身在噪声里。)
3.4 裁决:上一轮的三条推断
-
推断 #2「逐层发射的空隙」被否定 :空隙只有 0.037 ms/token = 0.2%。
★★ 2026-10-01 二次订正:本条的口径太窄,结论在第二版被推翻。 0.037 ms 只覆盖 63 个层间边界 ;相内、相与相之间 的空档被算进了各相时间里。 nsys 实测:每次发射后有 ~2 µs 空档,合计 2.40 ms/token = 12.3% 。 ⇒ 「GPU 从头到尾没有等人」是错的 ;下面那句「融合不是为了省发射」也要改回来。
⚠ 审计 4a:这 0.037 是层间边界 (步起点→第 0 层、63 个层间、末层→循环尾)的测量, 相位内部 (子层里 6--8 个核、MLP 里 5 个核之间)的间隙不在这个数里 , 它们被算进了各自的相。所以精确表述是: 「在 63 个层间边界上未观测到可测量的空隙(≤0.6 µs/边界,已到
cudaEventElapsedTime约 0.5 µs 的分辨极限)⇒ 0.037 是上界」 ,再结合「每 token 约 640 次发射 vs 单 token 19 ms」⇒ 与「主机持续领先、GPU 无空转」一致 (是外推 ,不是直接观测)。 ⇒ 「合并小投影以省发射开销」不是为了省发射 ; 它的收益只能来自内核自身的冷启动/短核效率 。attn k/v的 233 GB/s 因此 不是发射间隙问题(这条也排除了一个替代解释)。 -
推断 #1:由「实测 + 微基准预测」联合定位 (审计 4b:上一版写「被证实并量化」过强): 非投影部分 7.01 ms/token = 36% (= 实测 step 19.482 − 预测投影 12.47),不随访存粒度变快。 最大一块是 GDN 的非投影,∈4.66, 4.84 ms(24--25%) :conv + β/a + qk-norm + 递推, 48 层 × ≈0.10--0.101 ms/层。 量级与上一轮用另一套数据反算的 0.096 ms/层一致(+5%) ------ 但两者都是 「实测 − 预测」,不是完全独立。
-
推断 #3(
attn k/v)保留:它占 0.2 ms 量级,且已被上面第 1 条限定为「核内效率」。
因此 1.44× 与「投影 1.63×」并不矛盾:只有 64% 的时间是会变快的那部分。 按本轮分解算结构性上限:
text
投影@v4 ≈ 12.47 × 1.6335 = 20.38 ms 其余 = 7.01 ms(不随本改动变快)
上限 = (20.38 + 7.01) / (12.47 + 7.01) = 27.39 / 19.48 = 1.406×
★ 这条上限反过来给上一轮的跨会话数字提供了一个(弱)独立支撑 : 27.39 = 实测后总量 + 微基准预测的投影节省量(12.47×0.6335),把它与 归档的改动前 27.2 比,差 0.7% ------ 一端是本会话实测 、另一端是 上一会话归档 ,中间靠微基准模型连接 ⇒ 不是纯循环。 ⇒ 「基线无法自证」的顾虑被削弱 (未消除:会话标识不可回溯、 归档端自身有 27.145/27.214/27.572 的抖动、且这条链仍依赖 1.6335 这个外插值)。
★ 本节不改写上一轮记的 1.20× 与 1.44× : 本轮 wall 口径 18.888/18.903 与上一轮的 18.916/18.935 在 ±0.8% 内 ⇒ 1.44× 不动; 1.20× 本轮未复测,保持原样(仍是单次运行、无方差)。
3.5 对路线图的影响(按可拿回的时间排序)
| 目标 | 可拿回的上限 | 备注 |
|---|---|---|
| GDN 子层非投影(递推/conv/β-a/qk-norm) | 4.66--4.84 ms(24--25%) | 新晋第一优先,比小投影融合大一个量级 |
| q4 投影本身(冷→bulk 的 24% 缺口) | 投影的 24% ≈ 3.0 ms(15%) | 需先把 12.47 ms 里被「短核/启动」吃掉的部分分离出来 |
| attn k/v | ≤0.3 ms(1.5%) | 已降级(见上一条订正) |
| 两处 RMSNorm | 0.83 ms(4.2%) | 每层 2 个「1 block × 256 线程」的极小核 |
★ 但这三项都还带一个未辨识项(审计 7,必须前置成落地门禁) : 0.10 ms/层 里究竟是延迟 还是吞吐 ,本轮没有区分 。48 个 GDN 层在时间轴上是强串行 (S_t 依赖 S_{t-1}),若这 4.8 ms 主要是小核延迟 / 依赖链 而不是带宽, 优化手段会完全不同(减核/融合 vs 提带宽),照「带宽」去改会白干。 ⇒ 在动 GDN 非投影之前,先用逐核(nsys 或减记录版探针)把 4.8 ms 再拆一层 , 并先做一次不带探针的确认(因为 4.84 同时继承了探针开销与微基准模型误差)。
3.6 审计结论与订正(第一步)
审计只做静态核对 (本轮未复跑;它明确声明了这一点),但抓到了一处方法学硬伤。
| # | 级别 | 结论 | 处置 |
|---|---|---|---|
| 4c | 需订正(最重要) | 「归因表合计 = 实测 step,差 0.02%」是定义闭合,不是独立校验 | ✅ 已改(加代数展开 + 明写「唯一独立对账是 lm_head 预测 0.454 vs tail 实测 0.453」) |
| 5a | 需订正 | 同一张表里混了 event 口径 (19.482)与 wall 口径(18.903);3.7% 的分母也没写 | ✅ 已拆表 + 写明 19.611/18.903/19.482 三者关系与两个分母 |
| 4d | 需订正 | 归因表未扣探针开销,而被提为一号目标的 GDN 非投影 正是「探针开实测 − 无探针预测」的残差;且 0.708 ms 落在哪一相未定位 |
✅ 已给区间 4.66, 4.84 并标「未定位」矛盾 |
| 4a | 需订正 | 「GPU 从头到尾没有等人」是外推;0.037 只覆盖层间边界,且已到 event 分辨极限 | ✅ 已收窄为「63 个层间边界无可见空隙(上界,≤0.6 µs/边界)+ 发射率论证」 |
| 4b | 需订正 | 「推断 #1 被证实」过强:非投影量全部继承微基准模型误差 | ✅ 已改为「实测 + 微基准预测联合定位」 |
| 6a | 需订正 | -CudaDefines 无护栏,可能把诊断宏落进出厂目录(同名 DLL) |
✅ 已加硬拒绝 (脚本层 exit 1) |
| 6b | 需订正 | 相对 -OutDir 的 Test-Path/New-Item 在 Push-Location 之前执行 ⇒ 基址不一致 |
✅ 已把 Push-Location $root 提前 |
| 2 | 建议 | ① mdl_ev_ms[5] 注释写「整步」但实际存层循环 ;② vc_mdl_free 漏复位 mdl_eps |
✅ 两处都改 |
| 1 / 3 / 8 | 已确认无误 | 编译期门控彻底 ;结构体布局不同不构成风险 ;事件句柄无泄漏/无双释放 ;探针不碰 device 内存、不改执行顺序 | --- |
| 5b / 5c | 成立 | 用 27.39 支撑 1.44× 的措辞(「削弱、未消除」)恰当;1.44×/1.20× 本轮不改写 | --- |
| 7 | 建议 | 路线图重排方向可站,但 4.84 的延迟 vs 吞吐未辨识 | ✅ 已写进第 3.5 节作为前置门禁 |
审计的总结论 :改动(探针)达标 ;结论方向达标,但「被独立验证过」这个更强的主张必须订正 ------ 「为什么是 1.44× 而非 1.64×」这个核心答案站得住,需要修的只是它的证据成色。
四、第二步 · Nsight 逐核 profile
第 3.5 节定了前置门禁:动 GDN 非投影之前,先用逐核手段判断那 4.8 ms 是延迟 还是吞吐 。 第二步用 nsys(逐核时间 + GPU 时间线)+ ncu(SpeedOfLight / Occupancy)把它做实------ 顺带把第一步的结论推翻了。
4.1 方法与环境坑(可复现)
powershell
# ★ nsys 的 protobuf 拒绝非 UTF-8 的 working_directory ⇒ 仓库根含中文时**直接失败**
# (libprotobuf ERROR ... working_directory contains invalid UTF-8 data)。
# 解法:subst 一条全 ASCII 路径,所有参数都走它。
subst X: <repo_root>
nsys profile --stats=true --force-overwrite=true -o X:\nsys_gdn64 `
X:\GitHupSRC\tools\build\out\q35_gpu_model.exe X:\Modl\Qwen3.5-27B-q8local `
--vqf X:\Modl\Qwen3.5-27B-q8local\model_q4.vqf --layers 64 `
--lib X:\GitHupSRC\build-x64\vllm_cuda.dll --iters 1
nsys stats --report cuda_gpu_kern_sum --format table X:\nsys_gdn64.nsys-rep # 逐核汇总
nsys stats --report cuda_gpu_trace --format csv X:\nsys_gdn64.nsys-rep # 逐核起止(算空隙)
ncu --kernel-name regex:k_gdn_delta --launch-count 1 --section SpeedOfLight --section Occupancy <exe> ...
- ⚠
-o X:\...的产物落在「仓库根」 (X: 映射的就是仓库根):实测.nsys-rep≈ 20 MB、.sqlite≈ 320 MB 。跑完必须删 (本轮已删),否则仓库里躺 340 MB。 更省事的写法是把-o指到%TEMP%。 - ⚠ 步数账目 (审计 8 订正):上表命令行没有
--ref⇒ 实际是 3 个 token 步 (T=1 首算 + 预热 + 1 计时),401×3 = 1203 次 rq gemv ✓ 与下表对得上。 审计复跑时带了--ref _q4ref64⇒ 7 步 / 2807 次 rq ,折算后逐项与本节表吻合 ≤1%。 - 被剖析的运行:
--layers 64 --iters 1,出厂档build-x64\vllm_cuda.dll(无探针 ,两轮都没开VLLM_CUDA_MDL_EVENTS)。 - ⚠ 这套工具是诊断用,不进任何构建线;它不改引擎一个字节。
4.2 逐核时间表(nsys cuda_gpu_kern_sum,3 步聚合后除以 3)
| 内核 | 次/token | ms/token | µs/次(均值) |
|---|---|---|---|
k_rq_q4_gemv_f32act(全部 q4 投影) |
401 | 12.256 | 30.6 |
k_gdn_delta(递推) |
48 | 2.646 | 55.1 |
k_f32_gemv(GDN 的 β/a,永不走 rq) |
96 | 0.899 | 9.4 |
k_rms_norm_unit(2×64 + final) |
129 | 0.357 | 2.77 |
k_add_ip(两处残差) |
128 | 0.117 | 0.91 |
k_attn_decode |
16 | 0.113 | 7.09 |
k_gdn_conv_silu |
48 | 0.087 | 1.81 |
k_gdn_beta_g |
48 | 0.070 | 1.47 |
k_gdn_gate_norm |
48 | 0.068 | 1.43 |
k_swiglu |
64 | 0.061 | 0.95 |
k_gdn_qk_norm |
48 | 0.060 | 1.25 |
k_attn_{q,k}_prep / rope_tab / out_gate |
64 | 0.088 | ~1.4 |
| 核时合计 | ~1169 | 16.84 |
一条真正的独立对账(这是第 3.3 节缺的那一条) :nsys 实测 q4 投影 12.256 ms/token vs 微基准 cold 速率外插的 12.47 ⇒ 差 1.7% 。 ⇒ 「投影 = 字节 × cold 速率」这个模型独立于「定义闭合」被外插对账了 (⚠ 措辞比上一版收窄:预测端仍含合成权重 的不确定性)。 k_f32_gemv 的 96 次/token 也精确对上「β/a 各一次 × 48 个 GDN 层」。
这两核的「形状」就是本节要看清的对象------k_gdn_delta 一个 block 一个 v-head、每线程 128 步串行扫描:
c
/* 文件名:src/npu/cuda/gdn_delta_twin.inc ------ 递推内核(ncu 显示 DRAM 3.04% / SM 1.19%)
* grid = 48 block(一个 v-head 一个)、block = 128 thread(thread = dv 独占 S 一列);
* 下面两条循环各 128 步,是**时间轴上的串行链**。 */
__global__ void k_gdn_delta(float *__restrict S, const float *__restrict q,
const float *__restrict k, const float *__restrict v,
const float *__restrict g, const float *__restrict beta,
float *__restrict o, int kh, int kd, int vh, int vd) {
const int vi = blockIdx.x;
const int dv = threadIdx.x;
...
float kv = 0.0f;
for (int dk = 0; dk < kd; dk++) { /* ① α 缩放 + (S·k):128 步串行,每步读+写全局 S */
float *sp = Sm + (size_t)dk * (size_t)vd + dv;
const float t = (*sp) * alpha; *sp = t; kv += t * sk[dk];
}
...
for (int dk = 0; dk < kd; dk++) { /* ② 秩一加 + (S·q):再 128 步 */
float *sp = Sm + (size_t)dk * (size_t)vd + dv;
const float t = *sp + sk[dk] * tmp; *sp = t; oa += t * sq[dk];
}
o[(size_t)vi * (size_t)vd + dv] = oa;
}
β/a 的投影则是另一种极端------只有 6 个 block:
c
/* 文件名:src/npu/cuda/f32_gemv_twin.inc ------ β/a 的 GEMV(rows=48、cols=5120)
* 一个 warp 一行,lane 以 32 为步长扫列;grid 只有 6 个 block(wpb=8)。 */
__global__ void k_f32_gemv(const float *__restrict W, const float *__restrict x,
float *__restrict y, int rows, int cols) {
const int warp = (int)((blockIdx.x * blockDim.x + threadIdx.x) >> 5);
const int lane = (int)(threadIdx.x & 31u);
if (warp >= rows) return;
const float *wr = W + (size_t)warp * (size_t)cols;
float acc = 0.0f;
for (int c = lane; c < cols; c += 32) acc += wr[c] * x[c];
for (int off = 16; off; off >>= 1)
acc += __shfl_down_sync(0xffffffffu, acc, off);
if (lane == 0) y[warp] = acc;
}
4.3 ★ 发射空档:每 token 2.40 ms(12.3%)
Direct 量(同一次运行内,cuda_gpu_trace 的逐核起止)。审计独立复跑复现(同会话、同一 boot):
逐轮原始读数见文末 [附录 · 表 A1](#附录 · 表 A1 "#%E9%99%84%E5%BD%95%E5%8E%9F%E5%A7%8B%E6%95%B0%E6%8D%AE%E8%A1%A8")。
★ 「这 2.4 ms 不是 CUPTI 造出来的」有两条独立论证(第一条是审计建议补的、更硬):
wall − Σ(内核时长)与追踪工具无关 :审计复跑在无 nsys 下测得 wall 18.942 ms/token (同一会话、同一 exe/DLL),而 nsys 下的 busy 只有 16.4--17.0 ms ⇒ 差 ~2.2 ms 。 若这 2.2 ms 是 CUPTI 加的,那么无 nsys 时内核必须比 nsys 下快 13% ------ 不成立 (CUPTI 的时长是 GPU 硬件时间戳,追踪开销加在主机发射路径上,不会拉长内核)。- 跨度 18.96/19.43 与无探针 wall 18.888/18.903 同值;若每次发射被凭空加 2 µs, 跨度应膨胀到 ~21 ms。
⚠ 残留不确定性:两条都建立在「CUPTI 不改变内核时长」之上;彻底闭合需要非 nsys 的 busy 测量 (例如给第一步的探针做「减记录版」)。⇒ 证据强度高、非绝对。
- 空档个数 1168/token ,均值 2.03--2.05 µs、中位数 2.02 µs(分布很窄)。
- ⚠ 窗口口径披露 :上面的「跨度」定义为「该步第一次 rq gemv (第 0 层的 MLP gate) 的起点 → 最后一次(lm_head)的终点」。它不含 第 0 层的
in_norm与其子层(≈0.1 ms, 约 0.5%)、也不含 H2D/D2H。idle% 是在这个窗口内算的,故不含首层那一段的空档 (按 11 次发射 × 2 µs ≈ 22 µs 估,不影响结论)。 - ★ 按「前一个核」分组 (审计 3 订正:上一版说「完全均匀、无离群组」过强): 分组均值横跨 1.2--2.9 µs (如
k_attn_rope_tab后 2.75、k_gdn_beta_g后 2.66、k_rq_q4_gemv后 2.31、k_add_ip后 1.97、k_rms_norm_unit后 1.46、k_attn_decode后 1.12 ------ 审计复跑同一张表的逐项值有 ±0.5--1 µs 出入 ⇒ 该表是单次运行样本 ), 且存在个别尖峰 (最大 42.3 µs,次大 10.0/9.5/9.2 µs),corr(前核时长, 空档) = 0.05(几乎不相关)。 正确表述是:没有任何单一内核承担全部空档(总量 2.4 ms 是 1168 次发射摊出来的), 而单次发射的空档在 1.2--2.9 µs 之间波动、偶有尖峰 。 ⇒ 「~2 µs 是每次发射的固定成本」这个说法只对「均值/中位数」成立 ; ★ 第 4.5 节的判别实验已把它证伪 :空档与 grid/block/smem/参数个数无关 , 但与内核时长 强相关 ------ 短核是主机侧 ~5 µs 受限、长核(30 µs)只剩设备地板 ~1.1 µs。 模型实测的 2.05 µs 是两者混合。 - 交叉印证:第 3.1 节里那 323 个 event 标记的实测开销 0.708 ms ÷ 323 = 2.2 µs/标记 ------ 与「每次 stream 操作 ~2 µs(设备侧 inter-kernel 间隔)」同一量级。 ⚠ 注意别混淆:主机侧
cudaLaunchKernel实测均值是 4.5 µs (>2 µs)⇒ 这 2 µs 不是主机 API 时间,而是设备侧的内核间隔(主机仍在领先)。
4.4 ★ 订正:上一轮的结论写错了
第一步说「空隙只有 0.037 ms ⇒ GPU 从头到尾没有等人」------错了 ,错在口径: gap = layer-loop − Σ4相 只覆盖 63 个层间边界 (0.59 µs/边界), 而相内、相与相之间 的空档被算进了各自相的时间里(Σ4相 = 18.99 ms 里其实含 ~2.4 ms 空档)。 正确表述见第 4.3 节:每次发射后有 ~2 µs 空档,合计 12.3%。
⇒ 连带影响:第一步里那句「融合小投影不是为了省发射 」要改回来 ------ 它正是为省发射 。但省多少要按账目算 (上一版写的「少 500 次 ⇒ 1 ms」没有账目,已作废 ): 可融合的是 GDN 的 4 个小核(192 次/token)与 attn 的 4 个小核(64 次/token), 各合成 1 次 ⇒ 省 192 次发射 × ~2 µs ≈ 0.39 ms(2%)(见第 4.7 节 ③)。 (小投影融合因此从「只为拿回 cold→bulk 缺口」升级为「同时省发射空档」。)
4.5 ★ 延迟还是吞吐?------ncu 硬证据:并行度饥饿
| 内核 | grid × block | duration | DRAM% | SM% | waves/SM | 占用率 |
|---|---|---|---|---|---|---|
k_gdn_delta |
48 × 128 | 59.55 µs | 3.04% | 1.19% | 0.02 | 8.21% |
k_f32_gemv(β/a) |
6 × 256 | 9.60 µs | 6.00% | 0.75% | --- | 16.33% |
★ 口径必须分开写(审计 4 订正) :两个「占用率」分母不同,不可并排当同一个数:
- 1.8% = 手算的全局线程槽位占比(48×128 = 6144 线程 ÷ 348k);
- 8.21% = ncu 的 achieved occupancy(per-SM,分母是该 SM 的常驻 warp 上限)。
两者说的是一件事的两面(块太少 ⇒ 大多数 SM 空着 ⇒ 少数活跃 SM 内部也没填满)。 waves/SM = 0.02(grid=48 除以「SM×每 SM 常驻块」)⇒ 「整张卡基本没被用起来」这个解读成立 。 可信度排序 :waves/SM(纯几何,最稳)> occupancy > DRAM%(replay 下最不稳 : replay 的 L2 状态与真实交错运行不同,而 DRAM% 对 L2 命中率极敏感 ⇒ 只当量级,±10% 以上 )。 Duration 也被 replay 抬高约 8%(59.55 vs nsys 的 55.1/56.7 µs),故 DRAM%/SM% 实为被低估 5--8%。
k_gdn_delta的流量口径(审计 5 订正):- 状态唯一足迹 = 48 × 128×128 × 4 B = 3.146 MB/层(读+写各一遍 = 6 MB);
- 但内核把 S 扫了两遍 (递推里的 α 缩放 +
Σ S·k,再秩一更新 +Σ S·q) ⇒ 访问量 2R+2W ≈ 12.6 MB ;只有 L2 吸收第二遍时,DRAM 流量才 ≈ 6 MB (3.1 MB 状态完全装得进 5090 的 L2)------这个假设必须写明; - 旁路流量(q/k/v/o/g/β)≈ 100 KB/层 = 状态的 1.6% ⇒ 可忽略;
- 用 ncu 反推:
3.04% × 1792 GB/s × 59.55 µs ≈ 3.2 MB⇒ 读主导 (写在 L2 窗口内未落 DRAM)。 3.2 MB 与 6 MB 差 1.9× ,上一版写「同量级」偏松,正确说法是上面这两句。
- β/a 的
k_f32_gemv更极端:只有 6 个 block (rows=48、wpb=8)。
⇒ 结论:GDN 非投影是「延迟 / 并行度」瓶颈,不是带宽瓶颈。 优化手段必须是把每层的工作切给更多 block/warp ,降字节数没用 (它只用了 3% 的 DRAM)。 这一条正好回答了第 3.5 节那个未辨识项。 ⚠ 但「能拿回多少」本轮没有实测(见第 4.7 节的降级)。
4.6 修正后的归因(核时 + 空档,均为 nsys 直接量)
| 项 | ms/token | 占比 |
|---|---|---|
| q4 投影(401 次发射) | 12.26 | 64.5% |
k_gdn_delta(48 次) |
2.65 | 13.9% |
β/a k_f32_gemv(96 次) |
0.90 | 4.7% |
| 其余小核(norm/add/swiglu/attn/小 GDN) | 1.03 | 5.4% |
| 发射空档(1168 次 × ~2 µs) | 2.40 | 12.6% |
| 合计 | 19.24 | 100% |
(与第一步的探针口径表对照:投影 12.47/64% 与这里 12.26/64.5% 一致; 「子层非投影 5.59」在这里被拆成 delta 2.65 + β/a 0.90 + 小 GDN 0.29 + attn 0.24 + 残差/add 0.12
- norm 0.36 ≈ 4.56,其余约 1.0 ms 落在相间空档里。)
4.7 路线图(修正后的前两名)
| 目标 | 可拿回 | 依据 | 代价 |
|---|---|---|---|
| ① GDN 内核并行度 | ⚠ 方向(未实测) ------ 上一版写的「~2.8 ms(15%)」是外推,已降级 | 事实只有:k_gdn_delta 2.65 ms @ DRAM 3.04% 、β/a k_f32_gemv 0.90 ms @ 6.00% 。「若提到 30% 峰值 ⇒ 0.54+0.18 ms」是 5× 提速的假设,无任何实测 |
改内核;动之前必须先做判别实验 + 纯读 shape 探针 |
| ② 消掉发射空档(CUDA Graph) | ≈1.8 ms(9.3%)=乐观(上限)外插 (未在模型路径实测,禁止当收益引用) | 按 1168×(2.05−0.53) µs;0.53 µs 是 grid=1 的 graph 每节点成本下界 (grid=4096 时是 2.45 µs)⇒ 实际残留更大、可拿回更少。mix 实测差 4.41 ms 更乐观。引擎已有 graph 基建,缺的是把模型路径 做成可捕获:换非默认流 (capture 不能捕 legacy default stream)+ 去 H2D/D2H + pos 走 d_dyn。★ 隐性收益:主机 CPU 发射成本 ≈5 µs/次 × 1138 = 5.7 ms/token 现在被 GPU 藏住,内核变快后会浮上来 |
接线,风险中等 |
| ③ 融小核(顺带省发射) | 0.39--0.86 ms (省 192 次) | 账目 :GDN 4 个小核(192 次)与 attn 4 个小核(64 次)各合成 1 次 ⇒ GDN 省 144、attn 省 48、共 192 次;按模型观测的 2.05 µs 算 0.39 ms,按短核侧的 5 µs 算 0.86 ms | 与①合并做更划算 |
★ 审计 6 阻断项,必须遵守的三条:
- ① 与 ② 不可直接相加 :若 ① 靠增加 block/发射数 实现(沿 vd 切、每 head 多块), 空档会上升 ,与 ② 部分抵消。净效应未知。
- ① 的「沿 K 拆」不是位级安全的 :
Σ S·k/Σ S·q是归约 , 沿 K 切会改归约序 ⇒ 触碰本系列的保序纪律 ,必须走位级/容差门禁。 数值安全的方向是沿vd(输出列,逐列独立)切更多 block ------ 但它不缩短 每线程 128 步的串行链 ⇒ 收益性质与「降延迟」不同。上一版写「不改数学」是错的。 - 「19.24 → 约 14.0 ms/token ≈ 71 tok/s」是「上限叠加估计」,不是实测收益 , 且 19.24 是 nsys 口径 (比无探针 wall 18.9 高 ~1.8%)⇒ 拿它当基线再外推是口径叠加。 违反「实测收益区间」纪律的写法一律不得对外引用。
优先级结论(仍然成立) :小投影融合因空档数据回升 ; GDN 内核并行度 与发射空档 并列第一梯队;attn k/v 仍是最小的一块。 但两项收益都必须先实测再引用。
4.8 审计结论与订正(第二步)
审计独立复跑了(同会话背靠背、无 nsys 与 nsys 各一遍),并清理了自己的临时产物。
| # | 级别 | 结论 | 处置 |
|---|---|---|---|
| 1 | 已确认无误 | CUPTI 没有自造那 2.4 ms ⇒ 对第一步的推翻不必撤回 。复跑:无 nsys wall 18.942 ,nsys 跨度 18.86--19.39 ,busy 16.36--16.99 ,idle 12.4--13.4% ;逐核次数(401/48/96/129/128/16)逐项吻合、空档 1168 个 mean 2056 ns median 2016 ns | ✅ 并采纳其更硬 的论证:wall − Σ(内核) 与追踪工具无关 |
| 6 | 阻断 → 需订正 | ①「~2.8 ms(15%)」是 5× 外推、无实测 ;① 的「沿 K 拆 / 不改数学」触碰保序纪律 ;①②不可直接相加 ;「19.24→14.0 ms/71 tok/s」是上限叠加估计 | ✅ 全部降级/改写(三条禁令) |
| 3 | 需订正 | 「~2 µs 固定、与内核种类无关、无离群组」过强:分组均值 1.2--2.9 µs、corr(前核时长,空档)=0.05、有 42 µs 尖峰;且该表是单样本 |
✅ 已改写并标为未辨识 |
| 4 | 需订正 | 「1.8%(全局线程槽位)」与 ncu「8.21%(per-SM achieved occupancy)」分母不同却并排 ;DRAM% 在 replay 下最不可信 |
✅ 已分写两口径 + 可信度排序 |
| 5 | 需订正 | k_gdn_delta 流量:唯一足迹 3.146 MB 、内核2R+2W ≈ 12.6 MB 、DRAM≈6 MB 依赖 L2 吸收第二遍 ;ncu 反推 ≈3.2 MB ⇒ 与 6 MB 差 1.9×,上一版「同量级」偏松 |
✅ 已重写 |
| 2 | 小订正 | 窗口内另有 32 次 D2D memcpy ≈25 µs/token,需披露(含不含都不改结论) | ✅ 已披露 |
| 7 | 收窄 | 「独立对账」宜写「独立于定义闭合的外插对账,预测端含合成权重不确定性」 | ✅ 已改 |
| 8 | 需订正 | 第 4.1 节记录不全:① 未提 -o X:\ 会把 20 MB rep + 320 MB sqlite 落在仓库根 ;② 命令行缺 --ref,自检账目「3 步/1203 次」与带 --ref 的 7 步/2807 次对不上 |
✅ 已补全 |
| --- | 已确认无误 | 未触碰保序纪律(本轮无引擎代码改动);仓库无 nsys* 残留、subst X: 已删 |
--- |
审计的总结论 :测量方法达标 (核心命题经独立复跑成立); 结论的收益数字不达标------已按上表把「未实测外推」全部降级为「方向 / 上限估计」。
五、第三步 · 判别实验
第 4.3 节的量测留了一个未辨识的前提:「每次发射的代价是个常数」。第三步用一个不碰引擎 的 独立工具 tools/q35_launch_gap.cu 把它拆开------而且不依赖「独立测内核时长」。
5.1 方法:用 stream 档 − graph 档 定义每次发射的空档
两档跑的是同一串内核、同样的 grid/block、同样的 duration ,唯一差别是发射/调度路径。 相减就把「内核时长」这个第 4.3 节里分不开的量消掉了。 (第一步的事件探针做不到:它只能给「相之间」的边界,而且标记自己也要 ~2 µs。)
- 使用非默认流 (
cudaStreamBeginCapture不能捕获 legacy default stream ------ 这一点对引擎接线很重要:引擎现在全在 stream 0 上发射,要做 graph 必须换流)。 - 两种模式共用同一个发射函数
issue_one()⇒ 差别只可能来自发射路径。 - 每个扫描档:2000 次发射/序列 × reps=20。
5.2 结论②:空档与 grid / block / smem / 参数个数无关,但与内核时长有关
单位都是 ns/次发射 。★ 审计订正:上一版这张表的 stream/graph 两列整体差了 2× (把 ms/序列 当成了 ns/次;gap 列当时是对的,因为 gap 用 (sq-gq)*1e6/nl 算)。 下表是重算 + 补了 host(CPU) 列 (std::chrono::steady_clock 包住「只入队不 sync」的循环)。
| 扫描 | 范围 | stream | graph | gap | host(CPU) |
|---|---|---|---|---|---|
| grid | 1 → 4096(blk=256) | 4973--6237 | 525--2454 | 3783--4632 | 4977--6199 |
| block | 32 → 1024(grid=64) | 4882--5035 | 587--705 | 4235--4447 | 4964--5051 |
| 动态 smem | 0 → 49152 B | 4998--5059 | 561--593 | 4404--4492 | 4996--5056 |
| 参数个数 | 2 → 12 | 4979--5089 | 556--580 | 4423--4530 | 4997--5118 |
| 真 q4 gemv 17408×5120(30.5 µs/核) | grid=2176 | 31705 | 30557 | 1148 | 31851 ⚠ |
★ 新增的关键证据(审计 2 要求):host(CPU) ≈ stream。 短核那几档里 host(CPU) 与 stream 档逐行相等 (5051 vs 5048、4979 vs 4997...) ⇒ 那 ~5 µs 是「主机 CPU 执行 cudaLaunchKernel 的成本」,不是设备前端命令处理 (若是设备侧固定成本,graph 档也必须付它,而 graph 只有 0.53--0.70 µs)。
⇒ 正确结论:发射空档有两个上限,取大的那个
- 主机侧 CPU 发射成本 ≈ 5 µs/次 (与 grid/block/smem/参数都无关)------短核时它是瓶颈;
- 设备侧 kernel 间地板 ≈ 1.1 µs/次 ------长核时(30.5 µs ≫ 5 µs)主机早跑在前面, 观察到的就是它(q4 行 gap=1148 ns)。
模型实测的平均 2.05 µs 正是两者的混合(长核走地板、短核走主机)。 ⇒ 审计要的判别实验答案:「~2 µs 固定成本」不成立。
⚠ q4 那一行的 host(CPU) 列无意义 :那时 GPU 比主机慢,主机被反压 (队列满时 launch 会阻塞), 所以测到的「host 时间」≈ GPU 速率而不是 CPU 成本。只有短核档的 host 列读数有效。
5.3 结论①:graph 的成本
- graph 的每节点成本与 grid 有关,不是常数 (审计 3 订正): grid=1 → 525 ns 、grid=1024 → 949、grid=4096 → 2454 ns 。 上一版说的「地板 0.57 µs」只对 grid=1 成立。
--mix(复刻一个 token 的 1137 次发射,q4 投影 401 次 、权重 slab 13732.0 MiB = 真实池大小):
| 档 | ns/次 | 序列合计(1137 次) |
|---|---|---|
| stream | 14817 | 16.85 ms |
| graph | 10942 | 12.44 ms |
| 差(= 可拿回) | 3875 | 4.41 ms(26%) |
| host(CPU) | 15188 | 17.3 ms |
- ⚠ 审计订正 3:上一版写的「graph 档 ≈ Σ(内核时长)」是假设、不是测量 (右侧分项是凑出来的 ⇒ 与第一步同类,不可当独立校验 )。 要用它必须另求独立的 Σ:对
--mix跑nsys --stats=true求和, 或断言cudaGraphGetNodes() == 1137排除节点合并。 - ⚠ mix 的 gap(3.9 µs)比模型的(2.05 µs)大 ⇒ 是「乐观侧」 (方向没说反,审计 4 确认): 原因有两条:① 736 个非 q4 发射用的是廉价 stub (真实对应物含 55 µs 的
k_gdn_delta); ② mix 把 401 个 q4 全排在最前、736 个 stub 排在后面 ,而真实 token 是逐层交替 ⇒ 「主机 vs 设备谁在前」的时序结构不同。 (另:mix 的host(CPU)=15188 ≥ stream=14817⇒ mix 自己是主机受限的, 这也说明它偏向「主机受限」那一侧。) - ⇒ 模型上的可拿回量 :按
1168 × (2.05 − 0.53) µs ≈1.8 ms(9.3%) , 但 0.53 µs 是 grid=1 的下界 (模型内核 grid 高达 2176 ⇒ graph 残留会更大) ⇒ 这 1.8 ms 是乐观(上限)外插 ,不是地板 (审计 5 订正:上一版叫「地板外插」方向反了)。 真值必须把 graph A/B 打在模型路径上。
5.4 对结论与路线图的更新
- 第 4.3 节的「每次发射 ~2 µs」改为「2.05 µs 是两种机制的合成,见第 5.2 节」 ; 「与内核种类无关」这个说法被判别实验证伪 (与时长强相关,与 grid/block/smem/参数无关)。
- ② 的收益从「上限 2.4 ms」改为「≈1.8 ms(9.3%)=乐观(上限)外插」 ; 对应的实现方式不变 :把模型路径搬到非默认流并捕成 graph。
- 顺带解释了主机侧那笔账 :CPU 发射成本实测 ≈5 µs/次 (第 5.2 节的
host(CPU)列), 1138 次 ⇒ ≈5.7 ms/token 的 CPU 时间 ,现在被 GPU 的 16.6 ms 藏住了 (所以它不在关键路径上);但它是个隐患------一旦内核变快,主机就会浮上来。 ⚠ 注意 WDDM 下主机不能任意跑前 (队列/批量提交有深度限制), 所以「藏住」是平均值意义 上的,不是无条件的。graph 同时消掉这笔账。 - 融合小核的账(发射数统一为 192 ): 每层 4 个小核 → 1 个,GDN 省 3×48 = 144、attn 省 3×16 = 48 ⇒ 省 192 次 (不是 256 )。 ⇒ 按模型观测值 2.05 µs 算 0.39 ms ,按短核那一侧的 5 µs 算 0.86 ms ⇒ 区间 0.39--0.86 ms 。(上一版写的「0.4--1.0 ms」把「小核总次数 256」当成了「省下的次数」, 两端口径不一致,已作废。)
5.5 审计结论与订正(第三步)
审计独立复跑 (同 sweep/mix 命令、独占 GPU、跑完清理),gap 列与本节逐行吻合。
| # | 级别 | 结论 | 处置 |
|---|---|---|---|
| 1 | 成立 | 「空档不是常数、与 grid/block/smem/参数无关、与内核时长强相关」复现成立(gap:grid 3918--4617、block 4328--4582、smem 4424--4449、参数 4460--4500、q4 1223) | 核心保留 |
| 1 | 需订正 | 「唯一差别/相减就消掉内核时长」缺前提(两档内核时长相等;未控时钟状态) | ✅ 已补前提 |
| 2 | 成立 + 补实验 | 「两机制取大者」方向对(graph 短核只 612 ns ⇒ 那 4.5 µs 不可能是设备固定成本);但「主机 CPU vs 设备前端」本轮数据分不开 | ✅ 已加 host(CPU) 计时列 ⇒ 实测 host≈stream ,判定为主机 CPU |
| 3 | 需订正 | 「graph ≈ Σ(内核时长)」是定义闭合 ;且 graph 每节点成本随 grid 上升(612→2440 ns),0.57 µs 只对 grid=1 | ✅ 已改写并标为假设 |
| 4 | 成立 | 「4.39 ms 是乐观侧」方向没说反 | ✅ 保留,并补第二条乐观原因 |
| 5a | 需订正(真错) | 第 5.2 节表的 stream/graph ns/launch 两列整体差 2× |
✅ 已重算(gap 列原本就对) |
| 5b | 需订正(不自洽) | 「0.4--1.0 ms」两端混用了 192 与 256 | ✅ 已统一为 192 次 ⇒ 0.39--0.86 ms |
| 5c | 需订正(命名) | 「地板外插」方向反了 ------ 0.57 µs 是 grid=1 的下界 ⇒ 1.8 ms 实为乐观/上限 | ✅ 已改称「乐观(上限)外插」 |
| 6 | 已确认无误 | BOM/CP936/missing closing quote 因果链成立(审计复现 5 处报错 + C4819(936)) |
✅ 措辞已按 cl.exe 与「字节对齐依赖」修正 |
| 7 | 已确认无误 | 无仓库残留(_bomtest_* 已删、GPU 已释放) |
✅ 建议清理 nvcc 的 .lib/.exp 副产物 |
| 8 | 已确认无误 | 本轮无引擎改动 ⇒ 未触碰保序纪律 | --- |
审计的总结论 :工具与方法合格且可复现 ;对第二步的方向性修正站得住 , 但表格口径(2×)、收益区间(192/256)、两处命名/机制措辞必须订正后才可对外引用 ------均已订正。
六、串起来的教训
三步走下来,真正的收获不是「空档占 12.3%」这个数字,而是三版测量的口径差异本身。
6.1 三版测量的口径差异:事件计时为什么看不见空档
| 版本 | 仪器 | 量的是什么 | 空档结论 |
|---|---|---|---|
| 第一版 | 逐相 cudaEvent |
相邻两个标记之间的区间 | 0.2%(错) |
| 第二版 | nsys cuda_gpu_trace |
每个核的起止时间戳 | 12.3% |
| 第三版 | stream − graph 微基准 | 拉发射路径的差 | 不是常数 |
第一版的盲区是结构性的:它量的是「相与相之间」的边界,而空档长在相的里面 ------ 子层里有 6--8 个核、MLP 里有 5 个核,它们的间隙被摊进了两侧的相时间 。 gap = 层循环 − Σ4 相 只覆盖 63 个层间边界 ,能看见的只是这 63 个缝, 真正的 1168 个缝全被平均掉了。要看见核与核之间的缝,探针的粒度必须细到「每个核」 , 而事件标记自身还要 ~2 µs(0.708 ms ÷ 323 = 2.2 µs/标记),比它要量的缝还大。 ⇒ 这就是为什么必须换 nsys:它用的是 GPU 硬件时间戳,不占发射路径。
6.2 「每次发射的空档」这条结论
把三版接起来,得到一条可引用的表述:
每次发射的空档 = 主机侧 CPU ≈5 µs/次 与 设备地板 ≈1.1 µs/次,取大的那个。 短核走主机那一支(~5 µs),长核走设备那一支(~1.1 µs); 模型实测的平均 2.05 µs 是两者的混合。
这条结论对「怎么改」有直接约束:
- 融小核 能省发次数(192 次 ⇒ 0.39--0.86 ms),但只对短核那一支有效(把发射压到设备地板);
- CUDA Graph 消掉的是主机侧那 5 µs (外加一笔被藏住的 5.7 ms/token CPU 时间), 它消不掉设备地板 ------所以可拿回量只有
1168 × (2.05 − 0.53) ≈ 1.8 ms,且 0.53 还是下界; - ① 与 ② 不可相加:如果为了提并行度去增加 block/发射数,空档反而会上升。
而第二版那笔「你以为是瓶颈」的账------k_gdn_delta 的 2.65 ms------被 ncu 钉成并行度饥饿 (DRAM 3.04%、SM 1.19%、0.02 waves/SM),降字节数没用。这是三步里最硬的一条独立证据。
6.3 graph 不是万能的
第 15 篇说「CUDA Graph 是零数值风险的纯加速」,那是真的------但只在 launch-bound 的场景下 。 第 22 篇又记了一个反例:位级档里 graph 是负收益(9.90 → 8.45 tok/s,0.85×), 因为 101 ms/tok 已计算受限,图捕获反而摊上 ~870 节点的重放开销。
本篇补上第三条边界:即使在 launch-bound 的场景,graph 也只能消掉主机那一半。 判别实验给出的两个机制里,设备地板(≈1.1 µs/次)是与发射路径无关的 , 换成 graph 依然存在,且每节点成本随 grid 上升(grid=4096 时 2454 ns,比 grid=1 的 525 ns 高 4.7×)。 ⇒ graph 的收益有下限,别拿 grid=1 的 0.57 µs 当「地板」。
CPU 对照(迁移前基线)
- CPU 参考:CPU 侧无对应实现(测量方法学)------ 「发射空档」是 GPU 内核发射时序概念,CPU 侧不存在同构物。
- 迁移要点 :无迁移(纯测量):逐相
cudaEvent→ nsys 逐核 → stream−graph 判别实验;结论「每次发射空档 = 主机≈5µs 与设备地板≈1.1µs 取大者」;ncu 证k_gdn_delta并行度饥饿(DRAM 3.04%)。 - 真机验证 :命中 E15 :逐相
cudaEvent计时step=49.309 ms、layer-loop=48.859、4ph-sum=48.821 gap=0.038 0.1%、tail=0.450(首步冷缓存 口径)。nsys/ncu 逐核计数(含subst绕非 UTF-8 cwd)仍需专用环境。
附录:原始数据表
表 A1 · ★ 发射空档:每 token 2.40 ms(12.3%)(原第 4.3 节)
| 步 | GPU 时间线跨度 | 核时(busy) | 空档(idle) | idle% |
|---|---|---|---|---|
| #0(首算,冷) | 19.956 ms | 16.909 ms | 3.047 ms | 15.3% |
| #1(预热) | 18.957 ms | 16.587 ms | 2.371 ms | 12.5% |
| #2(计时步) | 19.433 ms | 17.034 ms | 2.399 ms | 12.3% |
| 审计复跑(7 步均值) | 18.86--19.39 | 16.36--16.99 | 2.38--2.57 | 12.4--13.4% |
七、小结
- 「这个仪器量的是什么」先于任何结论:同一件事,事件探针量出 0.2%、nsys 量出 12.3%、 判别实验量出「一半是主机、一半是设备」------三个数都对,只是各自在回答不同的问题。
- 测量粒度要匹配要量的对象:事件探针的标记自身 ~2 µs,比它要量的缝还大; 要看见核与核之间的空档,必须换用 GPU 硬件时间戳的仪器。
- ncu 的 DRAM%/SM%/waves 把「延迟瓶颈」钉死 :
k_gdn_delta只用 3% DRAM、0.02 waves/SM ⇒ 是并行度饥饿,降字节数没用------这是三步里最硬的一条独立证据。 - 「定义闭合」不是「独立校验」:把非投影的定义代进求和,合计必然等于「实测四相和 + 预测 lm_head」; 真正的独立对账只有一条(预测 lm_head 0.454 vs 实测 tail 0.453)。
- 收益只到「乐观(上限)外插」为止 :graph ≈1.8 ms、融小核 0.39--0.86 ms, 都是方向或上限,未在模型路径实测就不得当收益引用。
- ① 与 ② 不可相加:增并行度往往增加发射次数,会与省发射的部分对冲------净效应未知。
相关篇目:第 25 篇、第 27 篇 源码与配套资源:本仓库 gitee.com/pei-xiaogua...; CPU 推理源码 gitee.com/pei-xiaogua...