PyPTO 开箱性能调优实战:Decode Attention 多 Matmul 独立 TileShape 配置(F-16)
【免费下载链接】pypto-gymPyPTO-Gym 是基于 PyPTO 编程框架构建的算子与模型样例仓库项目地址: https://gitcode.com/cann/pypto-gym
本篇技术指南以 PyPTO-Gym 仓库中cannbot-skills/ops/pypto-op-perf-tune/tune-frontend/cases/per-matmul-tile-shapes.md案例文档为核心,系统讲解如何在 Decode Attention 这类含多个 shape 特征各异的 matmul 算子中,通过为每个 matmul 独立设置set_cube_tile_shapes来消除 L1 空间浪费,获得 7.8% 的执行时间收益(配合前置优化累计 -46.1%)。读完本文,你将掌握"L1 不超过实际轴长""小轴不切、大轴大 tile""M 极小时 mL1 也要小"等核心调参原则,并能依据仓库源码与测试理解 Cube TileShape 的底层约束与编译期验证方法。
场景背景:统一 TileShape 为何会浪费 L1
在 Decode Attention 算子中,一次前向需要依次执行三个 matmul,且三个 matmul 的 shape 特征差异很大。如果沿用"一把钥匙开所有锁"的思路,用同一组set_cube_tile_shapes作用于全部 matmul(对应优化点目录 F-16 多 Matmul 差异化 Cube TileShape 中明确标记的"禁忌"项),必然导致部分 matmul 的 L1 tile 超过其实际轴长。
L1 是 Cube 单元的数据驻留缓存,容量有限(如 512KB)。当某个 matmul 的 K=128,而配置的kL1=256时,实际数据只有 128 个元素,多出的 128 元素 L1 空间完全浪费,并挤压了其他轴的 L1 可用空间。这正是本案例优化前(基线 257.12 us)的性能症结。
核心原则:四个调参准则
案例文档提炼了四条可复用的核心原则:
- L1 不超过实际轴长:如果 K=128,则
kL1设 256 无意义(实际数据只有 128),反而浪费 L1 空间; - 小轴不切:轴值较小时令
L0 = L1 = 实际值,不做切分; - 大轴大 tile:轴值大时用较大的 L1 减少切分次数,从而减少任务数与调度开销;
- 每个 matmul 前独立设置:不同 shape 的 matmul 最优 tile 不同,必须在每个 matmul 调用前分别设置。
其中第 4 条在 Cube TileShape 设置规范 中被标记为强制性的"独立设置原则":同一算子中多个不同 shape 的 matmul,必须在每个 matmul 前分别调用set_cube_tile_shapes,不要用统一值。
三个 Matmul 的 Shape 特征分析
| matmul | M | K | N | 特点 |
|---|---|---|---|---|
| Q@K^T | 4 | 128 | 2048 | K 小,N 大 |
| attn@V | 4 | 2048 | 128 | K 大,N 小 |
| output_proj | 1 | 4096 | 4096 | K/N 都大 |
从表格可以直观看出三个 matmul 的差异化诉求:
- Q@K^T:K=128 是小轴,不切;N=2048 是大轴,用大 tile 减少切分;
- attn@V:K=2048 是大轴,用大 tile;N=128 是小轴,不切;
- output_proj:K/N 都大(4096),两轴均用大 tile。
代码对比:从统一配置到独立配置
优化前(统一 tile,基线 257.12 us)
pypto.set_cube_tile_shapes([16, 256], [128, 256], [256, 256]) scores_fp32 = pypto.matmul(q_grouped, k_cache, pypto.DT_FP32, b_trans=True) # ... vector ops ... attn_output = pypto.matmul(attn_weights, v_cache, pypto.DT_BF16) # ... result = pypto.matmul(attn_output_flat, o_weight, pypto.DT_BF16)存在的问题:
- Q@K^T:K=128,但
kL1=256浪费 L1; - attn@V:N=128,但
nL1=256浪费 L1; - 所有 matmul 共用同一配置,无法按各自特征优化。
优化后(独立 tile,237.12 us)
# Q@K^T: K=128 小不切,N=2048 大 tile pypto.set_cube_tile_shapes([16, 16], [128, 128], [256, 256]) scores_fp32 = pypto.matmul(q_grouped, k_cache, pypto.DT_FP32, b_trans=True) # ... vector ops ... # attn@V: K=2048 大 tile,N=128 小不切 pypto.set_cube_tile_shapes([16, 16], [256, 256], [128, 128]) attn_output = pypto.matmul(attn_weights, v_cache, pypto.DT_BF16) # output_proj: K/N 都大,均用大 tile pypto.set_cube_tile_shapes([16, 16], [128, 256], [256, 256]) result = pypto.matmul(attn_output_flat, o_weight, pypto.DT_BF16)注意三个配置的差异点:
- M 轴统一收敛为
[16, 16](M 极小,见下文迭代过程分析); - Q@K^T 的
kL1从 256 缩到 128(贴合实际 K 轴长),nL1保持 256(N=2048 大轴); - attn@V 的
kL1放大到 256(K=2048 大轴),nL1缩到 128(贴合实际 N 轴长); - output_proj 的 K/N 均大,
[128, 256]/[256, 256]保持较大 tile。
参数语义与对齐约束(源码级依据)
set_cube_tile_shapes的函数原型与参数含义,参见 Cube TileShape 设置规范:
pypto.set_cube_tile_shapes([mL0, mL1], [kL0, kL1], [nL0, nL1]) # 高级用法:A/B 矩阵独立设置 K 轴切分 pypto.set_cube_tile_shapes([mL0, mL1], [kL0, kAL1, kBL1], [nL0, nL1])mL0/mL1:M 维度在 L0/L1 上的切分大小;kL0/kL1:K 维度在 L0/L1 上的切分大小;三维形式[kL0, kAL1, kBL1]可分别设置 A/B 矩阵的 K 轴切分(Decode M=1 场景让 A 矩阵整体驻留 L1);nL0/nL1:N 维度在 L0/L1 上的切分大小。
必须满足的对齐与整除约束(BF16/FP16 场景):
- L0 各维度必须 16 元素对齐(
L0_M、L0_K、L0_N均为 16 的倍数); kL0, kL1, nL0, nL1需满足 32 字节对齐;L0 <= L1且L1 % L0 == 0——这正是案例文档关键经验第 4 条提到的kL0 <= kL1 && kL1 % kL0 == 0,违反会导致编译失败。
此外,cube tile 的 L0/L1 切分还受硬件容量限制(L1 512KB、L0A/L0B 64KB、L0C 128~256KB,随平台而异),详见 npu-memory-arch.md。
迭代过程:数据驱动的三步调优
案例文档记录了完整的迭代调优轨迹,每一步都配套实测数据与原因分析:
| 尝试 | 配置方式 | 结果 | 原因分析 |
|---|---|---|---|
| 1 | 统一[16,256],[128,256],[256,256] | 257.12 us(基线) | 大轴浪费 L1,小轴 tile 不匹配 |
| 2 | 分设但 mL1=256 | 275.08 us(+7% 回退) | M 极小(4/1)时 mL1=256 浪费 L1,挤占 K/N 空间 |
| 3 | 分设 mL1=16 | 237.12 us(-7.8%) | M 极小不浪费 L1,空间让给 K/N 大轴 |
这个迭代序列蕴含两个重要方法论:
- 先分设、后缩 M:第 2 步先验证"分设"方向本身,但保留 M 轴
mL1=256导致回退 7%,说明分设后仍需逐轴细调; - 回退是有效信息:第 2 步的 +7% 回退不是失败,而是定位到"M 极小(M=4/M=1)时 mL1 必须收敛"这一关键事实——这正是最终第 3 步取得 -7.8% 收益的直接依据。
收益总结
- 执行时间:257.12 → 237.12 us(-7.8%);
- 累计(含前置优化):439.54 → 237.12 us(-46.1%);
- 精度:Max difference 0.000031,无变化。
精度无回退这一点在性能调优中至关重要——TileShape 是纯调度/搬运层面的优化,不改变计算语义,因此理论上不引入数值误差;本案例用实测 Max difference 0.000031 验证了这一结论,也提示任何调优都必须配套精度校验。
关键经验与常见陷阱
- L1 不超实际轴长:K=128 时
kL1=256没有意义,实际只有 128 个元素; - M 极小时 mL1 也要小:M=4 或 M=1 时
mL1=256浪费 L1,挤占 K/N 的 L1 空间——这是本案例第 2 次尝试回退的根因,也是最容易被忽略的一条; - 分设时要先设统一值确认编译通过:先让每个 matmul 前都设成同一个值,确认编译通过后再分别调整,避免一步到位引入编译失败时难以定位;
- 注意 L0/L1 约束:
kL0 <= kL1 && kL1 % kL0 == 0,违反会编译失败。
仓库源码佐证:独立 TileShape 的工程实践
该案例并非孤例,PyPTO-Gym 仓库的算子实现中大量采用"每个 matmul 前独立设置"的写法:
- pangu_fused_layer_dynamic_v2_bsh.py:在 Q@K^T 前使用
pypto.set_cube_tile_shapes([16, 16], [128, 128], [256, 256])再执行pypto.matmul(q_tile, k_tile, pypto.DT_FP32, b_trans=True)——与案例中 Q@K^T 的配置完全一致; - gqa_decode_attn_impl.py:Gemma-4-31B GQA Decode Attention 中 QK^T 用
pypto.set_cube_tile_shapes([4, 4], [128, 128], [64, 64]),PV 用pypto.set_cube_tile_shapes([4, 4], [64, 64], [128, 128]),两个 matmul 的 tile 随各自 M/N/K 特征(QK^T 的 K=D=256 分两段 128,PV 的 N=D=256 分两段 128)差异化配置,M 轴同样收敛到小值; - 优化点目录 F-16 中给出了另一组通用推导方法:L1 应设为 ≥ K 轴实际值且能被 L0 整除的值;若 K 值本身 ≤ 256,直接用 K 值(如 K=128 → L1=128);若 K 值 > 256,用 256(L1 上限通常 256);同时提醒"L1 过大会导致 L1 容量不足引发 spill,需实测验证"。
从源码结构可以推断,这类 Decode/GQA 注意力算子在仓库中普遍遵循"按 M/N/K 特征逐 matmul 独立配置 cube tile + M 轴收敛到小 tile"的写法,与案例文档总结的原则相互印证。
调优操作检查清单
对照 tune-frontend SKILL.md 的阶段化流程(阶段A 全局分析 → 阶段B 局部分析 → 阶段C 逐项优化),执行多 matmul TileShape 优化时建议依次完成:
- 列出算子内所有 matmul 的 M/N/K 实际值,填写诊断表;
- 对每个 matmul 按"小轴不切、大轴大 tile、L1 ≤ 实际轴长"推导初始配置;
- 先用统一配置确认编译通过,再逐 matmul 独立调整;
- 检查所有配置满足
L0 <= L1 && L1 % L0 == 0与 16 元素对齐约束; - 每次只改一个参数,实测性能与精度(Max difference),回退则记录原因;
- 最后对照 优化点全表 F-16 确认该项标记为"已尝试"并附实测数据。
通过本案例可复用的方法论,同类含多 matmul 的注意力类算子(QK^T + PV、MoE 分组 GEMM 等)均可先分析 M/N/K 特征,再逐 matmul 独立配置 Cube TileShape,以最小的前端代码改动换取稳定的开箱性能收益。
【免费下载链接】pypto-gymPyPTO-Gym 是基于 PyPTO 编程框架构建的算子与模型样例仓库项目地址: https://gitcode.com/cann/pypto-gym
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考