InsertCommFence Pass¶
概述¶
InsertCommFence 实现最新 PTOAS 在其 pto-memory-consistency pass 中定义、并下放给
编译器的 data-before-signal 内存一致性契约。「下放给编译器」是字面意思:ptoas 要求这些
标记,但不检查它们是否存在,因此满足契约完全是本 pass 的职责
(见 ptoas 不做守门)。该契约是双向的:
- 发布侧。
pto.comm.tnotify要求在其匹配的pto.cmo.cacheinvalid释放标记之后、 信号之前,显式插入一条pto.fence.barrier_all #pto.fence_scope<gm>—— 跨 rank 的写 必须在释放它的 notify 之前对 peer 可见。 - 消费侧。
pto.comm.twait(或成功的pto.comm.ttest)之后的可缓存 GM load 之前, 要求显式插入一条pto.cmo.cacheinvalid all #pto.address_space<gm>,使读方看到 peer 的最新写。
消费侧里 ttest 那一半描述的是硬件契约,而非本 pass 的行为:PyPTO 没有 ttest 算子,
因此 pld.system.wait 是本 pass 唯一能识别的消费侧点。将来若新增 ttest 算子,
StmtEffect 也必须把它归为 Effect::kWait。
两处缓存标记都是同一个 system.cacheinvalid op,按参数个数分两种形态:
| 形态 | IR | 降级为 |
|---|---|---|
| 区域 | system.cacheinvalid(tensor, shapes, offsets) |
pto.cmo.cacheinvalid … single_cache_line |
| 全 GM | system.cacheinvalid()(无参) |
pto.cmo.cacheinvalid all #pto.address_space<gm> |
system.fence 降为 pto.fence.barrier_all #pto.fence_scope<gm> —— 带 DDR 可见性的 GM
屏障,强于裸的 pto.barrier <PIPE_ALL>。
本 pass 插入什么¶
在 ptoas 0.50 上实测,该契约可归结为两条纯局部规则 —— notify 本身无需任何标记。
一趟结构遍历(InsertCommMarkers),不带任何控制流状态,插入:
- 每个本地发布写之后 —— 写入 window-bound 目标的
tile.store或tensor.write, 或写入 window-bound 本地目标的get:一条覆盖整张量的区域system.cacheinvalid, 紧跟一条system.fence; - 每个远端发布写之后 ——
remote_store/put:只插一条system.fence(其 peer 区域 cacheinvalid 由 codegen 发,见下); - 每个不透明发布写之后 —— 一个
Submit,或对未注册用户函数(其函数体不在本 pass 内分析, 没有单一可寻址区域)的调用:保守地插一条全 GMsystem.cacheinvalid()+system.fence; - 每个 wait 之后 —— 一条全 GM
system.cacheinvalid(消费侧在下一次可缓存读之前 的失效)。会做批量合并:连续的一段 wait,或一个纯 wait 循环(for/while的循环体 经if/seq嵌套后只含 wait 且至少含一条,例如 mesh composite 的for src: if src != me: wait(...)),在 wait 之间不会有任何内存访问,因此只在循环/序列 之后发一条全 GMcacheinvalid,而不是每个 wait 一条——每个屏障代次从(P-1)次整缓存刷新降为 1 次。遍历仍保持结构化(无数据流分析),用一个局部的纯 wait 循环判定; 同时含 load 的循环(如 ring 逐 step 的wait; load)不算纯循环,仍保留逐 wait 失效。 无 wait 的循环同样不算纯:空 body 是空真地“只含 wait”,因此判定要求 ≥ 1 条 wait—— 否则 pass 会给一个原本什么都不发的循环追加全 GMcacheinvalid(在一个以移除刷新为目标的 pass 里反而引入回归); - notify —— 什么都不插。
区域 system.cacheinvalid(target) 寻址的是 target 的本地 base,这对本地窗口写是对的。
但远端写 remote_store / put 写到的是 peer 偏移 GM 地址(local_ptr +
delems(peer)),本地 target view 寻址不到。peer 偏移只有在 codegen 里才知道
(EmitCommRemoteView)、目前无法在 IR 上表示,所以那条 peer 区域
pto.cmo.cacheinvalid <peer_view> single_cache_line 是 codegen 发的规避手段;而配对的 GM
释放 fence 始终由本 pass 插一条显式 system.fence(codegen 不得嵌入 fence),让释放排序在
IR 上可见、与本地写路径统一。(后续:给 peer 区域 cacheinvalid 一个 IR 表达,让 pass 拥有完整标记。)
store(win_a); store(win_b); notify (本地窗口写)
-> store(win_a); cacheinvalid(win_a); fence; store(win_b); cacheinvalid(win_b); fence; notify
for c: store; for p: notify (写与 notify 在不同循环)
-> for c: (store; cacheinvalid; fence);
for p: notify
wait; read
-> wait; cacheinvalid(); read
notify 为何无需标记、fence 为何落在写:ptoas 把所需的释放 fence 关联到发布写的
cacheinvalid,而非 notify。因此发布「先前(哪怕在另一个循环里)写入」数据的 tnotify
已由该写的 cacheinvalid; fence 满足 —— fence 不必紧挨 notify。纯 barrier notify
(完全无数据)什么都不需要。(此结论经实测验证:从 ring-allreduce 的 .pto 删掉 notify 侧
标记后 ptoas 0.50 仍接受;而删掉 wait 侧的 cacheinvalid all 在 0.50 上则被拒绝。)
ptoas 不做守门 —— 插标记是编译器的义务¶
ptoas 0.52 接受缺失标记的模块。 在 0.52 上重跑上述检查,用的是
tests/st/distributed/collectives/test_l3_allreduce_ring.py 里同一个 ring_step kernel:
| 变体 | ptoas 0.52 |
|---|---|
| 原样 | 接受 |
删掉 wait 侧 cacheinvalid all(0.50 上会被拒) |
接受 |
删光所有 cacheinvalid 和 system.fence |
接受 |
没有任何诊断信息,只是对应的指令不再生成:
| IR 标记 | 缺失时丢掉的指令 |
|---|---|
区域 cacheinvalid |
PTOAS__DCCI_SINGLE_CACHE_LINE(<GlobalTensor>) |
全 GM cacheinvalid() |
dcci((__gm__ void*)0, cache_line_t::ENTIRE_DATA_CACHE) |
system.fence |
pipe_barrier(PIPE_MTE2/MTE3/FIX); dsb(DSB_DDR) |
所以不存在编译期的安全网。缺一条标记不会构建失败,而是一个数据竞争:读方可能读到
陈旧的 cache 行,是否发生取决于时序、cache 状态、传输大小和 rank 数。不要把测试全绿
当作某条标记不必要的证据 —— 本 pass 自身的历史就说明这种失败是静默的:pin 还在 ptoas
0.50 期间,发布侧的区域 cacheinvalid 压根没有生成任何调用(hw-native-sys/PTOAS#995),
从未到达设备,而分布式测试套件全程保持绿色。
区域 cacheinvalid 目前覆盖整个目标张量(以全 0 offset 覆盖完整 shape),复用类型的
dim 表达式。收窄为精确写入子区域(写自身的 (shapes, offsets) 就在写入点旁边)是后续
升级项。
标记落在写 / wait / notify 所在处(必在作用域内)¶
把区域 cacheinvalid 紧插在其写之后,目标 Var 天然在作用域内(写刚用过它)—— 无论它是
window 参数、别名(dv = pl.tensor.view(win); remote_store(dv))、循环携带的 iter_arg,
还是分支内定义的值。不需要任何跨作用域跟踪,也绝不会 silent drop:每条标记都落在需要
它的 op 旁边,任何嵌套层级皆然。裸单语句的分支/循环体会就地包裹
(body -> { body; markers });首次运行后该体成为 SeqStmts,故本 pass 幂等。
在流水线中的位置¶
它在 Default 流水线中运行于所有会重排语句的 pass
(SkewCrossCorePipeline、LowerPipelineLoops、CanonicalizeIOOrder ...)之后。插入的
op 无操作数、无依赖边,若更早插入可能被挪离其 notify/wait;放在这里可让它们在 codegen 前
保持相邻。它之前的 pass 只改动编排体(Orchestration 与 Graph),因此本 pass 看到的 InCore IR 正是
codegen 最终降级的 IR。
本 pass 标记哪些写¶
| 情形 | cacheinvalid | fence |
|---|---|---|
写入 window-bound DistributedTensorType 的 tile.store(peer 可 remote_load) |
pass(本地区域,dst arg 2) | pass |
写入 window-bound DistributedTensorType 的 tensor.write(标量写,ConvertTensorToTileOps 有意不转换它) |
pass(本地区域,dst arg 0) | pass |
目标为 window-bound DistributedTensorType 的 pld.tile.get / pld.tensor.get |
pass(本地区域,dst arg 0) | pass |
pld.tile.remote_store / pld.tile.put / pld.tensor.put(peer 偏移写) |
codegen(peer 区域,IR 规避) | pass |
Submit,或对未注册用户函数的调用(没有单一可寻址区域) |
pass(全 GM,无参) | pass |
remote_load(结果是 tile、不写 GM),以及目标是普通 Tensor 而非 window-bound
DistributedTensor 的 tile.store / tensor.write / get,不是发布写 —— 完全不插标记。
算法 —— 一趟结构遍历,带消费侧批处理¶
本 pass 只携带一项控制流状态 —— 一个标志位:在访问纯 wait 循环(for/while 的 body
经 seq/if 嵌套后只含 pld.system.wait —— 至少一条 —— 且控制表达式不触达内存;无 wait
的循环不算纯 wait 循环)的 body 时抑制逐 wait 的 invalidate:
- 每个本地发布写处追加
region cacheinvalid; fence; - 每个远端发布写(
remote_store/put)处只追加fence(peer 区域 cacheinvalid 由 codegen 发,见下); - 每个 wait 处追加
cacheinvalid()—— 批量:纯 wait 循环在循环后共享一条全 GMcacheinvalid(),连续 wait 序列在序列后共享一条。wait 之间没有内存访问,因此最后一次 wait 之后 invalidate 等价于每次 wait 之后都 invalidate —— 在 mesh composite 中每个屏障代际的(P-1)次整缓存冲刷变为 1 次。 - notify 保持不动。
if/for/while 的 body 正常递归访问;唯一的特殊处理是包裹裸单语句 body(作为 if/for
唯一 body、且无外层 SeqStmts 的写/wait),使其标记也能落上 —— 裸纯 wait 循环 body 同样获得
抑制,并在循环后落一条 cacheinvalid()。由于其余规则都是局部且只追加,控制流无关紧要:某个
循环内的写会被正确标记,无论其 notify 是否在另一个循环。读取 GM 的控制表达式(tensor.read
降级为缓存加载)会使循环不再“纯”,从而 invalidate 绝不会被推迟到可能读到陈旧 peer 数据的读取之后。
store(win); notify -> store(win); cacheinvalid(win); fence; notify
store(win); for: notify -> store(win); cacheinvalid(win); fence; for: notify
for: { notify; store(win) } -> for: { notify; store(win); cacheinvalid(win); fence }
写之后紧跟一条 fence 的已存在区域 cacheinvalid、以及紧接 wait 之后已存在的全 GM
cacheinvalid,都会被识别且不重复插入,故本 pass 幂等。
与 Codegen 的关系¶
remote_store 与 put 的 codegen 各自在 store 之后发一条 peer 区域
pto.cmo.cacheinvalid <peer_view>(peer 偏移只有在这里才知道、且目前无法在 IR 上表示 ——
一种规避手段;配对的 GM fence 由本 pass 以 system.fence op 提供,所以这里不发 fence)。
put 额外在 TPUT 与其 cacheinvalid 之间保留一条尾部 pto.barrier <PIPE_ALL>:TPUT 是 DMA,
GM fence 只排序内存、并不 drain 发起 DMA 的 MTE 管线,缺这条 barrier 时后面的 notify 可能在
(原子)TPUT 真正落到 peer 之前就发出 —— test_l3_put 的 atomic-add / 子区域用例在真机上会
flaky;且实测 MTE3 级 barrier 不够(只有 PIPE_ALL 稳)。PTOAS#872 的 workaround,待 PTOAS 自行
drain tput 后移除。TPUT/TGET 的前置屏障与 TGET 的尾部屏障同样保留不动。
后续: 远端写的 peer 区域 cacheinvalid 是唯一还留在 codegen(而非 IR op)的标记,因为 peer
地址(local_ptr + delems(peer))暂无 IR 表达。有了一等的 IR 表达后,InsertCommFence 就能像本地写
那样拥有远端写的完整标记(cacheinvalid + fence)。
消费者¶
流水线下游无消费者。PTO codegen 通过既有的 op handler 降级插入的 system.cacheinvalid
(按参数个数选区域或全 GM)与 system.fence;没有其它 pass 需要理解它们。