LowerAutoVectorSplit Pass(向量自动拆分下降)¶
在 ExpandMixedKernel 之前,将带 AUTO pl.split 的混合 InCore 函数转换为
显式 split_aiv 形态:在 cube→vector 边界插入 tile.aiv_shard,在
vector→cube 边界插入 tile.aic_gather,仅对向量子区域沿拆分轴折半,注入
tile.get_subblock_idx(),并在函数上打 split + split_aiv 标记。
这是唯一的自动拆分下降路径:它始终运行,紧邻 ExpandMixedKernel 之前。运行后
每个拆分函数到达 SplitVectorKernel 时都已带
split_aiv 标记,因此该 pass 只打属性(其 split_aiv 分支)——其旧的逐算子折半驱动
已被删除,折半机制现仅存于 split_axis_utils,由本 pass 共享。
本 pass 同时是一等公民区域节点 SplitAivScopeStmt(for aiv_id in
pl.split_aiv(...))的唯一消费者。该区域作为结构节点存活于 parse → SSA →
ResolveBackendOpLayouts;在此处每个区域被就地下降,作用域包装被擦除,因此没有
任何 SplitAivScopeStmt 会到达 ExpandMixedKernel(pass 20)或 codegen。
为什么需要本 pass¶
用 pl.split 编写的混合 InCore 函数在同一函数体中描述 cube 与 vector 工作,拆分
意图仅由函数级 split 模式表达。实现该拆分有两种方式:
SplitVectorKernel中的后期逐算子折半 —— 在ExpandMixedKernel已经把函数 体分为带跨核tpush/tpop的 AIC + AIV 之后,再逐算子折半 AIV 函数体。这重复了tile.aiv_shard/tile.aic_gather已经编码的边界语义。- 早期显式下降(本 pass) —— 在
ExpandMixedKernel之前,把 AUTOpl.split函数体改写为手写显式核所用的同一split_aiv形态。随后ExpandMixedKernel中 单一的算子驱动边界分支会统一地把tile.aiv_shard/tile.aic_gather折叠为带 拆分标记的tpush/tpop——自动核与手写核走完全相同的下游路径。
方式 2 是当前路径。它与旧的逐算子折半逐字节一致(分阶段收敛期间已验证),因为两者调用
同一套 split_axis::ProcessStmts 机制,仅入口与边界处理不同。
API¶
| C++ | Python | 层级 |
|---|---|---|
pass::LowerAutoVectorSplit() |
passes.lower_auto_vector_split() |
Program 级 |
Pass 属性¶
| 属性 | 值 |
|---|---|
| Required | SSAForm |
| Produced | SSAForm |
| Invalidated | — |
来源:include/pypto/ir/transforms/pass_properties.h
(kLowerAutoVectorSplitProperties)。
作用范围¶
仅当全部满足时改写函数:
func_type_ == FunctionType::InCore,且- 带函数级拆分模式(
UpDown/LeftRight,mode != None),且 - 尚未为
split_aiv(手写显式核保持不动——它们已带显式 shard/gather 形态),且 - 确为混合(cube↔vector):其汇总亲和性为
MIXED,与ExpandMixedKernel判定is_mixed所用的ClassifyCallAffinity/CombineAffinity完全一致。
其余一律原样透传。最后一条很关键:纯向量 pl.split 函数(例如把一个逐元素算子
拆到两个 AIV lane,既无 cube 也无 C↔V 边界)没有可收敛的边界,故保持不动——
ExpandMixedKernel 会照旧把它转成普通 AIV 函数并剥掉其 split 属性,保留其原先
(未拆分)的行为。若在此处对其下降,剥离后它将只带 split_aiv 而无 split 模式,
SplitVectorKernel 会因此报错。
显式 SplitAivScopeStmt 区域路径¶
除上述 AUTO 整函数路径外,函数体仍携带一个或多个 SplitAivScopeStmt 区域的
InCore 函数走一条独立的区域路径(LowerExplicitRegionFunction),它在 AUTO
路径之前判定。每个区域携带各自的 split_ 模式,因此可处理单一函数级模式无法表达
的多模式情形。区域局部的 tile_vars / var_replacements 映射保证折半后的变量不会泄漏
到同级区域或区域外的全宽算子。任何区域之外的语句以全宽发出。所有区域下降后,作用域
包装被丢弃,函数被打上 split_aiv + split_aiv_region_validated(后者通知
ExpandMixedKernel 跳过其单一函数级模式的转置检查——
改由本 pass 用每个区域正确的拆分轴校验各自的转置风险)。
函数级 AUTO split(optimizations=[pl.split(mode)],包括 SplitMode.NONE)与显式
pl.split_aiv 区域是互斥的——同时携带二者的作用域会被拒绝。若需在携带区域的作用域上
指定自定义跨核槽位数,请使用 optimizations=[pl.cross_core_slot(slot_num=N)]:它只决定
pipe 大小,不标注任何拆分。该检查在更早的
OutlineIncoreScopes 中执行,那里作用域自身的 split_
(用户的 pl.split)与其区域都仍可见;否则本区域路径会按区域下降并静默丢弃函数级 split。
(提取后二者会无法区分地合并:单个 pl.split_aiv 区域会合法地派生出一个函数级代表
split 模式,故此处无法再检测该冲突。)
按区域的 split_ 模式处理三种区域体形态:
- 数据并行 · 全宽体(
UpDown/LeftRight,无显式边界算子):区域体持有全宽向量计算。 区域路径注入按区域的subblock_idx,将向量算子路由到共享的split_axis::ProcessStmts折半机制(按区域局部进行),并校验按区域的转置风险。这是自动收敛形态产生的范式。 - 数据并行 · 显式边界体(
UpDown/LeftRight,已存在tile.aiv_shard/tile.aic_gather):用户已手动切分 cube tile 并在每 lane 的半块上编写向量计算,故区域体 已是半宽形态。区域路径检测到这一点(RegionBodyHasExplicitBoundary)后原样透传区域 体——不再折半,也不注入重复的subblock_idx。若在此处再次折半将导致双重切分(下游的 Acc→Vec move 会被误判为新的 cube→vector 边界并被改写成第二个aiv_shard),从而产生一个 无任何分配的孤立 Acc memref 并使 PTO codegen 崩溃。ExpandMixedKernel会像处理手写 split_aiv 核一样,把显式边界折叠为tpush/tpop。 - 任务并行体(
None):没有拆分轴——两个 AIV lane 都运行完整区域体,由作者通过 区域的aiv_idlane 索引(例如按aiv_id跨步的循环)分派各自不相交的工作。区域路径 原样透传区域体(不折半、不本地化偏移、不注入subblock_idx;作者的aiv_id = get_subblock_idx()绑定已携带 lane 信息)。None区域内的tile.aiv_shard/tile.aic_gather会被拒绝(无拆分轴可切分)——由AivSplitValid校验器与此处的常开保护 共同拦截。该函数仍会被标记split_aiv,因此下游ExpandMixedKernel/SplitVectorKernel会把它派发到两个 AIV lane(经由dual_aiv_dispatch),而非 lane-0-only 的非拆分 replay(后者只针对非split_aiv核)——故两个 lane 都运行完整函数体。 当区域的 tile 无法折半(单位维)或归约必须保持全宽时使用本模式。
显式边界区域内允许出现哪些算子¶
由于该区域体是原样拼接的,其中每个 vector 算子都必须已经是 per-lane 的——保持全宽的
算子会在两个 AIV lane 上运行出完全相同的结果。ValidateMixedExplicitRegion 负责校验这一
点,并以可操作的错误信息列出违规算子。满足以下任一条件的 tile 生成算子会被接受:
| 接受条件 | 原因 |
|---|---|
(传递地)消费了 tile.aiv_shard 的结果 |
依构造即处于 half-width 数据流中。 |
纯生成算子——tile.full / tile.ci / tile.random(以及 tile.create,它归类为 SHARED,本就不会被报告) |
其结果仅是自身属性的函数:不读取任何 tile、不读取内存,因此无论作者写的是什么 extent,per-lane 复制都是正确的。 |
携带地址的算子——tile.load / tile.slice / tile.extract——且其地址参数引用了区域的 aiv_id |
作者已显式做了 per-lane 定位,例如 data[base + aiv_id * HALF : ...]。仅偏移参数计入(tile.load 第 1 个、tile.slice 第 2 个、tile.extract 第 1–2 个)——出现在 shape 或 valid_shape 中的 lane 引用并不会移动窗口,因此不予接受。 |
其余归类为 VECTOR 的算子都会被报告。有两点需要注意:
- 生成算子仅对其自身被接受,它不会加入 half-width 数据流。
z = pl.full([FULL, N]); y = pl.add(z, z)仍会在y处被拒绝——全宽生成算子不得为其 消费者背书。 - lane 引用仅在携带地址的算子上被信任。在其他算子上,lane 派生的标量只是一个普通操作数,
并不能说明结果的宽度——因此
pl.set_validshape(full_width_tile, 1, aiv_id * HALF)无法把一个全宽 tile 洗白进区域。
该校验证明的是意图而非范围:偏移按 lane 跨步、但 extent 仍为全宽的 load 会被接受,
此时两个 lane 会读到重叠的窗口。这与本 pass 从不检查 tile.store 的 lane 相关偏移是同一种
信任。
由于区域经由通用的 BeginScope/EndScope 构建且不被提取,它可嵌套在 pl.range /
pl.pipeline 循环或 if 之内;区域路径会递归进入复合语句,找到并下降每个区域,同时保留
外围控制流。
区域必须不被 scope 包裹¶
区域下降会递归进入 ForStmt / WhileStmt / IfStmt / SeqStmts,但刻意不进入
ScopeStmt:scope 携带提取(outlining)与名字可见性语义,区域局部的折半不应跨越它。因此本
pass 运行时每个区域都必须已不被 scope 包裹——通常由
OutlineIncoreScopes(pass 8)保证,它会把外围的 InCore
scope 提取为独立函数。
该保证存在一个缺口,故本 pass 选择强制校验而非假定成立。pass 8 只对 Opaque /
Orchestration 函数提取 scope,而解析器无论外围函数类型如何,都会把顶层的
for aiv_id in pl.split_aiv(...) 包进一个 InCore scope。因此直接把函数声明为
pl.FunctionType.InCore 时,到达此处的区域仍被 scope 包裹:
@pl.function(type=pl.FunctionType.InCore) # pass 8 会跳过该函数
def f(self, a: pl.Tensor[[128, 128], pl.FP32],
c: pl.Out[pl.Tensor[[128, 128], pl.FP32]]) -> pl.Tensor[[128, 128], pl.FP32]:
for aiv_id in pl.split_aiv(2, mode=pl.SplitMode.NONE): # 被包进 InCore scope
base = aiv_id * 64
c = pl.store(pl.exp(pl.load(a, [base, 0], [64, 128])), [base, 0], c)
return c
下降完成后,LowerExplicitRegionFunction 会重新扫描函数体,对任何存活下来的区域抛出
ValueError,并把源位置指向 pl.split_aiv 那一行。修复方式:改用普通的 @pl.function /
@pl.jit(Opaque)让 pass 8 提取该 scope,或把区域移出外围 scope。
该守卫也正是 split_aiv_region_validated 标记可信的依据:只有当每个区域都确实被消费后才写入
attrs,因此 ExpandMixedKernel 凭该标记跳过自身的 func-mode
检查时,背后总有一次真实的逐区域校验。若无此守卫,被 scope 包裹的区域会既未下降、又未校验,
却仍被标记为“已完成区域校验”,问题要到很晚才以 PTO codegen 的内部断言
(SplitAivScopeStmt reached PTO codegen)暴露。
拆分轴分派¶
SplitMode(int) |
拆分轴 | 折半的向量子区域 |
|---|---|---|
None(0) |
—(无拆分轴) | 不折半——任务并行;tile 保持全宽,由 aiv_id 分派两个 lane |
UpDown(1) |
维 0(高度) | 行 |
LeftRight(2) |
维 1(宽度) | 列 |
SplitDimension(mode) 对 UpDown 返回 0,对 LeftRight 返回 1
(split_axis_utils);对 None 不调用(区域路径先对 None 分支——无轴可推导)。
算法¶
LowerFunction 改写一个混合 InCore 函数:
1. split_dim = SplitDimension(mode); split_int = int(mode)。
2. InjectSubblockIdx(func, is_aiv=true) 在函数体顶部插入
subblock_idx = tile.get_subblock_idx()
(若 'subblock_idx' 已占用则取新名)。
3. LowerStmts 遍历扁平函数体:
边界 tile.move(ClassifyMoveDirection):
CUBE_TO_VECTOR —— 将 move 替换为
tile.aiv_shard(full_cube_tile, split=int(mode)) -> 半
推导出的半类型已经带有消费侧 lane 内存(Vec):切分推导器让 memory_space
保持为空,由 OpRegistry::Create 用 tile.aiv_shard 的 set_output_memory
声明填充,因此本路径与显式 pl.aiv_shard 形式读取的是同一处声明。将结果
var 连同其半尺寸种入 tile_vars,并记录 旧->新 var 重绑。cube 源(matmul /
Acc 结果)保持全尺寸。
VECTOR_TO_CUBE —— 插入
tile.aic_gather(half_vector_tile, split=int(mode)) -> 全
将源解析到其折半后的 var 使 gather 把 半 -> 全 翻倍,随后保留对折叠后全尺寸
tile 的原 cube 放置 move(命名为 "<dest>_mat",以便 ExpandMixedKernel 的
V->C 边界据此命名其合成的 tpop)。
亲和性门控(ClassifyCallAffinity):
VECTOR 亲和叶子 —— 将单条语句送入
split_axis::ProcessStmts({stmt}, ..., is_aiv=true):与已删除的
SplitVectorKernel 驱动所用的同一机制。沿 split_dim 折半 tile.load /
tile.store / tile.slice / tile.reshape / 计算结果,按 subblock 本地化偏移,
在 tile_vars 中跟踪折半 var。
CUBE 亲和叶子 —— 全尺寸透传,绝不折半。
ForStmt / IfStmt —— 递归进入函数体处理向量内容。
4. CheckNoCubeTileHalved 重新遍历改写后的函数体,断言没有 CUBE 亲和算子消费或产生
tile_vars 中的 tile(亲和性门控绝不能把折半 tile 漏入 cube 操作数)——失败时
INTERNAL_CHECK。
5. transform_utils::Substitute 应用 var_replacements;DeepClone 脱离共享子树。
6. WithSplitAivAttrs 打 split + split_aiv(丢弃任何先前的 split / split_aiv /
dual_aiv_dispatch 条目)。
逐算子向量折半(沿拆分轴折半形状、按 subblock_idx * half 本地化偏移、tile.slice
静态形状参数与结果类型同步折半、rank-1 load 的 reshape 按 lane 切片、拒绝在拆分轴上
归约、保留单元素拆分维、循环 iter_arg/return_var 跟踪)全部由
split_axis::ProcessStmts / ProcessStmt 产生;同样的事实由
tests/ut/ir/transforms/test_lower_auto_vector_split.py 验证。
当拆分维不是单元素维时,自动折半会拒绝根生成器 tile.ci 和 tile.random。它们的
生成值依赖位置,因此仅修改结果类型并不充分;正确改写还需要按 lane 调整 shape 与生成器
状态,而本 pass 不会合成这些调整。请将算子移到会自动折半的拆分区域之外。单元素拆分维与
已有显式边界的半宽区域仍保持不变。
亲和性门控¶
仅折半向量工作,cube 工作保持全尺寸。亲和性由
core_affinity::ClassifyCallAffinity(按内存空间)决定:产生或消费 Vec tile 的算子
为 VECTOR;matmul 操作数与 Acc/Mat cube 结果为 CUBE。C→V 边界 tile.aiv_shard
是接缝:全尺寸 cube tile 是其输入,半尺寸向量 tile 是其输出。CheckNoCubeTileHalved
是兜底——若 cube 操作数被缩小则触发。
示例 —— cube→vector 边界,向量区域折半(UpDown)¶
混合核:cube tile(Mat)跨入 Vec,向量 add 在其上运行,结果被存储。
之前(InferTileMemorySpace 之后的混合 InCore):
@pl.function(type=pl.FunctionType.InCore, attrs={"split": pl.SplitMode.UP_DOWN})
def split_auto(qk: pl.Tile[[128, 128], pl.FP32, pl.Mem.Mat],
out_0: pl.Out[pl.Tensor[[128, 128], pl.FP32]]):
popped: pl.Tile[[128, 128], pl.FP32, pl.Mem.Vec] = pl.tile.move(qk, target_memory=pl.Mem.Vec)
y: pl.Tile[[128, 128], pl.FP32, pl.Mem.Vec] = pl.add(popped, popped)
return pl.store(y, [0, 0], out_0)
之后:
@pl.function(type=pl.FunctionType.InCore,
attrs={"split": pl.SplitMode.UP_DOWN, "split_aiv": True})
def split_auto(qk, out_0):
subblock_idx: pl.Scalar[pl.INDEX] = pl.tile.get_subblock_idx()
popped: pl.Tile[[64, 128], pl.FP32, pl.Mem.Vec] = pl.tile.aiv_shard(qk, split=1) # C->V, 半
y: pl.Tile[[64, 128], pl.FP32, pl.Mem.Vec] = pl.add(popped, popped)
return pl.store(y, [0 + subblock_idx * 64, 0], out_0)
cube 操作数 qk 保持 [128, 128];向量子区域折半为 [64, 128],store 偏移按
subblock 本地化。
示例 —— vector→cube 边界保持全尺寸(UpDown)¶
V→C tile.move 变为 tile.aic_gather;对折叠后 tile 的 cube 放置 move 保持全尺寸
[128, 128] Mat——cube 侧绝不会看到折半 tile:
# `v` 是 affinity gate 产出的每 lane 折半 tile,例如 [64, 128]。
gathered_mat: pl.Tile[[128, 128], pl.FP32, pl.Mem.Mat] = pl.tile.aic_gather(v, split=1)
gathered: pl.Tile[[128, 128], pl.FP32, pl.Mem.Mat] = pl.tile.move(gathered_mat,
target_memory=pl.Mem.Mat)
操作数必须是每 lane 的折半 tile。 tile.aic_gather 声明为 HALF → FULL,因此
gather 把 [64, 128] 加倍为 [128, 128],恰好等于 cube 放置 move 所保留的全尺寸
结果类型。这种一致性是前置条件而非保证:向量值可能以未折半的形式到达边界——
例如直接使用的 Vec 参数,或 split 维为 1 而被 affinity gate 特意保留的 tile。对
这类操作数做加倍会得到 [256, 128] 的 gather,而其后的 move 仍是 [128, 128],
这与 tile.move 的保形契约矛盾,且产生的 IR 无法通过 print→parse 往返。既然没有
折半就不存在正确的 gather,本 pass 会以可操作的 ValueError 拒绝它:
LowerAutoVectorSplit: the V->C boundary tile.move here carries a full-width
vector operand 'vec'. tile.aic_gather reassembles the two AIV lanes' per-lane
halves into the full tile the cube expects, so its operand must be a value the
split halving produced (a tile.load / tile.slice / elementwise result inside the
vector sub-region). An un-halved value has no half to gather — either derive the
per-lane half first (load or slice the value inside the split function) and move
that to the cube side, or, if the split axis is a singleton that cannot be
halved, keep the value on the vector side.
gather 沿操作数自身的 split 轴,而非函数的 split 轴。 tile.reshape 可能迁移
split 轴——rms_norm 的 [N, 1] ↔ [1, N] 列 reshape 会把它从 dim 0 移到 dim 1——
而 TileInfo::split_dim 记录了它最终所在的位置。gather 使用该维度对应的
split 编码发射(dim 0 → 1,dim 1 → 2),因此在 UpDown 函数中,lane 本地
的 [1, 8] 操作数经由 split=2 gather 为 [1, 16],与 move 一致;若改为对函数
轴加倍则会得到 [2, 8]。若追踪到的 split 维不在 {0, 1} 内,则无法表示为 2D
gather 的 split 属性,会被拒绝。
gather 结果是 Mat 而非 Vec:边界算子声明的类型指的是消费侧 lane 的空间,
而 AIC 会把 V→C 传输 pop 进 L1。(Vec 指的是生产侧 lane,与镜像算子
tile.aiv_shard 相矛盾——后者为其 cube 产出的操作数声明向量侧的 Vec。)随后的
cube 放置 move 才把 tile 放到最终的操作数空间——matmul 操作数为 Mat → Left;
此处的 Mat → Mat 是空操作,仅因本 pass 保留了作者原有的 move 而存在。
实现¶
头文件:include/pypto/ir/transforms/passes.h
实现:src/ir/transforms/lower_auto_vector_split_pass.cpp
LowerFunction/LowerStmts—— 边界改写 + 亲和性门控折半。MakeReshapeOpCall—— 构造tile.aiv_shard/tile.aic_gather调用。CheckNoCubeTileHalved—— cube 操作数完整性兜底。WithSplitAivAttrs—— 打split+split_aiv。
共享机制:src/ir/transforms/utils/split_axis_utils.cpp
(ProcessStmts、InjectSubblockIdx、SplitDimension、IsReduceOnSplitAxis)
—— 逐算子向量折半,与 SplitVectorKernel 的独立拆分分支
(ProcessStandaloneSplitFunction)以及 AivSplitValid 校验器
(SplitDimension / IsReduceOnSplitAxis)共享。
Python 绑定:python/bindings/modules/passes.cpp
测试:tests/ut/ir/transforms/test_lower_auto_vector_split.py 以及
tests/st/codegen/torch/test_torch_codegen_cross_core.py 中端到端 pl.split
golden 场景(test_lower_auto_vector_split_golden)。
相关¶
ResolveBackendOpLayouts—— 紧邻其前运行。ExpandMixedKernel—— 紧邻其后运行;把tile.aiv_shard/tile.aic_gather折叠为带拆分标记的tpush/tpop。SplitVectorKernel—— 下游;仅为本 pass 产生的split_aiv函数打属性,外加无拆分 dual-AIV 路径。