跳转至

GM 缓存访问策略

一种声明式策略,用于描述 kernel 如何从全局内存(GM,global memory)读取某个 张量。CachePolicy.BYPASS 表示"流式读取该张量 —— 不要为它花费缓存"; CachePolicy.DEFAULT 则是当前所有读取默认采用的普通缓存访问。

该策略是作者声明的契约(contract),绝不是编译器推断出来的提示(hint)。因此它必须 显式书写,粒度二选一,并且从 DSL 一路原样传递到代码生成(codegen)。

要求 PTOAS >= v0.64(toolchain/versions.env 中的 PTOAS_VERSION),并且 只在 a2a3 上真正生效。BYPASS 声明会变成 pto.tload 上的一个 cache_policy 属性,外加一个携带该设备 uncached 别名距离的 offset 操作数,由汇编器加到该条 load 的源地址上。参见codegen 发射什么与架构差异。

两个书写面

书写面 粒度 写法 适用场景
pl.set_cache_policy(t, policy) 所在作用域(scope)内对 t 的每一次读取 pl.at(...) / pl.spmd(...) body 顶层的一条独立语句 张量编程 —— GM 读取是隐式的(pl.matmul、pl.assemble、切片),没有 load 调用可供标注
pl.load(..., cache=policy) 单次读取 load 上的一个 kwarg Tile 编程 —— 该次访问本来就由你显式写出

pl.slice / tensor.slice 刻意不提供 cache= kwarg:slice 计算的是地址描述符 (address descriptor),并不搬运数据。策略属于真正发起 GM 读取的那个 op。

作用域声明

@pl.program
class Demo:
    @pl.function
    def main(self, a: pl.Tensor[[256, 128], pl.FP32], b: pl.Tensor[[128, 256], pl.FP32],
             out: pl.Out[pl.Tensor[[256, 256], pl.FP32]]) -> pl.Tensor[[256, 256], pl.FP32]:
        with pl.at(level=pl.Level.CORE_GROUP, name_hint="mm"):
            pl.set_cache_policy(b, pl.CachePolicy.BYPASS)     # every read of b streams
            c: pl.Tensor[[256, 256], pl.FP32] = pl.matmul(a, b, out_dtype=pl.FP32)
            out = pl.assemble(out, c, [0, 0])
        return out

单次访问的 kwarg

tile = pl.load(x, [0, 0], [32, 32], cache=pl.CachePolicy.BYPASS)

契约

CachePolicy.BYPASS 对该张量断言了两件事:

断言 由谁保证 编译器是否检查?
kernel 只是流式读取这些字节 —— 不存在值得缓存的复用 作者 否(这是性能层面的断言)
kernel 运行期间没有任何一方写这些字节 作者 部分检查 —— 见下文

对同一批字节混用带缓存的写与 bypass 的读,是一个一致性(coherency)缺陷:两条通路 可能看到不同的数据。编译器无法跨任务、跨 rank 或跨 host 证明"不存在并发写者",因此 一致性是作者的契约。这也正是该策略绝不作为默认值、也绝不由优化 pass 推断出来的原因。

编译器能检查的那一部分,它确实做了:对本作用域自身会写的张量声明 BYPASS, 会在外提(outlining)阶段被拒绝(见错误)。作用域自身的参数方向 (param direction)已经说明了它是否写该张量,所以这类自伤式的一致性缺陷会被直接拦下, 而不是被信任放过。

优先级

一次 GM 读取的生效策略,按以下顺序取第一个命中的:

  1. 它自己显式写出的 cache= kwarg,否则
  2. 它所读参数对应的作用域声明,否则
  3. CachePolicy.DEFAULT。

显式写法在两个方向上都优先 —— cache=pl.CachePolicy.DEFAULT 可以在一个 bypass 作用域内,把某一次访问单独放回缓存:

with pl.at(level=pl.Level.CORE_GROUP):
    pl.set_cache_policy(b, pl.CachePolicy.BYPASS)
    hot = pl.load(b, [0, 0], [32, 32], cache=pl.CachePolicy.DEFAULT)  # cached anyway
    rest = pl.load(b, [32, 0], [32, 32])                              # BYPASS (declared)

body 中已经存在的 load(手写的,或由更早的 pass 产生的)与编译器合成的 load 适用同一 条规则:除非该 load 自己写了 cache=,否则声明对它生效。

声明可以写在哪里

pl.set_cache_policy 挂到所在作用域上,而 ScopeStmt 的 attrs(属性)在作用域开始 时就已固定。因此 parser 会预扫描作用域 body 的顶层语句,在解析 body 之前先把这些 标记(marker)提升(hoist)到作用域上。

位置 是否接受 原因
with pl.at(level=pl.Level.CORE_GROUP, ...): 顶层 是 该作用域会成为设备 kernel,声明正是针对它的参数做解析
for i in pl.spmd(N): / 内联 with pl.spmd(N): body 的顶层 是 挂到 body 被外提进入的 InCore 载体上
with pl.at(<非 CORE_GROUP level>): 顶层(Hierarchy) 语法上接受 会解析到该外提函数上;但目前没有任何 pass 把它下沉到 load(见限制)
dispatch 形态的 with pl.spmd(N): body(调用预先定义好的 kernel) 否 GM 读取发生在被调用者内部 —— 请在那里声明
pl.cluster(...) / pl.manual_scope() / pl.scope() body 否 这些作用域只做协同调度或选择依赖语义,本身不发起 GM 读取
作用域内嵌套在 if / for 里 否 有条件执行的声明是编译器无法校验的承诺
函数 body,但不在任何作用域内 否 没有可挂载的作用域

parser 还强制另外两条规则:

  • 按 Var 身份跟踪,绝不按名字。 声明指的是作用域处活跃的那个绑定。之后重新绑定 同名变量(b = self.foo(b))得到的是一个新值,声明不覆盖它。
  • 必须是裸变量名、张量类型、且已绑定。 属性 / 下标 / 调用表达式并不命名任何绑定; 非张量绑定没有 GM 读取可管辖;在 body 内部创建的张量不会被作用域捕获(capture)。

对同一个绑定重复声明属于冗余而非错误 —— 保留第一条,因此该 attr 始终是一组互不相同 的张量。

错误

消息(节选) 抛出方 原因
pl.set_cache_policy() must be a standalone statement directly inside a pl.at(...) / pl.spmd(...) scope body Parser(ParserSyntaxError) 写在作用域之外,或嵌套在 if / for 内
pl.set_cache_policy() has nothing to attach to on this <Kind> scope Parser(ParserSyntaxError) Spmd dispatch body、pl.cluster 或运行时作用域
pl.set_cache_policy() takes exactly two positional arguments (no keywords) Parser(ParserSyntaxError) 元数不对,或使用了关键字形式
pl.set_cache_policy() first argument must be a bare variable name Parser(ParserSyntaxError) t.field、t[0]、f(t) —— 没有可跟踪的绑定
pl.set_cache_policy() argument '<n>' is not defined at this point Parser(ParserSyntaxError) 作用域起始处该名字尚未绑定
pl.set_cache_policy() argument '<n>' is not a tensor Parser(ParserTypeError) 只有 GM 张量读取才有缓存策略
pl.set_cache_policy(...) references tensor '<n>', which is not captured by the scope body OutlineIncoreScopes(CHECK_SPAN → ValueError) 作用域 body 既不读也不写该张量,因此它未被捕获,没有参数承载该策略
pl.set_cache_policy(<n>, CachePolicy.BYPASS) is not allowed on a tensor this scope writes (<dir>) OutlineIncoreScopes(CHECK_SPAN → ValueError) 对同一 kernel 自己会写的字节做 bypass 读取,是一致性缺陷

外提阶段这两条拒绝属于用户错误,而非编译器 bug —— 所以用 CHECK_SPAN,它会附带 IR 源码位置。

载体链路

声明在下沉过程中三次更换载体(carrier)。每一跳都有其存在理由,彼此不可互换。

pl.set_cache_policy(b, BYPASS)                 statement, consumed at parse
  -> ScopeStmt.attrs_["cache_policy_vars"]     parse .. pass 9   (Var identity)
  -> Function attr "cache_policy"              pass 9 .. pass 11 (param INDICES)
  -> tile.load kwarg "cache"                   pass 11 .. codegen
  -> codegen: `cache_policy` attribute on each emitted `pto.tload`
跳 载体 负载类型 写入方 消费方
1 ScopeStmt.attrs_[kAttrCachePolicyVars] vector<pair<VarPtr, int>> DSL parser OutlineIncoreScopes(pass 9)
2 Function.attrs_[kAttrCachePolicyParams] vector<pair<int32_t, int>>,按索引排序 pass 9 ConvertTensorToTileOps(pass 11)
3 tile.load 的 cache kwarg int(ir::CachePolicy) pass 11 PTO codegen

保证这条链路不出错的几点设计考量:

  • 不放在 TensorView 的字段上。 普通 kernel 参数根本没有 tensor_view_,在那里 打策略会强行造出一个 TensorView —— 从而牵入严格的 TensorViewCanonical verifier; 而且 MaterializeTensorStrides 会通过 一个位置参数构造函数重建该 view,会静默丢掉这个字段。
  • 参数索引只在 pass 9..11 之间有效。 二者之间只夹着 OutlineClusterScopes,它不会 改动已外提 InCore 函数的参数列表。而下游的 pass 会改: InjectGMPipeBuffer 与 MaterializeDistTensorCtx 追加参数, MaterializeValidShapeSymbols 则在前面插入。这正是 pass 11 转换完成后必须擦除该 attr 的原因。
  • kwarg 是 int 而不是枚举。 它沿用 tile.store 的 atomic kwarg 做法,因此 序列化器、反序列化器、structural_hash 与 structural_equal 都无需新增枚举分支。 出于同样的理由,pl.CachePolicy 绑定为可转 int(nb::is_arithmetic),DSL 直接 传 int(cache)。
  • cache kwarg 能存活到流水线末端,理由与 target_memory 相同 —— 它挂在 op 的 kwargs 上,后续没有任何 pass 会重写它。

打印与往返

作用域 attr 打印为标记语句,而不是像 no_dep_args= / dumps= 那样的头部 kwarg, 因为语句才是 parser 接受的书写面:

with pl.at(level=pl.Level.CORE_GROUP, name_hint="mm"):
    pl.set_cache_policy(b, pl.CachePolicy.BYPASS)
    ...
性质 行为
顺序 位置归一化 —— 无论作者原本把它们写在哪里,标记总是打印在最前面;parser 也会从 body 的任意位置把它们提升上来
Spmd 内联形态 从嵌套的 InCore 载体打印 —— 它的 pl.at(...) 头部被 Spmd printer 内联掉了
其余为空的作用域 只含一条声明的作用域打印该标记,而不是 pass
函数 attr(cache_policy) 打印为 (索引, 策略) 元组列表,因此在 pass 9 与 pass 11 之间 —— 它唯一存在的窗口 —— 抓取的 pass dump 可以重新解析

codegen 发射什么

一次 BYPASS 读取会变成发射出的那条 load 上的一个属性,外加一个操作数 —— 从张量地址 到同一批字节的 uncached 别名之间的距离:

func.func @main(%arg0: !pto.ptr<f32>, %arg1: !pto.ptr<f32>,
                %__pypto_l2_cache_offset: i64) {
  ...
  pto.tload ins(%b__ssa_v0_pview : !pto.partition_tensor_view<256x256xf32>)
            outs(%b__ssa_v0_mat  : !pto.tile_buf<loc=mat, ...>)
            {cache_policy = #pto.load_cache_policy<l2_bypass>}
            offset = %__pypto_l2_cache_offset : i64

只有属性本身并不移动地址:PTOAS 只对同时声明了 l2_bypass 的 load 施加该 offset, 而没有 offset 的声明会编译成普通的带缓存 load。二者合在一起才构成绕过 L2 —— 也正因 如此,真正让这个特性落地的是 offset 而不是属性,而只有 a2a3 才有这个 offset。

offset 从哪里来

A2/A3 把每个 GM 页映射两次 —— 一次带缓存,一次不带 —— 对 uncached 别名发起的 load 不会在 L2 中分配。两个映射之间的距离是只有驱动才知道的逐设备取值 (rtGetL2CacheOffset),因此它不能在编译器里写成常量:本机报告 0x80000000000, 而 pto-isa 注释中写的是 0x100000000000。

simpler 每个 Worker 查询一次,并把它带到每个核的 GlobalContext,incore kernel 通过 get_l2_cache_offset(args) 读取(simpler PR #2323)。生成的 kernel wrapper 在入口处 只读一次 —— 该值在一次 dispatch 内不会变化,逐 load 读取等于为一个常量反复走 GM —— 然后通过合成参数 %__pypto_l2_cache_offset 向下传递:

extern "C" __aicore__ void kernel_entry(__gm__ int64_t* args) {
    // ... tensor unpacking ...
    uint64_t __pypto_l2_cache_offset = get_l2_cache_offset(args);
    main(a__ssa_v0, b__ssa_v0, out__ssa_v0, __pypto_l2_cache_offset);
}

PTOAS >= v0.64 会把这一对变成对源描述符副本的地址运算 —— 因此同一张量的其它 load 仍使用带缓存的地址 —— 随后发出一条普通 TLOAD:

-  TLOAD(v45, v50);
+  __gm__ uint8_t* v51 = reinterpret_cast<__gm__ uint8_t*>(PTOAS__GLOBAL_TENSOR_DATA(v50));
+  __gm__ float* v52 = (__gm__ float*) (v51 + v5);
+  GlobalTensor<float, ...> v53(nullptr);
+  v53 = v50;
+  TASSIGN(v53, v52);
+  TLOAD(v45, v53);

0 是合法取值而非失败:不提供别名的 a2a3 设备返回 0,addr + 0 就是原地址,此时该声明 损失的是带宽而不是正确性。模拟器天然属于这种情况。注意这是设备返回 0,与"该架构 根本没有别名"不同 —— 后者压根不会拿到 offset 操作数。

这次发射有五条性质值得写明,因为每一条都在 tests/ut/codegen/test_cache_policy_codegen.py 中有对应断言(参数顺序在 tests/ut/codegen/test_prefetch_codegen.py):

性质 原因
CachePolicy.DEFAULT 什么都不发射 未声明策略的 kernel 保持本特性出现之前的 PTO 形态,因此属性、操作数与参数就是两个在其余方面完全相同的 kernel 之间唯一的差异
该属性按 load 发射,而不是按张量 它是指令的性质;若两条 load 只有第一条带 hint,第二条仍会在 L2 中分配(被取代的 [CacheBypassUnsupported] 诊断刻意是"每张量一次"—— 粒度正好相反)
offset 参数每个 kernel 只追加一次 一个运行时取值服务于全部绕过缓存的 load,入口处读一次即可
它与 MX 的 layout 合并进同一个属性字典且排在其后,操作数则跟在字典之后 PTOAS 要求所有存在的属性在同一个字典里;把 layout 留在前面可使未声明策略的 MX load 保持逐字节一致
a5 只发射属性,不发射 offset 那里没有第二份映射可寻址(见架构差异),也没有可供读取距离的入口

架构差异

双重映射是 a2a3 的属性,建立在它之上的一切也都是:

目标 当前一条 BYPASS 声明的实际效果
a2a3 设备 load 对 uncached 别名发起,即 addr + get_l2_cache_offset(args)。这正是该特性存在的场景
无别名的 a2a3 设备 驱动返回 0,addr + 0 即原地址,读取照常带缓存 —— 结果正确,只是少了一项优化。模拟器天然如此
a5 codegen 只发射属性、不发射 offset,而 PTOAS v0.64 把裸属性降级为普通 TLOAD。该声明被接受,但不产生任何效果

a5 并不是"缺一个 offset 等着填":它根本没有第二份映射可寻址。pto-isa 在 a5 的 TLOAD 上以指令操作数的形式携带 L2 hint,未来的 a5 路径应当落在那里 —— v0.64 并未把 它接到 cache_policy 上,因此在 a5 上,生成的 CCE 里声明过的 load 与未声明的毫无区别。

更旧的汇编器

发射本身是无条件的 —— offset 是 v0.64 新增的,更旧的汇编器会在 pto.tload 的校验处拒绝它。实际上走不到那一步:在汇编第一个 .pto 之前,codegen 会运行 ptoas --version,拒绝任何比本仓库所固定 PTOAS_VERSION 更旧的汇编器,报错中同时给出两个版本。

限制

  • 只有 InCore kernel 的 load 会接收该声明:把 GM 读取变成 tile.load 的是 ConvertTensorToTileOps,而它变换的是 InCore 函数。请把策略声明在会成为设备 kernel 的那个作用域上(CORE_GROUP 的 pl.at,或 pl.spmd 内联 body)。
  • 该策略管辖的是读。没有对应的 store 侧机制;对被写张量声明 BYPASS 会被拒绝, 而不会被重新解释为别的含义。

实现位置索引

层次 文件
枚举、attr key include/pypto/ir/expr.h(CachePolicy、kAttrCachePolicyVars、kAttrCachePolicyParams)
Op 注册 src/ir/op/tile_ops/memory.cpp(tile.load 的 .set_attr<int>("cache"))
DSL python/pypto/language/op/tensor_ops.py(set_cache_policy)、python/pypto/language/op/_tile_memory.py(load(cache=...))
Parser python/pypto/language/parser/ast_parser.py(标记提升 + 各项拒绝)
外提 src/ir/transforms/utils/scope_outline_utils.cpp
下沉 src/ir/transforms/tensor_to_tile/incore_conversion.cpp
Printer src/ir/transforms/python_printer_stmt.cpp(PrintScopeCachePolicyStmts)
Codegen src/backend/common/pto_ops_memory.cpp(MakeTileLoadCodegenPTO)
合成参数 src/codegen/pto/pto_codegen.cpp(MemRefCollectorVisitor::UsesL2BypassLoad、签名发射)
Kernel wrapper python/pypto/backend/pto_backend.py(_uses_l2_cache_offset、_generate_kernel_wrapper)
运行时读取入口 runtime/src/a2a3/runtime/*/common/intrinsic.h(get_l2_cache_offset)、runtime/docs/l2-cache-bypass.md

另请参阅