跳转至

LowerPipelineLoops Pass

在 tile 层级展开 pl.pipeline(N, stage=F) 循环:将循环体复制 F 份以启用 ping-pong 缓冲,同时保留外层顺序循环。

概述

pl.unroll(N) 在 SSA 之前的 slot #1 完整展开循环为 N 份副本。用户使用它通常并非需要 N 份副本,而是希望获得不同的 tile MemRef —— 否则 MemoryReuse 会把生命周期相邻的 tile 合并为同一缓冲区,导致 ping-pong 失效。

LowerPipelineLoops 提供更精细的开关:在 tile 层级把循环体复制 F 份(典型值 2–4),保留外层 N/F 次顺序迭代。每个副本获得独立的定义变量(保持 SSA),各自操作独立的 tile。

仅有新鲜 SSA 变量并不足以让各副本占用独立缓冲:F 份副本在程序序上是顺序的,它们的 per-clone tile 生命周期不相交——这恰好是 MemoryReuse 会将其合并为同一缓冲(破坏 ping-pong)的条件。为使 stage 分离显式化,本 pass 给副本 k 中每个产生 tile 的 Call 打上 pipeline_membership 属性记录 (group, stage=k)(见 include/pypto/ir/transforms/utils/attrs.h)。嵌套 pipeline 的 tile 会按每层复制区域各携带一个 membership 对,从而在每一层都保持分离。

cube 累加器是唯一的例外——它们不被打标记。 流水线 stage 会对它加载的操作数做双缓冲:这些加载与上一 stage 的计算重叠,因此两个 stage 的操作数缓冲确实同时存活(co-live),必须占用独立缓冲。而累加器不是加载得到的;它由单个串行化的 cube(一个 tile.matmul* MAD)写入——cube 在开始下一块的 MAD 之前会先完成当前块的 MAD,无论调度器重叠了多少 stage。因此某个 stage 的累加器永远不会与下一 stage 的累加器同时存活;给它打标记只会让 MemoryReuse 的容量门控为每个 stage 申请一块 L0C 缓冲、随后又收回为一块——这种冗余分离会触发一条虚假的 PH-MR-001,并且对于嵌套 N 层 pipeline 的累加器会膨胀到 2^N 块申请缓冲。不打标记时,drain-before-next 的累加器仅凭生命周期就合并到它真正需要的那一块 L0C 缓冲上(启用双缓冲 L0C 时,由 AutoTileMatmulL0 发射两个真正同时存活的累加器来驱动,而非依赖 membership 标记)。该例外的判定依据是生产者算子,而非仅凭 Mem.Acc:一个同样以 Acc 为目标的数据搬运算子(例如 tile.extract(..., target_memory=Acc))是真正的 per-stage 缓冲、会跨 stage 重叠,因此像其它被加载的操作数一样保持打标记。

MemoryReuse角色感知的粒度消费该属性——禁止所有跨 stage 复用(depth = F)会让每个中间结果都需要 F 份独立拷贝,在真实 kernel 上超出片上预算(例如 stage=4 的 RMSNorm 需要 4 × 67 KB > 188 KB UB)。只有 load 缓冲真正需要 per-stage 私有(以便第 i+1 次迭代的预取与第 i 次的计算重叠)。因此遗留规则为:当两个 tile 同 group、不同 stage 且至少有一个是 loadtile.load / tile.read)时,禁止它们共享缓冲,且 L0 matmul 空间完全豁免;不同 stage 的计算中间结果仍可合并。默认路径是容量门控(#1475):它按可负担的双缓冲深度对操作数 L0 空间(Left/Right/Bias)做 per-stage 分离,仅在某空间容量未知时才回退到遗留判定。累加器(Acc)两条规则都不触及——LowerPipelineLoops 不给它们打标记(见上),因此它们总是合并到串行化 cube 所需的那一块 L0C 缓冲上。

由于该标记是通用的 op-call 属性,它会经 python printer/parser(attrs={"pipeline_membership": "..."})序列化,以便在测试框架每个 pass 后执行的 print→parse 往返中存活。

pl.pipeline(...) 在内部生成 ForStmt(kind=ForKind::Pipeline, attrs={"pipeline_stages": F})。结构性不变量 kind == Pipeline ⇔ pipeline_stages 属性存在(双向;由 PipelineLoopValid 验证器强制)保证 kind 与属性始终成对存在。LowerPipelineLoopsF > 1 时触发:复制循环体并把属性下调为 1 作为降级后的标记位,保留 ForKind::Pipeline 让下游 CanonicalizeIOOrder 继续作用。再次运行 LowerPipelineLoops 看到 factor == 1 即跳过(自然幂等)。

前置条件: SSAForm、SplitIncoreOrch、IncoreTileOps、TileOps2D、TileMemoryInferred、NormalizedStmtStructure。

流水线位置: 位于 SkewCrossCorePipeline(及 NormalizeReturnOrder)之后、CanonicalizeIOOrderInitMemRef 之前。跨核(cube/vector)pipeline 循环已被上游 skew pass 改写为 ForKind::Sequential,因此到这里只剩同核 pipeline 循环(GM→L1、L1→L0、嵌套 matmul stage 循环)仍为 ForKind::Pipeline,由本 pass 复制。此时 tile 结构决策已完成;同时早于 CanonicalizeIOOrder/InitMemRef/MemoryReuse,使其看到每个副本独立的 tile 变量。

API

C++ Python 级别
pass::LowerPipelineLoops() passes.lower_pipeline_loops() 函数级
from pypto import passes
result = passes.lower_pipeline_loops()(program)

DSL 语法

# 每次外层迭代复制循环体 4 次;外层循环 16 次,步长为 4。
for i in pl.pipeline(64, stage=4):
    tile_x = pl.tile.load(input_a, [i * 128], [128])
    pl.tile.store(tile_x, [i * 128], output)

行为

对于 attrs_["pipeline_stages"] = FF > 1)的循环:

  • 主循环:步长为 F*step,循环体为 F 份副本组成的 SeqStmts,kind 仍为 ForKind::Pipeline,属性下调为 1(降级后的标记位)。kind 与属性成对保留以维持 PipelineLoopValid 不变量,使 IR 在 print/parse 往返中保持一致(输出形式为 pl.pipeline(..., stage=1))。
  • 克隆细节:每份副本通过 DeepClone(body, {loop_var → new_var + k * step}, clone_def_vars=true) 生成。每个副本拥有新鲜的定义变量,既保持 SSA,又给 MemoryReuse 提供独立的 tile 身份。

stage=1 是无操作触发:本 Pass 保留循环原样(kind 与属性都不动),仅递归进入循环体处理嵌套 pipeline。CanonicalizeIOOrder 随后基于该标记位完成 IO 重排并降级 kind / 移除属性。用户手写的 pl.pipeline(stage=1)factor>1 路径输出后的循环走相同流程 —— 都需要 IO 重排但无需进一步复制;这也使再次运行 LowerPipelineLoops 自然幂等。

根据 start / stop 是否为编译期常量,分为两种降级模式,区别仅在主循环的 stop 与余数处理方式。

静态边界 —— startstopstep 均为编译期整数

迭代次数 T = (stop - start) / step

  • 主循环终点为 start + (T // F) * F * step
  • T % F != 0,再发射一段SeqStmtsT % F 份克隆体,偏移为 start + (T // F) * F * step + j * stepj ∈ [0, T%F)),直接扁平化到外层作用域。余数已知,无需运行时分派,也无需任何包装结构。
  • 当源循环存在 iter_args 时,尾部克隆后附加 AssignStmt 将源循环的 return_vars 绑定到尾部最终 yield 表达式,保证下游引用仍然有效。

动态边界 —— start / stop 为运行时 Expr(step 仍为静态且为正)

  • 计算总迭代数 trip_iters = ceil_div(stop - start, step)step == 1 时退化为 stop - start,Pass 直接发射简化形式。
  • main_iters = trip_iters / factor(向下取整),并把 main_end = start + main_iters * (factor * step)AssignStmt 绑定为 SSA 变量 unroll_main_end
  • 主循环 for i in range(start, main_end, F*step)
  • 以 SSA 变量 unroll_rem 绑定 rem_iters = trip_iters - main_iters * factorstep == 1 时等价于 stop - main_end,Pass 直接发射该简化形式)。通过级联 IfStmt 根据迭代数分派:
if rem_iters == 1:    <1 份克隆>
else if rem_iters == 2: <2 份克隆>
else if rem_iters == 3: <3 份克隆>
# ...
else if rem_iters == F-1: <F-1 份克隆>
# rem_iters == 0 不匹配任何分支,跳过尾部。

每个分支 body 为 k 份克隆体组成的裸 SeqStmts(若源循环存在 iter_args 则追加一条 YieldStmt)。外层 IfStmt 携带 return_vars:最外层即原循环的 return_vars,内层级联分支使用新鲜变量,通过一系列 YieldStmt 向上传递。SSA 依然干净:每个分支自包含,任何条件定义的变量都不会逃出其 IfStmt。

约束

约束 原因
step 必须为编译期整数常量 主循环步长及各副本偏移均依赖 factor * step 为整数
动态边界要求 step > 0 动态 trip 计算公式假设正步长;负步长需使用静态边界
stage= 仅支持 pl.pipeline() 该特性作用域限定于 pl.pipeline()pl.range() / pl.parallel() / pl.unroll() 语义不同

示例

静态 —— 迭代次数已知(N=10F=4

# 变换前
for i in pl.pipeline(0, 10, 1, stage=4):
    tile_x = pl.tile.load(input_a, [i * 128], [128])
    pl.tile.store(tile_x, [i * 128], output)

# 变换后:主循环覆盖 [0, 8),kind=Pipeline(标记位)、属性下调为 stage=1;
# 尾部克隆直接扁平化到外层作用域
for i in pl.pipeline(0, 8, 4, stage=1):
    tile_x_0 = pl.tile.load(input_a, [i * 128], [128]); pl.tile.store(tile_x_0, [i * 128], output)
    tile_x_1 = pl.tile.load(input_a, [(i + 1) * 128], [128]); pl.tile.store(tile_x_1, [(i + 1) * 128], output)
    tile_x_2 = pl.tile.load(input_a, [(i + 2) * 128], [128]); pl.tile.store(tile_x_2, [(i + 2) * 128], output)
    tile_x_3 = pl.tile.load(input_a, [(i + 3) * 128], [128]); pl.tile.store(tile_x_3, [(i + 3) * 128], output)

tile_x_4 = pl.tile.load(input_a, [8 * 128], [128]); pl.tile.store(tile_x_4, [8 * 128], output)
tile_x_5 = pl.tile.load(input_a, [9 * 128], [128]); pl.tile.store(tile_x_5, [9 * 128], output)

动态 —— 运行时 n

# 变换前
for i in pl.pipeline(0, n, 1, stage=4):
    tile_x = pl.tile.load(input_a, [i * 128], [128])
    pl.tile.store(tile_x, [i * 128], output)

# 变换后
unroll_main_end: pl.Scalar[pl.INDEX] = ((n - 0) // 4) * 4 + 0
for i in pl.pipeline(0, unroll_main_end, 4, stage=1):  # 降级后的标记位
    <4 份克隆体与静态示例相同>

unroll_rem: pl.Scalar[pl.INDEX] = n - unroll_main_end
if unroll_rem == 1:
    tile_x_t0 = pl.tile.load(input_a, [unroll_main_end * 128], [128])
    pl.tile.store(tile_x_t0, [unroll_main_end * 128], output)
else:
    if unroll_rem == 2:
        <偏移 unroll_main_end + 0+1  2 份克隆体>
    else:
        if unroll_rem == 3:
            <偏移 unroll_main_end + 0+1+2  3 份克隆体>

本 Pass 之后,CanonicalizeIOOrder 作用于全程序的每一个 SeqStmts,将 load 上拉、store 下沉,使各副本的输入 tile 同时活跃,从而 MemoryReuse 不能合并它们。主循环与尾部克隆都能从 ping-pong 缓冲中受益。

相关

  • CanonicalizeIOOrder —— 下一个 Pass,对 ForKind::Pipeline 作用域内的 SeqStmts 做 IO 顺序规范化
  • UnrollLoops —— slot #1 的全展开 Pass,仍是 pl.unroll(N) 的主要降级路径