通算融合算子(六):从慢 38% 到快 20%——归因方法、调优史与踩坑大全

前五篇教你把融合算子写对。这一篇教你把它调快——以及更重要的: 每次变慢/出错时,怎么在没有 profiler 的条件下快速定位原因。素材是我们 七轮真实迭代的完整记录:2363→2264µs 之前,先经历了 3613µs 的惨败。

1. 先立规矩:公平口径

对比自研算子和基线,最容易骗自己的地方是计时口径:

  • 调度元数据要计入。我们的调度表每轮在 GPU 上重建(约 250µs), 基线(vLLM)每轮也在做等价的路由整理——两边都算或都不算。我们曾有 版本漏计了这 200µs,“1.48ד实为 1.34×;
  • 多档位报数。expert 数 × token 数至少各扫两档,单点结论会误导 (我们有优化在一档赚 470µs、另一档亏的先例);
  • 波动档位看 min 和重复一致性(共享机器有环境干扰),稳定档看中位数。

2. 七轮迭代史:数字先行

轮次e2e (NE=64/T=512)发生了什么
1环境类 bug 整轮报废(见 §5 踩坑表)
23613(比基线慢 38%)首次跑通。comm SM 从 4→24 扫出 1950µs 改善且不收敛 → 排队串行嫌疑
3~4又两轮环境/并发 bug(踩坑表 #2、#4)
52363(快 1.11×)pull_order(到达序=消费序)+ dispenser(全员收尾)兑现 −35%
62301comm SM 拐点确认 =24;RB=64 让 NE=256 档翻盘(−477µs)
72264(快 1.16×;T=1024 档 1.20×)调度重建瘦身;双峰异常定位为机器环境干扰

关键转折是第 2→5 轮的 1250µs。它不是靠试出来的,是靠三条账算出来的。

3. 三条账:没有 profiler 也能归因

第 2 轮拿到”慢 38%“之后,我们没有开 ncu,用三条纸面账完成了归因:

**账 1:资源 sweep 的形状。**把通信 SM 数从 4 扫到 24:e2e 改善 1950µs 且未收敛,同时”从 GEMM 抢走 20 个 SM 反而更快”。这一条直接排除两个假设 (带宽瓶颈、GEMM 瓶颈),指向第三个:通信 block 在排队串行。 sweep 曲线的方向和收敛性本身就是证据。

**账 2:波数账。**嫌疑确定后闭式验证:当时 layer1 给每个归约 job 开一个 block,2048 个 97KB-smem 的 block 在 k 个 SM 上要排 2048/k 波。 k=4 与 k=24 的波数差 × 每块几微秒的调度开销 ≈ 1700µs——和观测差值对上。 对不上就换假设,对上了才动手改。

**账 3:规模斜率。**绝对差随 token 数线性增长(~0.45µs/job)——与 “每 job 固定串行成本”的解释一致,排除一次性开销类假设。

三条账交叉指向同一个结论,才动手写修复(就绪序 dispenser + 全员收尾 + 到达序拉取)。第 5 轮一次兑现。先归因后动刀,每一刀都有预期值—— 预期不中本身又是新线索(见 §4 的 push 故事)。

4. 分阶段归因工具:把 e2e 拆开对数

融合 kernel 的 e2e 是一锅粥,必须有工具拆开。做法很朴素:多卡锁步逐段计时

  • 给每个融合段配一个”纯 GEMM 对照”
== 分阶段归因 (NE=64/T=512, 通信 SM=24, 单位 µs) ==
  sched(调度重建)      277     ← 最大的单项可压缩项(12%)!
  L0_fused            1231     L0_gemm_alone    977   → L0 暴露 254
  silu                 109
  L1_fused             695     L1_gemm_alone    509   → L1 暴露 186
  final_reduce          21

融合段 − GEMM-alone = 这一层暴露的通信/协议成本。这张表当场回答了 四个问题:GEMM 本体效率如何(1486µs,~210 TFLOP/s,比我们的纸面预估快 40%——之前的悲观预期全部来自低估了自己的 GEMM);两层各暴露多少; 通信 SM 8→24 的收益来自哪层(两层均分);下一刀应该切哪(sched, 不是任何一层 kernel)。

5. 一个被数据推翻的”正确”设计:push 化的故事

平台篇(第二篇)测得 push 带宽是 pull 的两倍还省 SM,于是我们理直气壮地 把 dispatch 从 pull 改成 push(生产者推 + chunk 水位,协议完全正确)。 实测:push 2511µs > pull 2363µs

复盘:TP 的 AllGather 只有 12.6MB,即使按 pull 的弱路径带宽也只要 ~540µs,完全藏在 1.5ms 的 GEMM 下面——通信根本不在关键路径上, 数据面的强弱无从兑现;而 push 方案为了正确性引入的本地 staging 中转 反而多付了一跳。分阶段表证实:push 与 pull 的差全部在 L0 暴露 (319 vs 254µs),其余分毫不差。

教训浓缩成一句:微基准的带宽结论只在该资源位于关键路径时才值钱; 方案选型先判 bound 类型(这卷是 GEMM-bound 还是 comm-bound), 再谈数据面选型。push 代码保留在开关后面冻结——等 shape 变成 comm-bound 的那天再解冻。

6. 踩坑速查表

每一条都真实消耗过我们至少半轮实验(一轮 = 一次上机往返):

#症状规则
1worker 里 torch.set_default_device(cuda) 劫持所有无显式 device 的张量创建host 侧索引表建到 GPU 上:报 device 混用,或 python 循环变成 1.6 万次单元素 GPU 写host 表每个创建写 device="cpu";用 TorchFunctionMode 守卫做成机器检查——我们写下纪律的当轮就再犯了,靠自觉不行
2跨卡原子丢增量(PCIe)计数器永远不满,自旋挂死;弱压力探针测不出完成通知只用”本地原子选举 + 单写者 st.release.sys”(第四篇)
3group::store 的 warpgroup 行置换结果”几乎对”,少数行错位计算侧加载与存储用同一置换公式;改 warp 数必重推
4__syncthreads 与 GEMM 内部 named barrier 混计数illegal instruction,一切静态检查无恙persistent block 换角色前盘点两侧 barrier id;汇合用空闲 id
5到达序 ≠ 消费序细粒度信号形同虚设,GEMM 停在整个通信后面通信按消费序排(pull_order);“就近优先”直觉在混源布局下失效
6block-per-jobe2e 对通信 SM 数极端敏感、随规模线性变差常驻块 + 原子 dispenser + 就绪序 + 全员收尾
7自旋 block 占满 SM整卡死锁自旋块数 < SM 数;等待的信号源必须常驻
8布尔 flag + 复位多轮迭代偶发错:快卡污染慢卡的上一轮单调序号,永不复位(第四篇)
9Windows 开发 → Linux 执行的 CRLFbash 第一行就报 $'\r': command not found.gitattributes 强制 *.sh/*.py eol=lf(机器检查)
10build 缓存按文件名判存在改了 .cu 不重编,跑出”幽灵结果”测试脚本开头清 build 目录

规律:#1 和 #9 的解法都不是”下次注意”,而是把纪律变成机器检查 (预检脚本、gitattributes、TorchFunctionMode 守卫)。人会在写下规则的 同一天违反规则——我们亲测。

7. 远程迭代的工程实践(如果你的开发机没有 GPU)

我们的开发循环是”本地写码 → 一键脚本拿到 GPU 机器跑 → 结果 zip 拿回来 分析”,每轮往返成本很高。让单轮信息量最大化的清单:

  1. 每步独立 timeout(协议 bug 常表现为自旋挂死,没有超时就全场空手);
  2. 失败不中断后续步骤,traceback 完整进日志(静默吞异常是大忌);
  3. 产物自带环境快照(拓扑、时钟、git rev)——数字才可解释;
  4. 顺手多带一组正交 sweep(我们的 comm SM sweep 本是顺手加的, 后来成了归因的决定性证据);
  5. 本地能验的先验完(表裁决、CPU 语义模拟),上机后出错直接排除两类原因;
  6. 预期账先写进文档,回流后逐项对数——偏差本身就是新线索。

8. 收官

七轮迭代的最终成绩(对未调优的 vLLM triton 基线,公平口径):

NE=64/T=512   2264µs  vs 2634  = 1.16×
T=1024        4199µs  vs 5021  = 1.20×
NE=128        2974µs  vs 3288  = 1.11×
NE=256(RB64)  4914µs  vs 5288  = 1.07×

比数字更有复用价值的是路径:算账立项(一)→ 平台事实(二)→ 可挂钩的 GEMM 骨架(三)→ 单写者协议(四)→ 表驱动的两个 kernel(五)→ 账驱动的迭代(六)。换一种并行模式、换一代硬件,数字全会变, 这条路径不变。

(全系列完。代码见 moe_bench 仓库 kernels/tk/tk_tp_scheme.py, 每轮迭代的原始记录在 docs/19~27。)