跳转至

InferTileMemorySpace Pass

为 InCore 函数中每个 TileType 变量推导片上 MemorySpace,插入 tile.move 来弥合生产者与消费者约束之间残留的不匹配,并让可证明为循环不变量的 Mat 操作数跨顺序循环保持驻留。

概述

FlattenTileNdTo2D 之后,每个 InCore tile 都拥有静态的 2D shape,但其 TileType::memory_space_ 仍未设置(或仅在通过 target_memory kwarg 显式标注的部分生产者上设置)。PTO-ISA 硬件暴露了多种不同的片上缓冲区——Vec(统一缓冲区 / 向量)、Mat(L1)、Left / Right(L0A / L0B 矩阵乘操作数缓冲区)、Acc(L0C 累加器)、Bias——大多数算子都对其输入和输出可使用的 memory space 施加约束。本 pass 就是这个约束求解器:它沿数据流前向传播 memory space,遵循显式的 target_memory kwarg,沿视图链反向传播需求,并在生产者与消费者无法在同一 space 上达成一致时插入 tile.move

本 pass 运行后,每个 InCore 变体函数 —— InCoreAICAIV,三者皆可由用户编写 —— 中的每个 TileType 都带有具体的 memory_space_,满足 ExpandMixedKernelInitMemRef 以及下游 codegen 所要求的 TileMemoryInferred IR 属性。

前置条件

  • 输入 IR 必须为 SSA 形式(SSAForm
  • 输入 IR 必须包含 InCore tile 操作(IncoreTileOps
  • InCore / Orchestration 拆分必须已完成(SplitIncoreOrch
  • 语句结构必须已规范化(NormalizedStmtStructure

使用时机:在 FlattenTileNdTo2D 之后运行(中间还隔着 LegalizeTileCastAutoTileMatmulL0CanonicalizeTileSlice),先于 InsertMxScaleAddr / ResolveBackendOpLayouts / ExpandMixedKernel。它是 tile memory 成为下游契约的标准时点——尤其是 InsertMxScaleAddr 的 scale 绑定、ExpandMixedKernel 的混合 kernel 检测和 InitMemRef 的缓冲区分配都直接读取该结果。

API

C++ Python 级别
pass::InferTileMemorySpace() passes.infer_tile_memory_space() Program 级

Python 用法

from pypto.pypto_core import passes

infer_pass = passes.infer_tile_memory_space()
program_inferred = infer_pass(program)

本 pass 重写所有 InCore 变体函数 —— 即 IsInCoreTypeInCoreAICAIV。Orchestration 与 Opaque 函数原样返回;若有 tile 从这类函数到达 InitMemRef,会作为编写错误报出 —— 非设备函数没有片上缓冲可供放置。

算法

每个 InCore 函数依次经历五个阶段,均由 IR Visitor / Mutator 实现。阶段 4 只构建一次自底向上的循环清单和完整的语法使用关系,并且每个循环的原始直接循环体只分析一次。嵌套循环会独立重写,该阶段因此保持 O(N) 复杂度。每次 pass 调用中,一条链最多跨过一层词法循环,而不会反复沿新建 preheader 向外移动。

阶段 0 — 反向需求收集(DemandCollector

对函数体执行一次遍历,记录两类信息:

  1. 对于其算子在 OpRegistry 中注册了 input_constraints 的每一个 Call,把每个受约束输入的 第一个 允许 memory space 记录为该输入变量的 "需求"。后端会把规范的(无需 move、最便宜的)space 排在第一位——例如 tile.store 列出 {Vec, Acc},因此 Vec 生产者无需 move,Acc 来源的 tile 也保留原 space。
  2. 按程序顺序记录两类 dst → src 需求边:
  3. 对于标记了 OutputMemoryInheritsInput() 的算子(如 tile.fillpadtile.slicetile.reshape),记录一条从输出变量指向第一个 tile 类型输入的边。
  4. 对于每个循环携带(loop carry),记录一条 iter_arg → init 边。阶段 1 会根据 init 为每个 iter-arg 播种 space,因此循环体对 iter-arg 提出的 space 需求,正是 init 生产者必须被放置的 space。pl.while_pl.range 的携带是同一种构造——iter_args_ / return_vars_ / body_ 结构相同——因此 WhileStmt 记录同样的边,并获得同样的阶段 1 播种与反向传播。

随后在这些边上反向传播需求:单次反向序遍历即可达到不动点。两类边在 SSA 中都严格向后——inherit-input 算子的 dst 总在 src 之后定义,而 carry 的 init 定义在循环之前——因此一次反向扫描即可完成 O(N) 的不动点。循环的 carry 边在下降进入循环体之前记录,于是循环体内的边会被先扫描,读到 carry 边时该 iter-arg 自身的需求已经确定。当同一变量上两个需求冲突时,非 Vec 的需求获胜(ShouldOverrideDemand)——Vec 是宽松的默认值,应被来自 compute 算子的特化需求覆盖。

正是这一阶段使 slice(tensor) → fillpad → matmul 链能把 matmul 的 Left / Right 需求一直传回 tile.slice 的输出。随后阶段 1 把该生产者解析为 Mat —— 即下表中 cube 需求所映射的中转 space —— 再由阶段 2 插入 Mat -> Left / Mat -> Right 搬运。正是该需求让结果选择 Mat 而非 Vec;否则操作数会走更长的 GM -> UB -> L1 -> L0 路径。

carry 边把同样的推理延伸到循环边界之外:在循环外加载、并被携带进 matmul 操作数槽位的权重,会被中转到 Mat,而不是退化为 Vec 并让一个纯 cube 操作数经由 vector core 走 GM -> UB -> L0B。

Kind traits。 所有读取调用实参、yield 值或 carry init 的环节一律使用 AsVarLike,绝不使用 As<Var>。循环携带的操作数是 IterArg,它拥有自己的 ObjectKind,因此不会被 As<Var> 匹配(参见 ir-kind-traits.md)。一旦用 As<Var> 读取,该操作数会对整条流水线同时隐身:阶段 0 不记录需求,阶段 2 不排队 tile.move,阶段 3 不做替换,TileMemoryInferred 校验器也不会报告由此产生的约束违规——于是 tile.matmulRight 槽位保留了一个 Vec 操作数,问题只在后端才暴露。

阶段 1 — 前向分析(TileMemorySpaceAnalyzer

遍历函数体,为每个 TileType 变量分配一个 MemorySpace,结果存入 var_memory_ map。

对每个 LHS 为 TileTypeAssignStmt,分析器按 RHS 的形式分派:

  • 调用 tile.* 算子的 CallInferFromOp(见下文解析表)。
  • 调用非 tile.* 算子但产出 TileType 的 Call → 默认为 Vec
  • 普通 SSA 别名 y = x → 继承 x 的 memory space。Python 前端在消除已经具备匹配 valid_shape 的输入上的空操作 tensor.fillpad(pad=zero) 时会发出此种别名;别名在值上等同于源,必须保持一致的 memory space。

对每个带 return_vars_ForStmt,访问完函数体后,分析器把每个 yield 变量的 memory space 拷贝到对应的 return_var_。同样的 space 还会被强制写到:

  • 对应的 iter_arg_ —— 用于覆盖累加器模式:循环体写入了 init 载体尚不具备的 space(如来自 matmul_accAcc)。过去这一步是必需的,因为 tile.create 默认打上 Vec、必须由反向传播覆盖;如今未指定 space 的 tile.create 会直接依据需求解析为 Acc,反向传播只需覆盖 AssignStmt 遍历访问不到的载体。如果不做这一步反向传播,最终的 tile.store 读到的是 Vec 类型 tile,会导致 ExpandMixedKernel 误判为混合 kernel,进而生成错误的 AIC/AIV IR。
  • iter_arg_ 下面的 TileType init_var_ 载体 —— 处理 IfStmtreturn_var(永远不会作为 AssignStmt 被访问)作为循环 init 的情形,以及嵌套循环以外层循环的 IterArg 作为 init 的情形。后者无法由外层循环自身的反向传播代劳:外层的反向传播要等整个循环体分析完才执行,因此循环体中途消费外层携带值的算子(如 tile.reshape 这类 output_inherits_input 算子)只能按该时刻的取值解析。

yield 查表与 init 载体查表都使用 AsVarLike 而非 As<Var>。原样透传的携带值——pl.yield_(a, b_next) 中的透传槽位——本身就是 IterArg,而嵌套循环也以外层循环的 IterArg 作为携带值的种子;IterArg 拥有独立的 ObjectKindAs<Var> 对两者都返回 null,于是该槽位被静默跳过。参见 .claude/rules/ir-kind-traits.md

对每个带 return_vars_IfStmt,分析器同样从分支 yield 记录每个 TileType phi 的 memory space(以 then 分支为准,else 分支作为兜底,phi 自身的标注作为最后兜底)。这是上述 ForStmt 循环携带传播的对偶,同样是关键的一步:若缺失,phi 永远不会进入 var_memory_,而所有查表的消费方都会在查不到时静默降级——InheritFromInput 会退化到从其他实参继承,阶段 2 的 CheckInputConstraints 会直接跳过该实参(排入任何 tile.move,于是算子声明的输入空间就被违反了),阶段 3 也会跳过重新标注。暴露该问题的形态是:对 if/else 累加器 phi 做 pl.cast 时,尽管 tile.cast 要求 Vec,实参却仍停留在 Acc,使得 ExpandMixedKernel 找不到可下降为 tpush_to_aiv / tpop_from_aic 对的边界 tile.move。这里从 yield 推导而非直接读取 phi 的标注,是因为分支可能在同一轮运行中被重新推断(即上文的累加器模式),此时该标注已经过时。

阶段 1 的逐算子解析表

生产者类型 解析得到的 memory space
已注册 cube 算子(tile.matmultile.matmul_mx 等) 来自 op memory spec(Acc
其他未注册算子 Vec
已注册但无 MemorySpec 的算子 Call 返回类型已设置且非 DDR,则使用之;否则 Vec
deduce_output_memory 返回 Some(s) 的已注册算子(如 tile.matmul → Acc s
output_inherits_input 算子(如 tile.slicetile.fillpadtile.reshape),且解析器返回 None 第一个 tile 输入的 space;否则 Vec
HasRetargetableMemoryKwarg() 算子(如 tile.loadtile.create),且解析器返回 None(kwarg 缺失) 阶段 0 的需求若为 VecMat 则使用之;cube 操作数需求(LeftRightLeftScaleRightScaleBias)解析为 Mat;否则继承输入;否则 Vec
tile.* 算子,deduce_output_memory 返回 None,且既非 retargetable 也非 inherit 继承输入;否则 Vec

对 retargetable 生产者执行 "夹逼到 {Vec, Mat}" 是有意为之:面向 DDR 的 tile.load 不能直接产出 Left / Right / Acc / Bias;即便下游需求是这些 space 之一,生产者也必须停在 Mat(或 Vec),由阶段 2 插入 tile.move 抵达特化 space。

这段夹逼逻辑放在 StagingSpaceForLoadsrc/ir/memref.cpp)里而不是内联在此处,因为本 pass 并非唯一调用者:ConvertTensorToTileOps 中的 input_reqs 桥接,会用同一个函数、依据本阶段读取的同一份 set_input_memory 声明,推导它所创建的每个 load 的空间。只保留一份,才能保证桥接产生的 load 与本 pass 重定向的 load 不会把同一个操作数放进不同的 buffer。

究竟停在两者中的哪一个,由需求决定,而非由某个默认值决定。cube 操作数需求会把生产者解析为 Mat:L1 是 tload 能填充、且 MTE1 随后能搬入 L0A/L0B 的唯一缓冲区 —— Mat -> Left / Mat -> Right 是 PTOAS(TMovOp::verify)唯一实现的搬运对。若改为路由到 Vec,不仅要多走 GM -> UB -> L1 -> L0 一条链,更糟的是会把仅供 cube 使用的操作数放到 vector 核上,ExpandMixedKernel 随后会将其识别为混合 kernel 并拆分到 AIC/AIV。

Acc 单独处理:任何 target、任何路径都无法把数据搬入 Acc,只有矩阵单元才写 L0C。因此必须作为累加器的 tile 只能在 Acc创建,阶段 2 无法为其架桥。OpRegistry::Create 会拒绝显式 space 无法抵达 Acc 约束的操作数,但未设置 space 的生产者仍会携带该需求到达本 pass,上述夹逼不能把它吞掉。

因此,当需求指向一个没有入边的 space(IsTileMoveEverPossibleInto)时,依据生产者已注册的 execution-memory-access 证据分流:

生产者 证据 结果
tile.create no_execution_memory_access() 直接满足需求 —— 该分配直接诞生在 Acc
tile.load functional_execution_memory_access() 面向用户的报错 —— MTE2 只填充 {Vec, Mat},从不写 L0C,任何放置都无法满足

以注册表的证据而非算子名清单作为判据,意味着后续新增的生产者会按其实际行为自动归类。若改为落到 Vec 兜底,阶段 2 会用一条任何 target 都未实现的 tile.move(搬入 Acc)去 "修复" 该不匹配 —— 这条非法 IR 会一直存活到后端才中止,且报错既不指明 tile 也不指明创建它的源码行。阶段 2 对该情况设有断言(INTERNAL_CHECK_SPAN),确保它不会再被静默生成。

阶段 1 从不覆盖已有的 target_memory kwarg。如果用户写了 pl.load(..., target_memory=Mat),而下游 matmul 需要 Left,则 load 仍保持 Mat,并由后续插入 tile.move

公开 Python load builder 会把 TensorLayout.MX_A_ZZ / MX_B_NN 源上省略的 target 规范化为 target_memory=Mat。原始 tile.load IR 仍必须携带该 target; 如果缺失或传入其他目标,类型推导会在本 pass 运行前报错。

阶段 2 — Move 收集(MoveCollector

再次遍历函数体。对每个其算子带 input_constraintsCall,检查每个受约束输入变量在 var_memory_ 中的解析结果是否在允许列表内。任何不匹配都会记录为 MoveKey = (producer_var, target_space) 加入 needed_moves_,其中 target_space 取该输入槽允许列表的第一个。阶段 3 会在每个外层 SeqStmts 作用域(即每个插入点缓存作用域)内最多为每个唯一 key 物化一个 tile.move,因此同一 (producer_var, target_space) 仍可能在兄弟作用域(如 then / else 分支)中分别物化。

阶段 3 — 重写(TileMemorySpaceMutator

完整的 IRMutator 重写,产出新的函数体:

  1. 变量重写(VisitExpr_(Var) —— 对每个解析到 space 的 TileType 变量,构造一个新的 Var,其 TileType 携带 memory_space_。当 space 改变时,同时把 tile_view_ 刷新为目标 space 的隐式视图(例如 Acc 期望 col_major / row_major / fractal=1024,而非 Vec 风格的 row_major / none_box / fractal=512)。结果缓存在 var_cache_,使得对同一变量的多次引用保持身份一致。
  2. tile.move 插入(VisitStmt_(SeqStmts)InsertMovesForConsumer —— 在每个 RHS 为受约束 CallAssignStmt / EvalStmt 处,对每个挂着待处理 MoveKey 的输入,在消费者之前新增一条 tile.move 形式的 AssignStmt。新 Var<orig>_<TargetSpace>)记入 created_moves_,作用域绑定到外层 SeqStmts,从而 IfStmt then 分支里发出的 move 不会泄漏到 else 分支(否则会留下悬空 SSA 引用)。当后端已配置时,会查询 BackendTileLayoutSpec::input_layouts,让插入的 tile.move 携带消费者所需的 blayoutVec 目标还会带上 slayout=none_box),避免后续 ResolveBackendOpLayouts 的修复。
  3. 参数替换(VisitExpr_(Call) —— 用 created_moves_ 中已有的项替换每个受约束的输入参数。
  4. Retargetable 生产者 kwarg 重写(VisitStmt_(AssignStmt) —— 对注册了 HasRetargetableMemoryKwarg() 的算子,若阶段 1 把输出解析到与 kwarg 不同的 space(或 kwarg 缺失),则重写 Calltarget_memory kwarg 与结果 TileType,使之匹配。这让 codegen 与赋值左侧 Var 的注解保持一致;这是必要的,因为阶段 1 可能基于反向需求做出解析,而 kwarg 永远看不到这些需求。
  5. LHS / RHS 类型同步 —— 当 VisitExpr_(Call) 在替换被 move 后的参数后,借由 OpRegistry 重建 Call,结果类型可能与 LHS Var 的原类型不同(重建的 call 会看到布局变化后的输入)。Mutator 把 LHS VarTileType 同步到重建 call 的 shape / dtype / memref / view,同时保留变量重写阶段选定的 memory_space_,保证 roundtrip 等价。

阶段 4 — 循环不变量 Mat 驻留(loop_invariant_mat_residency

所有 space 显式化后,一个独立的内部 transform 会识别形如 tile.load(GM → Mat) → tile.transpose_view* → tile.move/tile.extract(Mat → Left/Right) 的不变量前缀。对于精确的单一使用链,它会把整个不变前缀移到循环 preheader。它也会识别由编译器生成的 Mat panel:该 panel 的完整只读使用图可经过 transpose_view 和一个或多个 move / extract 分支到 matmul 的匹配操作数位置。在这种情况下,只会移动整个 panel 的 GM→Mat load;依赖 K 的 Left/Right 分级仍保留在原始循环或 pipeline 中。这样可将优化严格限定为驻留 matmul 操作数,而不是通用 tile LICM。因此静止 tensor-level 操作数只从 GM 加载一次,而依赖循环的对端操作数仍正常流式加载。

这是 issue #2077 所要求的更广泛驻留行为中的保守首个子集,并不是通用的 tensor-level residency contract。直接进入或由程序外部进入的 InCore 函数没有可分析的调用者证据,因此会拒绝该优化。不同的外部 tensor 参数同样会被拒绝:PyPTO 没有运行时 noalias 契约来保证其底层分配互不重叠。当前只有 root orchestration IR 内由 tensor.create 创建的存储能够提供正向调用者 provenance。若要覆盖外部操作数,必须增加可强制执行的 no-alias 契约或带非提升回退路径的运行时检查;本 transform 不会自行假设这一点。AutoTileMatmulL0 的分支已可在不移动其 K-dependent L0 extract 的前提下支持:panel 驻留与可选的 Mat→L0 前缀移动会独立分析。

候选资格首先依赖编译器私有的 provenance。ConvertTensorToTileOps 会标记其生成的所有 GM → Mat bridge load;该标记经过打印、flatten 和 L0 自动分块后一直保留到本阶段。阶段 4 随后证明带标记的 load 符合上文所述的精确静止前缀或只读 matmul panel 分支。用户手写的 tile.load(..., target_memory=Mat) 不带此标记,因此本优化绝不会提升它,从而保证显式 tile 程序仍由用户控制。

首版合法性规则有意保持严格:

  • 循环必须为 Sequential,边界是常量,step 为正,且至少执行一次;
  • 被移动的赋值必须是循环体顶层、无条件执行的语句;
  • GM 源必须是方向为 ParamDirection::In 的直接 tensor 参数,且 load 带有编译器生成的 Mat bridge 标记;
  • InCore 函数必须至少有一个来自 root orchestration 函数(即没有程序内调用者的 orchestration 函数)的直接 Call,并且该 InCore 函数的每个调用点都必须是这种 root-orchestration 直接 CallSubmit 调用点总会使候选失效,因为异步提交不能作为正向别名证据;
  • 在每个此类调用点,候选 Tensor In 实参必须解析到由 tensor.create 创建、归编译器所有的分配;普通别名以及 tensor.slice / tensor.assemble / tensor.view 别名会规范化到该存储 root,所有可写 Tensor Out / Tensor InOut root 都必须已知且均不得与候选 root 重叠;InCore 函数自身也不能写入该 root,而无关 scalar 和其他只读 Tensor In root 不参与此过滤;
  • offset、shape 以及整个被移动的依赖前缀都必须是循环不变量;
  • 循环头(边界或 loop-carried 初始值)或循环体子树内出现任何函数调用、任务提交、跨核操作、同步、缓存维护或未知 builtin 时都会拒绝驻留,因为移动到 preheader 可能使 load 在迭代之间越过未知或隐藏的顺序效应;其他直接控制流或有副作用语句若出现在 candidate 之前,则会关闭可提升前缀;
  • 精确可移动前缀中的每个值只能有预期的单一语法使用;驻留 panel load 可以有多条完整计数的直接只读路径,但每条路径只能由 Mat transpose_view 别名以及后续 Left/Right move / extract 组成,并且每个生成的 L0 值只能用在 matmul-family call 的匹配操作数位置;普通 SSA 别名、Submit 实参、嵌套表达式、循环初始值、yield、return 以及不支持或额外的消费者都会使候选失效;
  • 被移动的结果不能是 loop-carried 值或 yield 值;
  • 函数中所有实际拥有分配的 MatLeftRight tile 都必须具有静态大小,且按分配器对齐后的全函数上界不得超过后端容量;
  • 函数中不得存在尚未表示为 tile 分配、因而无法计入容量的显式保留缓冲区区域。

InOut / Out 源、外部输入分配、手写 tile load、直接或从程序外进入的 InCore 函数、Submit 调用点、经过 InCore wrapper 或被调用的 orchestration helper 的调用、未知的候选或可写调用点 root、候选/写入别名、额外语法使用、条件 load、动态或零次循环、容量未知的情形、被 yield 或循环携带的结果,以及依赖循环变量的 extract 都会安全拒绝并保持 IR 不变。即使其他调用安全,只要有一个调用点不安全或不是 root 调用也会使候选失效。首版实现有意不传播 wrapper 证据:语法上不同的 wrapper 参数仍可能在 wrapper 自身的调用者处发生别名。容量检查只统计实际拥有分配的值,而不重复统计零拷贝 view 或 SSA 别名;它采用与 InitMemRef / AllocateMemoryAddr 相同的字节大小和地址对齐规则,并包含循环外已存活的分配。若某个 memory space 中的分配可能被后续 pipeline lowering 复制,也会拒绝该 space 的驻留,除非被移动的前缀位于不受影响的 space。该全函数上界刻意强于任一规划器的生命周期复用,因此 residency 重写不会在 PyPTO 或 PTOAS 规划器下引入后续容量失败。嵌套循环会独立处理;一次 pass 调用只会将链移到其直接词法 preheader。本阶段不会全局重映射参数,也不会把依赖 K 的 L0 extract 移出 AutoTileMatmulL0 的 pipeline 循环。

驻留示例

对于 root orchestration 函数先创建全新 LHS 存储、再调用 InCore kernel 的 tensor 程序,静止 LHS 的 bridge 会移到用户循环之前,而依赖 N 的 RHS 仍在循环中流式加载:

# Tensor 源程序
for n, (acc,) in pl.range(0, 256, 128, init_values=(out,)):
    rhs_n = pl.slice(rhs, [128, 128], [0, n])
    c_n = pl.matmul(lhs, rhs_n, out_dtype=pl.FP32)
    result = pl.yield_(pl.assemble(acc, c_n, [0, n]))
# ConvertTensorToTileOps、L0 自动分块和 InferTileMemorySpace 之后
lhs_mat = pl.tile.load(lhs, [0, 0], [16, 128], target_memory=pl.Mem.Mat)
lhs_left = pl.tile.move(lhs_mat, target_memory=pl.Mem.Left)
for n, (acc,) in pl.range(0, 256, 128, init_values=(out,)):
    rhs_mat = pl.tile.load(rhs, [0, n], [128, 128], target_memory=pl.Mem.Mat)
    rhs_right = pl.tile.move(rhs_mat, target_memory=pl.Mem.Right)
    c_n = pl.tile.matmul(lhs_left, rhs_right)
    result = pl.yield_(pl.tile.store(c_n, [0, n], acc))

为便于阅读,上例省略了内部 provenance 属性和 root orchestration 调用。调用者先执行 fresh_lhs = pl.create_tensor([16, 128], dtype=pl.BF16),再把它作为 lhs 传入;因此编译器能够证明该分配与外部可写 out 不同。仅仅传入不同的外部 lhsout 参数并不充分。另一个只读 rhs root 与写别名过滤无关。缺少可信存储 provenance 时,仍保留原来的循环内放置方式。

通用 memory-space 推导示例

来源:tests/ut/ir/transforms/test_infer_tile_memory_space.py::test_matmul_gets_acc

优化前

@pl.program
class Before:
    @pl.function(type=pl.FunctionType.InCore)
    def main_incore_0(
        self,
        x: pl.Tensor[[16, 128], pl.BF16],
        y: pl.Tensor[[128, 128], pl.BF16],
        out_0: pl.Out[pl.Tensor[[16, 128], pl.FP32]],
    ) -> pl.Tensor[[16, 128], pl.FP32]:
        x_tile: pl.Tile[[16, 128], pl.BF16] = pl.load(x, [0, 0], [16, 128])
        y_tile: pl.Tile[[128, 128], pl.BF16] = pl.load(y, [0, 0], [128, 128])
        z_tile: pl.Tile[[16, 128], pl.FP32] = pl.matmul(x_tile, y_tile)
        out_0: pl.Tensor[[16, 128], pl.FP32] = pl.store(z_tile, [0, 0], out_0)
        return out_0

优化后

@pl.program
class After:
    @pl.function(type=pl.FunctionType.InCore)
    def main_incore_0(
        self,
        x: pl.Tensor[[16, 128], pl.BF16],
        y: pl.Tensor[[128, 128], pl.BF16],
        out_0: pl.Out[pl.Tensor[[16, 128], pl.FP32]],
    ) -> pl.Tensor[[16, 128], pl.FP32]:
        x_tile: pl.Tile[[16, 128], pl.BF16, pl.MemorySpace.Vec] = pl.load(x, [0, 0], [16, 128])
        y_tile: pl.Tile[[128, 128], pl.BF16, pl.MemorySpace.Vec] = pl.load(y, [0, 0], [128, 128])
        x_tile_L: pl.Tile[[16, 128], pl.BF16, pl.MemorySpace.Left] = pl.move(
            x_tile, target_memory=pl.MemorySpace.Left
        )
        y_tile_R: pl.Tile[[128, 128], pl.BF16, pl.MemorySpace.Right] = pl.move(
            y_tile, target_memory=pl.MemorySpace.Right
        )
        z_tile: pl.Tile[[16, 128], pl.FP32, pl.MemorySpace.Acc] = pl.matmul(x_tile_L, y_tile_R)
        out_0: pl.Tensor[[16, 128], pl.FP32] = pl.store(z_tile, [0, 0], out_0)
        return out_0

发生的变化:

  • 两个 tile.load 的输出都得到 pl.MemorySpace.Vec(无 target_memory kwarg,且这两个输入也未传播到可达的 Mat 需求)。
  • tile.matmuldeduce_output_memory 把输出解析为 Acc
  • tile.matmul 的输入约束(LeftRight)与生产者的 Vec 不匹配,因此阶段 2 记录了两个 move key,阶段 3 在消费者前插入了 x_tile_Ly_tile_R

如果用户改写为 pl.load(..., target_memory=pl.MemorySpace.Mat),阶段 1 将遵循 kwarg,tile.load 输出已为 Mat。matmul 仍然需要 Left / Right,因此会从 Mat 出发插入 move——这也正是 test_matmul_full_pipeline 测试的标准全流程。

实现

头文件include/pypto/ir/transforms/passes.h

实现src/ir/transforms/infer_tile_memory_space_pass.cpp

Python 绑定python/bindings/modules/passes.cpp

测试tests/ut/ir/transforms/test_infer_tile_memory_space.py

本 pass 还在同一 .cpp 中注册了 TileMemoryInferred PropertyVerifier,在需要校验 TileMemoryInferred IR 属性时运行。它在每个 InCore 函数上检查两条不变量:

  1. AssignStmt 定义的每个 TileType Var 都已设置 memory_space_
  2. 每个具有已注册 input_constraintsCall 输入所引用的 tile,其 memory_space_ 都在允许集合中。

Pass Properties

属性 取值
Required SSAFormIncoreTileOpsSplitIncoreOrchNormalizedStmtStructure
Produced SSAFormTileMemoryInferredNormalizedStmtStructureAivSplitValidAccToGmStoreValid
Invalidated AivSplitValid

TileMemoryInferred 属性是本 pass 建立的契约。下游 pass(尤其 ExpandMixedKernelInitMemRef)依赖该契约,配套的属性 verifier 守护回归。

AccToGmStoreValid 只有在这里才可判定:tile.store 是否从 Acc 收窄写入 GM,取决于本 pass 解析出的 memory space。AivSplitValid 被失效并重新产生也是同一原因——这是最后一个能观察到 AIV split 边界内存的验证点(此前在 ConvertTensorToTileOps 处该操作数的 space 可能仍未解析),再往后 LowerAutoVectorSplit 就会切换到 lowered 阶段的验证契约。

作用范围

函数类型 行为
InCore(含 AICAIV 进行变换
Orchestration 不变
Opaque 不变

tile 参数 在阶段 1 起始处按函数类型分别处理:

函数类型 tile 参数 原因
InCore 拒绝(INTERNAL_CHECK InCore kernel 由 orchestration 调用,后者只使用 tensor;出现 tile 参数说明前序 pass 生成了非法签名
AIC / AIV 接受,并作为分析的种子 二者是由混合 kernel 调用的 sub-worker,tile 参数正是常规的跨核交接

参数的 space 属于签名的一部分 —— 由调用方决定该 tile 位于何处 —— 因此本 pass 从不推断它。AIC / AIV 的 tile 参数若省略 space,属于用户错误,报错会给出应补充的标注。