English | 中文版
11. 把安全卫士延伸到 ingested linalg 内核
摘要:第 10 章把安全卫士跑在
.acl.pto文件上——由本仓库自己的mlir_to_pto后端产出的 PTO-MLIR。本章把同一个卫士延伸到 ascend-rs 管线之外 进来的内核:第三方前端(例如torch-mlir)产出的上游linalgdialect MLIR。本章给出两条路径。Path A 是一段约 300 行的 projector,直接从ascend_tileMLIR 合成一个 stage-2Plan,在上面跑第 10 章六遍 check 中的一个子集;Path C 则把每一个 ingested 内核都通过真实的mlir_to_pto → ptoas --print-after-all → parse_stage2链路,把六遍 check 完整跑在PlanMemoryPass之后的 plan 上。两条路径都通过同一个环境变量ACLRS_LINALG_SAFETY接入 ingress 驱动,并且都能在adablue上纯主机运行(使用 x86 版ptoas,无需 NPU)。两条路径互为补充:Path A 快、能抓整块 tile 级别的问题;Path C 精度更高,在 blocked matmul 场景下尤其关键,因为在那里 Path A 会对容量做保守的 over-approximation。
11.1 Path A:给 ingested ascend_tile 的 projector
第 10 章把卫士跑在 .acl.pto 文件上——mlir_to_pto 后端产出的 PTO-MLIR。一个合乎情理的后续问题是:同一个卫士能不能对来自 ascend-rs 管线之外 的内核说些有用的话?具体说,就是来自第三方前端(例如 torch-mlir)的上游 linalg dialect MLIR。linalg 桥(第 7 章)消化这些内核,把它们降到我们的 ascend_tile 形式,再交给 mlir_to_cpp 去产出 AscendC。Ingested 内核在此之前一直是仓库里唯一一条 完全没有 Rust 侧安全分析的代码路径——“Rust safety card” 故事里的一个明显空档。
本节补上这个空档。把 §10.4 同样的 check_* 遍 pass 重新对准 ingress 路径,就能在 benchmarks/linalg/kernels_adversarial/ 下四个对抗性 fixture 中抓到三类 bug。这条路径是刻意做得极简的——约 300 行 projector 直接从 ascend_tile MLIR 合成一个 stage-2 Plan——并通过一个环境变量接入 ingress 驱动。
11.1.1 Ingress 为什么与 .acl.pto 不同
§10.2 的 stage-2 卫士从 ptoas --print-after-all 开始,那里每一个 tile 都已经具备完整的 (space, offset, rows, cols, dtype, blayout, slayout)。Ingested linalg 什么都没有:前端发出的是 llvm.func @kernel(...) attributes {hacc.entry},函数体是一连串 llvm.call @ascend_tile_<op>_<dt>(%args...) intrinsic——纯粹的 op-and-operand 汤,不含放置信息。
我们有两种诚实的选择:
- Path A(本节):合成一个 naive stage-2 plan——给每一个 SSA 值分配它自己的 UB slot、offset 顺序递增——然后在这个 plan 上跑卫士。搭起来便宜,但价值有上限:能抓整块 tile 级别的问题,但永远看不到真实的 buffer 复用。
- Path C(§11.2):把每一个 ingested 内核都通过真实的
mlir_to_pto→ptoas --print-after-all→parse_stage2链路,直接复用 §10.2 的代码不改一行。精度更高,尤其在 blocked matmul 上,因为那里 Path A 会对容量做保守的 over-approximation。
Path A 是默认的快速路径;Path C 把完整卫士跑在 PlanMemoryPass 之后的 plan 上,能抓到一类 Path A 在结构上根本看不到的 bug。下文内容描述的是 commit 381340fc 实现下的 Path A;§11.2 讲 Path C。
11.1.2 一个改变“哪些 check 适用“的 SSA 性质
在讲 projector 之前,有一个关于输入格式的观察是基础性的:来自 torch-mlir 的 linalg 是 SSA 形式,而 SSA 形式 会自动给重名去重。源码层面的 y = x + x 降下来后变成一个 tile 被传给 linalg.generic 两次;而背靠背的 WAW(%t = f(%a); %t = g(%b))根本无法表达,因为第二次绑定会得到一个全新的名字。因此卫士的六遍 check 中有两遍对 ingested linalg 不适用:
check_aliasing找的是不同 SSA 名字落在重叠 offset 上——SSA 形式在构造上就排除了这种情况。- 原本的
check_linear_useWAW 规则查找“一次写后再一次写同一 slot“——SSA 形式会给第二次写重命名,所以触发不了。
能 活着 进到 projected plan 里的模式是 write-never-read:某个 op 产生了一个 SSA 值,后续没有任何 op 读它。这是源码层面的 aliasing 和源码层面的 WAW 经过 SSA 之后都会塌缩进去的规范形状。为了抓它,我们加了一遍新 check:
// crates/pto_to_rust/src/safety.rs
pub fn check_dead_writes(f: &PlanFunc, rep: &mut SafetyReport) {
let mut read_slots: BTreeSet<&Ssa> = BTreeSet::new();
let mut written_slots: BTreeSet<&Ssa> = BTreeSet::new();
for op in &f.ops {
for s in op.reads() { read_slots.insert(s); }
for s in op.writes() { written_slots.insert(s); }
}
for w in &written_slots {
if !read_slots.contains(w) {
let producer = f.ops.iter()
.position(|op| op.writes().iter().any(|s| s == w));
let where_clause = producer
.map(|i| format!(" (produced by op #{})", i))
.unwrap_or_default();
rep.violations.push(SafetyViolation::warn(
&f.name, SafetyKind::DeadTile,
format!("tile `{}` is written but never read{} \
— the producing op is dead code",
w.0, where_clause),
));
}
}
}
check_dead_writes 接进 check_all(所以手写 PTO 的覆盖率也因此提升——原来 50 个 case 的语料依旧全绿),同时也接进新增的 check_ingress 子集。
11.1.3 Projector
pto_to_rust::project(&ascend_tile_src) -> ProjectResult { plan, warnings }(约 300 行,位于 crates/pto_to_rust/src/ascend_tile_ingress.rs)遍历 ascend_tile MLIR 文本,给每一个 llvm.func @name ... attributes {hacc.entry} 发射一个 PlanFunc。规则刻意做得很小:
| 输入形式 | 产出的 slot / op |
|---|---|
%c = llvm.mlir.constant(N : i32) | 记录下的 shape 常量 |
llvm.call @ascend_tile_load_<dt>(%buf, %r, %c) -> %t | 在 UB 里给 %t 分配下一个顺序 offset;产出 TLoad op |
llvm.call @ascend_tile_store_<dt>(%buf, %t, %r, %c) | TStore { tile: %t } |
llvm.call @ascend_tile_<unop>_<dt>(%a) -> %t,<unop> ∈ {exp/log/sqrt/rsqrt/tanh/abs/neg/sigmoid/silu/relu/softmax/rms_norm} | 分配 %t;TUnary { src: %a, dst: %t } |
llvm.call @ascend_tile_<binop>_<dt>(%a, %b) -> %t,<binop> ∈ {add/sub/mul/div/max/min} | 分配 %t;TBinary { a, b, dst } |
llvm.call @ascend_tile_matmul_<dt>(%a, %b) -> %t | 分配 %t;TMatmul(所有参数都放在 UB——见下) |
其他 llvm.call @ascend_tile_* | TUnary 占位 + 一条 warning |
有两处设计选择值得明说:
- 每个 SSA 都有自己的 slot。 projector 不建模 buffer 复用——那件事由后面
mlir_to_cpp的真实分配器负责。因此容量是 保守的 over-approximation:一个在真实分配器里能瘦到 64 KiB 的 kernel,在 projected plan 里可能被算成 512 KiB。这是刻意的权衡——对对抗性 fixture 而言,这种 over-approximation 正是我们想要的信号;对生产拟合过的 kernel 它会在 capacity 上产生误报(已记录的限制,Path C 的 to-do)。 - Matmul 放在 UB,不在 L0。 从
ascend_tile形式里恢复不出 Left/Right/Acc 的标注。projector 把所有操作数放进 UB,op 标成TMatmul;但check_ingress子集 不跑check_op_constraint和check_matmul_bounds——跑了只会把每一个 matmul 都报成放置错误。那两遍 check 是 Path C 的领地。
check_ingress 刚好跑六遍中的五遍:aliasing + capacity + dead_tiles + dead_writes + linear_use。(前两个依然值得跑——aliasing 在 SSA-projected plan 上是空操作,等于 no-op;capacity 能抓到整块 tile 的严重超限情形。)
11.1.4 接进 ingress 驱动
linalg_to_ascendc 二进制(负责消化 linalg MLIR 产出 AscendC .cce 的工具)里多了一段 opt-in 块:
// crates/mlir_to_cpp_tests/src/bin/linalg_to_ascendc.rs
if let Ok(mode) = std::env::var("ACLRS_LINALG_SAFETY") {
let projected = pto_to_rust::project(&ascend_tile);
for w in &projected.warnings {
eprintln!("linalg-safety [projector]: {}", w);
}
let spec = pto_to_rust::default_a5_910b2_cann85();
let report = pto_to_rust::check_ingress(&projected.plan, &spec);
let mut err_count = 0usize;
for v in &report.violations {
let sev = match v.severity {
pto_to_rust::Severity::Error => { err_count += 1; "error" }
pto_to_rust::Severity::Warning => "warning",
};
eprintln!("linalg-safety [{}] {}: {} (in `{}`)",
sev, v.kind.label(), v.message, v.func);
}
if mode == "error" && err_count > 0 {
eprintln!("linalg-safety: {} error(s), aborting \
(ACLRS_LINALG_SAFETY=error)", err_count);
std::process::exit(3);
}
}
ACLRS_LINALG_SAFETY=1以 advisory 模式跑:warning 打印出来,发射继续。ACLRS_LINALG_SAFETY=error把任何Severity::Error提升为退出码 3,与.acl.pto路径上ACLRS_PTO_SAFETY=error已有的约定保持一致。
还有一个同级小工具 linalg_safety_dump,把 projected 的 Plan(slots + ops)连同完整报告一起打印出来——当一个 ingress fixture 的行为出乎意料,想看 projector 到底搭出了什么时,很顺手。
11.1.5 四个对抗性 fixture
benchmarks/linalg/kernels_adversarial/ 下放了四份 .mlir 输入,每一份都刻意对准一类 bug。它们都很小(一个 function,≤3 个 op),这样 projected plan 是透明的。
| Fixture | 源码层面模式 | 期望的报告 |
|---|---|---|
aliasing_same_tensor_twice.mlir | linalg.generic { %arg0, %arg0 } → add | clean — SSA 把第二个操作数去重了 |
capacity_overflow_1x131072.mlir | 对 1×131072 的 f32 tile(512 KiB)做 exp | capacity 错误 — UB 上限 192 KiB |
dead_tile_unused_intermediate.mlir | %t = exp(%a) 算出来就丢掉;return %a + %b | 在 %t 上报 dead-tile warning |
waw_double_write.mlir | 两个 linalg.generic 共享同一个 outs | 在第一个 op 的 SSA 上报 dead-tile warning(SSA 已重命名) |
用 ACLRS_LINALG_SAFETY=1 驱动每一个 fixture,逐字输出如下(在 adablue 上 commit 381340fc,release 版 linalg_to_ascendc):
$ for f in aliasing_same_tensor_twice capacity_overflow_1x131072 \
dead_tile_unused_intermediate waw_double_write; do
echo "=== $f ==="
ACLRS_LINALG_SAFETY=1 crates/mlir_to_cpp_tests/target/release/linalg_to_ascendc \
benchmarks/linalg/kernels_adversarial/$f.mlir /tmp/out.cce 2>&1 \
| grep -E '^linalg-safety' || echo '(clean — no findings)'
done
=== aliasing_same_tensor_twice ===
(clean — no findings)
=== capacity_overflow_1x131072 ===
linalg-safety [error] capacity: vec high-water 1048576 B exceeds capacity 196608 B
(on Ascend910B2 (CANN 8.5)) (in `adv_capacity_overflow`)
=== dead_tile_unused_intermediate ===
linalg-safety [warning] dead-tile: tile `%t2` is written but never read
(produced by op #2) — the producing op is dead code (in `adv_dead_tile`)
=== waw_double_write ===
linalg-safety [warning] dead-tile: tile `%t1` is written but never read
(produced by op #1) — the producing op is dead code (in `adv_waw`)
aliasing fixture 报 clean 是故事里诚实的那一半:SSA 把 %arg0, %arg0 在 projector 看到之前就归并成了一个操作数,卫士通过保持沉默来说明这点。error 模式把 capacity finding 提升为 exit 3:
$ ACLRS_LINALG_SAFETY=error crates/mlir_to_cpp_tests/target/release/linalg_to_ascendc \
benchmarks/linalg/kernels_adversarial/capacity_overflow_1x131072.mlir /tmp/out.cce
linalg-safety [error] capacity: ...
linalg-safety: 1 error(s), aborting (ACLRS_LINALG_SAFETY=error)
$ echo $?
3
11.1.6 复现实验
两套测试把这条链路端到端盖住;两套在 adablue-probe 上都是绿的:
$ cargo test -p pto_to_rust --test adversarial_ingress --release
test adv_aliasing_same_tensor_twice_clean ... ok
test adv_capacity_overflow_flagged ... ok
test adv_dead_intermediate_and_dead_write_flagged ... ok
test adv_waw_double_write_flagged ... ok
test ingress_aliasing_projects_cleanly ... ok
test ingress_capacity_1x131072_flagged ... ok
test ingress_dead_intermediate_caught_by_dead_write ... ok
test ingress_waw_caught_as_dead_write ... ok
8 passed; 0 failed
前四个跑的是手写的 PlanFunc 值(卫士本身);后四个跑的是 projector 本身——从 .mlir 文本出发,断言 project() + check_ingress() 产出预期的 Violation 集。所以加一个新的对抗性模式 = 一个 .mlir + 一个测试,不需要新的卫士代码。
11.1.7 这条路径抓不到什么
把边界明说出来会让主张落在“Rust safety 作用于 ingested 内核,在这些边界内“而不是“Rust 抓到一切“:
- 跨 op 的 buffer 复用 bug。 projector 给每个 SSA 都配自己的 slot,所以
mlir_to_cpp::analyze_kernel真实分配器级别的冲突不经检查地滑过去。堵这个口子是 Path A 的后续功课:把复用决策反馈回 projector,让 capacity 数字和 aliasing 面与上线时的实际占用一致。 - Matmul 放置 + blocked 形状。 projected plan 没有 Left/Right/Acc,所以
check_op_constraint和check_matmul_bounds被刻意跳过。更糟的是,对 blocked matmul——即mlir_to_pto把大的N切成多段 per-op 小块——Path A 的 capacity check 汇报的是 pre-blocking 占用,这是误报。Matmul 的 fidelity 是 Path C(§11.2)的事;下文的matmul_row_overflowfixture 是实证演示。 - 数值正确性。 卫士是结构性的;一份会产出错误结果但分配无误的 fixture 会通过。
即便有这些限制,四个 demo fixture 已经立下新的基线:ingested linalg 不再是未分析的输入。§10.4 的六遍 check 现在在 ascend-rs ingress 边界的两侧都能说话,而 ACLRS_LINALG_SAFETY=error 给了下游构建系统与 ACLRS_PTO_SAFETY=error 在自发 kernel 上相同的 advisory-或-hard 开关。
11.2 Path C:在 Post-PlanMem Plan 上跑完整的卫士
§11.1 对自身上限是诚实的:Path A 只能看到对 ascend_tile 文本做一遍遍历能告诉它的东西——看不到 buffer 复用,看不到 matmul 放置,也看不到 mlir_to_pto 自己的 shape 决策(tile blocking、Kb 选择、fractal packing)。有意思的问题是:要补这些空档,究竟需要一整套新分析,还是能把 §10.4 已有的 六遍卫士原样复用在一份已经内含这些信息的 plan 上?Path C 说可以——只需把 ingested linalg 走完真实的编译流水线,然后在 ptoas --print-after-all emit 出来、PlanMemoryPass 之后的 MLIR 上跑卫士。不加新 pass,不造新 plan 格式,只加一个新的 driver。
11.2.1 在 adablue 上纯主机运行
早期卡住 Path C 的假设是 ptoas 只在 910c 上(aarch64、NPU 硬件)。事实上不是——它也有一份 x86 构建在 adablue 上的 ~/ptoas-x86/bin/ptoas,且这个二进制在不接 NPU 的情况下也能为静态分析产出正确的 --print-after-all 输出。所以 Path C 与 NPU 执行是干净可分的:静态 安全分析纯主机就能搞定,数值 验证才需要 910c。这和一个 cross-compile 工程里 cargo check / cargo test 的分工一致。
11.2.2 五跳
linalg.mlir ── 第 1 跳 ── linalg_to_ascend_tile
│
ascend_tile MLIR ── 第 2 跳 ── mlir_to_pto
│
.acl.pto (PTO-MLIR) ── 第 3 跳 ── ptoas --print-after-all (x86)
│
stderr 中的 stage-2 MLIR ── 第 4 跳 ── pto_to_rust::parse_stage2
│
post-PlanMem `Plan` ── 第 5 跳 ── check_all(完整六遍)
│
SafetyReport
第 1、2 跳是已有的 ingress 路径。第 3 跳把未改动的 x86 ptoas 作为子进程调用,从 stderr 抓取 --print-after-all 输出。第 4、5 跳就是 §10.2 的流程,不改一行——同一套 parse_stage2、同一套 check_all、同一套 DeviceSpec。Path C 只提供把这几段串起来的水管。
还有一个独立的 probe 二进制(linalg_path_c_probe,单一 .rs 文件)按跳逐个打 PASS/FAIL,主要作为加新 fixture 时的诊断工具。生产使用走 driver(§11.2.4)。
11.2.3 Path C 强于 Path A 的地方
Path C 的卖点是“更紧的 capacity,能抓 matmul bounds“,关于当前 fixture 上究竟能证明什么,我们应当诚实。在当前所有 benchmarks/linalg/ fixture 上跑 Path C(commit b6db7cae),findings 表如下:
| Fixture | 第 3 跳 rc | 第 5 跳 findings |
|---|---|---|
upstream/{add,exp,matmul,softmax} | 0 | clean |
adv/aliasing_same_tensor_twice | 0 | clean(SSA 去重 — 与 Path A 一致) |
adv/capacity_overflow_1x131072 | 1 | ptoas: vec overflow, requires 8388608 bits while 1572864 bits avaliable |
adv/dead_tile_unused_intermediate | 0 | 在 %5(post-PlanMem SSA)上报 dead-tile |
adv/waw_double_write | 0 | 在 %3(post-PlanMem SSA)上报 dead-tile |
adv/matmul_row_overflow(16×16 × 16×65536) | 0 | clean — Path A 报 capacity 8 MiB;Path C 正确 |
最后一行是实证意义上的增量。Path A 的 projector 对 raw linalg 张量占用做直接加总:光输出 tile 16×65536×4 = 4 MiB 就已超过 910B2 的 192 KiB UB 上限,check_capacity 就此报 error。但 mlir_to_pto 会把 N=65536 切成多段每段 N=32 的 per-op chunk,再发射 pto.tmatmul。Post-PlanMemoryPass 的 plan 里根本没有这么大的 tile,Path C 报 clean——这是正确答案。这正是 §11.1.7 警告过的那条“保守 over-approximation“限制的实证;Path C 是补救方案。
从端到端 probe 还学到两件诚实的事:
ptoas自身也有完整性边界。 在大形状(dims > 4095)上,ptoas 内置校验器比我们的check_matmul_bounds(ROW < 2^16)更早拒掉pto.tmatmul,所以在 ingested linalg 上后者多半是休眠的。该拒绝本身仍然会出现——Path C 把ptoas rc≠0视作Errorfinding,违规信息照样能到达用户手里——只是来自与 §10.4 check 不同的层。- Path A 与 Path C 的 SSA 名字不同。 Path A 报
%t2;Path C 报%5。两者都正确(同一个 tile,不同 dialect ——ascend_tilevs post-PlanMemoryPassMLIR),Path C 的名字与发射出来的 C++ 按字节对齐。
11.2.4 Driver 接线
ingress driver 在原本的 Path A 模式旁新增了 Path C 模式:
// crates/mlir_to_cpp_tests/src/bin/linalg_to_ascendc.rs
if let Ok(mode) = std::env::var("ACLRS_LINALG_SAFETY") {
let abort_env = std::env::var("ACLRS_LINALG_SAFETY_ABORT")
.ok().as_deref() == Some("1");
let abort_on_error = abort_env || mode == "error";
let err_count = if mode == "path-c" {
run_path_c(&ascend_tile) // 第 2..5 跳
} else {
run_path_a(&ascend_tile) // project + check_ingress
};
if abort_on_error && err_count > 0 {
eprintln!("linalg-safety: {} error(s), aborting", err_count);
std::process::exit(3);
}
}
旋钮如下:
| 环境变量 | 行为 |
|---|---|
ACLRS_LINALG_SAFETY=1 | path-a | Path A(projector + check_ingress),advisory |
ACLRS_LINALG_SAFETY=path-c | Path C(经 ptoas 的完整流水线),advisory |
ACLRS_LINALG_SAFETY=error | Path A + 在 error 上中止(保持与 §11.1 兼容) |
ACLRS_LINALG_SAFETY_ABORT=1 | 在 error 上中止,可与任一路径组合 |
ACLRS_PTOAS_BIN=<path> | 覆写默认的 $HOME/ptoas-x86/bin/ptoas |
run_path_c 把非零的 ptoas 退出码呈现为 Severity::Error finding,而不是硬崩。一个 ptoas 自己都拒绝的 kernel 本来就是 一个安全 finding——只不过是另一层抓到的。把它结构化成 error 让报告面保持统一。
11.2.5 Demo:在 matmul_row_overflow 上 Path A vs Path C
$ BIN=crates/mlir_to_cpp_tests/target/release/linalg_to_ascendc
$ ACLRS_LINALG_SAFETY=path-a $BIN \
benchmarks/linalg/kernels_adversarial/matmul_row_overflow.mlir /tmp/a.cce \
2>&1 | grep linalg-safety
linalg-safety [path-a] [error] capacity: vec high-water 8389632 B exceeds capacity
196608 B (on Ascend910B2 (CANN 8.5)) (in `adv_matmul_row_overflow`)
$ ACLRS_LINALG_SAFETY=path-c $BIN \
benchmarks/linalg/kernels_adversarial/matmul_row_overflow.mlir /tmp/c.cce \
2>&1 | grep linalg-safety
(无输出 — Path C 报 clean)
Path A 用一个 8.3 MiB 的 capacity claim 产生误报;Path C 正确地看到 post-blocking 的 plan,保持沉默。同一份 kernel、同一套卫士 pass,输入是 MLIR 不同的那一层——而这正是 Path C 存在的全部意义。
11.2.6 复现实验
三个 integration test 把 driver 端到端盖住;它们以各种模式启动 release 二进制并对退出码 + stderr 做断言:
$ cargo test --manifest-path crates/mlir_to_cpp_tests/Cargo.toml \
--test path_c_driver --release
test path_c_clean_upstream_add ... ok
test path_c_clean_where_path_a_overapproximates ... ok
test path_c_surfaces_ptoas_capacity_overflow ... ok
3 passed; 0 failed
测试会自动在 $ACLRS_PTOAS_BIN 或 $HOME/ptoas-x86/bin/ptoas 下寻找 ptoas,都找不到时以 skip 信息返回,因此没有 x86 ptoas 构建的 CI 也能保持绿灯。
11.2.7 非目标
Path C 并不声称 ingress 侧的卫士已经封掉所有缝隙:
check_op_constraint与check_matmul_bounds在 ingress 路径上大多数时候处于休眠。mlir_to_pto在第 2 跳先过滤掉大多数违规形状,ptoas再以比卫士ROW < 2^16更紧的dims ≤ 4095在第 3 跳过滤剩下的。这两遍 check 对 手写.acl.pto(§10.2 最初的目标)仍然有用,但在 ingress 路径上它们很少是第一道防线。- Path C 仍然信任
ptoas自己的流水线。 如果ptoas静默接受一份其自身与我们的 pass 都抓不到的放置错误 plan,Path C 会报 clean。§10.3 “卫士抓出 ptoas 盲点” 的主张仍然只适用于卫士知道怎么读的那些槽位。 - 数值正确性依然不在范围内。 与 Path A 一致。
Path C 确实 封掉的是 §11.1.7 点名的那个具体空档:blocked matmul 上 Path A 会保守地失败。任何未来的 matmul 密集型 ingested kernel(LLM MLP、attention projection、batched GEMM)现在都会拿到一个干净的结构性信号,而不是一条 capacity 误报——而且这一切通过在 lowering 里一个更有信息量的点上跑同一套 §10.4 的六遍卫士就能做到。没有新的卫士代码;价值来自把老卫士搬到更好的位置。
11.3 Worked Example:把 Softmax 一路跟到底
§11.1.5 与 §11.2.3 的 fixture 表把 add、exp、matmul、softmax 并列,覆盖面是公平的,但模糊了第 4 章的运行示例。本节单把 softmax_upstream_1x1024.mlir 拿出来,端到端走一遍两条 Path,再注入 ch11_make_bad_softmax.py 的 dead-tile 变体,呈现卫士给出的对比。同一对 fixture 也驱动 §11.6 的 demo 录制;本节是它的文字伴侣。
11.3.1 同一份 Softmax 的三种形态
fixture 是两行上游 linalg:
// benchmarks/linalg/kernels_upstream_shape_matched/softmax_upstream_1x1024.mlir
func.func @upstream_softmax_1x1024(%arg0: tensor<1x1024xf32>) -> tensor<1x1024xf32> {
%0 = tensor.empty() : tensor<1x1024xf32>
%1 = linalg.softmax dimension(1) ins(%arg0 : tensor<1x1024xf32>)
outs(%0 : tensor<1x1024xf32>) -> tensor<1x1024xf32>
return %1 : tensor<1x1024xf32>
}
经 hop 1 后变成 ascend_tile 形:一条 llvm.call @ascend_tile_softmax_f32(%in, 1, 1024) -> %t 加上配套的 load/store。经 hop 2 后变成 PTO-MLIR(pto.trowmax → pto.trowexpandsub → pto.texp → pto.trowsum → pto.trowexpanddiv,跨六个独立的 VEC tile)。在 Path C 的 hop 5 后,它已是一个为每个 tile 都标好 (space, offset, rows, cols, dtype) 的 stage-2 plan。
11.3.2 Path A 在干净 Softmax 上的结果
Path A 的投影器为每个 SSA 给一个独立的 UB 槽,跑 check_aliasing + check_capacity + check_dead_tiles + check_dead_writes + check_linear_use,报告:
$ ACLRS_LINALG_SAFETY=1 \
crates/mlir_to_cpp_tests/target/release/linalg_to_ascendc \
benchmarks/linalg/kernels_upstream_shape_matched/softmax_upstream_1x1024.mlir \
/tmp/sm.cce 2>&1 | grep linalg-safety || echo '(clean — no findings)'
(clean — no findings)
这是预期结果:上游 linalg.softmax 降到一条 ascend_tile_softmax_f32 调用,投影器用「一个 tile 进、一个 tile 出」表达,没有别名、死写、容量超限的可能。
11.3.3 Path C 在同一份干净 Softmax 上的结果
Path C 把同一内核经 mlir_to_pto → ptoas --print-after-all 进一步降低,对 PlanMemoryPass 之后的 plan 跑全部六个 pass:
$ ACLRS_LINALG_SAFETY=path-c \
crates/mlir_to_cpp_tests/target/release/linalg_to_ascendc \
benchmarks/linalg/kernels_upstream_shape_matched/softmax_upstream_1x1024.mlir \
/tmp/sm.cce 2>&1 | grep linalg-safety || echo '(clean — no findings)'
(clean — no findings)
Path C 的 plan 更丰富——六个 VEC tile 而非一个,带具体的 UB offset——但仍然干净。两条 Path 给出一致结论,但角度不同:Path A 说「源级 SSA 中没有别名模式」;Path C 说「分块后的放置中也没有别名」。两个都是值得做的安全声明。
11.3.4 对抗性变体:注入 48 个死 VEC tile
伴随脚本 blog/mdbook/scripts/ch11_make_bad_softmax.py 输出 ch11_sm_bad.acl.pto——同样的 softmax,在归约序列前加了 48 个额外的 pto.alloc_tile + pto.tload。每个额外 tile 都是 1×1024 f32(4 KiB),且没有任何下游 op 会读取它。因为它们是不同的 SSA 值,ptoas 的 PlanMemoryPass 可以随便放——而在这条 fixture 上它把若干个堆到了与活 tile %3 和 %11 相同的 UB offset。ptoas 以 rc=0 接受了程序;ccec 接受了 C++ 输出;bisheng 链接成可执行内核——而这个内核在运行时悄悄输出错误的 softmax 结果。
卫士对同一份 .acl.pto 的报告:
$ pto-diff --from-pto /tmp/ch11_sm_bad.acl.pto --ptoas /usr/local/bin/ptoas-bin/ptoas
[error] capacity: vec high-water 393216 B exceeds capacity 196608 B
(on Ascend910B2 (CANN 8.5))
[error] aliasing: tiles `%3` and `%108` overlap at vec offset 0x1000
[error] dead-tile: tile `%108` is written but never read
... (94 more findings) ...
96 errors, 0 warnings
ptoas 退出码:0。卫士退出码:3。同一编译器、同一份输入字节——只有卫士抓到了 bug。这正是 demo GIF 捕捉到的对比。
11.3.5 为什么 Softmax 是整章的合适锚点
add 与 exp 适合做大小为一的测试,matmul 是 matmul-bound check 设计目标,但两者都不能让本章演练全部卫士被设计去抓的结构性模式。Softmax 可以:它有多步归约(dead-tile 与 dead-write 适用)、有多个中间 buffer(aliasing 适用)、且在 COLS 较大时会触发 capacity。注入死 tile 的变体是唯一一个 ptoas、ccec、bisheng 全部接受、而卫士正确拒绝的 fixture——这正是安全层存在的全部理由。
同一份 softmax 至此已在书中以五种形态出现:
| 形态 | 章节 | 说明 |
|---|---|---|
| Rust 标量 | §4.2 | 源级 f32::exp() 降为 llvm.intr.exp |
| Rust 向量 | §4.3 | mlir_to_cpp 与手写 AscendC 性能持平(16K 上 1.02× 略快) |
| Rust tile(PTO) | §4.5–4.6 + 附录 J §J3–J4 | mlir_to_pto + ptoas 路径,双缓冲 2.4 µs/tile |
| 上游 linalg ingress | §4.7 + 附录 J §J5 | 桥与 (b) 字节相同,端到端 <8% 时序差 |
| 对抗性 PTO 变体 | §11.3 + 附录 J §J6 | ptoas rc=0;卫士 96 errors 拒绝 |
五个入口、一份 fixture、一个判断:当到达 bisheng 的字节不安全时,卫士是说「不」的那一层。