triton-mfu-analyzer
在昇腾 NPU 上测量单算子的 MFU(Model FLOPs Utilization,官方 operator_mfu 等价口径), 并按 kernel 拆解瓶颈,回答"低是低在哪一段、还有没有得优化"。 覆盖目录适配、极限 MFU 的 (case,倍率) 二维搜索、flops.py 编写与自检、按 dtype 修正峰值、kernel 级 MFU 拆解。 触发:当用户提到"测 MFU"、"算力利用
SKILL.mdops/triton-mfu-analyzer/SKILL.md
GitHub单算子 MFU 测量与瓶颈拆解
在真实 NPU 上测出算子的 MFU,并回答"低是低在哪一段、还有没有得优化"。
mfu = 理论 FLOPs / kernel 实际耗时 / 芯片峰值算力
口径来自昇腾 msprof-analyze 的 operator_mfu
(docs/zh/advanced_features/operator_mfu_instruct.md):
actual_tflops = FLOPs / (kernelDuration(ns) × 1e-9) / 1e12
mfu = FLOPs / (kernelDuration(ns) × 1e-9) / chipPeakFLOPS
官方那套要走 torch profiler(with_flops=True + mstx + Db 导出),由框架按注册公式
自动算 FLOPs。单算子 benchmark 场景没有这些,所以本 skill 用 ops-profiling 的
--quick --mfu 重建同一个公式:分子来自你写的 flops.py,分母来自 msprof 的
task_time.csv,峰值从 cube 规格推。
与
ops-profiling的关系(重要)本 skill 的采集脚本
scripts/ops_profiling/msprof_perf_summary.py是ops/ops-profiling/scripts/msprof_perf_summary.py的扩展副本,在其基础上增加了--mfu/--mfu-top-k/--mfu-peak/--impl等参数与 MFU 统计逻辑。 上游那份目前不支持--mfu(典型报错:unrecognized arguments: --mfu --impl), 因此本 skill 暂时自带副本以保证可独立运行。后续计划:待
--mfu能力在ops-profiling侧稳定后,将该副本合入上游, 本 skill 改为直接引用,避免长期维护分叉。修改采集逻辑时请同步评估两处影响。下文用
$SKILL_DIR指代本 skill 目录(ops/triton-mfu-analyzer)。
六步流程
只要目标是极限 MFU / 算力上界,第 2 步不能跳过: 数据集自带的是正确性用例(常见
[2,3,7,16]),在那种 shape 上测出的是 kernel 启动开销, 且数据全在 cache 里,访存墙根本不显形。
1. 适配目录布局
三个输入有两种布局,先判断当前是哪一种,这是最容易搞错的一步。
判据不要靠文件名,靠内容:
参考实现 = 含 class Model 的 .py,被测实现 = 含 class ModelNew 的 .py,
用例 = 每行含 inputs 列表的 .json/.jsonl。两种布局用同一套判据都能定位。
布局 A(默认):三个文件在同一个 verify 目录
批跑流程的 verify 目录是最常见的形态:
<算子目录>/op_<Op>_<时间戳>/ # 一次生成任务的产物根目录
<Op>.py ← torch 参考实现(class Model + get_input_groups)
<Op>.json ← 用例
<Op>_generated.py ← 生成的 triton 实现
output/iter_<n>/verify/
<Op>_torch.py ← torch 参考实现(与外层 <Op>.py 同)
<Op>_torch.json ← 用例
<Op>_triton_ascend_impl.py ← triton 实现
(也可能是 <Op>_triton_baseline.py / <Op>_triton_optimized.py 两份)
verify_result*.json ← 精度校验产物,不是用例
torch 实现在两处都有(外层 <Op>.py 与 verify 里的 <Op>_torch.py),内容相同,
取任一份即可;用例同理。triton 实现的后缀不固定:_triton_ascend_impl、
_generated、_triton_baseline/_triton_optimized 都出现过。
V=<算子目录>/output/<iter>/verify
python3 $SKILL_DIR/scripts/prepare_op_dir.py \
--ref $V/<Op>_torch.py \
--cases $V/<Op>_torch.json \
--impl $V/<Op>_triton_optimized.py \
--dst <工作目录>/<Op>
同一目录常有多份 _triton*.py,都含 class ModelNew(如 _baseline 与 _optimized)。
默认取
_optimized那一份。 这是被测的目标实现,_baseline只是对照。 只有一份时(_triton_ascend_impl、_generated等其它后缀)直接用那一份。 需要出优化前后对比时才两份各跑一遍,且分别记录成两行,不要混在一起。
选择顺序:*_triton_optimized.py > *_triton*.py(唯一时)> 其它 *_generated*.py 等变体。
布局 B:参考实现与生成代码分属两个仓
有的批次把参考实现留在 benchmark 仓、只把生成代码归档到另一个目录:
BENCH=<benchmark仓>/npu_benchmark/level4 # <N>_<Op>.py + <N>_<Op>.json
GEN=<生成代码归档目录>/Level4 # <N>_<Op>_<后缀>.py
python3 $SKILL_DIR/scripts/prepare_op_dir.py \
--ref $BENCH/<N>_<Op>.py \
--cases $BENCH/<N>_<Op>.json \
--impl $GEN/<N>_<Op>_<后缀>.py \
--dst <工作目录>/<N>_<Op>
配对规则(按编号前缀 <N>_ 匹配,某次 Level4 全量统计):
- 生成侧后缀不固定,实测 6 种:
_generated(53)、_triton_optimized(35)、_generated_code(8)、_triton_ascend_impl(2)、_optimized_code(2)、_code(1)。 用 glob<N>_*.py取即可——该批次同一编号只有一份实现。 - 参考侧同一编号可能有多份(
_old、_<hash>等变体)。 取有同名.json的那一份,这条规则能唯一确定。 - 两侧编号不是全集:benchmark 有而生成侧没有实现的编号需跳过。
- 生成代码仓按批次滚动,同一算子在不同批次下后缀可能不同。路径要跟当前批次走。
同级目录里的
.json不都是用例:verify_result*.json(精度校验)、perf_result*.json(性能结果)都是流程产物。max_shape_probe.py放大 shape 时会写入所有用例文件(见坑 7), 判错会直接覆盖掉这些结果。 判定用两道:先按文件名排除产物(EXCLUDE_PATTERNS), 再按内容确认(每行是含inputs列表的对象)。 只靠内容不稳——产物结构会变;只靠文件名也不够——下个批次多出新产物就漏了。
落地布局:源文件名 → 采集脚本要求的文件名
采集脚本 msprof_perf_summary.py 硬编码了三个文件名,prepare_op_dir.py 负责搬运改名。
对应关系按源文件名记:
| 源文件(verify 目录 / benchmark 仓) | 落地为 | 里面必须有 |
|---|---|---|
<Op>_torch.py |
model.py |
class Model + get_input_groups() |
<Op>_triton_optimized.py(多份时取 optimized) |
model_new.py |
class ModelNew |
<Op>_torch.json |
<Op>_perf_cases.jsonl 和 model.json |
每行一个含 inputs 的 case |
用例要落两份是因为:采集脚本优先读 *_perf_cases.jsonl,而参考实现改名成 model.py 之后,
它内部 os.path.splitext(__file__)[0] + ".json" 派生出的路径会变成 model.json。
少写任何一份都会静默拿到错数据(见 pitfalls 坑 3 与坑 7)。
prepare_op_dir.py 还会校验类名、补齐硬编码的用例路径。原目录不动。
2. 测定极限 MFU 的 (case, 倍率) 点(求极限 MFU 时必做)
python3 $SKILL_DIR/scripts/max_shape_probe.py \
--dir <工作目录>/<Op> \
--flops-tool $SKILL_DIR/scripts/check_flops.py \
--max-bytes 10e9 --max-ms 4000
把原始 case 放大后选出同时通过五道闸的配置,写回目录供后续采集直接使用。
放大哪些维度需要逐算子判断,不要交给脚本写死。 主要放大 seq_len 和 batch:seq 让 attention 的 FLOPs 二次增长而显存只线性增长, 是提 MFU 的主要杠杆;batch 提并行度。 等比放大所有维度则会让体积按
s^ndim爆炸(4D 张量 s=2 即 16 倍显存)。判断依据:同一符号的
(张量名, 轴)位置在所有 case 上取值必然相同, 按此分组可还原出 batch / seq_q / seq_k / heads / head_dim 的语义结构。 注意排除"每个张量的末轴"(多为 head_dim),它的取值范围常比 seq 还大。 具体分组实例与档位建议见$KB_ROOT/ops/triton/guides/tooling/mfu_max_shape_search.md。
极限 MFU ≠ 最大 shape 下的 MFU。 MFU 对 shape 不单调——小 shape 时数据在 cache 里, 放大后被打回 HBM,实测约 2/3 的算子放大后 MFU 反而下降。 极限 MFU 必须在 (case, 倍率) 二维候选空间里取最大值: 倍率维要含
s=1,case 维要含原始 top-3(不同 case 可能走不同代码路径, 实测 55_OutlookAttention 的 case11 只跑一个 kernel、MFU 高出 8 倍)。成本控制分两级:初筛用墙钟遍历候选池(按 FLOPs 降序取前 8 个原始 case
- 它们的合法放大变体,不开 msprof,只用于排序),精测只对前 3 名采 PMU。 最终数字一律取自精测的 kernel 时间。 取前 8 的依据、墙钟开销的实测标定见
$KB_ROOT/ops/triton/guides/tooling/mfu_max_shape_search.md。
五道闸(按代价从低到高,缺一不可):
| 闸 | 判据 | 漏掉的后果 |
|---|---|---|
| 1 输入预算 | 总字节 ≤ --max-bytes |
OOM |
| 2 参考实现接受 | class Model 可追踪且 FLOPs>0 |
shape 非法、分子算不出(实测 26/94 踩中) |
| 3 被测实现跑通 | ModelNew 在卡上不报错 |
— |
| 4 单次耗时 | ≤ --max-ms |
PMU 采集不现实 |
| 5 有效带宽 ≤ HBM | 输入字节/耗时 ≤ 1.3TB/s | 缩放静默未生效(实测 55/89 踩中) |
闸 2 和闸 5 是重点。"被测实现能跑"不是合法性判据——参考实现拒绝的 shape 语义上就不成立;闸 5 则是兜底,能挡住所有没预料到的失效方式。
放大不必然让 MFU 变高:实测 62 个算子里约 2/3 下降,因为小 shape 时数据在 cache 里,
放大后被打回 HBM,aic_mte2 成为主要的墙(多个达 0.9+)。这恰恰是真实负载下的样子。
完整方法(二维搜索、五道闸、两阶段、6 个坑)见 $KB_ROOT/ops/triton/guides/tooling/mfu_max_shape_search.md。
分子取语义值:
min(参考实现追踪值, 硬件实测值)。 两个可测分子的偏差方向相反——参考实现用稠密表达稀疏语义时偏大, kernel 有 tiling 补齐 / mask 丢弃时偏大——取较小者才接近 Model FLOPs。mfu_grid_search.py已自动执行该规则并在结果里记录num_src与两种口径的值。 详见$KB_ROOT/ops/triton/guides/tooling/mfu_pitfalls.md「分子该取哪个」。
3. 写 flops.py
放进工作目录,暴露 flops(case) -> float。这是整条链路上唯一没有硬件校验的环节:
写错不会报错,只会得到一个看起来很合理的数字。
分子来自 torch 参考实现,不是 triton 实现。 FLOPs 追踪的对象始终是含
class Model的 torch 文件(源目录里是<Op>_torch.py或<Op>.py), 它定义了算子的语义;triton 实现(<Op>_triton*.py/<Op>_generated.py) 只提供分母(kernel 耗时),不参与分子计算。源目录里没有
model.py—— 那是prepare_op_dir.py落地后的改名结果。 下文凡出现model.py处,指的都是工作目录里已落地的那份,对应源文件是<Op>_torch.py。
动手前先读 torch 参考实现的 forward——算子名字经常只描述最显眼的一段
(实测例:20_FlashAttentionV2 里 4 个投影占 94.7% 的 FLOPs,只算 attention
会让分子小 18 倍)。
想做 kernel 级拆解就再加 flops_parts(case) -> dict 和 KERNEL_PARTS。
可选:先生成初稿再改。 生成器按 AST 特征把算子分到 attention / GQA / sparse / paged / window / FFN / matmul 等类别,套用对应模板:
python3 $SKILL_DIR/scripts/flops_generator.py \
--model <工作目录>/model.py \
--cases <工作目录>/<OpName>.json \
-o <工作目录>/flops.py
生成结果必须人工 review——模板只覆盖常见形态,稀疏/变长/rope 的边界要自己核对。
写完必须自检:
python3 $SKILL_DIR/scripts/check_flops.py --op-dir <工作目录>
校验 flops_parts 之和、FLOPs 是否为 0 或超峰值、actual_seq_lengths 与 shape
是否自洽,并 monkey-patch torch.matmul/F.linear 跑一遍参考实现做独立对拍。
tracer 比值 ≈1.0 说明分子写对了;≈2.0 通常是漏了 causal 缩减;<0.5 多半漏算了投影。
→ 口径、模板、自检办法见 $KB_ROOT/ops/triton/guides/tooling/mfu_flops_guide.md
→ 完整实例见 examples/flops_mha_block.py
4. 采集
cd <工作目录>
python3 $SKILL_DIR/scripts/ops_profiling/msprof_perf_summary.py \
--quick --mfu --impl asc --output-dir . \
--warmup 5 --repeats 5 --keep-prof .
不指定 case 时自动按理论 FLOPs 降序只测 top-3 大 shape,这是默认也是推荐做法: MFU 随规模饱和,最大几个 shape 就代表峰值水平,小 case 只是白花采集时间。
50 case 全量实测验证过这条:尾部 10 个小 shape(S≤23)MFU 全部 ≤0.2%, 而自动选出的 top-3 在标称和 dtype 修正两种口径下都命中了全局最大值。
--impl asc只跑被测实现,省掉参考实现的采集时间(不需要加速比时用)--keep-prof必须加,否则第 5 步没有数据可拆--warmup/--repeats是被采集进程内的迭代数,解析时丢弃 warmup 份--mfu-top-k N改数量;--cases 3,7,9显式指定;--max-cases <N≥用例数>跑全量(50 case 约 1 小时,仅在需要完整分布时才做)
耗时参考:top-3 × (warmup 5 + repeats 5) 约 4 分钟。
5. 标定各 dtype 的算力上限
python3 $SKILL_DIR/scripts/peak_calib.py -o peak.json
ops-profiling 推的峰值与 dtype 无关,实际是 fp16/bf16 峰值。fp32 case 直接用它 会低估约 4 倍(实测 910B2C:fp16/bf16 ~314-320,fp32 ~85 TFLOPS)。
该脚本运行时会打印大量 CANN HCCL 的
tiling struct ... is conflict警告, 与 GEMM 峰值测量无关,可忽略;最终结果表格在输出末尾。
6. 拆解与修正
python3 $SKILL_DIR/scripts/mfu_decompose.py --op-dir . --peak-json peak.json
不需要重复指定 case、warmup/repeats 或 PROF 路径——这些全部从第 3 步产出的
performance.json 里读:采集测了哪三个 case,这里就拆哪三个。
输出每个 case 的:总 MFU(标称口径 / dtype 修正口径)、每个 kernel 段的耗时与
各自的 MFU,并给出最大值。明细落到 mfu_decompose.json。
--cases 46 可以只看其中几个;--report <另一份.json> 可以拆历史轮次。
输出怎么读
拿到一张这样的表(实测 20_FlashAttentionV2):
case dtype total us TFLOPS MFU@nom MFU@ach attn us attn MFU proj us proj MFU
46 float16 271.8 58.6 16.57% 18.7% 166.9 1.6% 104.9 45.8%
48 float32 588.7 36.0 10.18% 42.5% 197.7 7.7% 391.0 60.1%
三步判断:
- 先看 dtype 与
MFU@ach。MFU@nom只在同 dtype 内可比。 case 48 从 10.18% 变 42.5%,结论完全反转。 - 再看哪一段拖后腿。 case 46 的投影 45.8%(正常 GEMM 水平), attention 1.6%——差距全在 attention。同时注意理论 FLOPs 占比与耗时占比的错配: attention 只占 5.3% 的 FLOPs 却吃掉 61% 的时间。
- 最后判断瓶颈类型。 比同一 kernel 在 fp32 与 fp16 下的绝对吞吐: 若 fp32 没有掉到 fp16 的 ~1/4,说明 cube 在空等,优化 cube 利用率不会有收益。
→ 完整解读手法与全部已知坑见 $KB_ROOT/ops/triton/guides/tooling/mfu_pitfalls.md
与 PMU 口径的分工
| 本 skill(官方 MFU 口径) | PMU(ops-profiling --compare 的 ArithmeticUtilization) |
|
|---|---|---|
| 分子 | 理论 FLOPs(语义必需) | 实际执行 MAC(含冗余、tile padding) |
| 归一 | 全部 cube 核 × 峰值 | 活跃核 × 运行时频率 |
| 小 shape | 正确反映核铺不满 | 按活跃核归一会严重虚高 |
| 回答 | 离硬件极限多远、还能优化多少 | 瓶颈在哪条流水线 |
两者差距揭示冗余计算量(实际执行 FLOPs / 理论 FLOPs)。横向对比一律以官方口径为准。
文件
scripts/prepare_op_dir.py 目录适配 + 用例路径补齐 + 类名校验
scripts/max_shape_probe.py 测定可承载的最大 shape(五道闸 + 二分细化)
scripts/mfu_grid_search.py (case,倍率) 二维搜索极限 MFU(阶段一,墙钟计时)
scripts/flops_generator.py 按算子类别生成 flops.py 初稿(需人工 review)
scripts/check_flops.py flops.py 自检 + tracer 独立对拍
scripts/peak_calib.py 各 dtype 实测算力上限 → peak.json
scripts/mfu_decompose.py 按 kernel 拆 MFU + 按 dtype 修正峰值
scripts/ops_profiling/msprof_perf_summary.py 采集脚本(ops-profiling 扩展副本,支持 --mfu)
$KB_ROOT/ops/triton/guides/tooling/mfu_flops_guide.md flops.py 口径、模板、自检
$KB_ROOT/ops/triton/guides/tooling/mfu_pitfalls.md 坑清单 + 瓶颈判别手法 + 经验水位
$KB_ROOT/ops/triton/guides/tooling/mfu_max_shape_search.md 极限 MFU 的二维搜索、缩放规则、五道闸、6 个坑
$KB_ROOT/ops/triton/guides/tooling/mfu_pmu_guide.md PMU 是什么、各流水计数器含义、bound 判据、常见误读
$KB_ROOT/ops/triton/guides/tooling/mfu_profiling_guide.md --mfu 参数与产出字段说明
examples/flops_mha_block.py 实测用过的完整 flops.py(MHA block,含 causal)
examples/flops_sparse_flash_attention.py 实测用过的完整 flops.py(稀疏 + 变长 + rope)
依赖
- 真实昇腾 NPU 环境(
npu-smi可用)、CANN 环境变量已 source torch+torch_npu- 采集前确认
echo $ASCEND_HOME_PATH与which msprof指向预期的 CANN, 非交互 shell 可能读不到set_env.sh,会静默跑在旧 CANN 上(见 pitfalls 坑 6)