Kestrel_VLLM - 26 · 发射空档三步曲:事件计时 → nsys → 判别实验

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。 三个数 :nsys cuda_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%」, 第三次才回答「这个空档到底是什么」。

它是本系列方法论的集中体现:每一步都要问「这个测量在量什么」。

状态:✅ 已落地(并两次订正)。

一、问题与结论

  1. 三版测量的口径不同,结论也不同,且后一版推翻了前一版 :
    • 第一版(逐相 CUDA 事件计时):空隙只占 0.2%(0.037 ms/token)------错在口径太窄。
    • 第二版(nsys 逐核 profile):发射空档 2.40 ms/token = 12.3%------第一版的结论被推翻。
    • 第三版(判别实验):「每次发射 ~2 µs 固定成本」不成立 ------空档与 grid / block / smem / 参数个数无关,但与内核时长强相关。
  2. 第一版为什么看不见空档 :它量的是「相邻两个 event 标记之间的区间」, 而空档被摊进了两侧的相时间里 ------gap = 层循环 − Σ4 相 只覆盖 63 个层间边界 , 0.037 ms 是这条窄口径的上界,不是全 token 的空档总量。
  3. 空档的两个机制,取大的那个 :主机侧 CPU 发射成本 ≈5 µs/次 (短核时是瓶颈); 设备侧 kernel 间地板 ≈1.1 µs/次 (长核时主机早已领先)。模型实测的平均 2.05 µs 是两者混合。
  4. 最硬的一条 :ncu 显示 k_gdn_delta 只用 DRAM 3.04% / SM 1.19% / 0.02 waves/SM ⇒ GDN 非投影是延迟 / 并行度 瓶颈,不是带宽瓶颈。降字节数没用。
  5. graph 不是万能的 :它能把「主机侧 5 µs」那一半消掉,但消不掉设备地板那一半 。 每节点成本还随 grid 上升(grid=1 → 525 ns,grid=4096 → 2454 ns)。
  6. 可拿回的时间只是「乐观(上限)外插」 :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 空闲的窗口。

换句话说,「发射空档」是关键路径上的一段负空间 :它既不属于任何一个核, 也不属于任何一次往返。要量它,必须换一套能看见时间线的仪器。

本篇就是这条线的三步走------而且三步的结论互相打架:

  1. 设备侧 cudaEvent 分段计时(逐相)→ 空隙 0.2%;
  2. Nsight Systems 逐核 profile(逐核起止)→ 空档 12.3%;
  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):

  1. 相对 -OutDir 原先在 Test-Path/New-Item 时按 cwd 解析, 而编译器在 Push-Location $root 之后按 ** root∗∗解析⇒从别的cwd调用时会「目录建错地方」;已把'Push−Locationroot** 解析 ⇒ 从别的 cwd 调用时会「目录建错地方」; 已把 `Push-Location root∗∗解析⇒从别的cwd调用时会「目录建错地方」;已把'Push−Locationroot` 提到 OutDir 解析之前,三处基址统一。
  2. -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 裁决:上一轮的三条推断

  1. 推断 #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 因此 不是发射间隙问题(这条也排除了一个替代解释)。

  2. 推断 #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. 推断 #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 造出来的」有两条独立论证(第一条是审计建议补的、更硬):

  1. 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 硬件时间戳,追踪开销加在主机发射路径上,不会拉长内核)。
  2. 跨度 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 阻断项,必须遵守的三条:

  1. ① 与 ② 不可直接相加 :若 ① 靠增加 block/发射数 实现(沿 vd 切、每 head 多块), 空档会上升 ,与 ② 部分抵消。净效应未知。
  2. ① 的「沿 K 拆」不是位级安全的 :Σ S·k / Σ S·q 是归约 , 沿 K 切会改归约序 ⇒ 触碰本系列的保序纪律 ,必须走位级/容差门禁。 数值安全的方向是沿 vd(输出列,逐列独立)切更多 block ------ 但它不缩短 每线程 128 步的串行链 ⇒ 收益性质与「降延迟」不同。上一版写「不改数学」是错的。
  3. 「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)。

⇒ 正确结论:发射空档有两个上限,取大的那个

  1. 主机侧 CPU 发射成本 ≈ 5 µs/次 (与 grid/block/smem/参数都无关)------短核时它是瓶颈;
  2. 设备侧 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 对结论与路线图的更新

  1. 第 4.3 节的「每次发射 ~2 µs」改为「2.05 µs 是两种机制的合成,见第 5.2 节」 ; 「与内核种类无关」这个说法被判别实验证伪 (与时长强相关,与 grid/block/smem/参数无关)。
  2. ② 的收益从「上限 2.4 ms」改为「≈1.8 ms(9.3%)=乐观(上限)外插」 ; 对应的实现方式不变 :把模型路径搬到非默认流并捕成 graph。
  3. 顺带解释了主机侧那笔账 :CPU 发射成本实测 ≈5 µs/次 (第 5.2 节的 host(CPU) 列), 1138 次 ⇒ ≈5.7 ms/token 的 CPU 时间 ,现在被 GPU 的 16.6 ms 藏住了 (所以它不在关键路径上);但它是个隐患------一旦内核变快,主机就会浮上来。 ⚠ 注意 WDDM 下主机不能任意跑前 (队列/批量提交有深度限制), 所以「藏住」是平均值意义 上的,不是无条件的。graph 同时消掉这笔账。
  4. 融合小核的账(发射数统一为 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%

七、小结

  1. 「这个仪器量的是什么」先于任何结论:同一件事,事件探针量出 0.2%、nsys 量出 12.3%、 判别实验量出「一半是主机、一半是设备」------三个数都对,只是各自在回答不同的问题。
  2. 测量粒度要匹配要量的对象:事件探针的标记自身 ~2 µs,比它要量的缝还大; 要看见核与核之间的空档,必须换用 GPU 硬件时间戳的仪器。
  3. ncu 的 DRAM%/SM%/waves 把「延迟瓶颈」钉死 :k_gdn_delta 只用 3% DRAM、0.02 waves/SM ⇒ 是并行度饥饿,降字节数没用------这是三步里最硬的一条独立证据。
  4. 「定义闭合」不是「独立校验」:把非投影的定义代进求和,合计必然等于「实测四相和 + 预测 lm_head」; 真正的独立对账只有一条(预测 lm_head 0.454 vs 实测 tail 0.453)。
  5. 收益只到「乐观(上限)外插」为止 :graph ≈1.8 ms、融小核 0.39--0.86 ms, 都是方向或上限,未在模型路径实测就不得当收益引用。
  6. ① 与 ② 不可相加:增并行度往往增加发射次数,会与省发射的部分对冲------净效应未知。

相关篇目:第 25 篇、第 27 篇 源码与配套资源:本仓库 gitee.com/pei-xiaogua...; CPU 推理源码 gitee.com/pei-xiaogua...

相关推荐
echome8881 小时前
JavaScript async/await 错误处理:别让 Promise 静默失败
开发语言·前端·javascript·promise
PedroQue991 小时前
v0.2.0 架构升级:class + reactive 重塑状态管理
前端·uni-app
鶴哥只手遮天1 小时前
Angular 22 源码级深度解析-第 02 章 编译链路总览:从模板字符串到 `ɵɵ` 指令
前端·开源·angular.js
尘世中一位迷途小书童1 小时前
别再堆万字 Prompt:用 Agent Skills 给团队写一个「前端 Review」Skill
前端·ai编程·cursor
aixingpan1 小时前
aixingpan.cn API开发文档:api_docs_bichart_common2接口指南
前端·php
super_Omni1 小时前
text/PDF/Word → 解析成纯文本 → 分词 → 词表匹配 → 加权打分 → 低于阈值
前端·chrome
阶进遐1 小时前
浏览器工具自制指南·对话导出(3):消息池设计
前端
SameX1 小时前
HarmonyOS 用 relationalStore 做阅读统计 App —— 5 张表背后的 5 个 schema 决策
前端
LV技术派2 小时前
【成为 AI Native Builder · 02】什么是 AI Native Builder:一个人 + AI,交付完整闭环
前端·程序员·ai编程