PyPTO-Tensor 让你「快速写出算子」,PyPTO Professional模式让你「进一步挖掘性能」。

1. PyPTO Professional模式背景回顾

PyPTO Professional模式 是一门以 Python 为前端、构建在 PTO-ISA 高性能指令集之上的 DSL,与 PyPTO-Tensor 是同一个家族的两位成员:

PyPTO-Tensor PyPTO Professional
抽象载体 Tensor 图 Tile(二维数据块)+ RegTensor
谁做多核切分 框架 开发者(和算子强相关,通用算法性能有上限)
谁做 Buffer 复用 框架 开发者(和Tiling强绑定,通用算法性能有上限)
谁算 offset / 填指令参数 框架 框架
谁插核内同步 框架 框架(auto_mutex=True)
谁排核间流水 框架 框架(@pl.pipeline.stage)
定位 算法开发者快速实现 在高易用的前提下开放几乎全部硬件控制力

他两从架构上是共后端IR和PTO-ISA,前端表达上略有不同: PyPTO Professional 的设计哲学是立足易用性,并尽可能的提供更强的硬件控制力:

把「决定性能的决策」留给开发者,把「不决定性能但极易出错的苦力」交给框架。

多核怎么切、Tile 开多大、Buffer 摆在 UB 的哪个地址、流水开几级——这些直接决定你能不能摸到 0.9x+ 峰值,框架绝不替你猜。而 offset 换算、指令参数、event id 分配、同步配对、preload 展开——这些写对了性能不会更好、写错了直接精度出错或死锁,框架全包。

2. 易用性到底来自哪里

我们具体的看一下相比于传统算子编程,PyPTO Pro的易用性来源。

PyPTO Professional模式的易用性来源于三个具体的机制,每一个都精确对应前言里的一个痛点。

2.1 Tile 编程:让坐标回归坐标

痛点: offset 计算和指令参数填写复杂、易错,且与算法逻辑无关。

在 PyPTO Professional模式 里,片上数据的载体是 Tile——一个硬件感知的二维数据块。你搬运数据时,只需要说清楚一件事:这块 Tile 对应 Tensor 上的哪个坐标。

# 传统方式:手工把多维坐标摊平,再手工填 nBurst / lenBurst / srcGap / dstGap
# PyPTO Professional:
pl.load(tile_k, k, [kv_offset, 0])              # 绝对元素坐标
pl.load_tile(tile_q, q, [tile_row, tile_col])   # 或者按 Tile 块索引,框架自动乘以 tile shape
pl.load(cur_q, q, [sq_off, 0], order=[1, 0])    # order 降序 = 转置加载,DN layout

order=[1, 0] 这一个参数背后是一整套 transpose 搬运的指令参数配置。而 Tile 的 shapedtypetarget_memoryVec/Mat/Left/Right/Acc)、layoutND/NZ/ZN/DN)、valid_shape 这些信息都写在 TileType 里声明一次,之后每条指令的参数框架自动推导。

target_memory 这一个枚举,就把整个片上存储层次摊平给了你:

每一级你都能直接寻址,也都必须自己寻址——这正是「不交出硬件控制权」的具体含义。

计算侧同理,Tile 就是一等公民:

pl.matmul(qk_acc, qk_left, qk_right)                              # Cube
pl.move(qk_dst, qk_acc, acc_to_vec_mode=pl.AccToVecMode.DualModeSplitN)  # Acc → UB
pl.exp(x_exp, x_sub)                                              # Vector
pl.insert(p_mat_slot, p_f16_slot, [0, TS_HALF * sub_id])          # 子块拼接

你依然掌握着性能的方向盘:Tile 开多大、放在哪块片上内存、什么 layout,全是你说了算——addrs=0x4000 就是实打实的 UB 地址。框架接管的只是「从坐标到指令参数」这段纯机械翻译。

对于需要极致调优的场景,还有一层 Reg 编程@pl.vector_function 定义 VF 函数,用 vf.* 接口直接操作向量寄存器,数据流变成 GM → UB → Register → UB → GM,连寄存器的加载存储都归你管。抽象层次是可以逐级下探的,而不是一刀切。

2.2 基于 Tile 块的核内自动同步:告别 set_flag / wait_flag

痛点: 核内多缓冲流水的同步插入复杂、易错,写错就是死锁或踩数据。

先看传统写法的面临的难题。一个 double buffer 的 load-compute-store 流水,你需要:给每块 buffer 想好 event id → 在搬运和计算之间 set_flag(PIPE_MTE2, PIPE_V, id) → 在计算前 wait_flag(...) → 计算完再反向 set/wait 一遍防止下一轮搬运踩当前计算 → 每加一级流水,配对数量翻倍。

PyPTO Professional 的做法是:把「哪几块 Buffer 是同一条流水上轮转复用的」这个信息,直接声明在数据结构上。

# 一个 TileGroup = 同一条流水线上轮转复用的 N 块 Tile
k_l1_db = pl.make_tile_group(
    type=pl.TileType(shape=[TKV, TD], dtype=pl.DT_FP16,
                     target_memory=pl.MemorySpace.Mat, layout=pl.NZ),
    addrs=MA1,            # 起始地址你给
    mutex_ids=[2, 3])     # 两块 buffer,两个互斥标识 → double buffer

acc_db = pl.make_tile_group(..., addrs=CA0, mutex_ids=[10, 11, 12, 13])  # 想开4buffer?增加mutex_ids的数量即可

# 用的时候只有两个接口需要调用
cur_k = k_l1_db.next()      # 轮到下一块(流水推进)
cur_q = q_l1_db.current()   # 还用当前这块

然后打开开关:

@pl.jit(auto_mutex=True)

框架在解析每条 Tile 指令时,会扫描它的参数、识别出哪些参数是带 mutex_id 的 TileGroup 槽位、根据算子类型判断它落在哪条流水(MTE2 / MTE1 / V / M / FIX …),然后自动在指令前后配对生成 lock / unlock。地址重叠的不同 TileGroup(比如同一块 UB 区域的不同视图)会被识别并合并去重,避免重复加锁。

更进一步,它还懂硬件:当 matmul/store 带上 phase=Partial/Final 时,Cube 与 FixPipe 之间的握手由硬件 unit_flag 接管,框架会主动跳过 Acc 上的软件 mutex——因为那条锁不但冗余,还会占掉 M 流水 / FIX 流水的指令槽位,让 Cube 和 FixPipe 无法背靠背执行。而 L0A/L0B/L1 上的 mutex 则必须保留(unit_flag 不覆盖 MTE1→Cube 这条路径)。

这就是「框架接管苦力,但苦力干得比手写还细」的典型例子: 这类优化你手写时也能做,但需要记住十几条硬件规则并在每一处正确应用。框架只需要正确一次。

改流水级数的成本也从此变成:mutex_ids=[10, 11] 改成 mutex_ids=[10, 11, 12, 13]

2.3 基于 stage 的核间自动流水编排:排流水变得简单

痛点: Cube/Vector 融合算子的核间流水排布,是整个算子开发中最难写、最难改、最难调的部分。

这是 PyPTO Professional 最有杀伤力的能力,用 Flash Attention 来讲最清楚。

FA 的计算是四个阶段的链式依赖,而且 Cube 和 Vector 交替出场:

串行执行的话,Cube 算 QK 时 Vector 干等,Vector 算 Softmax 时 Cube 干等——利用率天然腰斩。正确做法是软件流水:让第 i+2 个 KV 块的 QK 和第 i 个块的 Softmax 并行跑起来。

手写这套流水意味着什么?循环要按流水深度展开、每个 stage 的输入要从不同深度的历史迭代里取、跨核 flag 要按 ring buffer 索引正确收发、prologue 和 epilogue 要单独处理边界。几百行,改一个流水深度就要重写一遍。

PyPTO Professional 的写法是——你只写串行版本,然后告诉框架「这四段是流水的四个 stage」:

@pl.pipeline.stage
def compute_qk(ki, sq_off, tick, q, k, cur_q_slot, k_l1_db, left_db, right_db, acc_db, qk_vec_db):
    """QK matmul(Cube)。写 qk_vec。"""
    cur_k_slot = k_l1_db.next()
    pl.load(cur_k_slot, k, [ki * TKV, 0])
    pl.move(qk_left, cur_k_slot); pl.move(qk_right, cur_q_slot)
    pl.matmul(qk_acc, qk_left, qk_right)
    pl.move(qk_vec_db.next(), qk_acc, acc_to_vec_mode=pl.AccToVecMode.DualModeSplitN)

@pl.pipeline.stage
def compute_p(...):   """Softmax(Vector)。读 qk_vec,写 p_mat。"""   ...

@pl.pipeline.stage
def compute_pv(...):  """PV matmul(Cube)。读 p_mat,写 pv_vec。"""   ...

@pl.pipeline.stage
def compute_gu(...):  """Flash update(Vector)。读 pv_vec,写 output。""" ...


@pl.jit(auto_mutex=True, pipeline=pl.pipeline.PipelineConfig(preload=2))
def fa_kernel(q, k, v, o, ...):
    ...
    # 主循环:完全串行的写法,没有任何一行同步代码
    for qi in pl.range(core_id, sq_tiles, num_cores):
        for ki in pl.range(0, skv_tiles):
            with pl.section_cube():   compute_qk(ki, ...)
            with pl.section_vector(): compute_p(ki, ...)
            with pl.section_cube():   compute_pv(ki, ...)
            with pl.section_vector(): compute_gu(ki, ...)

框架在前端做了这些事:

  1. 扫描跨核 Buffer。 找出所有配了跨核 forward/backward id 的 TileGroup(fwd_ids=[4, 5, 6]),确定每个 stage 对它们是读(R)还是写(W),以及访问发生在哪条流水上。
  2. 推导 stage 依赖与延迟。 按 stage 在循环体里的出现顺序赋予 delay(0, 1, 2, …),据此算出 ctx ring buffer 的深度(max_delay + 1)。
  3. 生成 preload 流水。 把串行循环重写成「第一个 stage 先预跑 preload 次,然后进入稳态交错」的形式,循环变量、offset 等跨迭代变化的量被自动收进 ctx 结构体并按延迟取用。
  4. 自动插跨核同步。 在每个 stage 的边界,按访问角色和流水,配对生成 set_cross_core / wait_cross_core,event id 按 ring buffer 索引自动分配。

于是,调整流水深度这件事,从「重写几百行」变成了改一个数字

pipeline=pl.pipeline.PipelineConfig(preload=3)   # 就这样

框架还贴心地准备了一个「渐进式信任」开关:

pipeline=pl.pipeline.PipelineConfig(sync_only=True)
# 不做流水变换,只在原串行循环里自动插跨核同步
# → 先确认串行版本正确,再打开完整流水

配合前面提到的 pipeline_generated.py dump,你的调试路径是完整闭合的——没有黑盒

每一步都可独立验证正确性,出问题时永远知道退回哪一步。

3. 写完了怎么使用:JIT & AOT

3.1 PyPTO Professional模式支持JIT使用

通过前端的@pl.jit装饰器,可以将kernel直接被python调用,且输入支持torch.tensor, 这是最方便接入网络的方式,不需要任何额外的文件。正如第2节中展示的那个add算子的示例一样。同时,通过@allow_in_graph以及相关的meta和schema的定义,也可以轻松的接入aclGraph。JIT方式配合磁盘缓存,只要编译一次。随着网络模型的不断变化,新增的融合算子层出不穷,JIT的方式目前正在越来越多的被接受。

3.2 PyPTO Professional模式支持二进制发布

很多 DSL 到这里就结束了——「你可以用它做实验」。PyPTO Professional 走完了最后一公里:用 PyPTO Professional 写的算子,可以像手写 Ascend C 算子一样,编成算子包二进制、通过 aclnn 接口发布到生产环境。同时kernel的代码和JIT中完全一致,无需修改任何一行

关键在于它没有另起炉灶,而是直接接入了 CANN 现有的算子编译流水:在二进制发布中,除了kernel代码的写作方式和tiling_key的交互方式不同,其余的整个编译链路上的发布件和编译产物都是相同的。PyPTO Professional 只替换了整条链路最末端的那一片叶子:kernel 源码从哪来。

  • 模板算子:一份 template<TEMPLATE_PARAMS>.cpp 编译 N 次;
  • PyPTO Professional :每个 TilingKey 折成常量,codegen 出一份专属的 kernel.cpp,逐 key 编译。

产物与手写算子完全一致,落在同样扁平的 kernel_meta 目录里:

kernel_meta/
├── my_op_mix_aic_<tilingkey>.o     # Cube 侧
├── my_op_mix_aiv_<tilingkey>.o     # Vector 侧
├── my_op.o                         # fat 链接产物
└── my_op.json                      # 含 supportInfo

支撑二进制发布的是两个编译期特化机制:

TilingKey —— 声明式的编译期分支。 用一个普通 Python 类声明维度,可选地写一个 is_valid 谓词剔除非法组合:

class FaTilingKey:
    NeedAttnMask = pl.TilingKeyField(bits=1, values=[0, 1])
    BlockM       = pl.TilingKeyField(bits=2, values=[0, 1, 2])

    def is_valid(self, key):
        need_attn_mask, block_m = key
        return not (need_attn_mask == 1 and block_m == 0)

@pl.jit(tiling_key=FaTilingKey)
def fa_kernel(...): ...

fa_kernel[stream, block_dim, {"NeedAttnMask": 1, "BlockM": 2}](...)   # JIT 侧这样用

is_valid 一处定义、两处生效:JIT 启动时拒绝非法 key,二进制发布时枚举全部组合并只编译合法的那些。字段按位打包进 64 位 key,与 Ascend C 模板 tiling key 的语义对齐。

DataType 特化。 datatype schema 让同一份 Kernel 按参数实际 dtype 编译出多份实例,dtype 从算子工程的 -DORIG_DTYPE_<PARAM> 宏自动解析——你不需要为 fp16 和 bf16 各维护一份代码。

自动产出 tiling struct 头、tilingkey 头和 infer 源文件,从「一个能跑的 .py」到「一个能进算子包的二进制」,中间过程全部自动完成。

4. 一些易用性提升的细节

  • Host 与 Device 同文件。 一个 .py 直接 python 跑,改一行立刻验证。首次调用 JIT,之后命中缓存。
  • Kernel 级调试接口。 pl.printf(编译期常量格式串)、pl.dump_data(可指定 offsets/shapes 只 dump 一个小窗口)、pl.pto_assertpl.trap——不用为了看一个中间值就去改 C++ 再重编算子工程。
  • 原生 Python 控制流。 if/elif/else 就是 if/elif/else,循环用 pl.range(core_id, n, num_cores),一行表达跨步多核切分。
  • 精确的错误诊断。 前端带 span tracker 和专门的 diagnostics 模块,报错定位到源码行列并附带修复建议,而不是「段错误」。
  • 中间产物全部落盘可读。 pipeline_generated.pykernel.cpp、tiling 头文件、.so 全在 build 目录里并排放着,随时可查。
  • 完整的算子库支持。 搬运、逐元素、比较/选择、融合乘加、矩阵乘、reduction、transpose、gather/scatter、cast/量化……以及 Reg 层的 vf.* 寄存器接口。

5. 一句话总结

PyPTO Professional 的赌注是:算子开发者应该把时间花在「切多大的块、开几级流水、Buffer 怎么摆」上,而不是花在「这个 offset 算对了吗、这个 event id 配对了吗」上。

前者是创造性的、决定性能上限的工作;后者是机械的、只决定你今晚能不能下班的工作。PyPTO Professional 把后者全部接管,同时一寸不让地把前者留给你——因为想摸到理论峰值 0.9x 以上,方向盘就必须在人手里。

如果你符合下面任意一条,那么欢迎使用PyPTO Professional,他会给你舒适的算子编程体验:

  • ✅ 手写过指令级 Kernel,被 set_flag/wait_flag 折磨过;
  • ✅ 被流水编排,核间同步插入折磨过(精度问题、卡死等);
  • ✅ 需要开发 Cube + Vector 融合算子(FA / MLA / SFA / 各种自定义融合);
  • ✅ 已有PyPTO-Tensor算子性能不满意,想要更细粒度的硬件控制;
  • ✅ 想要一条从「快速实验」直达「二进制发布」且不修改任何kernel源码的完整路径。
Logo

1331

更多推荐