跳转至

ExpandMixedKernel Pass

将混合 InCore 函数展开为独立的 AIC(Cube)+ AIV(Vector)内核,并包装在 Group 函数中。非混合 InCore 函数的 FunctionType 会被转换为 AIC 或 AIV。

概述

在 OutlineIncoreScopes 和 ConvertTensorToTileOps 之后,InCore 函数可能同时包含 Cube 操作(tile.matmul、tile.gemv 等)和 Vector 操作(tile.add、tile.exp 等)。部分操作如 tile.load、tile.store、tile.move、tile.reshape 根据其 tile 操作数的 MemorySpace 被分类为 Cube 或 Vector。包含两侧操作的函数是混合 InCore 函数。硬件要求 Cube 和 Vector 操作在不同的核心类型上运行,因此该 Pass 将它们拆分为:

  • AIC 函数(FunctionType::AIC)— 仅包含 Cube + 共享操作
  • AIV 函数(FunctionType::AIV)— 仅包含 Vector + 共享操作
  • Group 函数(FunctionType::Group)— 依次调用 AIC 和 AIV,替换原始函数

当已有 Group 函数调用该 InCore 函数时(如来自 OutlineClusterScopes),该 Pass 会就地改写该 Group 函数以直接调用 AIC + AIV,避免产生冗余的 Group 包装。若 standalone Spmd 包装函数调用该 InCore,则该 Pass 会保留 Spmd 包装,并在其下方创建新的 Group 被调函数,使 launch 语义继续保留在 FunctionType::Spmd 上。

对于非混合 InCore 函数(纯 Cube 或纯 Vector),该 Pass 将 FunctionType::InCore 转换为对应的类型,无需拆分:

  • 纯 Cube → FunctionType::AIC
  • 纯 Vector 或仅包含共享操作 → FunctionType::AIV

该 Pass 执行后,程序中不再存在 FunctionType::InCore 函数。

手写 Group 的共享参数 ABI

一次运行时 MixedKernels 提交会创建一个协同调度的 AIC/AIV task,其中所有 active lane 共用同一份 CoreTaskArgs payload。因此,两个已编译 kernel wrapper 必须按完全相同的 tensors-first 参数布局解包。由 mixed-kernel 拆分器生成的 Group 天然满足这一约束,因为两个成员调用都会转发完整的 Group 签名。

手写 pl.cluster() 则可能提交源签名不同的成员,例如 cube(q, k, v, sink, task) 与 vec(out, task);outline 后的 Group 会捕获这些值的 并集。本 Pass 会把两个 DSL 成员都归一化为该 Group 签名,并改写两个内层 Call 或 Submit,使其转发完整参数列表。未使用的参数仍是合法的 wrapper 参数;克隆后的 函数体依旧只引用原调用实际使用的值。

归一化还会使用与内联展开相同的类型绑定规则,将成员形参类型中的动态维度绑定到 改写后的 Group 实参。绑定同时应用于局部别名、循环携带值、表达式类型和声明的返回 类型。例如,成员的工作区 Tensor[[8, N]] 接收 Group 的工作区 Tensor[[8, t + 32]] 时,克隆后的函数体必须始终使用 t + 32,不能在类型中残留 未绑定的成员局部符号 N。替换只应用一次,以保留调用者尺寸表达式的原始含义。 扩展参数后的 Call/Submit 结果及接收结果的别名也使用归一化后的成员返回类型, 并保留 Submit 末尾的 TaskId。

本 Pass 首先保存 producer 每个返回位置及其所属函数中的 SSA 表达式、类型和可选 输出形参绑定。GroupOutputBindings 沿 consumer 实参的 SSA 别名和 tuple 投影, 确定它对应哪个 producer 调用的哪个输出,再解析调用点实际传入的存储,将其绑定到 统一的 Group 形参。producer 局部的 SSA 变量不会直接复制到 consumer 函数体。

新计算的 Tensor 结果已由 ConvertTensorToTileOps 转为显式 Out 形参 和 store。该 Pass 将内部输出存储提升为 Group 形参,由外层调用者分配,即使 Group 不返回它也可以传递。已有完整参数的写回复用原存储。这同样覆盖 Call/Submit 的乱序 tuple 返回、直接传入的 pair[i],以及局部 tuple 别名或嵌套 tuple 投影。 pl.no_dep 使用相同映射,同时考虑 Submit 的省略输出区间和尾部通信 context。 被调函数的摘要只在一次归一化过程中共享,结束后释放。view 和 slice 不作为完整参数 别名;没有调用者可见输出载体的结果会明确报错,避免生成自由变量(free variable)。 按现有 kernel 返回约定,设备计算的标量结果需要显式 Tensor 输出。

该绑定过程不会插入跨核握手。手写 Group 成员并发执行,仍需由原有同步协议保证 producer 的 store 先于 consumer 的 load。

当两个成员 kernel 都只属于该 Group 时,保留其原函数名。如果任一 kernel 还有其他 调用点,Pass 会生成一对 Group 私有 adapter,并把 import_peer_buffer(peer_func=...) 改写为 adapter 的 peer 名称,从而为其他调用者保留 原 ABI。外部源码和 runtime-bound 成员无法安全克隆函数体;若它们的参数布局不同, Pass 会拒绝该 Group,并要求两个声明使用相同签名。

归一化发生在 InjectGMPipeBuffer 之前,因此后端注入的 __gm_pipe_buffer 会追加到 已经一致的两个成员签名末尾。该过程不需要 sync_start:AIC 与 AIV 是同一个 mixed task 的 subslot,而 sync_start 控制的是多 block SPMD 启动准入。 当 Group 经由外层 Spmd wrapper 调用时,编排代码生成会沿完整缓冲区别名链追溯到 wrapper 形参,再排列共享参数 payload。

不可切分的转置:已前移,不在本 pass

交换了切分轴的 tile.transpose 无法被正确切分:每个 lane 的切分数据迁移到了另一个 维度,而切分仍按原轴减半,结果形状错误并会算错。本 pass 不再上报它——该检查已移到仍然 持有所需上下文的位置:

形态 上报者
pl.split_aiv 区域 AivSplitValid 检查 (l),以 LowerAutoVectorSplit 兜底
函数级 pl.split(无区域) LowerAutoVectorSplit

在此上报只会得到更差的诊断:会触发区域情形的那趟遍历是 AppendConsumed,而它正是擦除 了它本该指名的那个包装,因此既说不出区域的模式,也说不出出错的结果名,更给不出修法。至于 函数级情形,它之所以曾留在这里,只是因为本 pass 能看到每一个函数,包括 LowerAutoVectorSplit 不予下降的纯向量 pl.split 内核;那个 pass 现在会校验它的透传分支, 从而在不依赖迟到诊断的前提下补上这一缺口。

此处仍保留一条整函数的兜底检查,形式为 INTERNAL_CHECK_SPAN。 下文说明本 pass 可以在 InferTileMemorySpace 之后被单独调用,而单独调用不会校验 required 属性——只有 PassPipeline 才会。因此跳过 LowerAutoVectorSplit 的调用方会在 AivSplitLoweredValid 未成立的情况下到达这里;没有该兜底,内核会被静默展开,随后由 SplitVectorKernel 算出错误 形状。它是 internal 而非面向用户的:该失败是「pass 前置条件未满足」,而不是作者能从自己源码 读出的事实,因此消息指出被跳过的步骤,而不给出改写建议。

split_axis::FindTransposeSplitHazard 仍是共用的检测器:标记第一个在切分轴上非 singleton 的 tile.transpose 源(若源在切分轴上是 singleton,则不携带切分数据——即广播 no-op 情形—— 保持切分;动态的非 ConstInt extent 视为非 singleton,保守标记)。

SplitRegionConsumer 擦除包装,并在同一份 消费后的 body 上记录临时放置信息,供 mixed 判定和展开共用。纯 AIV 函数也在此消费区域。 没有区域的函数跳过区域消费及 body 重建。擦除区域的注释移到首条生成语句之前,不修改输入元数据;边界展开为传输操作时也保留注释。

本 pass 要求 AivSplitLoweredValid。源阶段严格检查边界操作数内存,lowered 阶段仍对本地定义的值执行此检查。展开时,内联调用与已绑定变量均计入词法放置信息,根据 生产 lane 是否拥有操作数进行检查,因此两侧共享的参数不受其内存注解限制。边界结果 内存仍必须属于消费侧 lane。

CV 边界的跨核心数据传输通过将显式 tile.move 操作拆分为 tpush/tpop 对来处理:

方向 AIC 侧 AIV 侧
Cube→Vector(如 Acc→Vec) tpush_to_aiv(source_tile) dest_var = tpop_from_aic()
Vector→Cube(如 Vec→Mat/Left/Right) dest_var = tpop_from_aiv() tile.move 适配 fractal 布局,然后 tpush_to_aic(adapted_tile)

Fractal TileView 布局:跨核传输 tile 的 TileView 由 BuildCrossCoreTransferView 根据目标内存空间计算,不同后端的映射不同:

Ascend950(a5)——硬件跨核 pipe 直接按 fractal 布局传输数据:

方向 Push/Pop TileView (blayout, slayout) 名称
Vec->Left col_major, row_major NZ
Vec->Right col_major, row_major NZ
Vec->Mat 需要显式在move中设置,否则同src —
Mat/Acc->Vec 需要显式在move中设置,否则同src —

Vec->Right 在边界上按 NZ 传输,而不是 Right 操作数最终的 ZN:a5 通过 TINSERT_IMPL<TInsertMode::NZ> 把 Vec tile 插入 Mat FIFO,因此桥接 tile 必须保持 NZ。ZN 属于 cube 侧在 pop 之后所做的 Mat → Right tile.move,比本表描述的边界晚 一步——与下面 a2a3 表格中写明的拆分相同。

Ascend910(a2a3)——跨核传输经过 GM → Mat,Mat 仅支持 NZ 布局。因此 Left 和 Right 目标在传输边界统一使用 NZ,最终的 Left/Right 布局由后续 Mat → Left/Right tile.move(MTE1)处理:

方向 Push/Pop TileView (blayout, slayout) 名称
Vec->Left col_major, row_major NZ
Vec->Right col_major, row_major NZ
Vec->Mat 保持原始布局 —
Mat/Acc->Vec 保持原始布局 —

在两种后端上,普通数据的 AIV 推送侧(V→C)都会在 tpush_to_aic 前插入一个 tile.move 将源 tile 转换为所需的 fractal 布局。tile.move 辅助函数(CreateMove)在结果类型携带 TileView 时会传播 blayout/slayout kwargs。Ascend950 MX scale 使用下文单独说明的 row/row carrier。

手写 pipe 同样获得该适配

上述规则描述的是边界移动路径,它只能看到本 pass 在展开 InCore 函数时构建的 pipe。完全手写的 pipe(pl.reserve_buffer、pl.{aic,aiv}_initialize_pipe 与 pl.tpush_to_aic)不会经过该 路径。在 RequiresVtoCFractalAdapt() 成立的后端上,这样的推送会把裸 ND tile 送进一个被 cube 按 fractal 解释的 FIFO,导致弹出 tile 的每个元素都错位。

AdaptManualVtoCPush 负责补上这个缺口。它作为本 pass 的最后一个阶段,遍历本 pass 产出的 每一个 AIV 函数,包括转成 AIV 的纯向量 InCore 函数,以及 mixed 函数拆出的 AIV 半边。普通数据仍 使用固定的 cube 侧 Mat transfer view。对于 MX,pass 会从 AIV import 解析到 AIC reserve,并按 V2C pipe id 及出现顺序配对 push/pop,从实际消费方 tpop 契约规划每次传输。peer 缺失、initializer 不唯一、push/pop 数量或 split 不匹配、slot 太小都会直接报错,不会猜测布局。

该改写同时支持 FP8E8M0 MX scale 和普通 data tile,保留原 push 的 kwargs/attrs(包括 id),并且 保持幂等:已经处于边界 view 的 push 不会再次改写。只有遇到手写 V→C push 时才查询后端能力。

经 GM 中转的跨核依赖

tile.move 并非数据跨越 CV 边界的唯一途径。当一条 lane 用 tile.store 写入某个 GM 张量、另一条 lane 用 tile.load 读取同一张量时,数据是经由 GM跨核的。由于这两个算子都不是 tile.move,CV 边界检测无法识别该依赖;若不加栅栏,拆分后的两个内核会在共享 GM 区域上发生数据竞争(issue #1433)。

CollectGmCrossLaneSyncs 检测此类 store/load 配对,并生成一个纯同步握手:数据仍照常经 GM 流动,同时由生产侧(store 之后)的 tpush 与消费侧(load 之前)的配对 tpop 建立缺失的 happens-before 关系。被 pop 出的 tile 仅作为栅栏令牌,随后由 FinalizeTpopTfrees 立即释放;BuildAutomaticPipeSetup 随后注入与 tile.move 边界相同的 pipe setup。

生产侧(写 GM) AIC 侧 AIV 侧
Cube store → Vector load tile.store ...; tpush_to_aiv(stored_tile) tok = tpop_from_aic(); tfree_to_aic(tok); tile.load ...

为避免死锁,仅当满足以下条件时才生成握手:(1) 该 GM 源张量恰好只有一个生产侧 store;(2) 对侧 load 位于同一结构化作用域(同一循环/分支,使 tpush/tpop 执行次数一致);(3) store 先于 load。跨越不同循环或分支的配对保持不变。

仅对 Cube→Vector 方向(cube tile.store → vector tile.load)插入栅栏。AIC 侧的 tpush 直接发送被存储的 tile(与常规边界 C2V 推送在两种后端上的行为一致),AIV 侧 tpop 落在 Vec。反向的 Vector→Cube 方向需要 tile.move 边界路径在 tpush_to_aic 前所做的 V→C fractal 布局适配;在该方向发送未经适配的原始 tile 会破坏跨核传输契约,因此 V2C 的 GM 交换保持不加栅栏。

当拆分后的内核包含跨核 tpush/tpop 时,该 Pass 还会自动在函数前缀补齐前端 pipe setup:

  • 消费侧插入 system.reserve_buffer(...)
  • 生产侧插入 system.import_peer_buffer(...)
  • 两侧分别插入 system.aic_initialize_pipe(...) / system.aiv_initialize_pipe(...)

此外,Pass 还会在每条消费侧 tpop 链后插入 system.tfree_to_aic(...) / system.tfree_to_aiv(...)。

这些 setup 参数由拆分后的函数体自动推导:

  • dir_mask:C2V=1、V2C=2、双向=3
  • id:自动生成 setup 时省略,因此 PTOAS 使用默认 frontend pipe id 0
  • slot_size:所有方向中 tile 字节大小的最大值(shape * dtype bits / 8)
  • slot_num:环形缓冲深度——每个方向默认均为 2,且始终显式发射;可按 scope 覆盖(见下文)
  • local_slot_num:可选的 A2/A3 本地消费者深度;省略时使用 slot_num
  • buffer_size:指定本地深度时为 slot_size * local_slot_num,否则为 slot_size * slot_num
  • buffer 名称:<func>_c2v_slot_buffer / <func>_v2c_slot_buffer
  • reserve-buffer 的 base:插入时统一使用 AUTO,随后由 AllocateMemoryAddr 解析成显式地址

当跨核方向使用了不同大小的 tile 时,Pass 会取所有观察到的 tile 字节大小的最大值作为 initialize_pipe 的公共 slot_size。较小 tile 写入时不会填满整个槽位,但不影响硬件正确性。用户手写程序仍然可以通过给 initialize_pipe 以及匹配的 tpush / tpop / tfree 传入不同 id 来创建多条独立 pipe。

MX scale 的 V2C 传输

在 Ascend950 上,mixed quant_mx → matmul_mx 路径会把两个结果都经 V2C 传递。FP8E8M0 fractal-32 scale 不再走 NZ TINSERT。完整有效的逻辑 row/row scale 直接推送;逻辑 col/col scale 会在完成 carrier 准备后使用官方零拷贝 tile.transpose_view,得到物理 shape 转置后的 row/row carrier。若生产者和消费者的逻辑布局不同,则先用真正的 Vec→Vec tile.move 转成消费方布局, 再生成 carrier。

AIC tpop 始终保留消费方公开的逻辑 shape、layout 与 valid_shape。逻辑 scale 部分有效时,生产侧 发送完整物理 box,使 ND insert 连续且按 32 字节对齐。Pass 会先物化一个私有 Vec carrier,再仅对该 carrier 使用 tile.set_validshape 扩大有效范围;这样 slice/reshape view 仍保持合法,原始逻辑 scale 的元数据也不会改变。消费侧仍忽略原始逻辑有效区之外的 padding。完整 carrier 的字节数必须放得进 配对 pipe 的 slot_size。

显式 pl.aic_gather 使用同一条 MX carrier 路径。类型推导保留 FP8E8M0 的 row/row/32、 col/col/32 scale 布局,以及张量级 MX_A_ZZ / MX_B_NN,使后续 Mat→LeftScale/RightScale 移动仍能识别 scale 格式。普通 shard/gather tile 继续使用目标内存空间的常规传输布局。

SplitMode.NONE 保留逻辑形状。对于完全有效的 scale box,UP_DOWN 拼接 MX_A_ZZ 的行带, LEFT_RIGHT 拼接 MX_B_NN 的列带。后者的转置视图会把两端的物理分片轴都转换为 UP_DOWN, 而 Mat pop 保留完整的逻辑 col/col 形状。Carrier 校验将每个生产者与对应的半份逻辑形状比较, 并按两条 lane 的总大小检查槽位。沿 scale group 轴分片和部分有效的分片 scale box 需要额外的 分块布局传输处理,因此会被拒绝。

Gather 得到的 Mat tile 会占用 FIFO 槽位,直到消费者释放它们。若先 gather 全部四个 MX matmul 操作数再消费,需要设置 pl.cross_core_slot(slot_num=4);即使 scale 布局正确,两个槽位仍不够。

覆盖槽位数(slot_num)

默认槽位数(cross_core_pipe::kDefaultAutoPipeSlotNum = 2)可以通过 pl.cross_core_slot(slot_num=N) 优化条目按 scope 调整:

# 加深自动插入的环,让 cube 能比 vector 核跑得更超前,
# 代价是消费侧的 buffer 空间。
with pl.spmd(4, optimizations=[pl.cross_core_slot(slot_num=4)]):
    ...

# 也支持 pl.at 形式,并可与彼此独立的 split 模式组合:
with pl.at(level=pl.Level.CORE_GROUP,
           optimizations=[pl.split(pl.SplitMode.UP_DOWN),
                          pl.cross_core_slot(slot_num=16)]):
    ...

该值作为 slot_num scope 属性记录,由 OutlineIncoreScopes 传播到被 outline 函数的 slot_num 属性,并在此 Pass 读取。它决定传输环和发射的 initialize_pipe 的 slot_num 属性。未指定本地深度时,它也决定保留 buffer 的大小(slot_size * slot_num)。无论 scope 是否覆盖该值,自动 setup 始终发射该属性: 缺少该子句时 PTOAS 会按 dir_mask 自行推导深度(单向 8、双向 4),从而越过按更浅 的环预留的 buffer。只有手写的 pl.system.{aic,aiv}_initialize_pipe 仍会走到该回退路径。slot_num 必须为正数,且与拆分正交——它只决定数据通道的大小,不划分计算。它对 scope 实际 使用的方向都生效(cube->vector、vector->cube 或双向),并适用于任何驱动 pipe 的 scope:完全没有 split 的内核同样会驱动一条(a2a3 上通过双 AIV 派发——见下文)。 仅当被 outline 的 scope 最终没有跨核操作时才会被忽略。

已废弃: pl.split(MODE, slot_num=N) 仍接受该槽位数,但会发出 DeprecationWarning。该写法迫使作者写出并不需要的 split 模式——即 pl.split(pl.SplitMode.NONE, slot_num=N) 惯用法——而 OutlineIncoreScopes 现在会在任何携带 pl.split_aiv 区域的 scope 上拒绝它。请把该值迁移到 pl.cross_core_slot(slot_num=N);打印器已统一输出为该形式。

在 A2/A3 上,可用 local_slot_num 缩小片上消费者 FIFO,同时保持 GM 传输环深度:

with pl.spmd(4, optimizations=[pl.cross_core_slot(slot_num=4, local_slot_num=1)]):
    ...

两个深度都必须为正整数,且 local_slot_num <= slot_num。省略 local_slot_num (或传入 None)保留现有空间占用。Outliner 传播两个属性;两端初始化操作发射相同的 显式本地深度,每个片上 reserve 按 slot_size * local_slot_num 分配。 GM 分配仍使用 slot_num,公共最大 slot_size 的计算保持不变。 Pass 会检查最终 FIFO 调度;如果本地深度小于同时保留的消费者 pop 数量,则拒绝编译。 它不会为了适配较小深度而重排消费者。

A5 拒绝显式本地深度,即使其值等于 slot_num,因为 PTOAS 的该前端 pipe lowering 不支持此属性。编写可移植源码时应省略它。与 slot_num 一样,它对没有自动跨核 pipe 的 scope 不生效。

对于消费侧跨核 tile,该 Pass 还会保证每个 tile.tpop_* 都有匹配的 system.tfree_*。若现有 tfree 明显过早,Pass 会在不重排彼此独立 tpop 链的前提下,将它延后到同一个 block 中更靠后的语句之后。若 AIC 侧的 tile.tpop_from_aiv 结果还需要做同侧 tile.move(例如 Mat -> Left/Right/Bias), 则生成的 system.tfree_to_aiv(...) 会改写为释放 canonical 的 popped tile,并可被延后到这条 carrier 链之后。

对于 Ascend910(a2a3),该 Pass 也支持没有函数级 split 模式的 mixed kernel(split 未设置或为 SplitMode.None)。此时 Pass 会保留单个 AIV kernel body,并为其打上双 AIV 派发标记;后续 lowering 会基于运行时 subblock_idx 插入分支:AIV lane 0 执行原始函数体,AIV lane 1 重放跨核握手,并将 tile-producing replay 路径强制为 valid_shape=[0, 0],同时屏蔽可见的 tile.store 写回。这样 pl.at(level=CORE_GROUP) 形式的 no-split mixed kernel 也能保持 AIC/AIV 握手对称,同时让第二个同步 lane 避免真实 DMA/计算工作。

此 replay 路径仅适用于非 split_aiv 的 mixed kernel。携带 pl.split_aiv 区域的函数——任意模式,包括任务并行的 pl.SplitMode.NONE——会被 LowerAutoVectorSplit(pass 23)标记 split_aiv,因此 SplitVectorKernel 会将其走 split 路径(两个 AIV lane 经由 dual_aiv_dispatch 派发), 而非上述 lane-0-only 的 replay。对 NONE 区域这正是所需:两个 lane 都运行完整函数体,处理由 aiv_id 分派的 不相交工作——丢弃 lane-1 的 store 会导致误编译。

前置条件:

  • 输入 IR 必须具有 tile 操作(需先运行 ConvertTensorToTileOps)
  • 输入 IR 必须已提取 InCore 作用域(需先运行 OutlineIncoreScopes)
  • Tile 操作必须已展平为 2D(需先运行 FlattenTileNdTo2D)
  • Tile 内存空间必须已推断(需先运行 InferTileMemorySpace)
  • 跨核 Fractal TileView 分配在 Ascend950 和 Ascend910 后端均受支持

使用时机:在 InferTileMemorySpace 之后运行,当 InCore 函数可能同时包含 Cube 和 Vector tile 操作时使用。

注意:该 Pass 已经在默认 tile 优化流水线中启用。调试问题或自定义 PassPipeline 时,也可以通过 passes.expand_mixed_kernel()(program) 显式调用。

API

C++ Python 级别
pass::ExpandMixedKernel() passes.expand_mixed_kernel() 程序级

Python 用法:

from pypto.pypto_core import passes

expand_pass = passes.expand_mixed_kernel()
program_expanded = expand_pass(program)

算法

阶段 1 — 预扫描:
  识别哪些 InCore 函数已有 Group 调用者,以及哪些调用者仍然需要保留原始函数名。

阶段 2 — 展开每个 InCore 函数 F:
  1. 递归分类所有语句的亲和性(包括循环/条件内部)
  2. 检测 CV 边界移动:跨越 cube↔vector 内存空间的 tile.move 操作
     (记录在独立的 boundary_moves 映射中,而不是作为单独的亲和性枚举值)
  2a. 检测经 GM 中转的跨核 store/load 配对(CollectGmCrossLaneSyncs):
      一条 lane 上的 tile.store 与另一条 lane 上读取同一 GM 张量来源的
      tile.load,调度为 tpush/tpop 同步栅栏(见上文“经 GM 中转的跨核依赖”)
  3. 如果不是混合的(没有 CUBE 操作或没有 VECTOR 操作,且没有边界移动):
     将 FunctionType 转换为 AIC(纯 Cube)或 AIV(纯 Vector / 仅共享操作)
  4. 构建 AIC 函数体:保留 CUBE + SHARED 语句,删除 VECTOR,递归处理 MIXED 循环
     - 对于边界移动(Cube→Vector):生成 tpush_to_aiv(source_tile)
     - 对于边界移动(Vector→Cube):生成 dest_var = tpop_from_aiv(),携带 fractal TileView
  4a. 对由算子驱动的边界(tile.aiv_shard / tile.aic_gather),根据该算子携带的
      MODE 与全宽 tile 的 extent,推导 tpush/tpop 对使用的 pto-isa split CODE
      (BoundaryTransportSplitCode -> split_axis::ShardSplitCode / GatherSplitCode)
      ——见下文“split code”
  5. 构建 AIV 函数体:对称(保留 VECTOR + SHARED,删除 CUBE)
     - 对于边界移动(Cube→Vector):生成 dest_var = tpop_from_aic(),携带 fractal TileView
     - 对于边界移动(Vector→Cube):生成 tile.move 适配 fractal 布局,然后 tpush_to_aic(adapted_tile)
  5a. 通过 BuildCrossCoreTransferView 为所有边界 tpop 结果类型和 tpush 前的 tile.move 操作分配 fractal TileView:
      - Ascend950:Left→NZ,Right→ZN,Mat/Vec→保持原始
      - Ascend910:Left→NZ,Right→NZ(Mat 仅支持 NZ),Mat/Vec→保持原始
  6. 修复两侧函数体中的循环携带状态
     - 删除在当前核心侧无用的 dead iter_args——"有用"包含 while 条件,并在循环自身的 yield 上做闭包,
       因此仅被用于喂给另一个存活槽位的 carry(多级 FIFO 轮转)会被保留
     - 为保留下来的 iter_args 补回缺失的 init value 定义
     - 当分支局部值被裁剪后,将悬空 yield 改写为 identity yield
     - 将函数参数和待重映射变量加入分支结果清理的初始作用域,
       在两侧保留仍被使用、直接引用参数或 `n - 1` 等表达式的 if/yield 结果
     - 将悬空的 tile.store 结果变量(被 AIC 侧拆分裁剪的 SSA 版本)重映射到对应的输出参数
  7. 对两个函数体运行死代码消除(递归进入循环)
  8. 再次归一化循环携带状态,因为 DCE 可能移除仅用于过渡的 SHARED 后续引用,
     使某些 iter_arg 到这一步才变成可删除
  9. 再次运行死代码消除,清理第二次裁剪后暴露出的 init-value 链
 10. 保证每条消费侧 `tpop` 链都有匹配的
      `system.tfree_to_aic` / `system.tfree_to_aiv`,并在需要时把明显过早的 free 延后到同一个 block 内更靠后的位置
 11. 如果拆分后的函数体包含跨核 tile 操作,且尚未带有 setup,
      则推导并 prepend reserve/import/initialize_pipe 前缀
 12. 创建 AIC 函数(无返回值)和 AIV 函数(原始返回值)
     - 对于 Ascend910 上的 no-split mixed kernel,为生成的 AIV 打上 dual-dispatch 元数据,
       使后续 lowering 在两个 vector lane 上派发同一个 AIV kernel,并将第二个 lane 的 tile replay
       路径改写为 `valid_shape=[0, 0]`
 13. 如果满足以下任一条件,同时创建 Group 函数(调用 AIC 和 AIV):
     - 仍有非 Group 调用者需要保留原始函数名(`needs_preserved_name`,例如 standalone Spmd 包装)
     - 尚不存在 Group 调用者(`!has_group_caller`)
 14. 如果已存在 Group 调用者且无需保留名称:跳过额外的 Group 包装

阶段 3 — 改写 Group 调用者:
  对于每个调用了已拆分 InCore 的 Group 函数,将 InCore 调用替换为
  AIC 调用 + AIV 调用序列(AIC 用 EvalStmt,AIV 用 AssignStmt)。

阶段 4 — 归一化手写 mixed Group 的 ABI:
  对于 AIC/AIV 调用未同时转发完整 Group 签名的 Group,把两个 DSL 成员函数体
  重建到同一份 canonical 参数列表上,并改写两个 Call/Submit 以传入完整列表。
  独占成员保留原函数名;否则创建 Group 私有 adapter,并同步重定向 peer_func。

亲和性分类:

亲和性 操作 分类规则
CUBE tile.matmul、tile.matmul_acc、tile.matmul_bias、tile.gemv、tile.gemv_acc、tile.gemv_bias、tile.batch_matmul、tile.batch_matmul_acc 始终为 CUBE(按操作名)
CUBE 或 VECTOR tile.load 按 target_memory kwarg:cube 侧内存(Mat、Left、Right、Acc、Bias)→ CUBE;Vec → VECTOR
CUBE 或 VECTOR tile.store、tile.reshape 按源 tile 的 memory_space:cube 侧内存 → CUBE;Vec → VECTOR
MIXED 跨越 cube↔vector 内存的 tile.move 跨核心侧的叶子移动 —— 同时记录在 boundary_moves 映射中(见下文)
CUBE 或 VECTOR tile.move(同侧) 按源 tile 的 memory_space
VECTOR 所有其他 tile.* 操作(tile.add、tile.exp、tile.sub 等) 始终为 VECTOR(按操作名)
VECTOR pld.tile.put、pld.tile.get、pld.tensor.put、pld.tensor.get 显式声明 set_core_affinity(VECTOR) —— TPUT/TGET 经由 VEC 暂存 tile 中转,ptoas 强制校验该约束
SHARED 非 tile 操作、函数调用、控制流、标量操作 —
SHARED pld.system.notify、pld.system.wait 按 ISA 属于核无关操作(纯标量/GM),因此不声明亲和性。notify 额外声明了 set_no_duplicate()(对两种 NotifyOp 形式都生效)—— cube 侧的副本可能在向量 lane 的 TPUT 落盘数据之前就释放对端。wait 则不声明:它会阻塞,其 cube 侧副本是有实际作用的
MIXED 包含 CUBE 和 VECTOR 子语句的复合语句 —
VECTOR 区域内、固有亲和性为 SHARED、未声明 lane 的 no-duplicate 调用 pass 内的区域放置

区域将 notify 放在 AIV,保留 wait 的双侧行为、显式 lane 声明和边界两端。 放置映射只存在于本 pass 内,不写入调用属性。共享契约见 LowerAutoVectorSplit。

CV 边界检测:当 tile.move 的源 tile 内存和目标内存位于不同核心侧时,该移动为 CV 边界。Cube 侧内存:Mat、Left、Right、Acc、Bias。Vector 侧内存:Vec。同侧移动(如 Mat→Left)按其源内存照常分类。边界叶子移动在亲和性上被标记为 MIXED,并额外记录在独立的 boundary_moves 映射中;跨核方向(Cube→Vector vs Vector→Cube)由 CollectCVBoundaryMoves、BuildCoreBody 等调用点通过 ClassifyMoveDirection 即时恢复。

嵌套结构处理:包含混合操作的 ForStmt、IfStmt 和 WhileStmt 会被复制到 AIC 和 AIV 函数体中,内部内容递归裁剪。

拆分后的循环状态修复:在构建 AIC/AIV 函数体时,Pass 会先保留共享的控制流骨架,因此某一侧可能暂时留下多余的 iter_args、缺失的 init value 定义,或引用已被裁剪分支局部值的 yield。Pass 会先在 DCE 前按固定顺序修复这些情况,再在 DCE 后做一次循环状态归一化,因为某些仅用于过渡的共享别名会在 DCE 后消失,进而让相应 iter_arg 变成真正可删除。最后再运行一次 DCE,清理第二次裁剪后暴露出的 init-value 链。

哪些 carry 算存活:循环体、while 条件中的引用,以及对应循环结果在循环后的使用,都会作为存活性分析的初始依据。即使最终结果未使用,仅被 while 条件读取的 carry 也必须保留。只被循环自身尾部 yield 读取的 iter_arg,只要它喂入的槽位存活,它本身就存活——多级 FIFO 正是这种形状:第 N 级除了把自己轮转进第 N-1 级的那条 yield 之外别无读者。因此存活性分析要在 yield 上做闭包,而不是只看函数体;裁剪后还会断言"while 条件和保留下来的 yield 值均不会引用刚被删除的 carry"。删错 carry 会留下一个自由变量,后续没有任何 Pass 会拒绝它,最终由 PTO codegen 以 "no MLIR mapping for MemRef base ..." 报出。

Group 包装函数的参数化返回:新建的 Group 包装函数在所有 返回位置都能追踪到参数回写(经 return_lineage::ReturnedParamIndices)时, 直接返回自身参数——AIV 调用作为纯 EvalStmt 发出,包装函数直接 return out_0,从而维持 ReturnParamsExplicit 不变量。只有当某个返回位置 不是参数回写时,才回退到旧的 result = aiv_call(); return result 形式。

split code

tile.aiv_shard / tile.aic_gather 携带作者选择的 mode(0 / 1 / 2);本 pass 生成的传输对携带的是 pto-isa 的 TileSplitAxis code(0..4),后者还表达了两个 AIV lane 的运行时 extent 之间的关系——消费侧正是靠它在 FIFO 槽位中定位 lane 1 的数据段:

Code pto-isa Lane 1 的数据段起点 要求
1 / 2 TILE_UP_DOWN / TILE_LEFT_RIGHT e1 * pitch e0 == e1
3 / 4 TILE_UP_DOWN_ODD / TILE_LEFT_RIGHT_ODD (e1 + 1) * pitch e0 == e1 + 1

BoundaryTransportSplitCode 从全宽 tile 上读取 lane extent eL = clamp(V - L*S, 0, S)——shard 取其操作数(Cube→Vector),gather 取其结果 (Vector→Cube)。S 是分区步长:默认为该 tile 的物理 half;当 LowerAutoVectorSplit 对 ragged 边界做了均分时,则取算子上 lane_stride=S 属性。因此奇数切分轴(奇数物理 box、偶数 box 内的奇数 valid extent,或均分 后的奇数 valid 区域)会取奇数 code;lane 1 为空时保持偶数 code;pto-isa 无法摆放的 ragged extent(只可能出现在 box 分区上)则被拒绝,并给出可行的取值。两侧 lane 的函数体用相同输入执行同一推导,因此 AIC 与 AIV 侧永远一致。 Vector→Cube 的 gather 没有奇数形态(pto-isa 的向量侧 producer 按 lane 1 自身的 extent 偏移),因此只使用偶数 code。完整推导见 LowerAutoVectorSplit。

示例 1:InCore 没有已有 Group 调用者

当 Orchestration 直接调用 InCore 时,创建新的 Group 包装函数。

之前(经过 InferTileMemorySpace 之后):

@pl.program
class Before:
    @pl.function(type=pl.FunctionType.InCore)
    def compute_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_mat: pl.Tile[[16, 128], pl.BF16] = pl.load(x, [0, 0], [16, 128], target_memory=pl.Mem.Mat)
        y_mat: pl.Tile[[128, 128], pl.BF16] = pl.load(y, [0, 0], [128, 128], target_memory=pl.Mem.Mat)
        x_left: pl.Tile[[16, 128], pl.BF16] = pl.move(x_mat, target_memory=pl.Mem.Left)
        y_right: pl.Tile[[128, 128], pl.BF16] = pl.move(y_mat, target_memory=pl.Mem.Right)
        z_tile: pl.Tile[[16, 128], pl.FP32] = pl.matmul(x_left, y_right)
        z_vec: pl.Tile[[16, 128], pl.FP32] = pl.move(z_tile, target_memory=pl.Mem.Vec)
        out_0 = pl.store(z_vec, [0, 0], out_0)
        return out_0

    @pl.function(type=pl.FunctionType.Orchestration)
    def main(self, x, y) -> pl.Tensor[[16, 128], pl.FP32]:
        out_0 = pl.create_tensor([16, 128], dtype=pl.FP32)
        return self.compute_incore_0(x, y, out_0)

之后(概念性 — 实际 IR 包含所有变量的类型注解;为简洁起见,这里省略自动生成的 pipe setup 前缀):

@pl.program
class After:
    @pl.function(type=pl.FunctionType.AIC)
    def compute_incore_0_aic(self, x, y, out_0):
        x_mat = pl.load(x, [0, 0], [16, 128], target_memory=pl.Mem.Mat)  # CUBE:加载到 Mat
        y_mat = pl.load(y, [0, 0], [128, 128], target_memory=pl.Mem.Mat) # CUBE:加载到 Mat
        x_left = pl.move(x_mat, target_memory=pl.Mem.Left)   # CUBE:Mat→Left(同侧)
        y_right = pl.move(y_mat, target_memory=pl.Mem.Right)  # CUBE:Mat→Right(同侧)
        z_tile = pl.matmul(x_left, y_right)              # CUBE 操作
        pl.tile.tpush_to_aiv(z_tile, split=0)        # 边界移动:推送 Acc tile 到 AIV

    @pl.function(type=pl.FunctionType.AIV)
    def compute_incore_0_aiv(self, x, y, out_0) -> pl.Tensor[[16, 128], pl.FP32]:
        # tpop 结果携带 fractal TileView(Vec 目标 → 保持原始布局)
        z_vec: pl.Tile[[16, 128], pl.FP32, pl.Mem.Vec, pl.TileView()] = pl.tile.tpop_from_aic(split=0)
        out_0 = pl.store(z_vec, [0, 0], out_0)           # VECTOR 操作
        pl.system.tfree_to_aic(z_vec)                    # 释放已消费的跨核槽位
        return out_0

    @pl.function(type=pl.FunctionType.Group)
    def compute_incore_0(self, x, y, out_0) -> pl.Tensor[[16, 128], pl.FP32]:
        self.compute_incore_0_aic(x, y, out_0)
        self.compute_incore_0_aiv(x, y, out_0)
        return out_0  # param-return: the AIV result writes through out_0

    @pl.function(type=pl.FunctionType.Orchestration)
    def main(self, x, y) -> pl.Tensor[[16, 128], pl.FP32]:
        out_0 = pl.create_tensor([16, 128], dtype=pl.FP32)
        return self.compute_incore_0(x, y, out_0)  # 调用 Group(同名)

示例 2:InCore 有已有 Group 调用者

当 OutlineClusterScopes 已经创建了调用 InCore 的 Group 函数时,该 Pass 改写已有 Group,而不创建新的包装函数。若 OutlineClusterScopes 创建的是 standalone Spmd 包装,则该 Pass 会保留该 Spmd 函数,并把它改为调用新的同名 Group 函数。

之前:

@pl.program
class Before:
    @pl.function(type=pl.FunctionType.InCore)
    def compute_incore_0(self, x, y, out_0) -> pl.Tensor[[16, 128], pl.FP32]:
        # ... 混合 Cube + Vector 操作 ...

    @pl.function(type=pl.FunctionType.Group)
    def compute_group(self, x, y, out_0) -> pl.Tensor[[16, 128], pl.FP32]:
        result = self.compute_incore_0(x, y, out_0)
        return result

    @pl.function(type=pl.FunctionType.Orchestration)
    def main(self, x, y) -> pl.Tensor[[16, 128], pl.FP32]:
        out_0 = pl.create_tensor([16, 128], dtype=pl.FP32)
        return self.compute_group(x, y, out_0)

之后 — 已有 Group 被改写,不存在 compute_incore_0 Group 包装:

@pl.program
class After:
    @pl.function(type=pl.FunctionType.AIC)
    def compute_incore_0_aic(self, x, y, out_0):
        # ... Cube 操作 + tpush ...

    @pl.function(type=pl.FunctionType.AIV)
    def compute_incore_0_aiv(self, x, y, out_0) -> pl.Tensor[[16, 128], pl.FP32]:
        # ... tpop + Vector 操作 ...

    @pl.function(type=pl.FunctionType.Group)
    def compute_group(self, x, y, out_0) -> pl.Tensor[[16, 128], pl.FP32]:
        self.compute_incore_0_aic(x, y, out_0)       # 改写后:AIC 调用
        result = self.compute_incore_0_aiv(x, y, out_0)  # 改写后:AIV 调用
        return result

    @pl.function(type=pl.FunctionType.Orchestration)
    def main(self, x, y) -> pl.Tensor[[16, 128], pl.FP32]:
        out_0 = pl.create_tensor([16, 128], dtype=pl.FP32)
        return self.compute_group(x, y, out_0)  # 不变

循环初值修复按词法作用域跟踪可见定义:外层定义、参数、循环变量、carry 以及等待跨核重映射的值在嵌套体中仍然可见。只补回缺失的 producer, 复合语句只向外导出结果变量,分支局部定义不会泄漏到兄弟分支。

实现

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

实现文件:

  • src/ir/transforms/expand_mixed_kernel_pass.cpp:混合核展开、跨核传输与 pass 流程。
  • src/ir/transforms/expand_mixed_kernel/group_abi.cpp:手写 Group 的 ABI 规范化、输出绑定分析与成员适配器(adapter)构造;私有头文件 group_abi.h 声明结果类型与入口函数。

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

测试:tests/ut/ir/transforms/test_expand_mixed_kernel_a2a3.py、test_expand_mixed_kernel_a5.py、test_expand_mixed_kernel_split_aiv.py 与 test_group_output_bindings.py。

Pass 属性

属性 值
所需 SSAForm, IncoreTileOps, SplitIncoreOrch, TileOps2D, TileMemoryInferred, NormalizedStmtStructure, AivSplitLoweredValid
产生 SSAForm, MixedKernelExpanded, NormalizedStmtStructure, HardSyncallOccupancyValid, AccCompactValid
失效 AccCompactValid, AivSplitLoweredValid

HardSyncallOccupancyValid 在此产生,并非因为本 pass 做了什么改写,而是因为它把每个 kernel 的 FunctionType 解析为 AIV/AIC/Group——这正是硬 syncall 占用率 verifier 所依赖的前置条件。该 verifier 只在本 pass 之后触发一次。

AccCompactValid 在此失效并重新产生:Cube->Vector 边界的 tile.move 在本 pass 中被重建为 tpush/tpop 对,并带上新构造的消费者类型,因此 Acc compact 契约必须在这份新 IR 上重新校验,而不能沿用 InferTileMemorySpace 的结论。

属性验证器

MixedKernelExpandedPropertyVerifier 会检查:

  • 剩余的 FunctionType::InCore 函数不再同时包含 Cube 和 Vector tile 操作
  • AIC 中的 tile.tpop_from_aiv 必须落到 MemorySpace::Mat
  • AIV 中的 tile.tpop_from_aic 必须落到 MemorySpace::Vec
  • 带跨核 tpush/tpop 的 AIC/AIV 函数必须同时具备所需的 pipe setup
  • 每个 AIC/AIV tile.tpop_* 都必须有匹配的 system.tfree_*
  • 跨核 tile 操作的 tile 形状必须是静态已知的(自动 pipe setup 所需)

这样,常见错误(缺少 initialize_pipe、缺少 reserve_buffer / import_peer_buffer、缺少 tfree、 非静态 tile 形状、跨核 tpop/tfree 不匹配)会在 ExpandMixedKernel 之后立即报出,而不是拖到 PTO codegen / ptoas 阶段。当前 verifier 只检查按 tile 值/op 的配对关系,并不证明 tfree 一定落在该 tile 的真实最后一次使用之后。

设计决策

决策 理由
基于 move 的 CV 边界检测 显式 tile.move 操作标记边界——无需脆弱的变量数据流分析
使用 boundary_moves 映射(而非独立枚举值) 边界状态可由 ClassifyMoveDirection 在调用点即时推导;以侧表存储可让 CoreAffinity 保持四个执行侧分类(CUBE / VECTOR / SHARED / MIXED)
数据移动操作基于 MemorySpace 分类 tile.load/tile.store/tile.move/tile.reshape 根据其操作的内存空间服务于 Cube 或 Vector;InferTileMemorySpace 在该 Pass 之前已设置此信息
tpop 结果携带 Fractal TileView tpop 结果类型直接携带 fractal TileView(NZ/ZN),而非剥离布局——下游 Pass 和 codegen 无需额外推断即可看到正确布局
AIV 侧 tpush 前插入 tile.move V→C 传输要求数据为 fractal 布局;在 tpush_to_aic 前插入显式 tile.move 使布局转换在 IR 中可见
CreateMove 传播布局 kwargs 当结果类型携带 TileView 时,blayout/slayout 作为 kwargs 转发,使生成的 tile.move 调用自描述
Group 保留原始函数名 无已有 Group 调用者时:Orchestration 调用点无需修改——不需要重写调用点
改写已有 Group 调用者 当 Group 已调用 InCore 时(如来自 OutlineClusterScopes):就地改写以调用 AIC + AIV,避免冗余的 Group→Group 嵌套
保留 standalone Spmd 包装 当 standalone Spmd 调用 InCore 时:保留 FunctionType::Spmd,在其下创建 Group 被调函数,并继续由 Spmd 持有 core_num / sync_start
参数复制到所有三个函数 简化连接;DCE 在下游 Pass 中移除未使用的参数
递归处理复合语句 正确拆分 ForStmt、IfStmt、WhileStmt 内部的混合操作
两阶段拆分后循环状态修复 先保证 loop-carried state 合法,再在 DCE 移除死共享别名后重新裁剪 iter_arg,最后再跑一次 DCE 清理暴露出的 init-value 链
自动生成 pipe setup tensor 级 mixed kernel 无需手写 reserve_buffer / import_peer_buffer / initialize_pipe;Pass 会根据跨核 tile 操作自动推导
自动生成 tfree 链 消费侧拆分内核会补齐缺失的 tfree、将其改写为释放 canonical 的 popped tile,并在需要时把明显过早的 free 延后到同一个 block 内更靠后的位置;但不会重排彼此独立的 tpop 链顺序
Max-slot-size 策略 取所有 tile 字节大小的最大值作为单一 initialize_pipe.slot_size,对齐后端自动生成 setup 的假设,并保留旧有双向 dir_mask=3 行为