草稿
[WIP] Stage-5 knowledge base, pl-native engine skills, and cann-bench targeting for pypto-pro-op-orchestrator #585
[WIP] Stage-5 knowledge base, pl-native engine skills, and cann-bench targeting for pypto-pro-op-orchestrator #585
草稿
hid22900163创建于 15 天前
73 个文件变更+11062-231
@@ -657,7 +657,7 @@ if pto_kernels is not None and pto_kernels.USE_ACL_GRAPH:
657 657 
658**⚠️ 单算子替换性能预期:**658**⚠️ 单算子替换性能预期:**
659 659 
660-当只替换单个独立算子(如 RMSNorm)而未做前后融合时,**PTO 模式通常比基线慢 2-3x**。原因:(1) JIT kernel 首次编译;(2)kernel launch + dtype cast 开销 > 原 CANN 融合 kernel;(3)单算子无法抵消调用开销。660+当只替换单个独立算子(如 RMSNorm)而未做前后融合时,**PTO 模式通常比基线慢 2-3x**(实测数据见 `references/directory_structure.md`)。原因:(1) JIT kernel 首次编译;(2)kernel launch + dtype cast 开销 > 原 CANN 融合 kernel;(3)单算子无法抵消调用开销。
661 661 
662**这属于正常现象**,不是 bug。RMSNorm 的收益来自与相邻算子的融合(如 pre-attn RMSNorm + QKV projection),单算子替换仅验证**路由逻辑正确性和精度一致性**。真正的性能提升见后续 round:融合 pre-attn、post-attn 等复合算子。662**这属于正常现象**,不是 bug。RMSNorm 的收益来自与相邻算子的融合(如 pre-attn RMSNorm + QKV projection),单算子替换仅验证**路由逻辑正确性和精度一致性**。真正的性能提升见后续 round:融合 pre-attn、post-attn 等复合算子。
663 663 
@@ -185,7 +185,7 @@ README 末尾追加性能对比表格和复现命令,方便用户 run:
185| baseline | `python3 scripts/ask_{model_name}.py --prompt "你好" --device <id> --output-length 50` | 7.4s | 3.1s | 9.7 tok/s | 7313 MB |185| baseline | `python3 scripts/ask_{model_name}.py --prompt "你好" --device <id> --output-length 50` | 7.4s | 3.1s | 9.7 tok/s | 7313 MB |
186| pto | `python3 scripts/ask_{model_name}.py --prompt "你好" --device <id> --output-length 50 --use-pto` | 7.0s | 10.0s | 3.0 tok/s | 7313 MB |186| pto | `python3 scripts/ask_{model_name}.py --prompt "你好" --device <id> --output-length 50 --use-pto` | 7.0s | 10.0s | 3.0 tok/s | 7313 MB |
187 187 
188-> 单算子替换时 PTO 比基线慢 2-3x 属正常(JIT 首编 + kernel launch 开销),收益来自多算子融合。188+> 单算子替换时 PTO 比基线慢 2-3x 属正常(上表为实测;JIT 首编 + kernel launch 开销),收益来自多算子融合。
189```189```
190 190 
191> 表格数据从 `--report-file` 输出的 JSON 中提取,命令与表格一一对应。如被替换算子名不同或使用 `--use-acl-graph`,应收录对应命令和数据行。191> 表格数据从 `--report-file` 输出的 JSON 中提取,命令与表格一一对应。如被替换算子名不同或使用 `--use-acl-graph`,应收录对应命令和数据行。
@@ -1,6 +1,6 @@
1---1---
2name: pypto-pro-environment-check2name: pypto-pro-environment-check
3-description: PyPTO-Pro 环境检测与反馈技能。当 PyPTO-Pro 工作流(Stage 1–4)任意阶段遇到疑似环境问题(软件/硬件)时统一加载:torch_npu / pypto_pro 导入失败、npu-smi 无响应、NPU 设备不可见或不可用、CANN 未配置、kernel 编译/运行超时疑似设备 hang 等。采用「事实验证优先、脚本诊断兜底」两步法 + 设备 hang 三段式评定,用于区分环境故障与算子故障;只执行有界检测并返回结构化证据,不安装依赖、不修改环境。触发词:环境检查、环境问题、环境验证、environment check、设备 hang、卡死、超时。3+description: PyPTO-Pro 环境检测与反馈技能。当 PyPTO-Pro 工作流(Stage 1–5)任意阶段遇到疑似环境问题(软件/硬件)时统一加载:torch_npu / pypto_pro 导入失败、npu-smi 无响应、NPU 设备不可见或不可用、CANN 未配置、kernel 编译/运行超时疑似设备 hang 等。采用「事实验证优先、脚本诊断兜底」两步法 + 设备 hang 三段式评定,用于区分环境故障与算子故障;只执行有界检测并返回结构化证据,不安装依赖、不修改环境。触发词:环境检查、环境问题、环境验证、environment check、设备 hang、卡死、超时。
4---4---
5 5 
6# PyPTO-Pro 环境检测(pypto-pro-environment-check)6# PyPTO-Pro 环境检测(pypto-pro-environment-check)
@@ -16,12 +16,13 @@ description: 设计 PyPTO-Pro 算子的 tile 级执行方案。当 SPEC、Golden
16## 两条实现约束(设计阶段须落实)16## 两条实现约束(设计阶段须落实)
17 17 
18> 完整定义与证据门槛见`$CANNBOT_CONFIG_ROOT/references/performance-constraints.md`。进入 R0 前必须读取;设计阶段须在 R3(地址分配)和 R1(API 映射)中落实:18> 完整定义与证据门槛见`$CANNBOT_CONFIG_ROOT/references/performance-constraints.md`。进入 R0 前必须读取;设计阶段须在 R3(地址分配)和 R1(API 映射)中落实:
19-> 1. 所有需要 buffer 切换/轮转的 tile 一律用 `make_tile_group` + `auto_mutex`,`make_tile` 仅限单次使用 scratch tile。手动同步的适用范围见 `pypto-pro-material-explore` SKILL「实现选择规则」节。R3 落实 buffer 管理方式,R6 落实 cross_core 同步方案。19+> 1. 所有需要 buffer 切换/轮转的 tile 一律用 `make_tile_group` + `auto_mutex`,`make_tile` 仅限单次使用 scratch tile。自动排序须显式传 `mutex_ids=`——只给 `depth=` 而无可用 `mutex_ids` 的 group 轮转正常但**不携带任何排序信息**,且 `make_tile_group` 对未知/拼错的关键字(如 `auto_mutex=`、`mutex_id=`)静默丢弃、无任何告警(源码阅读结论,CANN 9.2.0 安装态解析器,2026-08-25,非上机实测;见 [`sync-stitch.md`](../pypto-pro-op-kb/constraints/sync-stitch.md))。手动同步的适用范围见 `pypto-pro-material-explore` SKILL「实现选择规则」节。R3 落实 buffer 管理方式,R6 落实 cross_core 同步方案。
20> 2. Vector 选择按该规范写入 DESIGN.md §1:已选 KB 模板明确要求当前步骤使用 `pl.*` 时按模板,否则使用 `vf.*`;本阶段不运行候选实验。20> 2. Vector 选择按该规范写入 DESIGN.md §1:已选 KB 模板明确要求当前步骤使用 `pl.*` 时按模板,否则使用 `vf.*`;本阶段不运行候选实验。
21 21 
22## 知识库22## 知识库
23 23 
24-知识来源与读取边界见下方「结构化 Binding 流程(architect)」24+知识来源与读取边界见下方「结构化 Binding 流程(architect)」;按
25+[`pypto-pro-op-kb/ROUTER.md`](../pypto-pro-op-kb/ROUTER.md) 每次只读取一个与当前决策相关的参考。
25[pattern selector](../pypto-pro-op-kb/patterns/pattern-index.md) 仅用于确认已选 pattern 的验证26[pattern selector](../pypto-pro-op-kb/patterns/pattern-index.md) 仅用于确认已选 pattern 的验证
26状态:`validated skeleton` 可作为代码起点,`conceptual only` 只能用于推导,不得据此扩选。27状态:`validated skeleton` 可作为代码起点,`conceptual only` 只能用于推导,不得据此扩选。
27 28 
@@ -32,6 +33,16 @@ description: 设计 PyPTO-Pro 算子的 tile 级执行方案。当 SPEC、Golden
32> 不要凭页面描述照写。33> 不要凭页面描述照写。
33平台专属约束必须先探测目标平台。API 文档与当前环境的官方样例仍是签名和行为的权威来源。34平台专属约束必须先探测目标平台。API 文档与当前环境的官方样例仍是签名和行为的权威来源。
34 35 
36+**在划分 Phase / Section 结构之前先查 pattern selector**:可复用的 pl 数据流骨架就在
37+[`pypto-pro-op-kb/patterns/`](../pypto-pro-op-kb/patterns/pattern-index.md),选好再设计比设计完再对照便宜。
38+ 
39+⚠️ **Pro 流程只允许单一 `@pl.jit` kernel**,所以除 `Validation` 列之外,还须看 pattern 页标题
40+下方是否带 `Pro: not legal` banner。带该 banner 的页(当前无此类页面)所描述的多 kernel 形态属于「知识
41+成立、Pro 设计非法」——产出这样的 DESIGN.md 只会在 `stage4-check` #5 被拦下并浪费一轮迭代;只取
42+其融合单 kernel 子形态(同一个 `@pl.jit` 内多个 section)。机器可读的清单是
43+[`pypto-pro-op-kb/topology-map.json`](../pypto-pro-op-kb/topology-map.json) 的 `target_gated`;pattern selector 本身只标注
44+validation 状态,不含合法性列,不要从中推读。
45+ 
35## 输入46## 输入
36 47 
37| 来源 | 路径 | 用途 |48| 来源 | 路径 | 用途 |
@@ -163,7 +174,7 @@ EXPLORE_REPORT.md §3只用于提供候选映射。逐项核对API参考页后
163 174 
1641.`target_memory` 把 R2 的 tile 分组,**每个内存空间各自从 `0x00000` 开始**连续排列地址,不重叠。UB/L1 首地址须 32 字节对齐;L0A/L0B/L0C 的对齐以对应 API 文档 / 官方指定算子为准。1751.`target_memory` 把 R2 的 tile 分组,**每个内存空间各自从 `0x00000` 开始**连续排列地址,不重叠。UB/L1 首地址须 32 字节对齐;L0A/L0B/L0C 的对齐以对应 API 文档 / 官方指定算子为准。
1652. 标注同地址不同 layout 的 tile 对(如有)1762. 标注同地址不同 layout 的 tile 对(如有)
166-3. 分配方式应使用 `make_tile_group` + `auto_mutex`:TileGroup提供多槽buffer,代码通过`next()`或显式下标选择槽位,`auto_mutex`根据mutex信息管理执行域内部的跨Pipe依赖(见上方「实现约束」)。177+3. 分配方式应使用 `make_tile_group` + `auto_mutex`:TileGroup提供多槽buffer,代码通过`next()`或显式下标选择槽位,`auto_mutex`根据mutex信息管理执行域内部的跨Pipe依赖(见上方「实现约束」)。mutex 信息须以显式 `mutex_ids=` 传入:只给 `depth=` 而无可用 `mutex_ids` 的 group 轮转正常但不携带任何排序信息,拼错的关键字被静默丢弃(源码阅读结论,CANN 9.2.0,2026-08-25,非上机实测;见 [`sync-stitch.md`](../pypto-pro-op-kb/constraints/sync-stitch.md))。
1674. **逐空间**验证该空间上的 tile 总大小不超过其容量上限。容量值以 EXPLORE_REPORT §7 探测记录为准——§7 必含 UB 容量;含 cube 时须补探 L1/L0 各空间容量(§7 未记录则回退 material-explore 补测,不得在此臆测数值)1784. **逐空间**验证该空间上的 tile 总大小不超过其容量上限。容量值以 EXPLORE_REPORT §7 探测记录为准——§7 必含 UB 容量;含 cube 时须补探 L1/L0 各空间容量(§7 未记录则回退 material-explore 补测,不得在此臆测数值)
1685. **double buffer 地址规划**`make_tile_group` 的 buffer 数 > 1 时地址占用按倍数放大,须在地址表中显式反映(buffer 数、受影响 tile、是否需 PONG 地址)。buffer 数取值参照官方指定算子中相似算子的实际配置1795. **double buffer 地址规划**`make_tile_group` 的 buffer 数 > 1 时地址占用按倍数放大,须在地址表中显式反映(buffer 数、受影响 tile、是否需 PONG 地址)。buffer 数取值参照官方指定算子中相似算子的实际配置
169 180 
@@ -192,6 +203,7 @@ EXPLORE_REPORT.md §3只用于提供候选映射。逐项核对API参考页后
192 203 
193**输出**:填入模板 §4:204**输出**:填入模板 §4:
194- 参考样例路径与可复用结构点205- 参考样例路径与可复用结构点
206+- **工作区参考实现对照表(架构冻结前必填)**:对 PRO_MATERIAL_INDEX §B / EXPLORE_REPORT §4 及 dispatch/prompt 点名的**每个**工作区内同类参考实现各填一行——归约/遍历形态(如单遍寄存器累积 vs 多遍整 tile)、每元素字节预算(B/elem,按该参考的搬运量估算)、缓冲方案,以及本设计是否偏离该参考、偏离理由。任一点名参考的形态与本设计不同而无偏离理由,R8 不得通过;工作区无点名参考时填「无点名参考实现」
195- 本算子的Section代码结构、结果单元、跨Tile状态生命周期、动态循环上界、各Module内的循环嵌套和分核信息的获取位置207- 本算子的Section代码结构、结果单元、跨Tile状态生命周期、动态循环上界、各Module内的循环嵌套和分核信息的获取位置
196- `is_fusion=true`时补充第一阶段每次交给下一阶段的数据范围、产生下一份数据的循环索引、`task_id`递增位置、交替阶段链、候选预加载轮数、逐阶段delay计算表,以及启动、稳定运行和末尾剩余阶段的执行时序;使用上下文缓冲时补充字段、深度和索引208- `is_fusion=true`时补充第一阶段每次交给下一阶段的数据范围、产生下一份数据的循环索引、`task_id`递增位置、交替阶段链、候选预加载轮数、逐阶段delay计算表,以及启动、稳定运行和末尾剩余阶段的执行时序;使用上下文缓冲时补充字段、深度和索引
197- 引用`loop_design.md`,说明采用的Section和循环组织方式209- 引用`loop_design.md`,说明采用的Section和循环组织方式
@@ -309,6 +321,7 @@ EXPLORE_REPORT §4中的官方指定算子用于核对完整调用方式,不
309| | cross_core 同步方案是否正确(存在跨执行域或跨Block/subblock依赖时):手动预加载流水的阶段延迟、上下文槽位、就绪/释放事件、初始释放事件、同步点和event_id是否参照权威文档与当前实现 | 回到 R4/R6 修正 |321| | cross_core 同步方案是否正确(存在跨执行域或跨Block/subblock依赖时):手动预加载流水的阶段延迟、上下文槽位、就绪/释放事件、初始释放事件、同步点和event_id是否参照权威文档与当前实现 | 回到 R4/R6 修正 |
310| **一致性** | R0-R7 各轮输出是否存在矛盾(如 API 需要的 tile 在 R2 中缺失) | 回溯到矛盾产生的轮次修正 |322| **一致性** | R0-R7 各轮输出是否存在矛盾(如 API 需要的 tile 在 R2 中缺失) | 回溯到矛盾产生的轮次修正 |
311| | 证据链是否完整(每个决策都有来源) | 补充缺失的文档引用或官方指定算子路径 |323| | 证据链是否完整(每个决策都有来源) | 补充缺失的文档引用或官方指定算子路径 |
324+| | 工作区参考实现对照表完整(每个点名参考一行:归约/遍历形态、B/elem、缓冲方案、是否偏离及理由;偏离项均有理由) | 回到 R4 补齐对照表 / 重审偏离决策 |
312| | 每个内存空间(UB/L1/L0A/L0B/L0C)的 tile 总用量分别不超过各自容量上限(R3 逐空间验证,含 cube 时须查 L1/L0) | 回到 R3 重排地址 / R2 缩 tile |325| | 每个内存空间(UB/L1/L0A/L0B/L0C)的 tile 总用量分别不超过各自容量上限(R3 逐空间验证,含 cube 时须查 L1/L0) | 回到 R3 重排地址 / R2 缩 tile |
313| **条件性检查** | 若 §6 填“不涉及cross_core”,确认不存在Cube↔Vector或跨Block/subblock的数据依赖 | 回到 R0 重新评估 |326| **条件性检查** | 若 §6 填“不涉及cross_core”,确认不存在Cube↔Vector或跨Block/subblock的数据依赖 | 回到 R0 重新评估 |
314 327 
@@ -193,6 +193,14 @@ SCALE = 1.0 / sqrt({D_logical}) # 缩放因子(若算子有 scale 步骤)
193- **可复用结构点**: {该样例中可复用的Section / 循环 / 分核信息获取方式}193- **可复用结构点**: {该样例中可复用的Section / 循环 / 分核信息获取方式}
194- **补充参考**(如有): {`../pypto-pro-material-explore/references/official_samples.md` 清单中其他参考样例路径及参考点}194- **补充参考**(如有): {`../pypto-pro-material-explore/references/official_samples.md` 清单中其他参考样例路径及参考点}
195 195 
196+### 工作区参考实现对照表(架构冻结前必填)
197+ 
198+> 对 PRO_MATERIAL_INDEX §B / EXPLORE_REPORT §4 及 dispatch/prompt 点名的**每个**工作区内同类参考实现各填一行;无点名参考时填「无点名参考实现」。任一参考的形态与本设计不同而「偏离理由」为空,§9 评估不得通过。
199+ 
200+| 参考实现(路径) | 归约/遍历形态 | 每元素字节预算(B/elem) | 缓冲方案 | 本设计是否偏离 | 偏离理由 |
201+|---|---|---|---|---|---|
202+| {路径} | {如 单遍寄存器累积 / 多遍整 tile 归约 / 逐行流式} | {估算值及口径} | {单缓冲 / double buffer / 槽位数} | {否 / 是} | {偏离时必填:证据或量化依据} |
203+ 
196### 本算子结构说明204### 本算子结构说明
197 205 
198- **Module / Section对应关系**: {每个Module放在哪个Section;相邻同域Module是否共用一个Section}206- **Module / Section对应关系**: {每个Module放在哪个Section;相邻同域Module是否共用一个Section}
@@ -205,6 +213,14 @@ SCALE = 1.0 / sqrt({D_logical}) # 缩放因子(若算子有 scale 步骤)
205 213 
206> 同步点见 §6(R6),尾块处理见 §7(R7)。214> 同步点见 §6(R6),尾块处理见 §7(R7)。
207 215 
216+### 小 shape 固定开销策略(case 锚点填充 fixed-overhead / launch cost 子格时必填)
217+ 
218+> **条件性**:仅当 case 锚点填充 one-tile-per-core(parity)regime 格的 **fixed-overhead / launch cost** 子格时必填;未填充则本节填“不涉及”。判定即该行的几何无关触发问题(原文见 [dominant-cost-attack-matrix](../../pypto-pro-op-kb/playbooks/dominant-cost-attack-matrix.md) 的 fixed-overhead / launch cost 行;regime 格见 [capacity-and-regime-method](../../pypto-pro-op-kb/references/capacity-and-regime-method.md)):在最大合法并行度下,总工作量相对核数是否小到 `t_hw` 与该卡**实测**的单次启动固定成本相当,且被标记子格占 Σ R_i 的实质份额(R_i 可忽略的极小 case 不触发)。单次启动固定成本是卡特定值,任何 µs 字面量都不得替代实测;该卡未实测时子格按 **PROVISIONAL** 标记处理,arch-a5 恒定几何扫描即该行的首个探针——不得仅因扫描未做而填“不涉及”。几何(tile 尺寸、分核)变更后须回到本节重新判定。
219+ 
220+- **触发算术**: {逐项引用:锚点 case 的 `t_hw`、最大合法并行度下的工作单元数与核数、被标记子格的 Σ R_i 份额;结论:填充 / 未填充 / PROVISIONAL(注明该卡单次启动成本未实测)}
221+- **小 shape 策略**: {dispatch(host 侧按 tilingkey 分发)/ rung(编译期档位特化)/ straight-line(直线化)的选择及理由}
222+- **规避的单次启动成本**: {所选策略避免的是哪一项 per-launch 固定成本,如设备侧运行时分支、逐行档位判定、标量序言}
223+ 
208### CV 手动预加载流水设计(`is_fusion=true`时必填)224### CV 手动预加载流水设计(`is_fusion=true`时必填)
209 225 
210> 依据[CV融合算子手动预加载流水设计](../references/cv_fusion_pipeline.md)。先确定第一阶段每次交给下一阶段的数据范围,以及哪些循环索引会产生下一份数据,再为这些数据分配连续编号,并展示稳定运行时Cube与Vector同时处理不同编号数据的时序。226> 依据[CV融合算子手动预加载流水设计](../references/cv_fusion_pipeline.md)。先确定第一阶段每次交给下一阶段的数据范围,以及哪些循环索引会产生下一份数据,再为这些数据分配连续编号,并展示稳定运行时Cube与Vector同时处理不同编号数据的时序。
@@ -345,6 +361,7 @@ pl.set_validshape(tile_a, [valid_m, valid_n]) # 运行时告知硬件
345|--------|------|------|361|--------|------|------|
346| R0-R7 输出无矛盾 | ✅ / ❌ | {交叉验证} |362| R0-R7 输出无矛盾 | ✅ / ❌ | {交叉验证} |
347| 所有决策有证据支撑 | ✅ / ❌ | {证据链检查} |363| 所有决策有证据支撑 | ✅ / ❌ | {证据链检查} |
364+| 工作区参考实现对照表完整,偏离项均有理由(见 §4) | ✅ / ❌ | {回 R4 补齐对照表 / 重审偏离决策} |
348| 各内存空间(UB/L1/L0A/L0B/L0C)tile 总用量分别不超各自容量上限(R3 逐空间验证,含 cube 时须查 L1/L0) | ✅ / ❌ | {回 R3 重排地址 / R2 缩 tile} |365| 各内存空间(UB/L1/L0A/L0B/L0C)tile 总用量分别不超各自容量上限(R3 逐空间验证,含 cube 时须查 L1/L0) | ✅ / ❌ | {回 R3 重排地址 / R2 缩 tile} |
349| `tile_dims` 使用时已关注大 stride 对性能的影响 | ✅ / ❌ | {回 R2 调整布局} |366| `tile_dims` 使用时已关注大 stride 对性能的影响 | ✅ / ❌ | {回 R2 调整布局} |
350| 条件性检查(若 §6 填“不涉及cross_core”,确认不存在Cube↔Vector或跨Block/subblock的数据依赖) | ✅ / ❌ | {R0 重新评估} |367| 条件性检查(若 §6 填“不涉及cross_core”,确认不存在Cube↔Vector或跨Block/subblock的数据依赖) | ✅ / ❌ | {R0 重新评估} |
@@ -17,6 +17,7 @@ description: 实现、调试并自验证 PyPTO-Pro 算子 kernel。用于按冻
17- `custom/<op>/EXPLORE_REPORT.md`:已核对的 API 约束和相似样例。17- `custom/<op>/EXPLORE_REPORT.md`:已核对的 API 约束和相似样例。
18- `custom/<op>/PRO_MATERIAL_INDEX.md`:需要回看原文时的 API、官方样例和教程路径。18- `custom/<op>/PRO_MATERIAL_INDEX.md`:需要回看原文时的 API、官方样例和教程路径。
19- flat 的 `custom/<op>/KB_SELECTION.json`,或 split 布局下全部 `custom/<op>/<class>/KB_SELECTION.json`,以及其中选中的所有参考。19- flat 的 `custom/<op>/KB_SELECTION.json`,或 split 布局下全部 `custom/<op>/<class>/KB_SELECTION.json`,以及其中选中的所有参考。
20+- `custom/<op>/DESIGN_BINDINGS.json`:Stage 3 冻结的 Knowledge Bindings;其中每条 `obligation + applies` 活动 requirement 都是本阶段义务,逐条落实规则见下方「KB usage 规范(Coder/Verifier 共用)」。
20 21 
21按 dispatch 交付:22按 dispatch 交付:
22 23 
@@ -32,7 +33,7 @@ description: 实现、调试并自验证 PyPTO-Pro 算子 kernel。用于按冻
32 33 
331. 一个交付文件只含一个 `@pl.jit` kernel,核心计算全部在该 kernel 内;wrapper 只启动一次 kernel,且启动不在 host 循环内。341. 一个交付文件只含一个 `@pl.jit` kernel,核心计算全部在该 kernel 内;wrapper 只启动一次 kernel,且启动不在 host 循环内。
342. 严格执行 DESIGN.md 已冻结的 Module 边界、API 序列、tile 属性/地址、循环、同步、尾块和 `vector_selection`。本轮功能或精度测试表明设计有误时上报疑似 `design_violation`,不得静默改设计。352. 严格执行 DESIGN.md 已冻结的 Module 边界、API 序列、tile 属性/地址、循环、同步、尾块和 `vector_selection`。本轮功能或精度测试表明设计有误时上报疑似 `design_violation`,不得静默改设计。
35-3. 轮转 tile 使用 `make_tile_group` + `auto_mutex`;`make_tile` 只用于不参与轮转的单次 scratch。不要在 `auto_mutex` 管理的 tile 上叠加手动 `sync_src`/`sync_dst`。36+3. 轮转 tile 使用 `make_tile_group` + `auto_mutex`;`make_tile` 只用于不参与轮转的单次 scratch。跨算子依赖的轮转 group 必须显式传 `mutex_ids=`——只给 `depth=` 而无可用 `mutex_ids` 的 group 轮转正常但携带任何排序信息,且 `make_tile_group` 静默丢弃未知/拼错的关键字(源码阅读结论,CANN 9.2.0,2026-08-25,非上机实测;见 [sync-stitch.md](../pypto-pro-op-kb/constraints/sync-stitch.md))。不要在 `auto_mutex` 管理的 tile 上叠加手动 `sync_src`/`sync_dst`。
364. wrapper 只做参数检查、读取 KB 约束列明的只读元数据、纯 Python 整数推导、`torch.empty` 分配当前 wrapper 合同声明的输出和一次 kernel 启动;完整边界与迁移方式见 [wrapper-boundary.md](../pypto-pro-op-kb/constraints/wrapper-boundary.md)。DESIGN、usage、`deviated` 或 profile 均不能放宽该硬约束。374. wrapper 只做参数检查、读取 KB 约束列明的只读元数据、纯 Python 整数推导、`torch.empty` 分配当前 wrapper 合同声明的输出和一次 kernel 启动;完整边界与迁移方式见 [wrapper-boundary.md](../pypto-pro-op-kb/constraints/wrapper-boundary.md)。DESIGN、usage、`deviated` 或 profile 均不能放宽该硬约束。
375. 测试通过 wrapper 调用 kernel;不得删改 DESIGN.md §8 的 case 来迁就实现,也不得把核心计算移到测试或 host 代码。385. 测试通过 wrapper 调用 kernel;不得删改 DESIGN.md §8 的 case 来迁就实现,也不得把核心计算移到测试或 host 代码。
38 39 
@@ -52,6 +53,7 @@ description: 实现、调试并自验证 PyPTO-Pro 算子 kernel。用于按冻
52| [templates/fp32-chain-precision-fragments.py.tmpl](templates/fp32-chain-precision-fragments.py.tmpl) | fp32 链路的精度安全片段 | 需要与 CPU 参考逐位对齐时 |53| [templates/fp32-chain-precision-fragments.py.tmpl](templates/fp32-chain-precision-fragments.py.tmpl) | fp32 链路的精度安全片段 | 需要与 CPU 参考逐位对齐时 |
53| [templates/kb-usage-template.json](templates/kb-usage-template.json) | `KB_USAGE.json` 字段骨架与单条记录示例 | 写入或核验 usage 时 |54| [templates/kb-usage-template.json](templates/kb-usage-template.json) | `KB_USAGE.json` 字段骨架与单条记录示例 | 写入或核验 usage 时 |
54| [scripts/list_idle_chip_ids.sh](scripts/list_idle_chip_ids.sh) | 查找空闲 NPU chip | 运行前按需执行 |55| [scripts/list_idle_chip_ids.sh](scripts/list_idle_chip_ids.sh) | 查找空闲 NPU chip | 运行前按需执行 |
56+| [pypto-pro-op-perf-tune](../pypto-pro-op-perf-tune/SKILL.md) | wrapper 动态反作弊检查:只检查 profile 中的 device op,不评价性能 | 需要验证 wrapper 未绕过 kernel 时 |
55| [KB CONTRACT](../pypto-pro-op-kb/CONTRACT.md) | KB JSON 的基础字段、路径与状态词表 | 读取 selection 或写 usage 前 |57| [KB CONTRACT](../pypto-pro-op-kb/CONTRACT.md) | KB JSON 的基础字段、路径与状态词表 | 读取 selection 或写 usage 前 |
56 58 
57参考之间是互补关系:DESIGN.md 决定“实现什么”,纯 Vector 模板或 Cube 官方样例提供“如何写”的主要起点,`KB_SELECTION.json` 已选参考补充必须落实的 pattern 和约束;三者不得相互替代。模板和样例不是 API 或性能事实源,使用时仍须核对目标版本 API 文档;与 DESIGN.md 或已选 KB 冲突的参考片段直接弃用,只有上游合同本身无法同时落实时才按根因分流,本 skill 不自行改合同。标为 conceptual 的片段不得直接复制成交付代码。59参考之间是互补关系:DESIGN.md 决定“实现什么”,纯 Vector 模板或 Cube 官方样例提供“如何写”的主要起点,`KB_SELECTION.json` 已选参考补充必须落实的 pattern 和约束;三者不得相互替代。模板和样例不是 API 或性能事实源,使用时仍须核对目标版本 API 文档;与 DESIGN.md 或已选 KB 冲突的参考片段直接弃用,只有上游合同本身无法同时落实时才按根因分流,本 skill 不自行改合同。标为 conceptual 的片段不得直接复制成交付代码。
@@ -86,6 +88,8 @@ L1 的 `suffix_k` 是累积序号:1 → `1`,2 → `12`,3 → `123`。文
86 - §8:至少 4 个目标测试 case;88 - §8:至少 4 个目标测试 case;
87 - §10:完整数据流。89 - §10:完整数据流。
88 90 
91+§5 的分核方案要有平台事实垫底。本次运行探测到的 `vector_core_num``core_num``torch.npu.get_device_name(...)` 设备名,必须在 **Stage 3 开始之前**就写进运行报告 `RUN_REPORT.md`(运行编排提供该报告时;本流程内的等价落点是 `custom/<op>/MEMORY.md`):同代 A5 存在核数不同的部件,全部真实,判别依据是设备名而非 `soc_version`(见 [arch-a5.md](../pypto-pro-op-kb/constraints/arch-a5.md)),没有这条记录的设计等于在无事实依据下选定了启动宽度。编码前确认记录在案;不在案就先探测一次并补记(命令、原始输出、`date -u +%FT%TZ` 时间戳齐全)再继续,并在交付报告里把这个缺口告知编排器。该记录只是口径校验,不是取值来源:kernel 的启动宽度仍然只能在运行时从 `get_platform_info()` 读取,把探测到的数字抄成字面量与抄表格同罪。DESIGN.md §5 冻结的核数与探测值不一致时停止编码,按 `design_violation` 返回。
92+ 
89L1 还要用 `module_interfaces.yaml` 核对当前 Module 的 `inputs``outputs``golden_steps` 和 section 类型。缺少关键合同,或上下游产物互相矛盾时停止编码并上报疑似 `design_violation`93L1 还要用 `module_interfaces.yaml` 核对当前 Module 的 `inputs``outputs``golden_steps` 和 section 类型。缺少关键合同,或上下游产物互相矛盾时停止编码并上报疑似 `design_violation`
90 94 
91### 3. 核对全部 API95### 3. 核对全部 API
@@ -150,6 +154,8 @@ TensorList(`is_list: true`)同样只能启动一次 kernel,且启动不得
150 154 
151- **验证范围**:L1 Module 只检 DESIGN/Module 合同分配给当前 Module,或当前 staged 实际承载的 active 及相关 validation scope,不提前检后续 Module;L0/finalize 检全部 active 与 scope。155- **验证范围**:L1 Module 只检 DESIGN/Module 合同分配给当前 Module,或当前 staged 实际承载的 active 及相关 validation scope,不提前检后续 Module;L0/finalize 检全部 active 与 scope。
152- **验证方法**:不得改变验证目标,输入就绪即执行;仅 L1 Module 可在原方法明确依赖尚未生成的最终 file/symbol、wrapper 或 final-only profile 时暂缓,并在本轮返回证据中记录四元组、原方法、缺失依赖和 `not_run_for_staged`。L0/finalize 不得暂缓。156- **验证方法**:不得改变验证目标,输入就绪即执行;仅 L1 Module 可在原方法明确依赖尚未生成的最终 file/symbol、wrapper 或 final-only profile 时暂缓,并在本轮返回证据中记录四元组、原方法、缺失依赖和 `not_run_for_staged`。L0/finalize 不得暂缓。
157+- **谓词有效性**:任何用于证明落实的验证方法都必须能证伪被守卫的那个缺陷——**缺陷成立时也能通过的方法无效**。「该常量只出现一次」「常量旁有来源注释」不检查取值来源;「符号存在」「文件里出现了这个 API」不检查写法。方法要落在缺陷写法与正确写法真正分岔的位置:来源类义务查取值链能否逐跳回溯到那次运行时查询,写法类义务查调用节点的结构位置。冻结的 `verification_method` 本身属于此类时是 requirement 语义错误:上报疑似 `design_violation`,附四元组和一份能通过该方法的反例写法,不得照抄执行。
158+- **结构谓词(向量交付必检)**:凡交付含 `@pl.vector_function` / `section_vector` 的实现,无论 Binding 写了什么,还须逐条实跑两条结构谓词并留证:**P-A** 到达 `block_dim` 的核数必须能逐跳回溯到一次 `get_platform_info()` 调用(允许 `min(cores, tasks)` 类钳制;字面量只允许出现在 `getattr` 兜底位;不得用裸字面量 grep 代替——`LANES = 64` 一类是正确的无关字面量);**P-B** 任何 `vf.update_mask(...)` 调用节点不得位于 `pl.range` 循环体内(形态与代价见 [vec-alignment-and-rotation.md](../pypto-pro-op-kb/constraints/vec-alignment-and-rotation.md) 的提升节)。命中即 FAIL。
153- **证据复用**:仅被检实现、验证输入、方法、检查内容和覆盖范围均未变化时复用;仍按四元组记录原方法、命令/输入、原始结果和结论。已存在或本轮写入的 usage 均用 `json.load` 预检;L0/finalize 的各 class usage 必须存在。159- **证据复用**:仅被检实现、验证输入、方法、检查内容和覆盖范围均未变化时复用;仍按四元组记录原方法、命令/输入、原始结果和结论。已存在或本轮写入的 usage 均用 `json.load` 预检;L0/finalize 的各 class usage 必须存在。
154 160 
155### 7. 运行并调试161### 7. 运行并调试
@@ -189,4 +195,5 @@ python custom/<op>/modules/test_<op>_module<suffix_k>.py
189- wrapper 的完整 host 调用链符合实现合同 #4。195- wrapper 的完整 host 调用链符合实现合同 #4。
190- DESIGN.md §8 全部 case 已实际运行并通过;测试数据、dtype、shape、value range 未被偷换。196- DESIGN.md §8 全部 case 已实际运行并通过;测试数据、dtype、shape、value range 未被偷换。
191- dev-only import 位于函数内,交付态模块可安全导入。197- dev-only import 位于函数内,交付态模块可安全导入。
198+- 启动宽度逐跳回溯到 `get_platform_info()`,字面量只出现在 getattr 兜底位;平台探测记录(`vector_core_num` + 设备名)在案。
192- 返回运行命令、逐四元组方法证据、原始结果、产物路径和分类 verdict;不声称 verifier PASS。若无需修改,明确说明并附本模式全部产物的检查证据,不得空返回或只给笼统结论。199- 返回运行命令、逐四元组方法证据、原始结果、产物路径和分类 verdict;不声称 verifier PASS。若无需修改,明确说明并附本模式全部产物的检查证据,不得空返回或只给笼统结论。
@@ -135,7 +135,7 @@ past two full cycles), plus M/N tails, multi-tile reuse and batch. Anything less
135leaves a boundary the rotation never crossed.135leaves a boundary the rotation never crossed.
136 136 
137**Performance stays a per-operator profiler decision.** A double-buffered137**Performance stays a per-operator profiler decision.** A double-buffered
138-rotation is worth roughly 1.2-1.7x on kernel-only medians for a mid-size138+rotation measures roughly 1.2-1.7x on kernel-only medians for a mid-size
139geometry, but the spread across shapes is wider than the mean, and a wider K/N139geometry, but the spread across shapes is wider than the mean, and a wider K/N
140tile is rejected whenever its *ranking* is unstable across shapes rather than140tile is rejected whenever its *ranking* is unstable across shapes rather than
141when its mean is worse -- an unstable ranking means the next shape decides the141when its mean is worse -- an unstable ranking means the next shape decides the
@@ -85,6 +85,32 @@ pattern must describe a distinct design decision for the class; a merely similar
85an unmet precondition, duplicate guidance or generic background reading is not selectable.85an unmet precondition, duplicate guidance or generic background reading is not selectable.
86Constraints remain independent of pattern selection and are never suppressed.86Constraints remain independent of pattern selection and are never suppressed.
87 87 
88+## Property keys
89+ 
90+`topology-map.json`'s `contract.property_keys` is the enum a selection's `properties` block
91+draws from, and `property_modifiers` routes on a subset of it. Every key is defined in prose
92+here as well as in the map: `long_axis` in particular routes both a constraint and a pattern, so
93+two planners reading the bare enum could disagree about when it holds and both be inside the
94+contract.
95+ 
96+Each key below is stated as the predicate that makes it true, together with the boundary that
97+decides it. For most keys that boundary is a **judgement call**; record the reasoning for a
98+borderline call in the selection artifact rather than resolving it silently.
99+ 
100+| Key | Holds when | Boundary |
101+|---|---|---|
102+| `dtypes` | The set of input/output element types the class must serve. Always recorded. | None; read it off the declared contract. |
103+| `ranks` | The set of input ranks the one kernel must serve. Always recorded. | None; read it off the declared contract. |
104+| `unaligned_shapes` | Some declared shape leaves an axis that is not a whole multiple of the tile or burst granule the design uses on that axis. | Depends on the tile geometry, so it is only decidable **after** a geometry is proposed. Re-check it if the geometry changes. |
105+| `tail_blocks` | Work items do not divide evenly across the chosen work-item count, so at least one item is partial and needs a runtime valid window. | Same dependency on geometry as `unaligned_shapes`; the two usually hold together and are not the same statement. |
106+| `long_axis` | One axis **exceeds what a single legal tile can hold for its dtype**, so the kernel must traverse it in more than one tile and carry state across the traversal. It is the property counterpart of the `long-axis` topology: the topology says the *computation* is shaped that way, the property says *these declared shapes* reach it. | The threshold is the widest tile the UB budget allows for the narrowest dtype in `dtypes` — a computed number, not a constant. The operational test used by the routed pattern is `ceil(axis / MaxN_top) >= 2`, where `MaxN_top` is the top rung of the width ladder. An axis that fits one tile for fp32 and not for fp16 makes this **true**, because one kernel must serve both. |
107+| `mixed_precision` | The computation is exposed at a different width from the declared io dtype — a widen → compute → narrow chain, or an accumulator wider than the operands. | Judgement only in the degenerate case where the widening is a single store-side cast; if any *arithmetic* happens at the other width, it holds. |
108+| `index_dtypes` | An input is an index tensor whose element width and signedness participate in correctness (addressing, out-of-range and duplicate semantics). | None; either an index tensor exists or it does not. |
109+| `dynamic_dims` | At least one extent is unknown at compile time and must be read from a runtime scalar or a tiling field. | None. |
110+| `is_list` | An input is declared as a list whose **length is a runtime value**, which the DSL's fixed parameter list cannot express directly. | None; it is a declaration, not an inference. |
111+ 
112+Adding or removing a key here is a contract change — see "Changing the contract" below.
113+ 
88## `KB_SELECTION.json`114## `KB_SELECTION.json`
89 115 
90The planner derives this file from computation topology and dtype/shape properties, never116The planner derives this file from computation topology and dtype/shape properties, never
@@ -130,3 +156,6 @@ accepts a justified `not_applicable` claim.
130Adding or removing a topology, property key, status, role scope, reference class or quantity156Adding or removing a topology, property key, status, role scope, reference class or quantity
131rule is a contract change. Update `topology-map.json`, raise both schema versions, update this157rule is a contract change. Update `topology-map.json`, raise both schema versions, update this
132document and the consuming skills, and keep the integrity checks green.158document and the consuming skills, and keep the integrity checks green.
159+ 
160+A **Correction/retraction** must chase and fix every other home of the corrected claim in the same
161+change; a copy left standing elsewhere re-litigates the correction.
@@ -9,24 +9,54 @@ for signatures and platform-specific behavior.
9| Select a retained study implementation | `pypto-pro-material-explore` | [examples/kernel-index.md](examples/kernel-index.md) |9| Select a retained study implementation | `pypto-pro-material-explore` | [examples/kernel-index.md](examples/kernel-index.md) |
10| Choose a reusable dataflow | `pypto-pro-op-design` | [patterns/pattern-index.md](patterns/pattern-index.md) |10| Choose a reusable dataflow | `pypto-pro-op-design` | [patterns/pattern-index.md](patterns/pattern-index.md) |
11| Check dtype and cast behavior | `pypto-pro-op-design` / `pypto-pro-op-develop` | [constraints/precision.md](constraints/precision.md) |11| Check dtype and cast behavior | `pypto-pro-op-design` / `pypto-pro-op-develop` | [constraints/precision.md](constraints/precision.md) |
12-| Check tile shapes and memory placement | `pypto-pro-op-design` / `pypto-pro-op-develop` | [constraints/tiling.md](constraints/tiling.md), then [constraints/memory-layout.md](constraints/memory-layout.md) |12+| Check tile shapes and memory placement | `pypto-pro-op-design` / `pypto-pro-op-develop` | [constraints/tiling.md](constraints/tiling.md), then [constraints/memory-layout.md](constraints/memory-layout.md) — if a **wide fp32 accumulator stays resident in UB across an inner loop**, sum UB *first*: it can bind before L0A, and a sample's tile size then does not transfer |
13| Check vector authoring choices | `pypto-pro-op-develop` | [constraints/vec.md](constraints/vec.md) |13| Check vector authoring choices | `pypto-pro-op-develop` | [constraints/vec.md](constraints/vec.md) |
14-| Check synchronization | `pypto-pro-op-design` / `pypto-pro-op-develop` | [constraints/sync-stitch.md](constraints/sync-stitch.md) |14+| Pass a float constant to a `vf.*` op — an epsilon, a floor, a guard — or a result is right on ordinary data and wrong only on degenerate data | `pypto-pro-op-design` / `pypto-pro-op-develop` | [constraints/vec-scalar-immediate.md](constraints/vec-scalar-immediate.md) — a `vf` scalar immediate is emitted with `%f`; anything below ~`5e-7` is silently deleted |
15+| Check synchronization | `pypto-pro-op-design` / `pypto-pro-op-develop` | [constraints/sync-stitch.md](constraints/sync-stitch.md) — a hand-written per-tile cube↔vector handoff inside **one** `@pl.jit` is measured to run, so a hang is not attributable to that construct |
16+| A kernel returns a different answer on each run of the same input, or a fused cube+vector kernel emits NaN | any | [constraints/sync-stitch.md](constraints/sync-stitch.md) — the open cube↔vector race reduces to a **minimal reproducer** (one task, one reduction chunk), and the remaining handle is **bisecting the inner loop**; `auto_mutex` and the producer-local contraction loop are exonerated **by measurement**, so this is a design defect and not a framework bug, though the inner unroll is *not* positively identified; then [references/investigation-discipline.md](references/investigation-discipline.md) §18 — a low-rate race needs **thousands** of runs, and "clean" is only an upper bound on the rate |
15| Check dynamic tails | `pypto-pro-op-design` / `pypto-pro-op-develop` | [constraints/tail-validshape.md](constraints/tail-validshape.md) |17| Check dynamic tails | `pypto-pro-op-design` / `pypto-pro-op-develop` | [constraints/tail-validshape.md](constraints/tail-validshape.md) |
16-| Select A5 constraints; use limits only after target detection | design/develop/perf skills | [constraints/arch-a5.md](constraints/arch-a5.md) |18+| Select A5 constraints; use limits only after target detection | `pypto-pro-op-design` / `pypto-pro-op-develop` / `pypto-pro-op-perf-tune` | [constraints/arch-a5.md](constraints/arch-a5.md) |
17| Decide what may run on the host | `pypto-pro-op-design` / `pypto-pro-op-develop` | [constraints/wrapper-boundary.md](constraints/wrapper-boundary.md) |19| Decide what may run on the host | `pypto-pro-op-design` / `pypto-pro-op-develop` | [constraints/wrapper-boundary.md](constraints/wrapper-boundary.md) |
18| Measure and tune a correct kernel | `pypto-pro-op-perf-tune` | [`pypto-pro-op-perf-tune` evidence protocol](../pypto-pro-op-perf-tune/references/evidence-protocol.md) |20| Measure and tune a correct kernel | `pypto-pro-op-perf-tune` | [`pypto-pro-op-perf-tune` evidence protocol](../pypto-pro-op-perf-tune/references/evidence-protocol.md) |
21+| Deciding whether a measured delta is real — how many arms do I need, can I compare two cards | `pypto-pro-op-perf-tune` / any | [`pypto-pro-op-perf-tune` evidence protocol](../pypto-pro-op-perf-tune/references/evidence-protocol.md) — paired arms on one card in one session; the measured floors are **0.291** within-session paired and **~0.908** across-session, and they are **per instrument** — ratios and absolute quantities do not convert; the *grader's* runners are an instrument too — one public runner reads **≈2.58 points low**, far above either floor, so never subtract two scores from different runners: [references/stage5-verdicts.md](references/stage5-verdicts.md) § External scores are instrument-qualified |
19| Localise a numerical error | `pypto-pro-op-develop` | [playbooks/numerical-error-localisation.md](playbooks/numerical-error-localisation.md) |22| Localise a numerical error | `pypto-pro-op-develop` | [playbooks/numerical-error-localisation.md](playbooks/numerical-error-localisation.md) |
23+| A clamp, epsilon or guard constant has no effect, or a small float constant reads back as `0.0` | `pypto-pro-op-develop` | [constraints/vec-scalar-immediate.md](constraints/vec-scalar-immediate.md) — same defect as the `vf` float-immediate row above, reached from the symptom rather than the cause: immediates are emitted at 6 decimal places, and the knee is ~5e-7, **above** the fp16 subnormal floor, so it is truncation and not underflow |
24+| A kernel is exact on a small shape and corrupts on a larger one, with the same key and the same generated code | `pypto-pro-op-develop` | [patterns/buffer-reuse-lifetime.md](patterns/buffer-reuse-lifetime.md) — a declared buffer count that overruns the next group's base; then [references/investigation-discipline.md](references/investigation-discipline.md) §13.2 — nothing is observable at ≤ 1 work item per core |
20| Quantize per row with a scale that is also an output | `pypto-pro-op-design` | [patterns/vec-per-token-dynamic-quant.md](patterns/vec-per-token-dynamic-quant.md) |25| Quantize per row with a scale that is also an output | `pypto-pro-op-design` | [patterns/vec-per-token-dynamic-quant.md](patterns/vec-per-token-dynamic-quant.md) |
21| Feed an integer Cube contraction directly into a floating-point epilogue | `pypto-pro-op-design` / `pypto-pro-op-develop` | [patterns/cv-quant-matmul-direct-epilogue.md](patterns/cv-quant-matmul-direct-epilogue.md) |26| Feed an integer Cube contraction directly into a floating-point epilogue | `pypto-pro-op-design` / `pypto-pro-op-develop` | [patterns/cv-quant-matmul-direct-epilogue.md](patterns/cv-quant-matmul-direct-epilogue.md) |
22| Changing a staged multi-phase Cube matmul and need target-version precision/performance gates before trusting an alternative | `pypto-pro-op-develop` / `pypto-pro-op-perf-tune` | [references/staged-cube-matmul-gates.md](references/staged-cube-matmul-gates.md) |27| Changing a staged multi-phase Cube matmul and need target-version precision/performance gates before trusting an alternative | `pypto-pro-op-develop` / `pypto-pro-op-perf-tune` | [references/staged-cube-matmul-gates.md](references/staged-cube-matmul-gates.md) |
23| Choosing or varying a kernel's per-launch `block_dim` / core count | `pypto-pro-op-develop` / `pypto-pro-op-perf-tune` | [references/pypto-pro-launch-block-dim.md](references/pypto-pro-launch-block-dim.md) |28| Choosing or varying a kernel's per-launch `block_dim` / core count | `pypto-pro-op-develop` / `pypto-pro-op-perf-tune` | [references/pypto-pro-launch-block-dim.md](references/pypto-pro-launch-block-dim.md) |
29+| Choosing a launch geometry or tile geometry — block count, `Rows`, "should I use every core" — or a launch is slower than its bytes while every core is busy | `pypto-pro-op-design` / `pypto-pro-op-perf-tune` | [constraints/tiling.md](constraints/tiling.md) — per-core load balance is the quantity the sweep ranks; sweep per shape; `section_vector` sizes from `vector_core_num`, **not** `core_num` |
30+| An elementwise / activation kernel is slower than its byte count predicts, and the dataflow is already trivial | `pypto-pro-op-perf-tune` | [patterns/vec-elementwise-tile-and-balance.md](patterns/vec-elementwise-tile-and-balance.md) — work division and tile width are all that is left; three triggers checkable in the host wrapper |
31+| Every core walks its own contiguous GM span, work is balanced, and the time still swings tens of percent between neighbouring tile widths / shard granules / buffer depths with no ordering | `pypto-pro-op-perf-tune` | [constraints/gm-concurrent-stride-aliasing.md](constraints/gm-concurrent-stride-aliasing.md) — one granule change tests it; the remedy is counter-indicated once the stream is at its move-only floor |
32+| Need a vector GM bandwidth ceiling to price a lever against, or two circulating ceilings disagree | `pypto-pro-op-perf-tune` | [constraints/arch-a5.md](constraints/arch-a5.md) § the measured vector GM floor — the floor-probe method, and a **per-SKU** number that must not be quoted as universal |
33+| Reduce rows whose last dimension is a handful of bytes | `pypto-pro-op-design` | [patterns/vec-narrow-row-packing.md](patterns/vec-narrow-row-packing.md) |
34+| One row of the reduced axis does not fit one tile, so the kernel splits it across column blocks | `pypto-pro-op-design` / `pypto-pro-op-perf-tune` | [patterns/vec-colblk-resident-row.md](patterns/vec-colblk-resident-row.md) — keep the blocks resident and hoist gamma; `3D read + D write``D read + D write` |
35+| One kernel must serve a last dimension spanning orders of magnitude, or several dtypes, or several ranks | `pypto-pro-op-design` / `pypto-pro-op-develop` | [patterns/tiling-key-width-ladder.md](patterns/tiling-key-width-ladder.md) |
36+| A vector kernel recomputes the same loop-invariant quantity, or its register pressure is set at compile time | `pypto-pro-op-perf-tune` | [patterns/vec-compile-time-register-count.md](patterns/vec-compile-time-register-count.md) |
37+| A compute chain is exposed at a narrower dtype than the operator's declared precision | `pypto-pro-op-design` / `pypto-pro-op-develop` | [patterns/vec-compute-exposed-narrow-dtype.md](patterns/vec-compute-exposed-narrow-dtype.md) |
38+| A correct `vf.*` kernel is far slower than its memory roof | `pypto-pro-op-perf-tune` | **first check the row width, in BOTH directions**: below the strided-load knee go to [patterns/vec-narrow-row-packing.md](patterns/vec-narrow-row-packing.md); wider than one tile holds go to [patterns/vec-colblk-resident-row.md](patterns/vec-colblk-resident-row.md); otherwise [constraints/vec.md](constraints/vec.md), then [constraints/vec-alignment-and-rotation.md](constraints/vec-alignment-and-rotation.md) |
24| Hit a framework limit, or debug something that makes no sense | any | [references/pypto-pro-framework-findings.md](references/pypto-pro-framework-findings.md) |39| Hit a framework limit, or debug something that makes no sense | any | [references/pypto-pro-framework-findings.md](references/pypto-pro-framework-findings.md) |
25| An API looks unsupported, or a correct-looking call returns stale/wrong data | any | [references/pypto-pro-dsl-limitations-a5.md](references/pypto-pro-dsl-limitations-a5.md) — severity-ordered, silent failures first |40| An API looks unsupported, or a correct-looking call returns stale/wrong data | any | [references/pypto-pro-dsl-limitations-a5.md](references/pypto-pro-dsl-limitations-a5.md) — severity-ordered, silent failures first |
26| An investigation keeps failing to converge, or you are about to trust a measurement | any | [references/investigation-discipline.md](references/investigation-discipline.md) |41| An investigation keeps failing to converge, or you are about to trust a measurement | any | [references/investigation-discipline.md](references/investigation-discipline.md) |
42+| A sweep over one axis shows behaviour tracking one candidate's parameter and not another's, and you are about to name a cause | any | [references/investigation-discipline.md](references/investigation-discipline.md) §19 — a sweep falsifies only a parameter that is **not** 1-to-1 with the swept axis, so it buys **exonerations**; an attribution is conditional on the candidate list being complete, and must be stated with it |
43+| Sizing a determinism or ablation matrix — how many configurations, how many runs per configuration | any | [references/investigation-discipline.md](references/investigation-discipline.md) §18.1 — breadth separates mechanisms, depth establishes presence/absence; a broad matrix of shallow cells is a table of unreliable booleans, and each cell must carry its run count and rate bound |
27| A whole run failed at once (0/N), or a change "did nothing", or a number reproduces suspiciously well | any | [references/investigation-discipline.md](references/investigation-discipline.md) §2, §13, then `pypto-pro-environment-check` |44| A whole run failed at once (0/N), or a change "did nothing", or a number reproduces suspiciously well | any | [references/investigation-discipline.md](references/investigation-discipline.md) §2, §13, then `pypto-pro-environment-check` |
28| Several agents are working in parallel on one shared record, or you are merging their branches | any | [references/investigation-discipline.md](references/investigation-discipline.md) §10 |45| Several agents are working in parallel on one shared record, or you are merging their branches | any | [references/investigation-discipline.md](references/investigation-discipline.md) §10 |
29| Map a Chinese/English hardware, pipe, tiling, or layout term to its meaning | any | [references/terminology.md](references/terminology.md) |46| Map a Chinese/English hardware, pipe, tiling, or layout term to its meaning | any | [references/terminology.md](references/terminology.md) |
47+| Recognise a failure someone has already hit | `pypto-pro-op-develop` / `pypto-pro-op-perf-tune` | [references/pitfall-records.md](references/pitfall-records.md) |
48+| Judge whether an ops-per-register lever can pay on a narrow reduction, or are about to generalise a perf measurement across dtypes | `pypto-pro-op-perf-tune` | [references/narrow-trailing-dim-roofline-a5.md](references/narrow-trailing-dim-roofline-a5.md) |
49+| Judging a Stage-5 tuning result, or a whole-set failure that might be the card rather than the kernel | `pypto-pro-op-perf-tune` | [references/stage5-verdicts.md](references/stage5-verdicts.md) — 结果词汇与环境故障分流;**PASS 判据以 `stage5-check` P1–P6/P8 为准** |
50+| Recording which tuning candidates were considered, and why one was rejected — especially a rejection that cites a proximity guard | `pypto-pro-op-perf-tune` | [references/candidate-ledger-schema.md](references/candidate-ledger-schema.md) — the serialized 候选覆盖账本 and its checks; a guard margin must take its value and its limit from the **same** instrument |
51+| A measured decomposition names a dominant cost term and you are choosing what to build next — or a run is about to call that term irreducible / write its completion section — or, with no decomposition yet, the case anchors show total work against core count at maximum legal parallelism so small that `t_hw` may be comparable to the card's per-launch fixed cost | `pypto-pro-op-perf-tune` | [playbooks/dominant-cost-attack-matrix.md](playbooks/dominant-cost-attack-matrix.md) — one row per transformation class over the largest measured term, each closed by a terminal verdict; the algebraic-reformulation row is the one no profiler suggests; the fixed-overhead / launch cost row is the one a-priori row, reachable before any decomposition exists |
52+| A lever frees a shared capacity (UB bytes, registers, buffer slots, workspace) and its first spend did not pay — or you are about to generalise a perf conclusion across shapes, dtypes or work division | `pypto-pro-op-perf-tune` | [references/capacity-and-regime-method.md](references/capacity-and-regime-method.md) — a release closes per enumerated spend, never on one; every conclusion carries the regime cell it was measured in |
53+ 
54+[references/pitfall-records.md](references/pitfall-records.md) is a symptom
55+index, not a rule page: it maps an observed error code, hang or wrong result to
56+the cause that was actually found. Read it when something fails, not when
57+designing. Each entry records the runtime it was observed on; confirm the
58+behaviour still reproduces on the detected target before treating an entry as
59+current.
30 60 
31Load [constraints/arch-a5.md](constraints/arch-a5.md) when runtime/build selects A5 or when61Load [constraints/arch-a5.md](constraints/arch-a5.md) when runtime/build selects A5 or when
32the workflow default A5 applies; do not use its numerical limits until the exact device and source are confirmed.62the workflow default A5 applies; do not use its numerical limits until the exact device and source are confirmed.
Mcannbot-skills/ops/pypto-pro-op-kb/check_kb_integrity.py+152-4文件内容审核中,请稍后刷新重试
@@ -8,10 +8,13 @@ Use these pages as focused supplements to the installed API documentation:
8| [tiling.md](tiling.md) | tile shape, layout, and memory-space legality |8| [tiling.md](tiling.md) | tile shape, layout, and memory-space legality |
9| [memory-layout.md](memory-layout.md) | address ownership, layout conversion, and overlap |9| [memory-layout.md](memory-layout.md) | address ownership, layout conversion, and overlap |
10| [sync-stitch.md](sync-stitch.md) | tile-group mutexes and section synchronization |10| [sync-stitch.md](sync-stitch.md) | tile-group mutexes and section synchronization |
11+| [vec-scalar-immediate.md](vec-scalar-immediate.md) | float immediates passed to `vf.*` ops, and the precision floor they are emitted at |
12+| [wrapper-boundary.md](wrapper-boundary.md) | what may and may not happen on the host side of the kernel wrapper |
11| [tail-validshape.md](tail-validshape.md) | dynamic dimensions and tail windows |13| [tail-validshape.md](tail-validshape.md) | dynamic dimensions and tail windows |
12| [vec.md](vec.md) | conditional tile-op / vector-function authoring |14| [vec.md](vec.md) | conditional tile-op / vector-function authoring |
13| [vec-alignment-and-rotation.md](vec-alignment-and-rotation.md) | vf lane and reduction-row alignment, buffer rotation, bare-tile sync |15| [vec-alignment-and-rotation.md](vec-alignment-and-rotation.md) | vf lane and reduction-row alignment, buffer rotation, bare-tile sync |
14| [vec-mask-width.md](vec-mask-width.md) | converting a mask between b8/b16/b32 element widths |16| [vec-mask-width.md](vec-mask-width.md) | converting a mask between b8/b16/b32 element widths |
17+| [gm-concurrent-stride-aliasing.md](gm-concurrent-stride-aliasing.md) | per-core contiguous GM spans walked in lockstep: when the shared stride aliases, and when rotating it costs |
15| [arch-a5.md](arch-a5.md) | A5-only platform discovery and evidence gate |18| [arch-a5.md](arch-a5.md) | A5-only platform discovery and evidence gate |
16 19 
17Do not load a platform-specific page until the target architecture is20Do not load a platform-specific page until the target architecture is
@@ -45,18 +45,24 @@ version.
45 a SKU's core count.45 a SKU's core count.
46- Re-run correctness after any dtype, layout, buffering, or tile-size change.46- Re-run correctness after any dtype, layout, buffering, or tile-size change.
47 47 
48-## The UB capacity dispute is settled, and the settling generalises48+## A5 UB is 253952 B, and how that is established generalises
49 49 
50-Two values were in circulation for A5 UB: **248 KB**, from the installed tutorial50+**A5 UB is `ub_size = 253952` 248 KB exactly.** `950PR_957x.ini` carries it, and so do
51-(`.../tile_based_python_programming/multi_core_partitioning_and_Tiling.md`, stated51+`950DT_957x`, `950PR_958x` and `950DT_958x`. The installed tutorial
52-once as the limit and once inside a worked FP16/FP32 budget example), and52+(`.../tile_based_python_programming/multi_core_partitioning_and_Tiling.md`) agrees, stating
53-**256 KB / 216 KB-with-SIMT** from a sibling DSL's device profile for the same53+248 KB once as the limit and once inside a worked FP16/FP32 budget example. A sibling DSL's
54-silicon.54+device profile for the same silicon says **256 KB / 216 KB-with-SIMT**; that is not the
55+budget the toolchain compiles against on this SKU, and the platform file is what decides.
55 56 
56-**The platform file decides it.** `950PR_957x.ini` carries `ub_size=253952`, which57+🔴 **The physical buffer is larger than the budget, and that is not a licence to spend
57-is 248 KB exactly, and so do `950DT_957x`, `950PR_958x` and `950DT_958x`. The58+it.** A direct probe (`references/pitfall-records.md`, the softmax UB-growth row)
58-tutorial and the platform config agree; the 256 KB figure does not describe the59+allocates and computes bit-exact at **259072 B** and faults at **265216 B** so the
59-budget the toolchain compiles against on this SKU.60+usable silicon exceeds `ub_size`, and 262144 sits in an untested bracket between the
61+two. Both facts hold at once. **Budget every design against the `.ini` `ub_size =
62+253952`; treat anything above it as unsupported even where a probe happens to pass.**
63+The single-tile gate that follows from it is `4 * TR * align64(D) * 4 ≤ 253952`, i.e.
64+`align64(D) ≤ 1984` at `TR = 8` fp32 — and `1984 = 31 × 64`, so the bound is itself
65+64-aligned and reachable.
60 66 
61Two things about *how* that was settled matter more than the number:67Two things about *how* that was settled matter more than the number:
62 68 
@@ -79,6 +85,94 @@ makes the whole capacity question cheap: `l0_a_size`, `l0_b_size`, `l0_c_size`,
79independently — are all keys in the same `.ini`. Read them there rather than from85independently — are all keys in the same `.ini`. Read them there rather than from
80any page, including this one.86any page, including this one.
81 87 
88+### The core counts are the exception: the `.ini` and the live device disagree
89+ 
90+**Capacities come from the platform file; core counts come from the runtime
91+platform query.** The two sources are not interchangeable, and on at least one
92+A5 installation they differ:
93+ 
94+| source | cube / vector cores |
95+|---|---|
96+| `950PR_957x.ini` (and the other `957x` variants) | 28 / 56 |
97+| the runtime platform query on the live device | **32 / 64** |
98+ 
99+Measured on Ascend950PR / CANN 9.2.0, 2026-08-24. A launch geometry sized from
100+the `.ini` would leave a quarter of the array idle on that box while every
101+device-free gate passed, because nothing in the toolchain compares the two.
102+ 
103+The rule this page already states — *read launch width from runtime platform
104+information rather than hard-coding a SKU's core count* — is therefore not
105+merely a portability preference: **the static file is a different number, not a
106+stale copy of the same one.** Capacities have no such live query (the platform
107+object exposes `soc_version`, `core_num`, `cube_core_num`, `vector_core_num` and
108+a derived `arch`, and no memory sizes at all), so the split is forced: byte
109+budgets from the `.ini` key, core counts from the query, and never the reverse.
110+ 
111+The permitted form, concretely: `from pypto_pro.runtime.platform import
112+get_platform_info`, then `_VEC = int(getattr(get_platform_info(),
113+"vector_core_num", <fallback>))`, read once at import and used to size the
114+vector launch. That query is the **only** legal source of a core count in
115+shipped code, and a literal is permitted **only** as the `getattr` fallback.
116+ 
117+🔴 **Neither number in this page's tables may be transcribed into a kernel.**
118+Both tables are records of specific parts; the SKU a grading run lands on is not
119+known at authoring time; and the two parts below report the same `soc_version`,
120+so nothing a device-free gate can read tells them apart. A literal that is
121+correct on the box in front of you therefore passes every local measurement and
122+is still wrong where it is scored. `VECTOR_CORES = 56 # from the table` is the
123+prohibited form, whichever row it was copied from — the tables here exist to
124+show the sources disagree, not to be read from. **Citing this page beside a
125+literal core count is a mis-citation.**
126+ 
127+**Three A5 parts in evidence here report DIFFERENT core counts**, and all are real:
128+ 
129+| part | cube / vector |
130+|---|---|
131+| `Ascend950PR_9579` | 28 / 56 |
132+| `Ascend950PR_9589` | **32 / 64** |
133+| `Ascend950PR_957b` | 28 / 56 (runtime query) |
134+ 
135+The `957b` row was added 2026-08-27 from a dynamic_quant Stage-5 run on box a5-238 card 3:
136+`torch.npu.get_device_name(3)` -> `Ascend950PR_957b`, and `get_platform_info()` on the live
137+device reported `core_num=28, cube_core_num=28, vector_core_num=56`, `soc_version=DAV_3510`.
138+It is a fourth distinct part name in this KB once `Ascend950PR_957c` (the grader, recorded in
139+`references/pitfall-records.md`) is counted, and it reports the same `DAV_3510` as the others
140+— which is the discriminate-by-device-name rule below, observed again rather than argued.
141+ 
142+🔴 **A recorded instance of the prohibited form, from the same run.** A previous Stage-5
143+attempt on that operator shipped `VECTOR_CORES = 56 # A5 vector_core_num (constraints/arch-a5.md)`
144+— a literal transcribed from the table above, *citing this page as its authority*. That is
145+exactly the form and exactly the mis-citation this section names. It was numerically right on
146+the box it was written on, which is precisely why it survived every local gate. The legal
147+form is the `getattr` query below. Measured against the literal on the same card in the same
148+session, the query moved exactly the cases whose tile count exceeds the core count and left the
149+cases where the cap cannot bind unchanged; the magnitudes live with that run's own evidence,
150+not on this page.
151+ 
152+So "56 blocks" on one page and "32 cores" on another are **different hardware, not a
153+contradiction** — and a per-core figure measured on one does not port to the other.
154+ 
155+🔴 **Discriminate by DEVICE NAME, not by `soc_version`.** Both parts above
156+report `DAV_3510`; `pypto-pro-op-perf-tune/references/evidence-protocol.md` states
157+that `DAV_3510` plus a core count does **not** uniquely determine a SKU. Record
158+`torch.npu.get_device_name(...)` beside every number, and read "56 blocks" on one
159+page and "32 cores" on another as *different hardware*, not a contradiction.
160+ 
161+**`section_vector` must size from `vector_core_num`, not `core_num`.** Reading the
162+wrong one launches on half the array. Magnitude and its caveat live in
163+`constraints/tiling.md` § launch geometry — quote it from there, with the caveat.
164+A block-count sweep result likewise does **not** port across SKUs: sweep on the part
165+you will be measured on.
166+ 
167+Two consequences for a design document:
168+ 
169+- A UB budget must be a **named module constant citing its `.ini` key**, since
170+ there is no API to read it from. That is not a workaround for laziness; it is
171+ the only available form.
172+- A core count written as a literal anywhere is a defect even when it is
173+ currently right, because the number that is right is the one the device
174+ reports at run time.
175+ 
82**What this does to the sibling DSL's device table.** Checked row by row against176**What this does to the sibling DSL's device table.** Checked row by row against
83`950PR_957x.ini`, it is right about L0A/L0B (64 KB each), L0C (256 KB), L1177`950PR_957x.ini`, it is right about L0A/L0B (64 KB each), L0C (256 KB), L1
84(512 KB), BT (4 KB), the 28/56 core split, and A2/A3's 192 KB UB — and wrong only178(512 KB), BT (4 KB), the 28/56 core split, and A2/A3's 192 KB UB — and wrong only
@@ -92,3 +186,115 @@ Platform configuration values are roofline inputs, not measured kernel
92performance. Use the target profiler to determine the current bottleneck. See186performance. Use the target profiler to determine the current bottleneck. See
93the platform-gated187the platform-gated
94[A5 roofline workflow](../../pypto-pro-op-perf-tune/references/a5-roofline-and-levers.md).188[A5 roofline workflow](../../pypto-pro-op-perf-tune/references/a5-roofline-and-levers.md).
189+ 
190+### The measured vector GM floor — and why it is a per-SKU number
191+ 
192+A vector GM bandwidth ceiling is **not** in the platform `.ini`, so unlike the capacities
193+above it cannot be resolved by reading a key. It has to be measured, and the answer is a
194+per-SKU number that varies by roughly a factor of two across A5 parts: **~2.2 TB/s load /
195+~2.35 TB/s store** (≈3.3–4.6 aggregate) on **Ascend950, 32 cores**, against **~1.48 TB/s**
196+aggregate on the 56-vector-core part measured below. Those two decide a tuning verdict in
197+opposite directions, which is why the floor probe below is run on the detected target rather
198+than quoted from a page.
199+ 
200+**Method — the floor probe.** Take the kernel you actually ship and delete 100 % of the
201+arithmetic, keeping the tiling, the launch geometry, the buffering and the DMA schedule
202+identical: the vector body becomes `load_align``store_align`. The result is numerically
203+wrong by construction and is never shipped; it prices the data movement of *that*
204+implementation, which is the bound a compute-side lever is competing against. Run it at two
205+sizes far past L2 so the measurement is DRAM-limited, one shape per process.
206+ 
207+**Result on `Ascend950PR_9579`** (28 cube / 56 vector, `soc_version DAV_3510`, CANN 9.2.0,
208+2026-08-25), a `section_vector` load+store-only kernel on 56 blocks:
209+ 
210+| working set (read + write) | aggregate |
211+|---|---|
212+| 268 MB | **1.49 TB/s** |
213+| 537 MB | **1.44 TB/s** |
214+ 
215+and the *full* kernels at the same geometry measure 1.53 and 1.44 TB/s — i.e. on the two
216+largest shapes, deleting all the arithmetic changes nothing beyond the round-to-round spread.
217+**So the ~1.48 TB/s figure is the one that describes this part**, and the 2.2/2.35 pair
218+describes a different SKU with a different core count.
219+ 
220+### A third part, probed the same way: `Ascend950PR_957b`
221+ 
222+Same method, 2026-08-27, on a per-token dynamic-quant vector kernel (`section_vector`, 56 blocks
223+from the runtime query, rung 16384 x 1 row, io and int8 tiles double-buffered). The shipped kernel
224+had 100 % of its arithmetic deleted with the tiling, launch geometry, buffering and DMA schedule
225+held identical; 10 timed launches, msprof `op_summary_PipeUtilization` Task Duration.
226+ 
227+| shape (bf16) | contractual traffic | move-only floor | achieved |
228+|---|---|---|---|
229+| 8192 x 16384 | 402.69 MB (268.44 read + 134.22 write int8 + 0.03 scale) | 324.2 us (median) | **1.24 TB/s** |
230+ 
231+So the aggregate move-only floor on this part reads below the ~1.44-1.53 TB/s recorded above for
232+`Ascend950PR_9579`, on a comparable working set. Both are 28/56 parts by the runtime query, which
233+is a reminder that the floor is a per-part measurement rather than a per-core-count one -- re-probe
234+it, do not carry either number across.
235+ 
236+**Two method notes that changed the reading here.** The floor probe's `min` came in at 297.4 us
237+while its 2nd-smallest and median were 320.6 and 324.2 -- the robust pair agreeing with each other
238+and the min standing apart. Taking the min would have overstated the floor's bandwidth by ~9 % and
239+understated how far the full kernel sits above it. The full kernel's own three statistics agreed
240+within 0.2 %, so only the probe needed the judgement. And the same kernel measured through msprof
241+and through the torch_npu profiler agreed to 0.01 % on this shape, so on this part the two
242+front-ends are interchangeable for a target-kernel duration -- which is worth knowing before
243+paying for a second collection to cross-check one.
244+ 
245+**🔴 The probe silently answers a different question on an L2-resident shape, and it does not
246+announce that it has.** The method above says to run "at two sizes far past L2 so the measurement
247+is DRAM-limited". Run on a shape that is *not* past L2 it still returns a number, and that number
248+is fixed launch/teardown cost plus L2-resident traffic -- not a movement floor. Dividing the
249+shape's bytes by it yields a plausible-looking TB/s that is an artifact of the division.
250+ 
251+**The discriminator is free, and it is the only thing that tells the two regimes apart: sweep at
252+least two shapes whose byte counts differ, and check whether the FLOOR tracks the bytes.** Measured
253+on one operator's five graded cases, 2026-08-27: across the four L2-resident cases the byte count
254+spanned **16.5 %** while the measured floor spanned **2.2 %** -- one case moved 14 % fewer bytes
255+than another and landed within 0.05 % of the same floor. The one case far past L2, on the same
256+kernel and the same probe, took **156x** the floor for **127x** the bytes, i.e. roughly
257+proportional. Same instrument, same day, opposite regimes.
258+ 
259+Consequence for a Stage-5 terminal-state claim: a floor probe only licenses a
260+`data_movement_bound` / `balanced_compute_movement` reading on the shapes where its floor scales
261+with size. On the L2-resident ones it licenses a statement about how much time the *arithmetic*
262+accounts for (there, 70-74 %) and nothing about which side the bound sits on -- and "arithmetic is
263+most of the time" is not `compute_bound`, which additionally needs the compute shown to be at a
264+hardware limit. Reading it as one is reading a ratio as a bound.
265+ 
266+### The constant-geometry byte sweep
267+ 
268+**And the fix, which is cheap: sweep the byte count at CONSTANT geometry.** Hold the rung,
269+rows-per-tile, launch width and DMA shape fixed and vary only how many bytes move, then fit the
270+floor. On one A5 part, one operator, four points spanning 3.1 MB to 201.6 MB at a single
271+geometry, the floor came out as **two regimes rather than one line**, with marginal slopes
272+**0.135 us/MB** on the L2-resident points and **0.986 us/MB** on the DRAM-limited ones -- a
273+**7.3x** difference on the same kernel and the same probe. The L2-regime fit
274+`floor_us = 1.643 + 0.1352 * MB` puts the **size-independent term at 1.643 us**, which on a ~3 MB
275+shape is four fifths of everything the probe returns.
276+ 
277+That is what makes a single-shape floor probe misleading below L2: the number is mostly the
278+intercept. The sweep separates them, and it costs one extra run per point. Two uses follow:
279+ 
280+- a terminal-state claim on an L2-resident shape becomes possible, because `a` and `b` decompose
281+ the floor into fixed cost and real movement. On the same operator this put four graded cases at
282+ **5-6 %** of runtime in movement against **70-74 %** in arithmetic (the arithmetic measured by
283+ ablation, not inferred), which rules `data_movement_bound` out on the numbers rather than on a
284+ ratio reading;
285+- the DRAM-regime slope from the sweep is directly comparable to a large-shape floor measured at a
286+ different geometry, which is a cross-check that both are measuring the same physical thing --
287+ here 1.01 TB/s from the sweep against 1.24 TB/s from a rung-16384 single-row geometry.
288+ 
289+🔴 Do not read the L2-regime slope as a bandwidth to design against. It is an effective figure for
290+a working set that fits, and it disappears the moment the shape does not.
291+ 
292+**Do not quote 1.44–1.53 TB/s as a universal A5 number.** It is one SKU, one core count,
293+one CANN version, measured by the method above; it is recorded here so the next run can start
294+from a measurement instead of a dispute, and so that the two circulating figures are visibly
295+attached to the parts they were taken on. Re-run the probe on the detected target before using
296+either. The consequence worth carrying is structural rather than numeric: **a modelled
297+memory-time that implies more than the measured aggregate is unreachable by any pure-vector
298+implementation on that part**, and a kernel measured at that floor has no compute-side lever
299+left — which is also the counter-indication gate on
300+[gm-concurrent-stride-aliasing.md](gm-concurrent-stride-aliasing.md).
@@ -0,0 +1,154 @@
1+# GM-side concurrent-stride aliasing across cores
2+ 
3+## Applies to
4+ 
5+Any kernel that gives each vector core its **own contiguous span** of one GM buffer and walks
6+that span in lockstep with every other core. The elementwise partitioning
7+`span = ceil(n / (cores * granule)) * granule`, with core `ci` starting at `ci * span` and
8+stepping one tile at a time, is the canonical shape; a long-axis traversal with one span per
9+core has it too. It is a property of the **partitioning**, not of the computation, so no
10+topology label decides whether it applies — read the wrapper.
11+ 
12+This page is about the **GM** side. The UB-side pitch rules
13+([vec-alignment-and-rotation.md](vec-alignment-and-rotation.md) § power-of-two store strides,
14+and the gather pitch in
15+[../patterns/vec-scan-prefix-dependent.md](../patterns/vec-scan-prefix-dependent.md)) are a
16+different mechanism at a different memory, and neither predicts this one.
17+ 
18+**Instrument for every number below.** Ascend950PR_9579 (28 cube / 56 vector,
19+`soc_version DAV_3510`), CANN 9.2.0, 2026-08-25, one flat elementwise kernel — 56 blocks,
20+16 KB tiles, depth 2, one contiguous span per core, zero cross-lane ops. Figures marked
21+`[iso]` are one shape per process, three rounds; the rest are a screening pass that runs a
22+whole shape suite in one process. **Read the measurement-hygiene note below before trusting a
23+screening figure** — it reversed the sign of a verdict here.
24+ 
25+## Symptom
26+ 
27+A kernel whose per-core work is provably balanced, whose UB budget is comfortable and whose
28+vector body is already minimal runs **tens of percent** above its own move-only floor on
29+*some* shapes and exactly at that floor on others, with no ordering in dtype, element count
30+or tile count. Every knob you turn is **non-monotone**: buffer depths 3 and 6 measure worse
31+than *both* depth 2 and depth 4 on one shape, and a tile-width sweep picks a different winner
32+per shape with no pattern.
33+ 
34+Reproducibility is what separates this from noise: round-to-round spread stays under 1 %, so
35+it is a deterministic address effect and does not average out.
36+ 
37+## Cause
38+ 
39+All `cores` cores issue their tile loads at the same rate from bases `ci * span`. At any
40+instant the concurrently-outstanding DMA addresses are therefore separated by **exactly
41+`span`** — and `span` is a value the wrapper computed from the element count, not a value
42+anyone chose. When `span` lands on an unlucky stride the concurrent accesses fall in the same
43+memory sets and serialise; a neighbouring value of `span` does not.
44+ 
45+## Diagnostic — move the stride without moving the work
46+ 
47+The decisive experiment is a change to `span` too small to change anything else. Raise the
48+shard granule that `span` is rounded up to by one step, and re-measure:
49+ 
50+```python
51+PART = 256 # elements; 32 B aligned for every dtype
52+span = ((n + cores * PART - 1) // (cores * PART)) * PART
53+```
54+ 
55+Raising `PART` from 256 to 512 on a 16.8 M-element bf16 shape moves the span from 299 776 to
56+300 032 elements — **0.09 %** — and moves the time **17.76 → 10.18 µs, −42.7 %**. (Screening
57+pass; this shape reproduces isolated, 17.405–17.717 µs `[iso]` for the same in-order arm.)
58+ 
59+A 0.09 % change in a stride cannot change how many bytes move or how many instructions issue.
60+It can only change which addresses are in flight together. **If the two granules land within
61+your noise floor, you do not have this defect**, and the remedy below will cost you.
62+ 
63+### 🔴 Measurement hygiene: one shape per process
64+ 
65+Batching several shapes into one process changes L2 residency enough to **reverse the sign of
66+a verdict**. On this kernel a 10.9 M fp32 shape read 19.07 µs batched and 21.10 µs `[iso]` on
67+the same binary; a rotation variant read a 24 % *regression* batched and a 7.6 %
68+*improvement* `[iso]`. A second shape's apparent +4.8 % regression under the shipped rotation
69+turned out to be **−0.09 %** once isolated — i.e. one of the two numbers that would have
70+rejected the lever did not exist. Measure one shape per process before any accept/reject on
71+this page.
72+ 
73+## Remedy — rotate the per-core visiting order
74+ 
75+The rotation moves **no bytes**, changes no tiling and changes no output: core `ci` starts at
76+tile `rot` of its own span and wraps, so the concurrent addresses stop being separated by a
77+single constant.
78+ 
79+```python
80+ntile = (span + TILE_ELEMS - 1) // TILE_ELEMS
81+rot = pl.min(ci, ntile - 1) # no division; see the spelling note
82+for t in pl.range(rot, ntile): # pl.range accepts a runtime scalar start
83+ body(t)
84+for t in pl.range(0, rot):
85+ body(t)
86+```
87+ 
88+| shape, dtype | in order `[iso]` | rotated `[iso]` | Δ |
89+|---|---|---|---|
90+| 16.8 M bf16 | 17.405 / 17.507 / 17.717 µs | **10.388 / 10.487 / 10.537** | **−39.7 %** |
91+| 10.9 M fp32 | 21.097 / 21.256 / 21.305 µs | **11.107 / 11.113 / 11.163** | **−47.4 %** |
92+ 
93+**The spelling is load-bearing, and the obvious one hides the result.** Written as
94+`(t + rot) % ntile` inside the loop it pays two integer divisions per tile and costs the
95+small, launch-dominated shapes **+3.1 % and +7.1 %** `[iso]` — enough to read as "rotation is
96+bad for small inputs, full stop", which is false. The two division-free passes above remove
97+one division; `pl.min(ci, ntile - 1)` in place of `ci % ntile` removes the last, and the
98+small-shape cost then disappears (−2.7 % to +0.8 % over four ~1 M-element shapes). `pl.min`
99+is an approximation of the modulus — it gives the first `ntile` cores distinct starting tiles
100+and lets the rest share the last one — and it was sufficient wherever the rotation paid at all.
101+ 
102+## Counter-indication — it taxes a stream that is already at the floor
103+ 
104+Rotation scatters a sequential stream, and a sequential stream is the best case for DRAM row
105+locality. Where the kernel already sits **at its move-only floor** — deleting 100 % of the
106+arithmetic changes nothing — there is no aliasing left to fix and the rotation is pure cost:
107+ 
108+| shape, dtype | in order | rotated | Δ |
109+|---|---|---|---|
110+| 67 M fp16 (268 MB) | 174.99 µs | 185.50 (screening) | **+4.33 % `[iso]`**, +6.0 % screening |
111+ 
112+That is one confirmed counter-example, not a class: the *other* shape that appeared to
113+regress under the same variant measured **−0.09 %** once isolated. Both facts belong here —
114+the cost at the floor is real, and it is smaller and rarer than a batched screen suggests.
115+ 
116+So this is **not** a blanket recommendation. The gate is two predicates, both answerable
117+before the rotation is written:
118+ 
119+1. the granule diagnostic moves the time by more than the noise floor, **and**
120+2. the shape is not already at its move-only floor — measure that floor directly by running
121+ the same kernel with the vector body reduced to `load_align; store_align`
122+ (method and the per-SKU number: [arch-a5.md](arch-a5.md) § the measured vector GM floor).
123+ 
124+Both held on the two shapes that gained 40–47 % and neither held on the one that lost 4 %.
125+Where the gate has to be decided per shape it wants a **compile-time rung** — a `tilingkey`
126+field selecting a rotated body and an in-order body — rather than a runtime branch; see
127+[../patterns/tiling-key-width-ladder.md](../patterns/tiling-key-width-ladder.md).
128+ 
129+## Probe
130+ 
131+One build, and no golden is needed, because the rotation is output-identical by construction:
132+ 
133+1. Write the kernel with per-core contiguous spans and a shard granule `PART` you can vary.
134+2. Pick a shape whose working set is L2-resident or a small multiple of it. The effect is
135+ largest there and disappears into DRAM latency at HBM scale.
136+3. Measure `PART` and `2 * PART` **one shape per process**, three rounds, reporting `min`
137+ next to the spread. A time difference far larger than the ~0.1 % span difference is the
138+ finding.
139+4. Add the two-pass rotation and re-measure. Its output must be **bit-identical** to the
140+ in-order run; if any output word moved, the rotation is wrong and the memory system is not
141+ what you measured.
142+5. Repeat step 3 on a shape you have separately shown to be at the move-only floor, and
143+ expect a few percent of cost there. That negative arm is what stops the finding from being
144+ generalised into a rule.
145+ 
146+## Related
147+ 
148+- [tiling.md](tiling.md) — launch geometry and per-core load balance. Balance and stride are
149+ independent quantities: the shapes above were already perfectly balanced.
150+- [arch-a5.md](arch-a5.md) — the measured vector GM floor this page's gate refers to.
151+- [../patterns/vec-elementwise-tile-and-balance.md](../patterns/vec-elementwise-tile-and-balance.md)
152+ — the partitioning that creates the lockstep span in the first place.
153+- [vec-alignment-and-rotation.md](vec-alignment-and-rotation.md) — the UB-side pitch rules;
154+ different memory, different mechanism, not a substitute for the probe above.
@@ -18,6 +18,12 @@ updating the golden and acceptance criteria.
18 "Widening a narrow-dtype reduction" below.18 "Widening a narrow-dtype reduction" below.
194. For quantization, make scale, rounding, clamp range, and dequantization194. For quantization, make scale, rounding, clamp range, and dequantization
20 placement explicit in both kernel and golden.20 placement explicit in both kernel and golden.
21+ Then **verify that every small constant survived code generation.** A float
22+ immediate below ~5e-7 — the magnitude a clamp floor, an epsilon or a denominator
23+ guard normally has — is emitted as a literal `0.0`, silently, so the guard is
24+ present in the source and absent from the kernel. See
25+ [vec.md](vec.md) § a float immediate below ~5e-7 for the mechanism and the
26+ multiplication workaround.
215. Derive tolerances from the project standard for the actual output dtype and275. Derive tolerances from the project standard for the actual output dtype and
22 algorithm; never copy a tolerance from an unrelated sample.28 algorithm; never copy a tolerance from an unrelated sample.
236. Validate boundary values, tails, and representative random inputs.296. Validate boundary values, tails, and representative random inputs.
@@ -48,10 +54,24 @@ the current one:
48 softmax or normalization therefore has no native narrow path at all: widen to54 softmax or normalization therefore has no native narrow path at all: widen to
49 FP32 before the exponential, not merely before the reduce. (Measured against55 FP32 before the exponential, not merely before the reduce. (Measured against
50 the installed API docs on Ascend950PR / CANN 9.2.0.)56 the installed API docs on Ascend950PR / CANN 9.2.0.)
57+ **`vf.div` has no BF16 row either** (installed `div` page, dtype table),
58+ which extends the rule from the exponential to the whole
59+ transcendental-or-divide class: widen to FP32 before **any** of them. A
60+ sigmoid-shaped activation is the case that catches people out, because it
61+ needs both — `exp` and then a reciprocal — so a bf16 chain that survived the
62+ reduce still has two more places to fail.
51- The register-level cast is63- The register-level cast is
52- `vf.astype(src, preg, *, dtype=..., layout=..., round_mode=..., saturate=...)`.64+ `vf.astype(src, preg, *, dtype=..., layout=..., round_mode=..., saturate=..., mode=...)`.
53 The tile-level cast is `pl.cast(out, src, *, mode=...)`, where the target dtype65 The tile-level cast is `pl.cast(out, src, *, mode=...)`, where the target dtype
54- comes from `out`'s dtype. **There is no `vf.cast`.**66+ comes from `out`'s dtype. **There is no `vf.cast`.** Note the two `mode=` kwargs
67+ are unrelated: at tile level it is the rounding mode, at register level it is the
68+ `MergeMode`, whose `MERGING` value the installed page marks
69+ 「当前不支持」.
70+- 🔴 **`preg` is a mask of the SOURCE width**, for every `vf.astype` including the
71+ widening ones: *「mask_tensor根据输入的源操作数进行有效元素筛选」*. Passing a
72+ destination-width mask to a widening cast **returns zeros under `layout=ONE`, with
73+ no error** — see the negative control in
74+ [vec.md](vec.md#bf16-at-register-level-what-vfastype-can-and-cannot-do).
55- **Both conversion APIs are platform-gated.** Their docs carry a75- **Both conversion APIs are platform-gated.** Their docs carry a
56 「产品支持情况」 section, and support is not universal across Ascend76 「产品支持情况」 section, and support is not universal across Ascend
57 generations. Confirm availability for the *detected* target before a design77 generations. Confirm availability for the *detected* target before a design
@@ -101,6 +121,71 @@ narrow register means **interleaving** the two results, not concatenating them.
101`dtype=pl.DT_BF16` — which is also the evidence that the `vf.astype` doc121`dtype=pl.DT_BF16` — which is also the evidence that the `vf.astype` doc
102table's silence on BF16 is a documentation gap, not a capability bound.)122table's silence on BF16 is a documentation gap, not a capability bound.)
103 123 
124+### Narrowing to INT8 has exactly one route, and the wrong one compiles
125+ 
126+The section above lists `vf.astype` and `pl.cast` as the two conversion APIs.
127+For **FP32 → INT8** on Ascend950PR / CANN 9.2.0 neither of them is available,
128+and the failure modes differ enough to matter:
129+ 
130+| candidate | result |
131+|---|---|
132+| `vf.astype` FP32 → INT8 | **absent** — no matching `vcvt` for the pair |
133+| `pl.cast` FP32 → INT8 | **absent** — TCVT has no int8 destination |
134+| `pl.cast` FP16 → INT8 | **compiles, and does not saturate**: `200` comes out as `−56` |
135+| `pl.quant(out, src, scale, mode=pl.QuantMode.SYM)` | the route |
136+ 
137+🔴 **The only candidate that compiles is the one that wraps.** A design that
138+reaches int8 by widening to FP16 first gets a clean build and silently modular
139+arithmetic at the range boundary — and the boundary is where a quantizer spends
140+its time. `pl.quant` saturates to `[−128, 127]` in hardware and rounds
141+half-to-even (matching `torch.round`), so use it and check that the generated
142+kernel contains no other narrowing.
143+ 
144+Note the rounding asymmetry while you are here: `pl.cast`'s default
145+`CAST_ROUND → ROUND_A` is tie-away-from-zero and does **not** match
146+`torch.round`; `mode=pl.RoundMode.CAST_RINT` is what agrees.
147+ 
148+### The max family propagates NaN — `maxNum` semantics are not used
149+ 
150+`vf.reduce_max`, `vf.max`, `vf.maxs` and `pl.maximum(dim=0)` **all** return NaN
151+when either operand is NaN, measured on Ascend950PR / CANN 9.2.0. Only
152+`vf.reduce_max` documents it. This is worth checking rather than assuming
153+because the IEEE-754 `maxNum` alternative — *return the non-NaN operand* — is
154+equally defensible and would silently convert an all-NaN row into a finite
155+statistic, which the non-finite comparison rules below fail on position rather
156+than on magnitude. `vf.abs` / `pl.abs` preserve NaN and map `−Inf → +Inf`.
157+ 
158+### The device's unsaturated `float → int` of a non-finite is not the host's
159+ 
160+The trap below says `float -> int` of a non-finite is undefined in C++ and
161+differs between hosts. The device value is now measured: an **unsaturated**
162+`float(+Inf) → int8` on Ascend950PR is **`−1`**, where an x86 host gives `0`.
163+Both are legal; neither is a bug. The consequence is a design rule —
164+**narrowing must saturate before the convert, not after** — and a validation
165+rule: a hand-rolled narrowing path for non-finite inputs **cannot be validated
166+on the host at all**, because the host answer is not evidence about the device.
167+A primitive that saturates (see the INT8 table above) removes both problems.
168+ 
169+### A multiplier that must be exactly zero is not covered by a ULP budget
170+ 
171+When a reciprocal-and-multiply replaces a divide, the usual licence is a ULP
172+sweep of the multiplier. That licence is valid on **finite** operands only, and
173+the qualifier is routinely dropped when the result is restated.
174+ 
175+If any row's statistic can be `±Inf`, the exact multiplier is `k/Inf = +0.0`,
176+and `±Inf × 0 = NaN` is the value the whole downstream mapping depends on. A
177+fast reciprocal returning `+1` ULP — the smallest denormal — restores `±Inf`,
178+which then saturates to the dtype extreme instead of mapping to zero: an error
179+of the full output range, confined to exactly the rows a random sweep is least
180+likely to weight. `−1` ULP is benign, so the failure is **one-sided** and a
181+symmetric sweep reports a reassuring average over one case that cannot fail and
182+one that always does.
183+ 
184+**Rule.** Where the reduced statistic can be non-finite, produce the multiplier
185+with a true divide. Enforce it by grepping the *generated* kernels for the fast
186+mnemonics (`vrec`, `vrsqrt`, a Newton iteration), not by reading the Python —
187+a reciprocal can arrive through a helper or a backend rewrite.
188+ 
104### A scale factor is not a substitute189### A scale factor is not a substitute
105 190 
106Pre-scaling shifts the representable window; it cannot widen it. Compare the191Pre-scaling shifts the representable window; it cannot widen it. Compare the
@@ -255,7 +340,100 @@ and the last 5% to `+inf`, and `[nan, nan]` yields random values under a 50% NaN
255mask. They are therefore reproducible offline — but `torch.randn` never reaches340mask. They are therefore reproducible offline — but `torch.randn` never reaches
256these paths, so they need their own sweep. Note also that such a range applies to341these paths, so they need their own sweep. Note also that such a range applies to
257*every* input it is declared for, so a non-finite can enter through a scale or342*every* input it is declared for, so a non-finite can enter through a scale or
258-gamma vector rather than through the obvious data input.343+gamma vector rather than through the obvious data input. Establish **which**
344+inputs it is declared for before relying on that: a **flat two-element** range —
345+the form most contracts are written in — is declared for the first input only,
346+which the next subsection is about.
347+ 
348+### A declared value range may not reach every input — check the generator
349+ 
350+**Trigger:** any argument of the form *"the inputs are symmetric about zero"*,
351+*"a large fraction of outputs sit near zero"*, *"the data is bounded by ±1"*
352+i.e. any precision plan whose conclusion depends on the **distribution** of the
353+inputs rather than on the arithmetic alone. It applies to multi-input operators;
354+a single-input operator cannot hit it.
355+ 
356+**Rule: read what the generator does before reasoning about error regions.**
357+A declared range and the data actually generated are two different things, and
358+the gap is silent — the data is well-formed, in a plausible range, and nothing
359+reports that four of five tensors were drawn from a distribution nobody chose.
360+ 
361+**Measured instance.** One generator, on a multi-input case set: a **flat
362+two-element** `value_range` such as `[-1, 1]` is treated as the range of the
363+**first input only**; the remaining inputs are padded with `None`, and `None`
364+maps to the float default `(0.0, 1.0)`. So a contract that reads as "all inputs
365+are U(-1,1)" produced one tensor at U(-1,1) and four at **U[0,1)**, mean ≈ 0.5.
366+Confirmed twice: from the generator source, and by running the real generator and
367+measuring the tensors.
368+ 
369+**Why it changes a decision rather than being trivia.** With the value tensor
370+one-sided and non-negative, an attention output is a convex combination of
371+non-negative numbers, so it concentrates near 0.5 and **essentially no output
372+sits near zero**: the measured three-region census over 20 cases was **normal
373+99.885–100.000 %**, small-value ≤ 0.006 %, cancellation ≤ 0.109 %. Under the
374+symmetric distribution the contract *appeared* to declare, the same operator's
375+census inverts — normal collapses to 0.0–70.5 %, cancellation rises to
376+29.0–95.2 %, and two cases reach **0.000 % normal**.
377+ 
378+Two orders of magnitude of population moves between error regions on a reading of
379+one line, and the region that dominates decides which bound is even reachable:
380+ 
381+- with the near-zero population empty, the small-value and cancellation regions
382+ are **never exercised**, and the only gate that can fire is the plain
383+ mean/max relative bound — the opposite of the usual advice, arrived at for the
384+ opposite reason;
385+- a precision term justified as protection for cancellation is then
386+ **unmeasurable on the visible set**. Keep it if it is cheap and the hidden set
387+ may differ — but record it as *margin*, not as *demonstrated necessity*, or
388+ the next reader will delete it as an optimisation.
389+ 
390+**The check, in order:** (1) find the declared range in the case set; (2) find
391+the generator function that consumes it and read how it normalises the field;
392+(3) run the generator and measure per-input mean and min/max; (4) only then write
393+down which error region dominates. Steps 1 and 4 alone are how the wrong premise
394+survives review.
395+ 
396+Evidence class: **harness source read plus a live generator run**, one case set,
397+2026-08-25. **Validation scope:** the flat-two-element form. A range declared
398+per-input as a list of pairs is a different path and was not measured — do not
399+assume it has the same defect, and do not assume it does not.
400+ 
401+### A lone ±Inf and a NaN do not have the same blast radius in a mean-of-squares normalisation
402+ 
403+Verified on NPU, the measuring card / Ascend950PR / CANN 9.2.0, 2026-08-14, in **fp32, fp16 and
404+bf16 for both signs**, against `torch.nn.functional.rms_norm`, for
405+`y = x / sqrt(mean(x²) + ε) · γ`:
406+ 
407+* a **NaN** anywhere in a row makes `mean(x²)` NaN, so **the whole row is NaN**;
408+* a lone **±Inf** makes `mean(x²) = inf`, and `rsqrt(inf)` is exactly `+0.0`, so the **finite
409+ lanes of that row become exactly 0.0** and **only the Inf lane becomes NaN** (`inf * 0 = NaN`).
410+ 
411+A kernel that takes the shortcut "any non-finite in the row ⇒ NaN the whole row" is therefore
412+wrong, and wrong in the way that costs the case outright: NaN positions are compared **before**
413+MERE/MARE (previous subsection), so it fails on position mismatch rather than on error size.
414+ 
415+**The scored set cannot catch this, and that is the durable part.** In `rms_norm`'s public 20,
416+the only case carrying `±inf` is `[1000003,2]` bf16 with `value_range [-inf, inf]`. The generator
417+sets the first 5 % of the flat buffer to `-inf` and the last 5 % to `+inf`; with
418+`n = 2000006 // 20 = 100000` **even** and `D = 2`, those blocks land exactly on row boundaries.
419+Measured row census on the real case data: **900003 rows with 0 inf lanes, 100000 rows with 2,
420+and none with 1.** No scored case contains a mixed Inf/finite row at all, so the blanket-NaN
421+kernel scores **20/20 on the visible set and fails the hidden set**. Do not treat "20/20 on the
422+public cases" as coverage for a non-finite rule; check the row structure the generator actually
423+produces.
424+ 
425+*Getting it right is a matter of construction, not of a guard.* No clamp, no saturate, and in
426+particular no `x == x` filter — `x == x` is **true** for ±Inf (see the trap above) and would
427+manufacture a NaN where the reference has a finite `0.0`. Nothing in the chain may branch on
428+non-finiteness: `x²` is never negative so the sum can never be `inf - inf`, `1/sqrt(+inf)` is
429+exactly `+0.0`, and pad lanes must be neutralised by mask **zeroing**, never by multiply-by-zero
430+— multiplying turns a stale NaN pad word into a row-poisoning NaN.
431+ 
432+*Detecting it takes a case the benchmark does not supply:* a row with exactly one `±Inf` among
433+finite values, asserted with `==` and not `allclose` — exactly one NaN, at that column, and
434+`(y[row][~nan] == 0).all()`. Repeat it with the Inf inside the ragged tail register and inside
435+any packed segmented-reduction path, and run an all-NaN shape followed by a finite shape **in the
436+same process** to prove pad lanes are zeroed rather than multiplied.
259 437 
260## Evidence438## Evidence
261 439 
@@ -269,3 +447,71 @@ gamma vector rather than through the obvious data input.
269 447 
270Use `$PYPTO_DEVKIT_DIR/docs/pypto_pro/api/` as the primary source for supported448Use `$PYPTO_DEVKIT_DIR/docs/pypto_pro/api/` as the primary source for supported
271dtype pairs and API semantics in the current SDK.449dtype pairs and API semantics in the current SDK.
450+ 
451+---
452+ 
453+## BF16 narrowing at register level is bit-exact with the tile cast
454+ 
455+The note above says the register-level cast is `vf.astype(...)` and the tile-level
456+one is `pl.cast(out, src, mode=...)`, and warns that both are platform-gated. For
457+the **FP32 → BF16** direction on A5 / DAV_3510 the gate has been measured, and the
458+answer is favourable enough to change how a BF16 output should be published:
459+ 
460+* the two produce **identical bytes**, verified on inputs that are exact BF16 ties
461+ (low 16 mantissa bits `== 0x8000`) as well as random values;
462+* the `round_mode` kwarg does not reach the instruction — the hardware always
463+ rounds nearest-even, which is what `CAST_RINT` asks for anyway;
464+* `CAST_RINT` and `CAST_HYBRID` do not compile for this pair at all.
465+ 
466+So a chain that computes in FP32 and ends in a single tile-level narrowing cast can
467+instead publish BF16 straight from the register file, deleting a whole UB→UB pass
468+(and its `auto_mutex` barrier) **without changing a single output byte**. Measured
469+on `rms_norm`: publish traffic per element falls from 10 B (4 B FP32 store + 4 B
470+cast read + 2 B cast write) to 2 B.
471+ 
472+### The reverse direction is symmetric: BF16 → FP32 reaches both lane parities
473+ 
474+A design that needs register-level widening does not have to be routed into a tile-level
475+`pl.cast`.
476+ 
477+**Measured on Ascend950PR (`DAV_3510`), CANN 9.2.0, 2026-08-25, by two independent probes** —
478+the second written from scratch rather than re-run — with a **source-width (b16)** mask:
479+ 
480+| `vf.astype(b16_reg, preg_b16, dtype=pl.DT_FP32, layout=…)` | result |
481+|---|---|
482+| `CastLayout.ZERO` | the **even** source elements, 64/64 lanes non-zero |
483+| `CastLayout.ONE` | the **odd** source elements, 64/64 lanes non-zero |
484+ 
485+for **bf16 and fp16 alike**, and the widen → narrow round trip is **bit-identical over all
486+128 lanes**. A `ZERO` + `ONE` pair therefore covers a whole narrow register with two
487+instructions and needs no `unpack`, no store-and-reload, and no tile-level pass.
488+ 
489+**The mask width is the whole trap, and it is the transferable part.** A
490+**destination-width (b32)** mask on a widening cast selects nothing under `layout=ONE`, and
491+the instruction returns zeros — **0/64 lanes non-zero, silently, with no error and no
492+warning.** One such reading looks exactly like an ISA that can only widen the even elements.
493+The installed `astype` page states the rule: the mask filters by the **source** operand.
494+ 
495+**Keep the negative control as the diagnostic.** Running the b32/`ONE` arm next to the
496+b16/`ONE` arm is what distinguishes "the hardware cannot do this" from "we asked wrongly", and
497+it costs one extra arm:
498+ 
499+| mask width | `ZERO` | `ONE` |
500+|---|---|---|
501+| b16 (source width) | match | **match** |
502+| b32 (destination width) | match | **all zeros** |
503+ 
504+The 2x2 is the point: sweeping the `layout` enum while holding the mask width fixed samples
505+one column and reads a broken cell as a property of the enum. **When an operation has two
506+type-carrying operands and one enum, sweep the cross product.**
507+ 
508+**What the open route does not change.** The only register-level bit reinterpretation,
509+`vf.bit_cast`, is **same-element-width only**, so the "BF16 is the top 16 bits of FP32" trick
510+is not expressible; and folding a tile-level *width-changing* cast into the vector function
511+measured negative on a compute-exposed kernel — the *route* is open, the *payoff* is
512+shape-dependent. On one shape class it does pay: a *same-width* fold that deletes two
513+whole-tile staging passes measured up to 16 % faster
514+([../patterns/vec-compute-exposed-narrow-dtype.md](../patterns/vec-compute-exposed-narrow-dtype.md)).
515+The lane mechanics of
516+both directions, and the narrowing side's op-count floor and the precondition it needs, are in
517+[vec.md](vec.md#bf16-at-register-level-what-vfastype-can-and-cannot-do).
@@ -2,42 +2,419 @@
2 2 
3## Rule3## Rule
4 4 
5-Use `make_tile_group(..., auto_mutex=True)` for rotation and intra-kernel5+Automatic ordering for rotation and intra-kernel buffer ownership is armed by
6-buffer ownership that the target API documents as auto-managed. Do not add6+two separate things: `auto_mutex` on the **`@pl.jit` decorator**, and explicit
7-manual synchronization to the same managed dependency without evidence.7+`mutex_ids=` on the `make_tile_group` it is to order. Use it for the buffer
8+ownership the target API documents as auto-managed, and do not add manual
9+synchronization to the same managed dependency without evidence.
8 10 
9Cross-section or AIC/AIV handoff is not implied by `auto_mutex`. When the data11Cross-section or AIC/AIV handoff is not implied by `auto_mutex`. When the data
10flow crosses engines or sub-blocks, copy the producer/consumer event sequence12flow crosses engines or sub-blocks, copy the producer/consumer event sequence
11from a matching official example for the installed SDK and validate it on the13from a matching official example for the installed SDK and validate it on the
12target.14target.
13 15 
16+### How automatic ordering is armed, and how it is silently lost
17+ 
18+Getting the spelling wrong fails silently rather than loudly. Read against the
19+installed parser on CANN 9.2.0:
20+ 
21+- `auto_mutex` is a **`@pl.jit` decorator kwarg** (`auto_mutex: bool = True`),
22+ not a `make_tile_group` kwarg.
23+- `make_tile_group` accepts exactly four keywords — `type=`, `addrs=`,
24+ `mutex_ids=`, `depth=`. The parser collects the call's keywords into a dict
25+ and then reads those four by name. **It never checks for unknown ones**, so
26+ any other keyword — `auto_mutex=`, or the singular misspelling `mutex_id=`, or
27+ a typo — is **dropped without a diagnostic**.
28+- What `mutex_ids` buys is the ordering metadata itself. A group declared with
29+ `depth=` and no usable `mutex_ids` is legal, rotates correctly, and carries
30+ **no mutex ids for `auto_mutex` to order against**. Rotation and
31+ synchronization are two separate things, and only one of them is what the
32+ ids provide.
33+ 
34+Put together, the failure is: a misspelled or misplaced keyword produces a group
35+that looks synchronized in the source, rotates in the generated code, and has
36+exactly the read-after-write exposure of a **bare `make_tile`** — the failure
37+signature already recorded on
38+[../references/pitfall-records.md](../references/pitfall-records.md) as
39+"computed bare tile reads stale data, no error".
40+ 
41+Two checks worth putting in a gate, because neither costs a device:
42+ 
43+1. **Every rotating group in a cross-op dependency carries explicit
44+ `mutex_ids`.** Assert the count in a source check rather than eyeballing it —
45+ and scope the check to code, since a `mutex_ids=` written in a comment or a
46+ docstring inflates a naive `grep -c`.
47+2. **`auto_mutex=` appears on the `@pl.jit` decorator and nowhere else.** Its
48+ default is `True` on this build, so an author who "set it on the group" gets
49+ the behaviour they wanted by accident and never learns the keyword did
50+ nothing — until the day the same idiom is copied onto a kernel that sets it
51+ `False`.
52+ 
53+#### The API page documents six keywords; the Python signature takes four
54+ 
55+`make_tile_group`'s **Python signature** takes only
56+`type` / `addrs` / `mutex_ids` / `depth` and carries **no `**kwargs`**, so
57+`auto_mutex=` there is silently dropped. The installed API *page* for
58+`make_tile_group` nevertheless documents **six** keywords, adding `fwd_ids` and
59+`bwd_ids`, and a shipped sample passes them.
60+ 
61+Both are right, and the reconciliation is worth stating because "the docs and
62+the signature disagree" is otherwise read as one of them being stale:
63+ 
64+- `fwd_ids` / `bwd_ids` are read by the **AST parser**, not by the Python
65+ signature, which is why they work despite not appearing in `inspect.signature`.
66+- They take effect **only when the generated pipeline is enabled**. On a kernel
67+ with a hand-written handoff they are accepted and inert.
68+ 
69+So the same call has **two different silent-ignore mechanisms** on it — one for
70+keywords the parser does not know (`auto_mutex=`), one for keywords it knows but
71+that the enabled transform never consumes (`fwd_ids`/`bwd_ids`). Neither warns.
72+Evidence class: **installed source + installed API page**, CANN 9.2.0, read
73+2026-08-25.
74+ 
75+---
76+ 
77+## The hand-written per-tile cross-core handoff runs
78+ 
79+**What was measured.** On a5 / `DAV_3510`, CANN 9.2.0 (cann master `f0b3a2609`):
80+one `@pl.jit` holding `section_cube` + `section_vector`, a **hand-written
81+per-tile** `pl.system.set_cross_core` / `wait_cross_core` handoff, **6 event
82+groups / 12 ids in `[0,16)`**, and two probability tiles in L1 written by the
83+Vector side and read by the Cube side — **compiled, launched, and computed
84+correct attention. It never hung, not once**, over a bring-up ladder, repeated
85+runs, and a 20-case suite. On runs where the race below did not fire, the
86+bring-up rungs matched the golden to `max_abs_error = 2.44e-4` — half the fp16
87+1-ULP budget at that output range — and an analytic causal-prefix probe returned
88+the answer **exactly**, `max_abs_error = 0.0`. The immediately preceding campaign
89+built the same construct for the same computation and went **0-for-13**:
90+thirteen hypotheses, nine mutation-ladder rungs, `aicore timeout` every time,
91+even though every ingredient passed in isolation — the cube half (including the
92+transposed NT load and a dual-accumulator contraction) and the register-level
93+softmax were each proven separately. A reference implementation written against
94+a different kernel language hit the identical wall on the identical construct,
95+so that failure is not an artifact of one language's lowering.
96+ 
97+> **Evidence class: measured on device.** One operator, one build, one session.
98+> **Validation scope:** this establishes that the *construct* compiles, launches,
99+> terminates, and can compute the right answer. It does **not** establish
100+> shipping correctness: the same kernel carries an unresolved cube↔vector race
101+> (next subsection), so every value above is a best-run figure and the same rung
102+> can be an order of magnitude worse on the next run; 4 of 20 cases landed
103+> outside tolerance, and the ladder aborted at the first failing rung, so the
104+> upper rungs were never exercised. **"The construct runs" is the claim; "the
105+> construct is solved" is not.**
106+>
107+> **What the analytic probe's `0.0` does and does not support.** That figure comes
108+> from a causal-prefix probe whose operands are **degenerate by construction**
109+> `q = k = 0` with `v[…, j, :] = j`, giving the analytically known expectation
110+> `y[i] = (prefix(i) − 1)/2`. It was built to pin the *length* of the causal
111+> prefix, because the plain zero-input case turned out not to exercise the mask,
112+> and for that purpose it is sound.
113+>
114+> - ✅ **Valid for the claim it is cited under.** Returning the analytically known
115+> answer *exactly*, across both layouts and both causal settings, is not
116+> something a kernel that hung, faulted, or computed the wrong reduction could
117+> do. Liveness and the value chain are established by it.
118+> - ❌ **Not valid as evidence of determinism**, of correct probability-tile
119+> *ordering*, or of the absence of a race. With `q = k = 0` every score is
120+> equal, so any permutation or duplication of probability tiles is numerically
121+> invisible. This probe read clean on the control *and* on a kernel later
122+> measured non-deterministic in five of five configurations.
123+>
124+> The generalisable form, and this is a clean instance of it: **a probe designed
125+> to verify one property can be silently useless for another, and degenerate
126+> operands are the usual reason.** The design was not the error; reading it as
127+> reassurance about synchronisation was. See
128+> [Locate a numerical error in a multi-stage kernel](../playbooks/numerical-error-localisation.md)
129+> §2b.
130+ 
131+**Corroboration from the installed samples.** Of the 18 files under the
132+installed `frontend/fa/` tree that call `set_cross_core`, **17 carry a single
133+`@pl.jit` holding both sections**, and the handoff is genuinely *per tile*
134+set/wait pairs inside nested `pl.range` loops, not once per kernel. One of them,
135+`frontend/fa/test_fa_perf_tkv_preload_dn_vf_bufid_bsnd.py`, closes with a
136+**20-iteration determinism loop plus `assert_close(rtol=5e-3, atol=5e-3)`**. The
137+two files that are *not* in the 17 are correctly excluded: one takes the
138+`PipelineConfig` route, one stages through GM.
139+ 
140+### Attribution — one of the three differences is established, two are inference
141+ 
142+Three things differed from the attempts that produced `aicore timeout`. The
143+page states which is which because a reader who picks the wrong one will
144+"reproduce" the fix and get nothing.
145+ 
146+1. **Established.** The `slots >= blocks` claim inside framework finding
147+ *"Close the accumulator: the last matmul of a K-loop needs `AccPhase.Final`"*
148+ was **retracted***"two slots suffice at any depth"*, verified to K=7168.
149+ The failed design's structural-impossibility argument rested on it
150+ explicitly, so with it void the L0 budget moves off the ceiling to
151+ **L0A 50 % / L0B 50 % / L0C 25 %** and a 2-slot rotation becomes legal at any
152+ contraction depth. This is a fact about the budget, not about the sync.
153+2. **Established that it changed; its causal role is inference.** The
154+ pipeline/sync engine was **rewritten** on this build, with **byte-identical
155+ `PipelineConfig` defaults** — so nothing in the API surface signals that the
156+ machinery underneath is different. An agent comparing two builds by their
157+ configuration will conclude, wrongly, that nothing moved.
158+3. **Established as a removed defect; its causal role is inference.** `phase=`
159+ was never armed anywhere in the new design. See the section
160+ "`phase=` is a hardware handshake, and only `pl.store` can answer it" below —
161+ a mis-set `phase` is a hang cause with no diagnostic, so its absence is a real
162+ change, but nothing in this run isolates it as *the* change.
163+ 
164+Nothing separates the three. **Do not report any one of them as "the fix."**
165+ 
166+### The bring-up ladder is mandatory, not advisory
167+ 
168+When this construct does fail, it fails as an **opaque
169+watchdog**. `aicore timeout` names the watchdog, not the mechanism. So add
170+exactly one mechanism per rung and make **rung 1 validate the cube↔vector
171+handshake alone** — one query tile, one KV step, no online recurrence, no mask,
172+no tail. A ladder built that way localises a hang to the rung that introduced it;
173+without one, you are back to enumerating hypotheses, which is how thirteen were
174+spent.
175+ 
176+### Still open: a cube↔vector race that survives inside a single reduction chunk of a single task
177+ 
178+Recorded as an **open, characterised defect**, because the characterisation is
179+the most reusable thing this campaign produced. It is not solved.
180+ 
181+**Symptom.** The kernel is **non-deterministic** — repeated runs on identical
182+input differ. Severity is **heavy-tailed**, not a bounded-outlier problem:
183+ 
184+| observed | figure |
185+|---|---|
186+| exact | `max_abs_error` 2.44e-4 (half the fp16 1-ULP budget at that output range) |
187+| mild | ~1.5 % of elements mismatched |
188+| whole-tensor garbage | `matched_ratio = 0.000015`, `max_abs_error = 0.9956` — fifteen matching elements per million, at the full output dynamic range |
189+| NaN | an analytic probe reported **22400 NaN position mismatches** |
190+ 
191+**The NaN route is the diagnostic one.** It means the race can corrupt the
192+online-softmax statistics into a **zero or negative denominator** reaching
193+`pl.expand_div`. A design argument that "the row sum is structurally ≥ 1" is a
194+statement about the *intended* dataflow and **does not survive a race** — under
195+corruption the invariant is simply not true. Treat any structural non-negativity
196+or non-zero argument as void while a race is open.
197+ 
198+**Localisation already established — the method is the reusable part.**
199+ 
200+- A **determinism matrix** over 6 configurations, varying task count, KV-tile
201+ count and head dim independently, run repeatedly on identical input and scored
202+ by mismatch count rather than by pass/fail.
203+- One **provable** defect was found and fixed by a full buffer live-range audit:
204+ a tile group's `.next()` cursor **free-runs across tasks** while a `kt % depth`
205+ event index **restarts each task**. The two agree only when the KV-tile count
206+ is even, so a release credit issued for slot 0 can authorise overwriting
207+ slot 1. **Fix:** index every event group by a **free-running counter that
208+ advances in lockstep with the cursor**, declared identically in both sections,
209+ never by `iteration % depth`.
210+- **That fix did not remove the race**, and the decisive observation is that
211+ after it a **single-task** configuration — no task boundary at all — began
212+ racing. That excluded the task boundary, buffer lifetime/`depth`, and event
213+ pairing (re-derived balanced on both sides). It was then read as one remaining
214+ defect *inside one task's cube↔vector handshake*; the next subsection is the
215+ probe that split that reading in two.
216+ 
217+**Ruled out by measurement — do not re-spend these.** Buffer live range vs
218+`depth` across every buffer; event set/wait balance; the task-boundary
219+cursor/event misalignment (a real defect, found, fixed, race survived);
220+`enable_slice` (two subsections down); and now **the task axis itself** — the
221+residual race is present at 1, 2 and 128 tasks and absent at 1 and 8 tasks, so
222+task count neither causes it nor prevents it. The dose-response sweep two
223+subsections down adds three more: **`auto_mutex`**, the **producer-local
224+contraction loop**, and every **cross-chunk** mechanism — cross-chunk recurrence,
225+slot wraparound, event-counter parity — none of which exist in the minimal
226+reproducer that still races.
227+ 
228+#### Two defects, separated by the head dimension
229+ 
230+**The intervention.** The lag-1 skew was collapsed: the consumer section waits on
231+its producer in the **same** iteration rather than one behind. Six line edits — a
232+loop trip count, two guards, two index expressions — and **no compensating change
233+anywhere else**, so the inference is unconfounded. Recompilation was established
234+before any result was read, three ways: a unique kernel name, a wiped build
235+directory, and **328 differing lines** of emitted C++.
236+ 
237+**The matrix.** a5 / `DAV_3510`, CANN 9.2.0, fp16, identical inputs, outputs
238+compared bitwise between runs.
239+ 
240+| task count | inner unroll trip count over the head dim | before collapse | after collapse |
241+|---|---|---|---|
242+| 1 | 2 | races | **clean** — 0 of 15 pairs differ, 8 launches, 2 processes |
243+| 8 | 2 | races — **5 distinct outputs in 6 runs** | **clean** — 0 of 15 pairs differ |
244+| 1 | 8 | races | **races** |
245+| 2 | 8 | races | **races** |
246+| 128 | 8 | races | **races** |
247+ 
248+Task count spans 1→8 across both clean cells and 1→2→128 across all three racing
249+cells, and partitions neither. **Every remaining failure has the inner trip count
250+at 8; every success has it at 2.** The correlation is exact, and it is the whole
251+of the result.
252+ 
253+🔴 **Read "clean" in that table at the sample size that produced it.** Those cells
254+are 6 runs / 15 pairs, and the sweep below establishes that at **16** runs five of
255+its eight points read as false negatives. So the two clean cells bound their race
256+rate loosely and **do not establish determinism**; what survives unweakened is the
257+*contrast* between cells measured the same way, and the order-of-magnitude
258+accuracy improvement, which is a separate observable. The sweep's `N <= 3` points
259+are clean at 3516 runs each, which is *consistent* with those cells but is not a
260+re-measurement of them — it was run on a different, minimal configuration. See
261+[Investigation discipline](../references/investigation-discipline.md) §18.
262+ 
263+Two conclusions follow, and they differ in kind:
264+ 
265+1. **The lag-1 skew is a genuine, demonstrated defect.** Collapsing it made two
266+ configurations bit-stable and, in those configurations, improved accuracy by
267+ an order of magnitude (`max_abs_error` 2.6e-3 → 2.0e-4). **It is not adopted
268+ as a fix**: collapsing the lag forfeits the cube/vector overlap the construct
269+ exists for. It is a *diagnostic*, and a reusable one — recorded as such on
270+ [Buffer lifetime and rotating tile groups](../patterns/buffer-reuse-lifetime.md).
271+2. **A second, independent defect remains, and it scales with the head
272+ dimension.** The task axis is exonerated **by measurement, not by argument**.
273+ 
274+The probe was designed as a binary discriminator: determinism returning would put
275+the defect in the skew construction, not returning would put it in the handshake
276+primitives. Determinism returned **at one trip count and not at another**, which
277+neither branch anticipated. A cheap discriminator is worth running, but its
278+premise is a hypothesis too.
279+ 
280+A 2-run pair is not a determinism measurement. The `8 tasks / trip count 2` cell
281+reads deterministic on one pair and produces **five distinct outputs in six
282+runs**, because it holds exactly one identical pair out of fifteen — and six runs
283+is not a determinism measurement either. The run-count and equivalence-class
284+protocol that follows is general method and lives on
285+[Investigation discipline](../references/investigation-discipline.md) §18, not
286+here.
287+ 
288+#### `auto_mutex` is exonerated, and the whole defect fits in a minimal reproducer
289+ 
290+A dose-response sweep over the head dimension was run **on the minimal
291+reproducer** — single task, single reduction chunk, lag already collapsed.
292+ 
293+**What was varied, and why it discriminates.** The head dimension `D`, over eight
294+points. Two structures scale with `D` and were confounded:
295+ 
296+- an **inner unroll**, trip count `N = D/64`, issuing one forward/backward
297+ handshake pair per iteration **across the section boundary** against **2-slot**
298+ groups, with four other cursors also advancing once per iteration through their
299+ own 2-slot groups;
300+- a **producer-local contraction loop**, trip count `M = D/128`, lying entirely
301+ inside the producer section and ordered by **`auto_mutex`**.
302+ 
303+`N` is monotone `1..8` across the sweep; `M` is a staircase `1,1,2,2,3,3,4,4`.
304+That difference is the whole discriminator, and it needs **no source edit**. Four
305+passes at rising run counts (16 / 100 / 400 / 3000), separate processes, and a
306+**separately compiled binary per point** — the build cache is keyed per tiling
307+key, so every point ran its own freshly compiled code.
308+ 
309+| `D` | `N` | `M` | runs | runs deviating from the modal output | verdict |
310+|---|---|---|---|---|---|
311+| 64 / 128 / 192 | 1 / 2 / 3 | 1 / 1 / 2 | 3516 each | **0** | clean |
312+| 256 | 4 | 2 | 516 | 159 (~31 %) | races |
313+| 320 | 5 | 3 | 3516 | 2 | races |
314+| 384 | 6 | 3 | 3516 | 9 | races |
315+| 448 | 7 | 4 | 3516 | 5 | races |
316+| 512 | 8 | 4 | 523 | 71 | races |
317+ 
318+**Onset at `N = 4`, perfectly monotone in `N`.** The decisive pair is
319+`D = 192` against `D = 256`: **`M` is held constant at 2 across it** and the
320+behaviour splits — 0 deviants in 3516 runs against 159 in 516. Every step the `M`
321+staircase predicts is crossed with **no** change in behaviour. The three clean
322+points pool to **0 in 10548 runs**; against the lowest racing rate seen anywhere
323+in the sweep that is P ≈ 3e-7.
324+ 
325+**Verdict.**
326+ 
327+- **`auto_mutex` is exonerated, and the producer-local contraction loop with it.**
328+ `auto_mutex` delivers the ordering it promises. The residual is therefore a
329+ **synchronisation-design defect** — a credit authorising more cursor advances
330+ than its depth — and **not a framework bug**. That distinction is exactly why
331+ the alternative could not be assumed away, and it is now settled by measurement
332+ rather than by argument.
333+- **The minimal reproducer still races.** One task, one reduction chunk, no
334+ cross-chunk recurrence, no slot wraparound, no event-counter parity — and still
335+ up to **4 distinct outputs**, the worst pair differing in ~46 % of the output
336+ elements. The whole defect fits inside one chunk of one task, so it is
337+ **bisectable by shortening the inner loop**: the ordinary method is available
338+ again.
339+- The race can produce **NaN** (1536 of them in one pass). It can destroy an
340+ online-softmax running max/sum outright, not merely perturb summation order —
341+ which confirms, rather than supersedes, the NaN reading recorded above.
342+ 
343+**Mechanistic corroboration, offered as observation and not as proof.** The onset
344+`N = 4` is exactly **twice the 2-slot depth**, and `N <= 3` is the regime in which
345+those 2-slot groups never complete a second wrap inside a chunk. The story fits;
346+nothing in this sweep tests it.
347+ 
348+🔴 **The limit on the inference — do not drop this half when quoting the result.**
349+`N` is in **bijection with `D`** in this design, so **the sweep cannot falsify
350+`N`**. What it measures is the **exoneration of `M`**, the only candidate carrying
351+a falsifiable staircase, and of `auto_mutex`. `N` is merely left standing. The
352+positive attribution to the inner unroll therefore rests on a **two-candidate
353+frame**: any other structure 1-to-1 with `D` would fit this data equally well.
354+**The directly measured results are the two exonerations.** The inner unroll was
355+not positively identified by measurement. The **general** rule this instance
356+produced — what a dose-response sweep can and cannot falsify, and how to state an
357+attribution that is conditional on a candidate list — is on
358+[Investigation discipline](../references/investigation-discipline.md) §19, not here.
359+ 
360+**What remains — one step, not a ladder. Bisect the inner loop inside the minimal
361+reproducer.** Shorten it, or split its handshake pairs, and find the trip count at
362+which determinism returns. That is now an ordinary bisection on the cheapest
363+configuration available, and it is also the only move that can break the
364+two-candidate frame, because it varies `N` independently of `D`.
365+ 
366+🔴 **Run counts, before any of this page's determinism wording is reused.** At
367+**16 runs, five of these eight points read as false negatives**; two of them still
368+looked deterministic at **400** runs and only revealed themselves at **3000**.
369+"Clean" here is an **upper bound on the rate** (~0.03 % at `N <= 3`), never zero.
370+Rates are also strongly **ambient-dependent** — a co-tenant workload ran
371+throughout — so they do not compare across passes, and every conclusion above
372+rests on presence/absence and on **within-process adjacent contrasts**, never on a
373+ratio of rates. The general rule this forced is on
374+[Investigation discipline](../references/investigation-discipline.md) §18.
375+ 
376+### `enable_slice` looks like the missing workaround. It is not.
377+ 
378+Many working fused cube+vector samples carry
379+`@pypto.options(pass_options={"enable_slice": False})`, immediately above the
380+kind of kernel that is racing, which reads like a required workaround for
381+exactly this class. Measured on the installed build, it is a dead end:
382+ 
383+- the installed configuration documentation states *"`enable_slice : bool` —
384+ Whether to enable slice-related processing. **Defaults to False.**"*;
385+- the shipped framework config file sets `"enable_slice": false`.
386+ 
387+**The samples are pinning a value it already has** — plausibly because the
388+options setter is process-global, so another test in the same session could flip
389+it. And it decorates a **pytest test function**, not a kernel, so it would not
390+travel with a delivered operator even if it did something. Adopting it could only
391+have made a local test pass while the delivered artifact still raced, i.e.
392+**hidden** the bug. Recorded so the next campaign does not spend a round on it.
393+Evidence class: **installed source + shipped config**, CANN 9.2.0.
394+ 
395+---
396+ 
14## On A5: which cube+vector fusion construct to reach for397## On A5: which cube+vector fusion construct to reach for
15 398 
16-Two different constructs are covered here, and only one of them hangs. Decide in399+Two constructs are covered here. Decide in this order:
17-this order — the same order the adjudication further down this page arrives at:
18 400 
19-1. **Try the generated pipeline path first.** `fwd_ids`/`bwd_ids` plus401+1. **A hand-written per-tile handoff inside one `@pl.jit` is a legitimate first
20- `@pl.pipeline.stage` plus `PipelineConfig` **does work** here, measured on the402+ choice** when the dataflow needs cube and vector to exchange a tile every
21- reference kernel and on a full staged kernel403+ iteration. It is what 17 of 18 installed FA samples do. **Copy the pipe/event
22- ([framework-findings §17](../references/pypto-pro-framework-findings.md)).404+ assignment from a sample rather than deriving it**, declare the shared groups
23-2. **Fall back to single-sided launches with GM intermediates** when the fused405+ outside both sections, and give each backward (release) group exactly `depth`
24- form fails. This is the proven escape, not evidence that fusion is impossible.406+ pre-published credits in the section prologue. Bring it up on the mandatory
25-3. **Do not hand-write a per-tile cross-core event sequence inside one `@pl.jit`.**407+ ladder above when this construct fails, it fails as an opaque watchdog.
26- That is the construct that hangs; see below.408+2. **The generated pipeline path** works where it applies — `fwd_ids`/`bwd_ids`
27- 409+ plus `@pl.pipeline.stage` plus `PipelineConfig`, measured on the reference
28-### The construct that does not run: hand-written per-tile handoff410+ kernel and on a full staged kernel
29- 411+ ([framework-findings §17](../references/pypto-pro-framework-findings.md)). It
30-Measured, repeatedly. A single `@pl.jit` holding a `section_cube()` and a412+ is not a safe default either: it was measured to fault on this same
31-`section_vector()` with a **hand-written** cross-core handoff on every tile413+ computation ("compiles with correct pipes, still faults").
32-compiles and then dies with `aicore timeout`. One attention kernel was taken414+3. **Single-sided launches with GM intermediates** remain the proven escape.
33-through **thirteen hypotheses and nine mutation-ladder rungs** on that construct415+ Reach for them when the fused form is blocked, not before — and check first
34-and never ran once, even though every ingredient passed in isolation — the cube416+ whether your delivery contract permits more than one kernel launch, because
35-half (including the transposed NT load and a dual-accumulator contraction) and417+ some do not.
36-the register-level softmax were each proven separately. A reference
37-implementation written against a different kernel language hit the identical
38-wall on the identical construct, so the limit is in the construct rather than in
39-one language's lowering. This says nothing about the generated pipeline path in
40-step 1.
41 418 
42### The proven fallback: decompose into single-sided launches419### The proven fallback: decompose into single-sided launches
43 420 
@@ -189,6 +566,43 @@ synchronization and replaces it with a hardware `unit_flag` that the paired
189`matmul(phase=Final)` drained by `pl.move(..., acc_to_vec_mode=...)` arms a566`matmul(phase=Final)` drained by `pl.move(..., acc_to_vec_mode=...)` arms a
190protocol whose other half cannot reply. Nothing rejects it.567protocol whose other half cannot reply. Nothing rejects it.
191 568 
569+### The trap chain, and why the resolution is "never arm it"
570+ 
571+Stated as a chain rather than as a rule, because the rule alone reads as
572+arbitrary and the chain is what makes it checkable at **design** time:
573+ 
574+1. `pl.move`'s space table has **no `Acc → Mat` row** (previous section), so an
575+ L0C accumulator reaches L1 only via `Acc → Vec → Mat`, or through GM.
576+2. Therefore, in any design whose accumulators drain on chip, **the drain is a
577+ `pl.move`**.
578+3. **`pl.move` has no `phase` parameter.** `phase=` is a hardware handshake and
579+ only `pl.store` can answer it.
580+4. Therefore `phase=` **must never be armed** in such a design. There is no
581+ spelling of the reply.
582+ 
583+The resolution is not "pass the right phase" — it is *not passing one*.
584+`phase: Optional[AccPhase] = None` defaults to `None`, and `None` keeps the
585+framework's **automatic M↔FixPipe synchronization on**. Arming `phase` turns
586+that automatic sync **off** and substitutes a protocol whose other half cannot
587+be written.
588+ 
589+This is worth writing down because every signal points the other way: the matmul
590+samples all carry `phase=AccPhase.Partial/Final`, copying them is the default
591+move, and **nothing rejects the result** — a mis-set `phase` is named on this
592+page as a hang cause *with no diagnostic*. One design review caught it by
593+walking the chain above and backing the `phase=` out of an already-reviewed
594+dataflow; the shipped kernel has **zero** `phase=` occurrences and does not hang.
595+ 
596+**Design-time check, costs nothing:** for every accumulator, name its drain
597+first. If the drain is `pl.move`, `phase=` is forbidden on every `matmul` and
598+`matmul_acc` feeding that accumulator. Only if the drain is
599+`pl.store`/`store_tile` is arming it even expressible.
600+ 
601+Evidence class: **installed API/source reading**, CANN 9.2.0, plus a device run
602+of the resulting kernel (which did not hang). It is *not* a controlled A/B —
603+`phase=` was removed at design time, so no armed variant of that kernel was ever
604+built.
605+ 
192Checklist item, before diagnosing any Cube-side hang or L0C fault:606Checklist item, before diagnosing any Cube-side hang or L0C fault:
193 607 
1941. For every `matmul` carrying `phase=`, name the drain. If it is not6081. For every `matmul` carrying `phase=`, name the drain. If it is not
@@ -23,6 +23,58 @@ both maximum and sum. See
23[online-softmax-tail.md](../patterns/online-softmax-tail.md), which is23[online-softmax-tail.md](../patterns/online-softmax-tail.md), which is
24conceptual only.24conceptual only.
25 25 
26+## A static assert on a tile's shape binds the **declared** extent, not the working one
27+ 
28+This is the rule that turns checklist item 3 from a limitation into a free
29+choice, and missing it makes whole shape bands look unimplementable.
30+ 
31+**The observation.** A `[R, 1]` FP32 tile in vector memory fails to store unless
32+`R * sizeof(dtype) % 32 == 0` — for FP32, `R` a multiple of 8. Declaring
33+`R = 12` fails the build:
34+ 
35+```
36+.../pto/npu/a5/TStore.hpp:178: static assertion failed ...
37+((layout == Layout::DN) && (Tile<...,float,12,1,...>::Rows * sizeof(float) % 32 == 0)) || ...
38+```
39+ 
40+Read literally that says "rows per tile must be a multiple of 8", and a
41+row-reduction or per-row-scale design then inherits `Rows % 8 == 0` as a
42+constraint on its tiling.
43+ 
44+**The escape.** The assert is instantiated from the tile's **declared** template
45+parameters. So declare a fixed, legal extent and narrow it at run time:
46+ 
47+```python
48+scale_g = pl.make_tile_group(
49+ type=pl.TileType(shape=[64, 1], dtype=pl.DT_FP32,
50+ target_memory=pl.MemorySpace.Vec), ...)
51+cur = scale_g.next()
52+pl.set_validshape(cur, [vrows, 1]) # vrows is a runtime value, any 1..64
53+```
54+ 
55+The declared `[64, 1]` is 256 B — eight whole 32-byte blocks — and satisfies the
56+assert once, at compile time, for every `vrows`.
57+ 
58+**Verified on hardware with a negative control**, which is what separates this
59+from a plausible reading of the assert. From a **single compile**,
60+`vrows ∈ {1, 5, 7, 8, 12, 17, 33, 53, 64}` all produced bit-exact results and
61+left rows beyond the valid window untouched; and declaring `[12, 1]` *without*
62+`set_validshape` reproduced the assert above. Both halves are needed: the
63+positive run alone would be consistent with the assert never having applied.
64+ 
65+**This escape is mandatory, not cosmetic, once the row is wide.** Taking
66+`Rows % 8 == 0` at face value forces at least 8 rows into every tile. Eight rows
67+of a 16384-wide FP32 row block is 512 KiB against a unified-buffer budget of
68+roughly 248 KiB, so the widest band becomes unimplementable outright — not
69+slower, impossible. A design that concludes "this shape cannot be done" from a
70+shape assert should first ask whether the assert is on the declaration.
71+ 
72+**The general form**, worth carrying to any assert of this kind: a compile-time
73+assertion can only see compile-time values. When it names a shape, check whether
74+the DSL offers a runtime window over that shape before treating the assertion as
75+a constraint on your tiling. The cost is one `set_validshape` per consuming
76+operation — which checklist item 4 already requires.
77+ 
26## Exception: `compact=1` tiles — the window is part of the layout78## Exception: `compact=1` tiles — the window is part of the layout
27 79 
28Checklist item 4 is wrong for any tile declared `compact=1`, and following it80Checklist item 4 is wrong for any tile declared `compact=1`, and following it
@@ -40,6 +40,78 @@ identity is what `auto_mutex` orders against, so a slot counter that steps by
40the wrong stride aliases two logical buffers onto one physical one while the40the wrong stride aliases two logical buffers onto one physical one while the
41event machinery still issues two credits.41event machinery still issues two credits.
42 42 
43+## A resident fp32 accumulator makes UB — not L0A — the binding space
44+ 
45+**Trigger:** a kernel that carries an **fp32 output accumulator resident in UB
46+across an inner loop**, whose width is the operator's largest dimension. Online
47+softmax, any streaming normalisation, any running reduction over a long axis.
48+The check is `accumulator_rows × wide_dim × 4` against the UB capacity, done
49+*before* the tile size is chosen.
50+ 
51+**Rule:** for this shape, **sum UB first**. The instinct on a cube-heavy kernel
52+is to budget L0A/L0B and treat UB as slack, and that instinct picks the wrong
53+tile size. It is also why **copying an official sample's tile constants does not
54+transfer** once the wide dimension grows: the sample's tiling was legal at *its*
55+wide dimension, and nothing in the sample says which space was binding.
56+ 
57+**Worked arithmetic**, so it can be re-derived at another width. Attention with a
58+query-tile height `TSQ` split across two vector sub-blocks and a head dim `D`, so
59+the resident output accumulator each sub-block owns is `[TSQ/2, D]` in fp32:
60+ 
61+| quantity | at `TSQ = 128`, `D = 512` | at `TSQ = 128`, `D = 448` |
62+|---|---|---|
63+| output accumulator `[TSQ/2, D]` fp32 | `64 × 512 × 4` = **131072 B** | `64 × 448 × 4` = **114688 B** |
64+| plus the minimum other resident tiles (the QK product staging, the two probability tiles, the PV staging, the narrowed output) | total **≥ 254976 B** | total **238592 B** |
65+| UB capacity (`ub_size`, `950PR_957x.ini`) | **253952 B** | 253952 B |
66+| verdict | **does not fit, even at one slot per group** | fits |
67+ 
68+So **`TSQ = TKV = 128` — the tile size every installed FA sample uses — is not
69+implementable at `D = 512`.** 64 is forced. It *is* implementable at `D = 448`,
70+which is why a sample or a design validated at a narrower head dim carries no
71+warning. The same design's L0 budget at `TSQ = TKV = 64` sits at **L0A 50 % /
72+L0B 50 % / L0C 25 %** — nowhere near binding, which is the whole point.
73+ 
74+Two consequences worth carrying:
75+ 
76+- **"Even at one slot per group" is the form the check must take.** A budget that
77+ only fails at two slots invites a slot reduction; this one fails at the floor,
78+ so it is a statement about the tile size and nothing else can absorb it.
79+- **Carry the tile height as a tiling-key rung**, not as a module constant, if
80+ the wide dimension is dynamic. The legal height is a function of it.
81+ 
82+Evidence class: **derived from installed platform capacities and the design's own
83+address map**, then confirmed by the address arithmetic of a kernel that ran on
84+a5 / `DAV_3510`, CANN 9.2.0. **Validation scope:** the fp32-accumulator shape
85+above. It is *not* a claim that UB binds for every cube+vector kernel — a design
86+without a wide resident fp32 accumulator will land somewhere else entirely. The
87+capacity figure comes from the platform `.ini` key, per
88+[arch-a5.md](arch-a5.md); carry the key, not the number.
89+ 
90+### `TSQ == TKV` is load-bearing, and the ambiguity behind it is now closed
91+ 
92+The installed `insert` API page states its bound check as
93+*"`row + src rows ≤ dst rows`, `col + src cols ≤ dst cols`"*. For an **NZ source
94+inserted into a ZN destination** that admits **two self-consistent readings**
95+the offset pair interpreted in the destination's logical frame, or in the
96+source's NZ frame — and **every installed sample has `TS == TKV == TD`**, so none
97+of them discriminates. Choosing `TSQ == TKV` is valid under both readings and is
98+the safe design-time move while the ambiguity stands.
99+ 
100+**This campaign closed it, both ways:**
101+ 
102+- **Empirically** — a one-hot probe returned the exact identity for every query
103+ row across **both** sub-blocks. Under the competing reading, the second
104+ sub-block would have written a different rectangle and the identity would have
105+ broken.
106+- **In the generated code** — the emitted `TINSERT` takes the offset pair as
107+ **`(kv_row, query_col)` in the NZ source frame**, with a destination
108+ `Tile<Mat, half, 64, 64, ColMajor>` and a source
109+ `Tile<Vec, half, 64, 32, ColMajor>`.
110+ 
111+So the source-frame reading is the real one. `TSQ == TKV` remains the
112+recommendation for anyone who has not re-verified this on their own build, since
113+it costs nothing and is correct under either.
114+ 
43## Cube-specific check115## Cube-specific check
44 116 
45Obtain dtype-dependent contraction geometry and Left/Right/Acc layout117Obtain dtype-dependent contraction geometry and Left/Right/Acc layout
@@ -56,3 +128,325 @@ one K alignment or one fractal shape for all dtypes.
56 128 
57The installed `$PYPTO_DEVKIT_DIR/docs/pypto_pro/api/` pages and official129The installed `$PYPTO_DEVKIT_DIR/docs/pypto_pro/api/` pages and official
58examples are the primary source for the current version.130examples are the primary source for the current version.
131+ 
132+---
133+ 
134+## Launch geometry on a5 / DAV_3510 — measured, run H (rms_norm, 2026-08-12)
135+ 
136+Instrument: `cold_probe.py`-derived paired sweep (`probe/sweep.py`), **cold** L2 (192 MB flush
137+inside the measured window), Level1 NPU profiler, all arms of a shape in ONE process so the arms
138+share a compiled binary and differ only in launch geometry. The measuring card, on a shared box.
139+🔴 Estimator note: on a shared box, contention is **one-sided** (it can only add time), so `min`
140+over reps is the unbiased estimator of uncontended kernel time — but `min` is *not* robust to
141+spuriously-fast profiler samples. Report `min`, 2nd-smallest and median together; where they
142+disagree, the robust pair wins. This changed two verdicts in run H (a "−11.0 %" became +0.8 %,
143+a "−66.8 %" became −36 %).
144+ 
145+### Shrinking blockDim for small inputs — DOES NOT APPLY to a `min(coreNum, n_tiles)` grid
146+ 
147+A widely-quoted rule (`blockDim = elems < SMALL_GRID_ELEMS ? min(coreNum,8) : coreNum`, with
148+15000/30000 per dtype) is a repair for kernels that launch `coreNum` blocks **unconditionally**.
149+A planner that already emits `blocks = max(1, min(coreNum, n_tiles))` **has the fix in a stronger
150+form**, and applying the threshold on top is a no-op below `n_tiles = 8` and a large regression
151+above it. Measured on `[11,13,17,67]` fp16 (162 877 elements, 304 tiles): grid 56 → 8 cost
152+**4.7364 → 19.5766 µs = +4.13×** *(measured on a 56-vector-core part; the ratio is the point, the counts are that part's)*, because `ceil(304/56)=6` waves become `ceil(304/8)=38`.
153+Break-even, derived from that measurement rather than assumed: capping an `N`-tile launch to 8
154+costs `(ceil(N/8)−1)·T_tile` and saves `(N−8)·d`; both scale as `N/8`, so the break-even sits at
155+`T_tile ≈ 0.32 µs` **almost independently of N**. Check that number before porting the rule.
156+ 
157+### Wave alignment is NOT the objective; tile size is
158+ 
159+Tiles are handed to blocks strided (`for t in pl.range(ci, nt, nc)`), so wall-clock is
160+`ceil(n_tiles/blocks) · T_tile`. It is tempting to minimise wave count. Measured, that is wrong:
161+`[31,129,2049]` fp32's best rung has **7 → 18 waves** and is still 9 % faster, and
162+`[3,7,11,4096]` fp32's one-wave rungs are 17–22 % **slower** than a three-wave rung. Twice more,
163+a launch that leaves cores idle beat one that filled them: writing `C` for the count the runtime
164+query returns, **`0.68·C` beat `C` by 30 % and `0.5·C` beat `C` by 41 %** — measured on a part
165+where `C = 56`, and stated as fractions because the fraction is the only part that could ever
166+travel. **Filling every core is not the objective.**
167+ 
168+🔴 **Those two magnitudes are per-shape observations, and as guidance they are
169+falsified, on 20 shapes at once.** They are the only numbers on this row, so they
170+read as an invitation to reduce the block count; the row's own instruction ("sweep it; do not
171+assume it") is the part that transfers. Swept on Ascend950PR_9579 / CANN 9.2.0, 2026-08-25, on
172+a flat elementwise kernel with per-core contiguous spans, substituting a half-array
173+`block_dim = min(0.5·C, ceil(n / PART))` for `min(vector_core_num, …)`: **every one of the 20
174+shapes regressed**, by **+10.6 % to +85.3 %** (mean speedup 0.735), with the worst hits on the
175+mid-size shapes where the tail wave is supposed to dominate. Full `vector_core_num` was correct
176+on all of them.
177+ 
178+Read the two results together rather than picking one: the 30 %/41 % wins were measured on a
179+`Rows`-tiled row-reduction where a *tail wave* dominated a small `n_tiles`; the 20 regressions
180+were measured where each core owns one contiguous element span and halving the core count
181+simply halves the achieved bandwidth. **Neither magnitude is a default.** Sweep the block
182+count for your shape, and note that neither of the shipped artifacts on either side reduces
183+it — see the paragraph below.
184+ 
185+🔴 **The shipped artifacts do NOT reduce the core count.** Verified in the two best-scoring
186+row-normalisation submissions: `_BLOCKS = get_platform_info().vector_core_num`,
187+read once at import, with a literal only as the `getattr` fallback — and nothing lowers it.
188+That measured lever tuned **`Rows`**, not the block count. The sweep result below is a
189+per-shape observation that never shipped.
190+ 
191+🔴 **Do not turn that into "use fewer cores".** It is a per-shape sweep result, valid only where
192+`n_tiles` is small enough that the tail wave dominates — and the 20-shape sweep above measured the
193+full queried count correct on every one of them. The opposite mistake has also been measured and
194+*shipped*: sizing the grid from `get_platform_info().core_num` — the cube count, half the vector
195+count on that part — instead of `vector_core_num` launched a `section_vector` kernel on half the
196+array and cost a
197+**1.900× median / 1.997× max** slowdown. That pair is recorded **here and nowhere else**, so
198+treat it as one observation rather than as independently corroborated, and do not cite a second
199+page for it. The only cross-check is order-of-magnitude and is arithmetic on a model rather than
200+a second measurement: halving the per-core count in a load model gives a geometric mean of
201+**1.985** over 20 shapes, consistent with the range above. The API half is directly confirmed —
202+a `section_vector` launch must be sized from `vector_core_num`, and a pure-vector kernel that
203+reads `core_num` gets exactly half the array. See
204+[../references/pypto-pro-launch-block-dim.md](../references/pypto-pro-launch-block-dim.md).
205+ 
206+The rule is *sweep the block count for your shape*, never *prefer fewer cores*, and
207+never read the count from the wrong constant.
208+ 
209+**This is not the other 28-vs-56 finding.**
210+[`pypto-pro-op-perf-tune`](../../pypto-pro-op-perf-tune/references/a5-roofline-and-levers.md)
211+reports that *merely declaring* `pl.section_cube()` costs **1.88x** on the vector path by halving the
212+launch from 56 blocks to 28. That is a **declaration** shrinking the grid of an otherwise unchanged
213+kernel; the paragraph above is an **API** mistake — reading `core_num` where `vector_core_num` was
214+meant. Same arithmetic, two unrelated causes, and neither magnitude transfers to the other case.
215+ 
216+### There is no closed-form optimal `Rows` — but `Rows` derived from `S` alone is a real defect
217+ 
218+Swept every legal `Rows` rung on all 18 Mode-0 public shapes. A rule "pick the rung whose
219+`Rows·MaxN` is nearest T" was evaluated offline against the measured table for
220+T ∈ {7k…12k}: **every T regresses at least one case by ≥ 6.5 %**, up to +32 %. Same rung
221+(`MaxN=1024`), same S band, opposite optima by dtype: fp32 wants `Rows=5`, fp16 wants `Rows=16`
222+and is +32 % worse at 5. **Do not ship a global `Rows` rule.**
223+ 
224+What *is* real: a `_pick_rows` that computes `bal = S // (coreNum · MIN_TILES_PER_CORE)` is a
225+function of **S alone and ignores MaxN**, so at the narrowest rung it starves the tile. At
226+`MaxN = 128` it picked `Rows = 8` (a 1 KB tile) where the UB ceiling was 64. Growing `Rows` to
227+the largest rung that still leaves `≥ coreNum/2` tiles measured **−12 % to −42 %, mean −27 %
228+(fp16) / −21 % (bf16), zero regressions**, over `S` from 56 to 2431 — a 43× range, both dtypes.
229+The same rule applied to **other** rungs regresses `MaxN=384` by **+40.8 %** and
230+`MaxN=4096` by **+22.1 %**. Gate it to the rung it was measured on.
231+ 
232+### Regrouping rows into tiles is bit-neutral
233+ 
234+Changing `Rows` changes only how rows are grouped; each row's reduction is independent of the
235+grouping. Verified byte-for-byte: **30 configurations, 5 shapes, 96 386 842 bytes compared,
236+0 differing bytes**. A `Rows` change that alters even one output word means something else moved.
237+ 
238+### The narrow-rung `Rows` floor — where growing helps and where it overshoots (run H, extended)
239+ 
240+Same instrument as above. The `bal = S // (coreNum * MIN_TILES_PER_CORE)` defect (Rows chosen from
241+S alone, MaxN ignored) starves the tile at EVERY narrow rung, not just the narrowest. Measured at
242+`MaxN` 192 and 256, both dtype classes, `S in {56,127,254,896}`: the planner's pick is beaten by
243+**-4.4 % to -48.8 %**, and `Rows = 8` is the best arm in 9 of those 12 points.
244+ 
245+But the FIX does not generalise the way the defect does. Growing `Rows` to the largest rung that
246+still leaves >= coreNum/2 tiles is right at `MaxN = 128` (measured best-or-within-5 % over S from 56
247+to 14336) and **wrong at `MaxN >= 192` once S is large**:
248+ 
249+| MaxN | dt | S | grow-to-cap vs planner |
250+|---|---|---|---|
251+| 192 | fp32 | 7168 | **+14.6 %** |
252+| 384 | fp16 | 4037 | **+17.6 %** |
253+| 384 | fp32 | 4037 | **+38.5 %** |
254+ 
255+That last row is public case `[11,367,373]`, and +38.5 % here reproduces a +40.8 % measured from an
256+independent sweep — the boundary is real.
257+ 
258+**The portable lesson: at rungs above the narrowest, use a FLOOR (`Rows >= 8`), not a target.**
259+A floor is provably inert for `S >= 8 * MIN_TILES_PER_CORE * coreNum` — here `8 * 4 * vector_core_num`, which is 1792 on a 56-core part — so it cannot
260+fire in the regime where growth overshoots, while still capturing the whole starved corner. Measured
261++0.0 % at all 12 large-S points and -4.4 %…-48.8 % at small S. Growth guards must be gated to the
262+single rung they were measured on.
263+ 
264+ 
265+---
266+ 
267+## Per-core load balance is the quantity the `Rows` sweep is ranking (measured, 2026-08-14)
268+ 
269+Everything above stands: there is no closed-form optimal `Rows`, and you sweep. What was missing
270+is the quantity the sweep is *ranking*, which turns a 27-point sweep into a shortlist of two or
271+three.
272+ 
273+Tiles are handed to blocks strided and there is no barrier, so wall-clock tracks the **longest
274+running core**, not the average one. For `S` rows at `Rows` rows per tile over `cores` cores, the
275+elements resident on the busiest core are
276+ 
277+ busiest(Rows) = ceil( ceil(S / Rows) / cores ) * Rows
278+ 
279+Measured on the measuring card / Ascend950PR / CANN 9.2.0, 2026-08-14, on `rms_norm`: 8 shapes ×
280+27 `Rows` points, each point a full pinned-harness single-case eval under both box locks. The
281+ranking of `busiest(Rows)` reproduced the measured ranking **exactly on 5 of the 8 shapes and
282+picked the winner on a 6th**. It **inverted on 2 of 8** — `[11,367,373]` fp32 and
283+`[3,7,11,4096]` fp32, where a wider tile won despite worse balance. So it is a **shortlist
284+generator, not a decision procedure**: take the two or three geometries it favours and time them.
285+ 
286+Shipped as a `_pick_rows` post-pass minimising `busiest(Rows)` over `[pick//2, pick)`, accepted
287+only for a better-than-1/16 improvement, tie-break to the wider tile — **and gated to
288+`tiles(pick) >= cores`, i.e. only a launch that already saturates every core.** It was the single
289+largest lever of that tuning stage:
290+ 
291+| shape, dtype | rung | `Rows` | µs | Δ |
292+|---|---|---|---|---|
293+| `[11,367,373]` fp32 | 384 | 32 → 20 | 100.76 → **88.02** | **−12.6 %** |
294+| `[33,127,769]` fp16 | 832 | 24 → 20 | 176.69 → 155.40 | −12.0 % |
295+| `[32,128,1024]` fp32 | 1024 | 24 → 20 | 227.29 → 204.67 | −10.0 % |
296+| `[32,128,768]` fp16 | 768 | 24 → 20 | 152.40 → 137.44 | −9.8 % |
297+| `[7,1009,1021]` fp16 | 1024 | 20 → 16 | 291.61 → 269.98 | −7.4 % |
298+ 
299+**Why the saturation gate is not cosmetic.** Below `tiles >= cores` the launch is core-*starved*:
300+every candidate sits in one wave, `busiest()` collapses onto the `pick//2` floor and stops
301+carrying information — and that regime already has its own measured rules on this page and in
302+the launch-geometry section of this page. The
303+**ungated** form measured slightly *faster* (median 13.153 vs 13.157) and was **rejected**,
304+because it moved a planner geometry that a frozen test pins. Both numbers are recorded so the
305+next run knows the gate costs ~0.03 % on that suite and buys a regime where the model is
306+meaningless.
307+ 
308+The same model also predicted a rejected lever's sign and rough magnitude on a *packed* narrow-D
309+path: widening the packed rung from 512 to 2048 raises the busiest core from 36864 to 49152
310+elements (+33 %) and measured **+26 %** wall time. Enumerating every legal geometry that packing
311+mode can express put the best reachable value at 35840 against the shipped 36864 — **2.9 %**
312+which is how that case was declared finished rather than searched further.
313+ 
314+### The `>= coreNum/2` endpoint slightly overshoots — a refinement, not an opposite sign
315+ 
316+The narrow-rung rule above grows `Rows` to the largest rung still leaving `>= coreNum/2` tiles,
317+measured at −12 % to −42 % against a starved planner pick. A sweep taken **past** that endpoint,
318+same operator and same shape `[11,13,17,67]` fp16 on the measuring card, 2026-08-14, measured `Rows` 64 → 48
319+at a further **−5.6 %**. Read the two together: growing out of the starved corner is the large
320+and reliable move, and the endpoint it lands on is a few percent wide of the optimum.
321+ 
322+🔴 **The two comparisons are not the same comparison** and must not be collapsed into one number:
323+the −12…−42 % figures (and the 30 % launch-geometry result recorded above) compare **38 vs 56**
324+blocks, while this refinement compares **38 vs 51**. Nothing here contradicts *filling every core
325+is not the objective* — the optimum is simply not exactly at the `coreNum/2` endpoint either.
326+**Regime matters — this is the opposite corner from the starved one above.** In the *starved* corner (narrow rung, small `S`) `Rows` is too **small** and growing it wins −4.4…−48.8 %. In the regime swept here (wide rungs, ample `S`) the reverse holds:
327+across all 8 shapes swept in that run the planner's `Rows` was too **large**, never too small,
328+which is the opposite corner from the starvation defect the section above repairs; both are real
329+and they are reached by different planners. The page's own instruction is what settles it: sweep.
330+ 
331+**Not established — recorded so it is not inherited as a constant.** The same run fitted a
332+per-launch fixed cost of `T ≈ 31 µs + 7.37 µs/MB` on that card, which would be 73 % of the
333+smallest case's entire 42.7 µs and would explain why the suite's worst ratio-to-floor case is
334+also its smallest. It is a **two-point fit with zero degrees of freedom**: it cannot be
335+falsified by its own data and it is not a measurement. **Do not use the 31 µs.** If a small-shape
336+floor matters to a run, sweep transfer size properly and derive one, and note that no routed page
337+models a launch constant at all.
338+ 
339+> **Where this statement lives, for anyone citing it.** The "do not use the 31 µs"
340+> ruling is **this subsection** — 「Per-core load balance … > The `>= coreNum/2`
341+> endpoint slightly overshoots …」 — not the wave-alignment section further up.
342+> A design review cited it against the wrong section, and because both sections
343+> discuss launch geometry the mis-citation read as plausible. When quoting a
344+> retraction, quote the heading path it sits under, not the nearest heading on
345+> the same subject: a retraction that is attached to the wrong section can be
346+> re-litigated by anyone who reads that section and does not find it.
347+ 
348+## `set_validshape` on the ROW axis: cost tracks the CRITICAL-PATH VALID rows (measured 2026-08-27, corrected same day)
349+ 
350+🔴 **RETRACTION, in place.** This section previously read, under the heading
351+`constraints/tiling.md › set_validshape narrows semantics, not cost — but only on the ROW axis`:
352+"Tile-op cost tracks the **DECLARED ROW COUNT**, not the valid row count." **That is wrong on the
353+row axis**, and the table below is why. The width finding in the next section is unaffected and
354+still stands — do not read this retraction as touching it.
355+ 
356+The corrected rule is:
357+ 
358+> Tile-op cost on the row axis tracks the **CRITICAL-PATH VALID ROW COUNT** — the
359+> `ceil(nrow / block_count)` rows the busiest core actually processes. The **declared** row count of
360+> the tile type has **no demonstrated independent effect**.
361+ 
362+Why the original reading failed. The evidence was a rows-per-tile experiment on a per-token
363+quantizer whose work division is a contiguous ROW SPAN. The entry claimed the rows actually
364+processed per core were unchanged between the two variants, and that column was called
365+load-bearing. It was load-bearing, and it was **incorrect**. The kernel launches
366+`min(vector_core_num, ntiles)` blocks with `ntiles = ceil(T / rows_per_tile)`, so **raising
367+rows-per-tile lowers the block count**, which *raises* `ceil(nrow / blocks)`. Declared rows and
368+critical-path rows moved together by construction, and the experiment could not separate them.
369+Block counts recovered from `Block Num` in the profiler's `op_summary.csv` for the same two
370+variants:
371+ 
372+| declared rows | declared ratio | blocks launched | crit-path rows `ceil(nrow/blocks)` | measured slowdown |
373+|---|---|---|---|---|
374+| 64 → 64 (pinned by the `LANES` cap) | x1.00 | 56 → 56 | 64 → 64 | **none (0.99)** |
375+| 39 → 62 | x1.59 | 53 → 34 | **39 → 61** | x1.41 |
376+| 10 → 17 | x1.70 | 52 → 31 | **10 → 17** | x1.47 |
377+| 5 → 8 | x1.60 | 52 → 32 | **5 → 8** | x1.38 |
378+ 
379+The rung that did not regress is still the control, but it controls for something different from
380+what was claimed: its declared rows were pinned by `LANES`, so its **block count and critical path
381+were also unchanged**. Every rung that regressed had its critical path lengthened.
382+ 
383+The separating experiment. A later variant changed **only** the launch clamp —
384+`min(cores, ntiles)``min(cores, nrow)` — leaving every tile type, every rung and every
385+declared row count untouched, and altering only how many rows the busiest core carries:
386+ 
387+| shape | declared rows | crit-path rows | measured |
388+|---|---|---|---|
389+| square, side = 1 rung width | 22, **unchanged** | 22 → 19 | **−9.3 %** |
390+| tall, width just over a rung | 39, **unchanged** | 39 → 37 | **−3.4 %** |
391+| control: crit-path pinned by the ceiling | 10, unchanged | 10 → 10 | **−0.6 % / +0.6 %** (null) |
392+ 
393+Declared rows are constant down that whole table. A strict declared-row model predicts zero
394+everywhere; the measured effects are large and track the critical-path ratio instead. The third row
395+is the control that rules out "more blocks is just better": it gains blocks with its critical path
396+pinned, and does not move.
397+ 
398+**The law, and its range of validity.** Time scales as `(critical-path rows)^0.67` on this
399+operator. The exponent was fitted on the *degradations* above (ratios 1.56–1.70) and then predicted
400+the two *improvements* (ratios 0.86–0.95) to within 0.2 pp — out of sample, in the opposite
401+direction. Treat 0.67 as an operator-specific fit, but treat the *shape* of the law — cost follows
402+critical-path rows, sublinearly — as the transferable part.
403+ 
404+🔴 **This cuts against a neighbouring idiom, so read the scope carefully.**
405+[../patterns/vec-row-reduce-broadcast.md](../patterns/vec-row-reduce-broadcast.md) prescribes
406+over-declaring the `[R,1]` reduction *output* tile and narrowing it — and that is correct, because
407+that tile is a couple of hundred bytes and its cost is irrelevant. The rule here is about **large
408+data tiles**: the same idiom applied to one is a real cost.
409+ 
410+**Consequence for any "releaser" argument.** The standard chain — free bytes per element, get more
411+rows per tile, go faster — has a negative second link, and the corrected mechanism makes it sharper
412+than the original wording did. Spending freed capacity on rows-per-tile **shrinks `ntiles`, which
413+shrinks a block count clamped to `ntiles`, which lengthens the critical path**. The cost is starved
414+parallelism, not expensive declared rows. Freeing capacity is not a gain until you have somewhere
415+profitable to spend it. Measure the spend, not just the release.
416+ 
417+**Corollary worth checking on any row-split kernel.** If the launch clamps blocks to `ntiles` while
418+the kernel divides work by row span, the clamp is a leftover from a tile-index split and is
419+leaving cores idle: `ceil(nrow/ncore)` is non-increasing in `ncore` and `nrow >= ntiles` always, so
420+clamping to `nrow` instead is **weakly dominant on every shape** and is bit-neutral. On the operator
421+above it was worth up to −9.3 % on the shapes where `ntiles < vector_core_num`, and exactly nothing
422+on the shapes where it was already saturated — so it costs nothing to try and the shapes that
423+benefit are identifiable in advance, without measuring, from `ntiles` alone.
424+ 
425+### The width axis: over-declaring is nearly free, and going below a 64-multiple backfires
426+ 
427+Same kernel chain, rows fixed, VALID width fixed, only the DECLARED width varied:
428+ 
429+| declared | valid | us | declared ratio | time ratio |
430+|---|---|---|---|---|
431+| 576 | 513 | 1.289 | x1.00 | x1.000 |
432+| 1024 | 513 | 1.313 | x1.78 | x1.019 |
433+| 2112 | 513 | 1.421 | x3.67 | x1.102 |
434+ 
435+Elasticity of time to declared extent is **0.04 on width**: a x3.67 width over-declare bought
436+x1.10. Do not spend effort tightening declared width. There is no declared-extent counterpart on
437+the row axis — cost there tracks the critical-path valid row count, not the declared count (see
438+「The law, and its range of validity」 above).
439+ 
440+🔴 **And tightening width below a multiple of 64 costs more than it saves.** On a D=67 row, the
441+tightest width still legal for an int8 output tile (`Cols % 32 == 0`) is 96, against the 128 a
442+64-multiple ladder would pick:
443+ 
444+| declared | valid | us |
445+|---|---|---|
446+| 128 | 67 | **1.038** |
447+| 96 | 67 | **1.195** |
448+ 
449+The narrower tile is **1.15x slower**. `LANES = 64` is stated elsewhere in this KB as a
450+correctness constraint for `vf` register access; it is also a throughput effect on the **tile**
451+path, and it dominates the width saving. A width ladder built from multiples of 64 is therefore
452+right even where a tighter legal width exists.
@@ -25,7 +25,34 @@ starts at `(n_regs-1)*64` and runs off the end of a tile whose width is not a 64
25 25 
26**Rule.** Size the tile to `align64(D)`. Then count UB slots honestly: double-buffered in and26**Rule.** Size the tile to `align64(D)`. Then count UB slots honestly: double-buffered in and
27out is **four** slots, so at 256 KB of UB a single vf tile needs27out is **four** slots, so at 256 KB of UB a single vf tile needs
28-`4 * TR * align64(D) * 4 ≤ 262144`, i.e. `align64(D) ≤ 2048` at `TR = 8`.28+`4 * TR * align64(D) * 4 ≤ 253952`, i.e. `align64(D) ≤ 1984` at `TR = 8`. Budget against the
29+`.ini` `ub_size = 253952`, **not** 256 KB — see `constraints/arch-a5.md` for the derivation.
30+ 
31+## The read and write sides do not share an alignment contract
32+ 
33+**Symptom.** `vf.load_align` faults **`507035`** at a 4-byte element offset into a `[1, N]`
34+FP32 Vec tile, while `vf.store_align(…, dist=pl.StoreDist.FIRST_ELEMENT)` at the **same**
35+offsets in the **same** kernel completes and produces correct data. Witnessed at element
36+offsets 0 / 4 / 8 bytes on a per-row-scalar staging path.
37+ 
38+**Cause.** They are different contracts, not one contract applied twice. `load_align` moves a
39+**whole 64-lane register** and therefore inherits the register-granule rule at the top of this
40+page. `FIRST_ELEMENT` is documented to align to the **dtype width**, which is what makes it
41+the in-contract way to write a single 4-byte scalar; the general `NORM` store carries a
42+32-byte constraint instead.
43+ 
44+**Rule.** Do not infer a legal load offset from a legal store offset, or the reverse. For a
45+per-row scalar: write it with `dist=pl.StoreDist.FIRST_ELEMENT`, and **read it back through a
46+32-byte-aligned row pitch** — give each row its own 32-byte staging row and load at
47+`row * (32 / itemsize)` — rather than at the compact element offset the store accepted. This
48+is the same 32-byte quantum as the per-token staging rule further down this page, arrived at
49+from the load side.
50+ 
51+**Why it is worth a rule.** Nothing validates the offset on the way through: the backend's
52+offset resolution is string arithmetic with no range or alignment check, and it emits the
53+store instruction directly. A design that pays for 32-byte alignment on the read side and
54+abandons it on the write side (or the reverse) is internally inconsistent and will look
55+correct until the faulting side runs.
29 56 
30## Reduction tiles: rows must be a multiple of 857## Reduction tiles: rows must be a multiple of 8
31 58 
@@ -68,8 +95,44 @@ narrowed, which took every last-axis case from a two-pass kernel to single-pass.
68> against the width budget, which shrank `TB` and took working bf16 rungs from 16 columns to 8,95> against the width budget, which shrank `TB` and took working bf16 rungs from 16 columns to 8,
69> regressing 14/20 to 8/20 before anything improved. Budget the narrow tile first.96> regressing 14/20 to 8/20 before anything improved. Budget the narrow tile first.
70 97 
98+### An UNLABELLED Vec tile is RowMajor, so `Cols * sizeof(T) % 32 != 0` fails to build
99+ 
100+**Which disjunct of that assertion you get is decided by a layout you may never have written.**
101+The full predicate is
102+`(RowMajor && NoneBox && Cols*sizeof(DType) % 32 == 0) || (ColMajor && NoneBox && Rows*sizeof(DType) % 32 == 0) || …`
103+with `alignedSize = 32` (the tile header's `static_assert`). The frontend's default-layout table
104+`_DEFAULT_LAYOUTS_A5` has **no `MemorySpace.Vec` entry** (`pypto_pro/ir/op/block_ops.py`), and
105+the lookup returns early when the default is `None`, so a Vec tile declared **without** an
106+explicit layout keeps `layout=None`, the kwarg is dropped from the IR, and the **C++ template
107+defaults** apply: `RowMajor`, `NoneBox`. Disjunct 1 is therefore the one being tested, whatever
108+the tile is for.
109+ 
110+**The consequence bites exactly the per-row scalar operand.** A `[R, 1]` FP32 scale or
111+reduction tile has `Cols * 4 = 4`, so disjunct 1 is false and bisheng fires the `static_assert`.
112+**Padding `Rows` does nothing** — the RowMajor disjunct tests `Cols`, so the familiar
113+over-declaration remedy protects the wrong axis. `Rows` padding only starts working once the
114+tile is on the ColMajor disjunct.
115+ 
116+**Rule.** Declare a `[R, 1]` Vec tile as `layout=pl.DN` and treat the kwarg as **mandatory,
117+not decorative**: it selects the ColMajor disjunct, where `Rows * 4 % 32 == 0` is satisfiable by
118+rounding `Rows` up to a multiple of 8 (which is what the over-declaration idiom above is
119+actually for).
120+ 
121+**Coupled caveat, already recorded in this KB:** a `DN` `[R, 1]` tile **rejects elementwise
122+ops**, and the historical workaround — declaring a row-major alias at the same address — re-arms
123+the tile-group aliasing corruption. Do not add the alias. Do the scalar arithmetic somewhere
124+that is not the DN tile.
125+ 
126+**Diagnostic.** The assertion is a compile-time event, so it is observable only at build; and a
127+shipped sample that omits the layout is **not** evidence the rule does not bind, because a
128+sample that was never compiled for that key proves nothing. Compile the key, or read the
129+predicate.
130+ 
71## `.current()` does not advance the rotation — `.next()` does131## `.current()` does not advance the rotation — `.next()` does
72 132 
133+> **Canonical home for the inert-rotation diagnostic.** Other pages cite this one; they must
134+> not restate the threshold or the magnitudes.
135+ 
73**Symptom.** Adding a buffer changes performance by less than 2%, and multi-buffering looks136**Symptom.** Adding a buffer changes performance by less than 2%, and multi-buffering looks
74worthless.137worthless.
75 138 
@@ -107,6 +170,35 @@ reduction scratch or its row-major alias.
107 170 
108## `vf.update_mask` per register costs 2.4x–6.5x — hoist it171## `vf.update_mask` per register costs 2.4x–6.5x — hoist it
109 172 
173+🔴 **Gate before applying: read [vec.md](vec.md) § "Performance: the mask is the first thing to
174+look at" and its degenerate-extent rules first.** The hoist replaces a per-register mask with a
175+hoisted constant plus one peeled tail, and the register count that peel is written from decides
176+whether it is safe: the `ceil(n/LANES) − 1` spelling reaches `update_mask(LANES)` and
177+`update_mask(0)`, and reaching a cross-lane reduce through either has **hung a device (507034)**
178+a hang, not a fault code, so the arm returns nothing. The `#18` pointer at the end of this section
179+is a different reference and does not cover it.
180+ 
181+> **Canonical home for this lever's magnitude.** Other pages point here; they must not restate a
182+> number. Which figure to quote depends on what you are claiming:
183+> - **A graded result exists and is NOT priced here.** The official per-case table (hidden set,
184+> named cohort, attribution decided in advance) lives in
185+> [vec.md](vec.md) § the mask lever — go there for the number you may quote as a *result*. The
186+> trigger it came from is a **column-block / multi-tile-per-row** inner loop; that site, not the
187+> operator, is what generalises.
188+> - **Dev-box range: 2.4x–6.5x** (this page's table). Use it to *rank* candidates, never to claim a
189+> score. Box and grader have disagreed in **sign** on the same change (+7.46 vs −1.92).
190+> - A single box figure (e.g. 5.16× on one rms_norm host) is one row of the table below, not a
191+> separate finding, and not a headline.
192+>
193+> If you are about to write a mask-hoist magnitude into another page, link this heading instead.
194+>
195+> **The same lever is measured independently upstream**, in
196+> [`pypto-pro-op-perf-tune`](../../pypto-pro-op-perf-tune/references/a5-roofline-and-levers.md)
197+> § "The per-register mask is a first-class cost, and hoisting it can be the whole win" — in ns per
198+> register group plus a shape-class ratio table. Those are that page's numbers and that page's units;
199+> the per-case table below is this page's. **Cite one or the other, never a figure assembled from
200+> both** — they were derived on different instruments and are not interchangeable.
201+ 
110**Symptom.** A `vf` kernel whose body is a handful of arithmetic ops runs several times202**Symptom.** A `vf` kernel whose body is a handful of arithmetic ops runs several times
111slower than its byte count implies, and profiles as vector-issue-bound rather than203slower than its byte count implies, and profiles as vector-issue-bound rather than
112bandwidth-bound.204bandwidth-bound.
@@ -126,6 +218,37 @@ pure overhead — and on a short body they dominate.
126**Rule.** Build one `vf.create_mask(pattern=pl.MaskPattern.ALL, ...)` outside the loop and218**Rule.** Build one `vf.create_mask(pattern=pl.MaskPattern.ALL, ...)` outside the loop and
127use it for every register.219use it for every register.
128 220 
221+> 🔴 **The hoist is a CORRECTNESS requirement, not only the performance lever the tables
222+> below measure.** `vf.update_mask` lowers to a special-purpose-register
223+> write whose **POST_UPDATE is base behaviour, not an opt-in kwarg**, and the installed
224+> `update_mask` page documents that it *mutates its scalar operand*:
225+> `scalarValue = (scalarValue < VL_T) ? 0 : (scalarValue - VL_T)`.
226+>
227+> So a `vf.update_mask(tail, …)` written **inside** a per-row loop does not merely cost the
228+> ratios in the tables below — it decrements `tail` on the first row, and every row from 1
229+> on then observes `tail == 0`, skips its tail peel and **silently drops up to 63 columns**.
230+> Ragged widths lose data; nothing faults, and a suite of power-of-two widths cannot see it.
231+> Two rules follow, and they hold even where the performance argument does not:
232+>
233+> * Hoist the mask out of the row loop **because the kernel is otherwise wrong**, then take
234+> the speed as a consequence.
235+> * **Do not reuse the scalar operand.** Feed `update_mask` a dedicated expression whose
236+> only consumer is that call; a scalar that also bounds a loop, sizes a store or addresses
237+> anything must not be handed to it.
238+>
239+> A cheap static guard, if the emitted code is inspectable: assert exactly one `plt_b32` per
240+> generated kernel, emitted **before** the first `for` of the vector scope. That is a
241+> mechanical check on an otherwise silent property.
242+ 
243+**Independently confirmed, and not only here.** The same lever is recorded in
244+[`docs/pypto-pro-dsl-limitations.md`](../../../../docs/pypto-pro-dsl-limitations.md) #18 from a
245+different campaign and a different operator family: hoisting the mask out of the per-register
246+loop recovered **550.75 → 398.06 µs** (the DMA floor) on a 67M index fill, and on the reference
247+generator's exp kernels moved mean SOL **0.300 → 0.961 across 20/20 cases**. Treat the two as
248+corroborating measurements of one effect rather than as separate findings — and read the two
249+caveats recorded with #18 (the tail loop must be its own pass; one hoisted variant faults the
250+device) before applying it, since neither is visible from the numbers on this page.
251+ 
129**Whether the tail path can then be dropped depends on what the inactive lanes' output is252**Whether the tail path can then be dropped depends on what the inactive lanes' output is
130used for, and getting this wrong corrupts results silently.**253used for, and getting this wrong corrupts results silently.**
131 254 
@@ -159,6 +282,21 @@ one module so neither could be served the other's binary:
159| 1538x1537 fp32, dim 0 | 8.36 us | 3.50 us | 2.4x |282| 1538x1537 fp32, dim 0 | 8.36 us | 3.50 us | 2.4x |
160| 2039x65520 fp32 | 655.86 us | 649.88 us | 1.01x |283| 2039x65520 fp32 | 655.86 us | 649.88 us | 1.01x |
161 284 
285+The same lever on `rms_norm`, a two-pass reduce-and-broadcast body, on a second Ascend950PR
286+host — interleaved A/B/A, run-to-run spread 0.02–0.11 %:
287+ 
288+| case | shape, dtype | per-register mask | hoisted | ratio |
289+|---|---|---|---|---|
290+| 4 | 16x256x4096 f16 | 158.83 us | 30.77 us | **5.16x** |
291+| 18 | 2x511x8192 bf16 | 80.83 us | 17.02 us | 4.75x |
292+| 8 | 16x511x2049 fp32 | 162.91 us | 96.63 us | 1.68x |
293+| 16 | 11x13x17x67 f16 | 5.34 us | 3.50 us | 1.53x |
294+ 
295+Both tables are the same curve. The fp32 row lands at 1.68x for the reason case 5 lands at
296+1.01x — it is the memory-bound shape in its suite, and the vector-side win has nowhere to go.
297+On the two cache-resident cases the dynamic mask accounted for **81 %** and **79 %** of total
298+kernel time.
299+ 
162**The gain scales with how far the kernel is from the DRAM roof, which is why it is easy to300**The gain scales with how far the kernel is from the DRAM roof, which is why it is easy to
163dismiss.** The largest row moves 801 MB and is bandwidth-bound at 1.23 TB/s, so the mask costs301dismiss.** The largest row moves 801 MB and is bandwidth-bound at 1.23 TB/s, so the mask costs
164it 1%; every shape that fits cache pays the full 2.4–6.5x. A kernel tuned only on its largest302it 1%; every shape that fits cache pays the full 2.4–6.5x. A kernel tuned only on its largest
@@ -205,23 +343,50 @@ class is invisible to flat-UB simulation.
205scratch 再显式合并」。按活跃 lane 数给 scratch 定尺,store 会越过末尾写入相邻 UB tile,343scratch 再显式合并」。按活跃 lane 数给 scratch 定尺,store 会越过末尾写入相邻 UB tile,
206而症状出现在**另一个** tile 上,定位方向天然是错的。344而症状出现在**另一个** tile 上,定位方向天然是错的。
207 345 
208-## A masked *continuous* store may round its active lanes up to a whole 32-byte block346+## A masked *continuous* store is LANE-EXACT
209 347 
210-**Unverified for `vf.store_align` treat as a hypothesis and run the probe below348+A masked continuous store writes exactly its active lanes: it does **not** round them up to
211-before relying on either behavior.**349+a whole 32-byte block. So it cannot spill past the active lanes onto the already-written
350+first lane group of the next row when lane groups are the outer loop, and no defensive write
351+ordering is needed to protect against that.
212 352 
213-A masked continuous store rounded its active lane count up to a whole 32-byte block353+**Measured**, with a discriminating sentinel tile and a **live control that fired** in the
214-(`align8(n)` lanes at b32), so the spill lands past the active lanes. That is harmless354+same run the control is what makes the negative result readable at all
215-while rows are written in increasing order — it lands on data the loop has yet to write —355+([investigation-discipline §13](../references/investigation-discipline.md)):
216-and wrong the moment a row spans several lane groups with the groups as the outer loop,
217-because the last group of row `r` then lands on the already-written first group of row
218-`r+1`. The *scatter* store on the same hardware was lane-exact: its mask is lane-wise and
219-it writes exactly the active lanes.
220 356 
221-**Probe** (one build): store `n < 8` fp32 lanes under mask at a row boundary with the next357+| store form | active lanes | elements actually moved |
222-row pre-filled with a sentinel, then read the sentinel back. If it was overwritten, masked358+|---|---|---|
223-`vf.store_align` rounds up too order the writes so the spill lands on not-yet-written359+| `vf.store_align(dist=pl.StoreDist.FIRST_ELEMENT)` | 1 | **exactly 1** |
224-data, or store the boundary through `vf.scatter`.360+| `vf.store_align(dist=pl.StoreDist.NORM)`, masked | 3 | **exactly 3** |
361+ 
362+No `align8(n)` round-up, and no whole-256-byte register write. The three candidate
363+behaviours — 1 element, `align8` = 8 elements, 256 bytes — are discriminated, and it is the
364+first.
365+ 
366+**Scope, which is the part worth carrying.** The discrimination ran on a **RowMajor
367+`[2, 256]`** tile. A `DN`-layout tile was **not** exercised, and neither was any distribution
368+mode other than the two above. So this settles the continuous masked store on a row-major
369+tile, and says nothing about the others.
370+ 
371+**This does NOT generalise to a distribution-mode store, and the two must not be
372+conflated.** A masked `vf.store_align(dist=INTLV_B32)` **ignores its predicate entirely** and
373+a distribution store writes the whole register — both are separately measured and are recorded
374+below under "A distribution-mode store writes the whole register, mask or no mask". Lane-exact
375+is a property of the *continuous* store.
376+ 
377+🔴 **Report the raw counts, never a probe's summary label.** The probe that produced the
378+table above printed the string `align8 round-up` next to a lane-exact 3-element result — a
379+label written before the data and never revisited. Anyone reading the log rather than the
380+counts would have recorded a round-up as *confirmed*. When a probe's whole job is
381+to discriminate between named branches, the branch names belong in the analysis, not in the
382+instrument.
383+ 
384+## Below one register's width the mask hoist stops paying
385+ 
386+**That boundary has been hit twice.**
387+At 8 or 16 columns the hoist measured +5 % with six cases 3–4 % *slower*; applying the identical
388+hoist to a D=2 packed path measured **-1.0 %** and was rolled back as inside noise. The
389+full-register loop does not execute there, so the hoist buys only bookkeeping.
225 390 
226---391---
227 392 
@@ -246,3 +411,149 @@ One myth was busted in the same investigation and should not be reintroduced: `i
246out-of-bounds access, misattributed.411out-of-bounds access, misattributed.
247 412 
248---413---
414+ 
415+## Per-token scalar staging wants one whole 32-byte row per token
416+ 
417+**Cross-DSL: EasyASC board measurement on Ascend 950. 未在 PyPTO-Pro 上验证——由
418+EasyASC 移植的假设 (unverified on PyPTO-Pro).** It does **not** override the
419+PyPTO-Pro measurement it sits next to; read that one first.
420+ 
421+**What this KB already measured, and which stays authoritative.** An FP16
422+scalar-Tensor store is not safe at a 32-byte ownership boundary — accuracy 0.90
423+overall and 0.90625 at `block_dim=32`, while **64 B and 128 B boundaries were
424+exact** and a whole-tile `pl.store` was exact at all three
425+([framework-findings, A5 probe session](../references/pypto-pro-framework-findings.md)).
426+The conclusion there is: prefer the beat-complete tile store.
427+ 
428+**What EasyASC adds, from the staging side rather than the store side.** Its
429+`constraints/a5.md` §12 reports that repeatedly moving *one scalar per token*
430+between GM and UB — `m > 1`, `n == 1` — into a compact `[1, L]` or `[L, 1]` UB
431+layout **can misaddress later rows on hardware while its Python simulator
432+passes**. Its prescription is to give each token one physical 32-byte UB row and
433+use only the first element of it:
434+ 
435+```
436+scalar_ub : [L, C0] # C0 = 32 B / sizeof(dtype); 16 for bf16, 8 for fp32
437+ # logical slice stays [L, 1]; the backing stays [L, C0]
438+```
439+ 
440+EasyASC is explicit that this is a **burst-pitch** requirement — a property of the
441+repeated one-element transfer — and *not* a general ban on unaligned scalar
442+addressing, which it says is fine for an isolated access.
443+ 
444+**Why it is worth carrying.** It is the same 32-byte quantum as the measured
445+PyPTO-Pro finding above, reached from a different direction (staging pitch rather
446+than concurrent-store ownership), by a different DSL, on the same silicon. Two
447+independent arrivals at "give the scalar a whole beat" is the kind of agreement
448+that should raise your prior before you spend a board session. It also predicts
449+something the PyPTO-Pro measurement did not test: that the hazard survives even
450+with a single writer, because it is about burst addressing rather than about two
451+cores sharing a beat.
452+ 
453+**Named probe:** stage `L` per-token fp32 scalars into a `[L, 1]` UB tile and into
454+an `[L, 8]` tile whose column 0 carries the payload, read both back, and compare
455+against the host values at `L` large enough to need several bursts. A clean `[L,
456+1]` result retires this section; a mismatch that appears only past the first burst
457+confirms it. Note the control requirement — the probe is void unless the same run
458+shows the checker can report a mismatch
459+([investigation-discipline §13](../references/investigation-discipline.md)).
460+ 
461+## A distribution-mode store writes the whole register, mask or no mask
462+ 
463+Independent corroboration, from a second DSL, of a silent failure this KB already
464+records — and a constructive consequence the original entry does not state.
465+ 
466+**Already measured here.** A masked `vf.store_align(dist=INTLV_B32)` **ignores its
467+predicate entirely**, writing every lane; the store succeeds and the data is wrong
468+only in the lanes the mask was supposed to protect
469+([framework-findings, A5 probe session](../references/pypto-pro-framework-findings.md)).
470+ 
471+**The corroboration.** EasyASC's `constraints/a5.md` §6.2 reports, on the same
472+silicon, that its `DIST_NORM_B8` store "writes the full 256-byte register even
473+when only a prefix mask is active". Same shape of defect, a different DSL, a
474+different distribution mode, and a *different* mask (a prefix rather than a
475+scatter pattern). Two arrivals make this look like a property of the
476+distribution-mode store path rather than an artifact of one lowering.
477+ 
478+**The consequence worth acting on, which is about allocation rather than
479+correctness.** EasyASC states it as a sizing rule: the scratch a masked
480+distribution store targets must be allocated for the **whole register**, 256
481+bytes, not for the active lane count. That is the constructive form — the
482+PyPTO-Pro entry tells you the data will be wrong; this tells you the buffer will
483+also be too small, which is the failure that shows up as corruption of whatever
484+was allocated next to it.
485+ 
486+**未在 PyPTO-Pro 上验证 for the sizing rule specifically** (the mask-is-ignored
487+half *is* measured here). To verify: place a sentinel immediately after a scratch
488+tile sized to the active lane count, issue a masked distribution store into it,
489+and read the sentinel back.
490+ 
491+## `D` not being a multiple of 64 costs nothing — register COUNT is what costs
492+ 
493+**Claim under test.** On `rms_norm`/A5, every public fp16 case with `D` a multiple of 64 beat
494+the CANN reference (ratio 0.91–0.92) and every fp16 case with `D` not a multiple of 64 lost to
495+it (1.08–1.48). A 7/7 split, and the near-controlled pair `[32,128,768]` vs `[33,127,769]`
496+one extra element of `D`, row count controlled to 2.3 % — showed **+21 %**. The natural reading
497+is a ragged-column-tail or 32-byte-burst-alignment defect.
498+ 
499+**It is not.** Measured on the pinned `kernel_eval` harness (`--perf-metric-strategy
500+kernel_details`), Ascend950PR, the measuring card, fp16, `S = 4096` held fixed while `D` walks. Same-shape
501+repeats inside the run drifted **0.00 %, 0.00 %, −1.88 %**, so the instrument resolves ~2 %.
502+ 
503+The decisive triple shares the `MaxN = 1024` rung, 16 registers per row and one UB footprint —
504+only alignment differs:
505+ 
506+| D | 32B-aligned | `D mod 64` | `D == MaxN` | µs |
507+|---:|:--:|---:|:--:|---:|
508+| 1008 | ✓ | **48** | ✗ | 10.50 |
509+| 1023 | ✗ | 63 | ✗ | 10.47 |
510+| 1024 | ✓ | 0 | ✓ | 10.44 |
511+ 
512+**0.6 % apart.** `D = 1023` — the width of the public case that loses 11 % — costs what
513+`D = 1024` costs. The same holds one rung down: `D = 80` (`mod 64 = 16`, 32B-aligned) costs
514+3.07 µs, exactly what `D = 128` (`mod 64 = 0`) costs, while `65/67/127` cost 3.22/3.19/3.14 —
515+a residual of ≤ 3.7 % that **shrinks with `D` and is gone by `D ≈ 1000`**, the opposite of the
516+direction the deficit needs.
517+ 
518+**So the modulus question is ill-posed: there is no modulus.** 64 is dead, and 128 and 256 die
519+with it. What predicts cost is `ceil(D/64)` — the register count — a *ceiling*, not a residue
520+class. Every `D` in `(64k, 64k+64]` costs the same, which is exactly why aligned and misaligned
521+`D` are indistinguishable once register count is controlled. Fitting the three points that
522+share `MaxN = 1024` and `Rows = 16` (13, 15, 16 registers → 9.04, 10.13, 10.47 µs) gives
523+**2.75 µs fixed + 0.486 µs per register**, reproducing all three to ±1 %.
524+ 
525+**Why the public split looked so convincing — the confound.** In that suite the aligned fp16
526+cases sit at `D = 768, 4096, 4096` and the misaligned ones at `D = 67, 769, 1021, 1023`. There
527+is **no aligned fp16 case anywhere in the `D ∈ [769, 2047]` band**, so "aligned vs misaligned"
528+is perfectly confounded with "`D` at 768/4096 vs `D` near 1024". Probing `D = 960, 1008, 1024`
529+— aligned and *in band* — breaks the confound: they are just as slow as 1023. Measured against
530+the fused reference (`torch_npu.npu_rms_norm`) on one clock, our ratio is 1.19 at `D = 768` and
531+1.40–1.42 at `D = 960/1008/1023/1024` alike. The ratio degrades with `D`-position on the rung
532+ladder, not with alignment.
533+ 
534+**Two hypotheses this also kills**, since the same points control them:
535+ 
536+* *Row tiling / ragged `S`.* With `D = 768` fixed, the ragged row counts of the slow cases cost
537+ **less** per row than the round one: 1.8611 (S=4191) and 1.8645 (S=4221) against 1.8945
538+ µs/1000 rows (S=4096). The remaining variation with `S` is `_pick_rows` landing on Rows 21 or
539+ 8 instead of 16 — a documented smooth function of `S`, not of its factorisation.
540+* *Burst contiguity collapse* (`D == MaxN``dst_gap == 0` ⟹ the whole `[Rows, D]` block moves
541+ as one burst instead of `Rows` short ones). `D = 1024` has it, `D = 1008` and `1023` do not,
542+ and all three cost the same. Worth 0 here.
543+ 
544+**What the `768 → 769` +21 % actually is.** It reproduces (+16.9 % at controlled `S = 4096`) and
545+then decomposes entirely into two already-known quantisations: 12 → 13 registers (+8.3 % of
546+vector work) and a `MaxN` rung jump 768 → 1024 (a 33 % wider tile for one extra element, worth
547+~10 % by the fit above). `D = 1040` clinches it — one register *more* than 1024, on the 2048
548+rung — at **10.20 µs, below** `D = 1024`'s 10.44. Only rung/`Rows` selection can do that;
549+alignment cannot.
550+ 
551+**Rule.** Do not spend a run making `D` "aligned" for a `vf` row kernel of this shape. Size the
552+tile to `align64(D)` because `load_align` moves a whole register and will otherwise fault
553+`507035` (first rule on this page) — that is a **correctness** constraint. It is not a
554+performance one. If a benchmark's slow cases all have ragged `D`, check first whether ragged
555+`D` is confounded with `D` magnitude or with register count in that suite; in this one it was.
556+ 
557+**Diagnostic that settles it in one eval run:** probe three `D` values sharing a rung and a
558+register count, one aligned-and-equal-to-rung, one aligned-but-below-rung, one misaligned. If
559+they land together, alignment is not your mechanism.
@@ -0,0 +1,134 @@
1+# Float constants that reach a `vf.*` op
2+ 
3+## Trigger
4+ 
5+Your kernel passes a **Python float literal** as the scalar argument of a
6+register-level op — `vf.maxs`, `vf.adds`, `vf.muls`, `vf.full`, any `vf.*s`
7+family member — or folds a float constant into a `@pl.vector_function` body.
8+That is the whole trigger; it does not depend on topology, dtype or shape.
9+ 
10+## Rule
11+ 
12+🔴 **A `vf.*` scalar immediate is rendered into the generated C++ with `%f`, so
13+it keeps six fractional digits and nothing else. Any constant whose magnitude is
14+below roughly `5e-7` becomes `0.000000f` — the constant is silently deleted.**
15+Build such a constant in registers instead, and assert the bit-identity of the
16+construction at import.
17+ 
18+There is no error at any layer. The Python is legal, the parser accepts it, the
19+kernel compiles, the launch returns, and every ordinary input produces the right
20+answer. The value only differs on the inputs the constant existed to protect,
21+which is why this survives a correctness suite that does not deliberately
22+contain degenerate data.
23+ 
24+## What was measured
25+ 
26+Ascend950PR / CANN 9.2.0, by emitting a throwaway probe kernel and reading its
27+generated `kernel.cpp`:
28+ 
29+| written | emitted |
30+|---|---|
31+| `vf.maxs(reg, 1e-12, preg)` | `vmaxs(dst, src, 0.000000f, preg, MODE_ZEROING)` |
32+| `vf.full(1e-12, preg, dtype=pl.DT_FP32)` | `vdup(dst, 0.000000f, preg, MODE_ZEROING)` |
33+| `vf.adds(reg, 1e-12, preg)` | same truncation |
34+| `vf.full(6.103515625e-5, …)` | `0.000061f` — representable, but not the value asked for |
35+| `vf.full(-3.0e38, …)` | `-3e+38` — large magnitudes are unaffected |
36+ 
37+So the defect is **one-sided**: it destroys small magnitudes and leaves large
38+ones alone. A sentinel is safe; a floor, an epsilon, a reciprocal-of-a-large-
39+number and a denormal guard are not. `6.103515625e-5` is the smallest normal
40+FP16 — a value that reaches this code path in any narrow-dtype design — and it
41+already loses four significant digits.
42+ 
43+## The workaround, and the assertion that makes it safe
44+ 
45+Compose the constant from a representable one:
46+ 
47+```python
48+_ROOT = 1e-6 # renders exactly as 0.000001f
49+assert struct.pack("<f", _ROOT) == ... # see below
50+...
51+root = vf.full(_ROOT, preg, dtype=pl.DT_FP32)
52+floor = vf.mul(root, root, preg) # exactly fp32(1e-12)
53+safe = vf.max(rmax, floor, preg)
54+```
55+ 
56+This is exact rather than approximate for this particular pair: `fp32(1e-6)`
57+squared is **bit-identical** to `fp32(1e-12)` (both `0x2b8cbccc`). That is a
58+property of these two values, **not a general law about squaring a square
59+root**, so the construction has to be checked, not assumed:
60+ 
61+```python
62+import struct
63+_F = lambda x: struct.unpack("<I", struct.pack("<f", x))[0]
64+assert _F(np.float32(_ROOT) * np.float32(_ROOT)) == _F(TARGET)
65+```
66+ 
67+Put the assertion at **import** time, not in a test. It costs nothing, it runs
68+in every process that loads the kernel, and it is the only thing standing
69+between a future edit of `_ROOT` and a silently wrong floor.
70+ 
71+Two further points on the workaround:
72+ 
73+- **Two ops, not one.** The extra `vf.full` + `vf.mul` are loop-invariant, so
74+ hoist them out of the row loop; the cost is then per call rather than per row.
75+- **Check the scalar op it replaces is gone.** `vf.maxs` with an immediate
76+ becomes `vf.max` against a register. Grep the generated kernels for the
77+ scalar mnemonic (`vmaxs`, `vadds`, …) and require a count of **zero** — a
78+ compile-time gate that cannot be satisfied by a passing correctness run.
79+ 
80+## The neighbouring failure: a constant that is not a number at all
81+ 
82+The same constant-folding path cannot express non-finite values. `float("nan")`
83+and `float("inf")` are lowered as the **bare, undeclared C++ identifiers**
84+`nanf` / `inff`:
85+ 
86+```cpp
87+vdup(nanreg, nanf, preg, MODE_ZEROING);
88+```
89+```
90+kernel.cpp:65:9: error: no matching function for call to 'vdup'
91+kernel.cpp:38:135: error: use of undeclared identifier 'inff'; did you mean 'inc'?
92+```
93+ 
94+This one is **loud** — it fails the build — so it is cheap in comparison. What
95+is worth recording is the **divergence**: the identical NaN and ±Inf values
96+delivered through a real GM tensor work correctly through the whole vector
97+path. Only the constant-fold route is broken. A kernel that needs a non-finite
98+sentinel must either take it from memory or use a large finite stand-in
99+(`-3.0e38`), and a design that assumed "the DSL has no NaN semantics" because
100+this failed to compile has drawn the wrong conclusion.
101+ 
102+**This escapes the kernel body.** A module-level Python sequence is hoisted
103+into the generated kernel even when the kernel never mentions it, so a list of
104+floats containing a NaN or an Inf anywhere in the kernel module's import graph
105+breaks the compile of an **unrelated** kernel. Keep such values as strings and
106+convert them inside a function.
107+ 
108+## What this does not cover
109+ 
110+- Integer immediates. The `%f` truncation is a float-formatting defect; an
111+ `int` scalar reaches the instruction intact.
112+- Tile-level ops. The measurement above is the register-level `vf.*` surface.
113+ A tile-level scalar-mode call (`pl.maximum(out, lhs, scalar)`) was not
114+ measured here; treat it as unknown and check the generated code before
115+ relying on a small tile-level immediate.
116+ 
117+## Related
118+ 
119+- [vec.md](vec.md) — the rest of the `vf` authoring rules.
120+- [precision.md](precision.md) — where a deleted floor actually shows up: the
121+ divide-by-zero guard of a per-row scale.
122+- [../patterns/vec-per-token-dynamic-quant.md](../patterns/vec-per-token-dynamic-quant.md)
123+ — the topology in which this was found, and the exact constant it deleted.
124+- [../references/pypto-pro-dsl-limitations-a5.md](../references/pypto-pro-dsl-limitations-a5.md)
125+ — the upstream report entry, with the codegen evidence.
126+ 
127+## Validation
128+ 
129+**Hardware-measured** on Ascend950PR / CANN 9.2.0 by reading generated C++ for
130+both the defect and the fix, and by an end-to-end all-zero-row case that fails
131+with the immediate and passes with the register-built constant. The `%f` format
132+string itself was not located in the emitter; the truncation is inferred from
133+its signature (six fractional digits, magnitude-only dependence), which is
134+consistent with every value measured but is not a source-level confirmation.
Mcannbot-skills/ops/pypto-pro-op-kb/tests/test_check_kb_integrity.py+57-0文件内容审核中,请稍后刷新重试
Acannbot-skills/ops/pypto-pro-pl-cube-bound/scripts/sizing.py+102-0文件内容审核中,请稍后刷新重试
Mcannbot-skills/plugins-official/pypto-pro-op-orchestrator/AGENTS.md+5-3文件内容审核中,请稍后刷新重试