跳转至

类型

PyPTO 程序里每个值都带有类型,说明它住在哪里、元素有多宽。写对注解,就是在告诉编译器该分配什么、允许做什么。

前置编程模型 § 内存层次

Concept

一条类型注解里编码了三件事,值得分开看 —— 它们的失败方式不同。

值住在哪里。 pl.Tensor 在 DDR,pl.Tile 是片上缓冲区,pl.Scalar 是寄存器宽度的值。这不是提示:在执行面上做张量操作、在控制面上做 tile 操作,都会被拒绝,而容器类型就是编译器判断的依据。

元素有多宽。 dtype 常量(pl.FP16pl.INT32 …)命名一种硬件元素格式。混用是合法的,但从不隐式:没有类型提升,宽度不同的地方必须写 pl.cast

调用方可以怎么用它。 参数方向 —— In(默认)、pl.Out[...]pl.InOut[...] —— 是签名的一部分,不是约定。编译器从方向推导任务依赖,所以方向声明错了得到的是一张错的依赖图,而不是一个编译错误。

Shape 默认是静态的,在解析期检查。pl.dynamic() 让某一维退出这种检查,代价是编译器本可以从这个维度推出的一切。

Quickstart:读懂一个签名

import pypto.language as pl
import torch
from pypto.runtime import RunConfig

CFG = RunConfig(platform="__PLATFORM__")
torch.manual_seed(0)
M = pl.dynamic("M")                       # an axis whose extent varies at run time


@pl.jit.incore
def scale_rows(
    x: pl.Tensor[[M, 128], pl.FP16],                    # In (default): read-only, DDR
    acc: pl.InOut[pl.Tensor[[M, 128], pl.FP32]],        # read-write, DDR
    out: pl.Out[pl.Tensor[[M, 128], pl.FP32]],          # write-only, DDR
    factor: pl.Scalar[pl.FP32],                         # scalar, passed by value
):
    tx = pl.load(x, [0, 0], [64, 128])
    scaled = pl.mul(pl.cast(tx, pl.FP32), factor)
    acc = pl.store(pl.add(pl.load(acc, [0, 0], [64, 128]), scaled), [0, 0], acc)
    out = pl.store(scaled, [0, 0], out)
    return acc, out


@pl.jit
def apply_scale(
    x: pl.Tensor[[64, 128], pl.FP16],
    acc: pl.InOut[pl.Tensor[[64, 128], pl.FP32]],
    out: pl.Out[pl.Tensor[[64, 128], pl.FP32]],
):
    acc, out = scale_rows(x, acc, out, 2.0)             # the dynamic axis binds to 64 here
    return acc, out


x = torch.randn(64, 128, dtype=torch.float16)
acc = torch.randn(64, 128, dtype=torch.float32)
out = torch.zeros(64, 128, dtype=torch.float32)
acc_before = acc.clone()

apply_scale(x, acc, out, config=CFG)

scaled = x.float() * 2.0
torch.testing.assert_close(out, scaled, rtol=1e-2, atol=1e-2)               # Out was written
torch.testing.assert_close(acc, acc_before + scaled, rtol=1e-2, atol=1e-2)  # InOut was read *and* written
元素 读作
pl.Tensor[[M, 128], pl.FP16] 二维 DDR 数组,M 行(运行期值),128 列,半精度
pl.InOut[...] kernel 既读又写 —— 编译器会把它同时排在此前的写者和读者之后
pl.Out[...] kernel 只写。在写入前读一个 Out 参数读到的是未定义内存
pl.Scalar[pl.FP32] 单个值,不是缓冲区
M = pl.dynamic("M") 该维编译期未知,每次启动时绑定

Mechanics

数据类型

常量 位宽 说明
pl.BOOL 1
pl.INT4 / pl.UINT4 4
pl.INT8 / pl.UINT8 8
pl.INT16 / pl.UINT16 16
pl.INT32 / pl.UINT32 32
pl.INT64 / pl.UINT64 64
pl.FP16 16 IEEE 半精度
pl.BF16 16 Brain float
pl.FP32 32 IEEE 单精度
pl.FP4 4 打包的 MXFP4 E2M1×2
pl.FP8E4M3FN / pl.FP8E5M2 8 MXFP8 数据格式
pl.FP8E8M0 8 MX 块缩放指数
pl.HF4 / pl.HF8 4 / 8 海思浮点格式
pl.INDEX 64 索引运算 —— 循环变量、维度
pl.TASK_ID 已派发任务的生产者句柄

dtype.get_byte() 返回向上取整后的可按字节寻址元素大小。对于按字节寻址的 dtype,只要字节数是算出来的而不是写死的字面量,就用它。4-bit buffer 不能使用 logical_elements * dtype.get_byte():PyPTO 会把所有语义 4-bit dtype 按每字节两个逻辑元素打包,物理大小为 ceil(logical_elements / 2)

nbytes = 256 * pl.FP32.get_byte()          # 1024, not 256

PyPTO IR 中的 FP4 shape 是以 nibble 计数的逻辑 shape,valid_shape 也使用相同单位。Torch/runtime 边界上的 torch.float4_e2m1fn_x2 使用物理 x2 carrier shape:末维的每个元素是一字节、承载两个逻辑 FP4。JIT 在入口展开末维,compiled-call metadata 与 orchestration allocation 将末维除以二;TensorTypeTileType 不保存单独的 storage_shape。Packed FP4 的逻辑末维必须是正偶数,静态 allocation/view shape 同样受此约束,动态宽度会在换算前检查。4-bit slice 起点必须落在字节边界;线性 nibble offset 为奇数时会直接报错。

4-bit 端到端执行按后端做能力检查。Ascend950 支持 pl.FP4INT4UINT4HF4 虽使用统一存储计数,但 in-core codegen 会拒绝。Ascend910B/A2A3 会拒绝所有 4-bit in-core dtype,因为它只有孤立的 FP16↔INT4 转换,没有配套的 packed load/store carrier ABI。

容器类型

类型 住在 写法
pl.Tensor[[shape], dtype] DDR x: pl.Tensor[[64, 128], pl.FP32]
pl.Tile[[shape], dtype] 片上缓冲区(默认 Vec) t: pl.Tile[[64, 64], pl.FP32]
pl.Scalar[dtype] 值,不是缓冲区 s: pl.Scalar[pl.FP32]
pl.Array[extent, dtype] 核内数组 a: pl.Array[16, pl.INT32]
pl.Tuple[T1, T2] 多值返回注解

pl.TaskIdpl.Scalar[pl.TASK_ID] 的便捷别名。

pl.Array 通常是创建出来的而不是注解出来的 —— 数组不跨函数边界,所以注解形式很少见。更新是函数式的:它产生一个新的数组值并重绑该名字,因此循环内的数组赋值和其他携带值一样是一个携带值。

arr = pl.array.create(16, pl.INT32)
arr[i] = value          # array.update_element — functional, rebinds arr
x = arr[i]              # array.get_element

布局

pl.Tensor 注解请写运行期行主序 shape,不要写布局标记。 布局是 IR 内部的事,pass 会从实际产生 / 消费各个视图的算子推导出来。

b: pl.Tensor[[N, K], pl.FP32]              # ✅ source shape, no marker

只写布局的简写 pl.Tensor[..., pl.DN] 不被支持:它会抛 ParserTypeError。矩阵乘需要转置操作数时,给 pl.matmula_trans=True / b_trans=True,或在使用处用 pl.transpose(x, -2, -1) 导出转置视图。对产生 DN 的算子做切片或 reshape,会自动继承 DN。

pl.ND 是默认的行主序布局,不需要写出来。pl.NZ 断言该张量在全局内存中的字节已经按 PTO 原生 NZ 分形序存放,于是 matmul 权重载入可以跳过在线 ND→NZ 转换。它是对现有字节的断言,不是转换请求:你写的 shape 和切片保持逻辑形式,编译器负责推导分块后的物理描述符。目前要求 dtype 为整字节、张量形状静态且分形对齐(shape[-2] % 16 == 0shape[-1] % (256 / dtype 位宽) == 0)、逻辑秩为 2 或 3[R, C][B, R, C],因为底层 NZ 描述符只有一个 batch 槽位),并作为 matmul 操作数读取;其余情形一律报错。

当一个张量的行不连续时 —— 大缓冲区里的一个窗口、外部传进来的跨步切片 —— 用 pl.TensorView 描述它,把 stride 显式写出来,而不是留给推断:

view = pl.TensorView(stride=[1024, 1], layout=pl.TensorLayout.ND, valid_shape=[16, 64])

只要给了 stridevalid_shapepad 三者之一,layout= 就是必填的。pl.TensorLayout 是这些布局常量所属的枚举 —— pl.ND 就是 pl.TensorLayout.ND

剩下两个布局常量是 pl.MX_A_ZZpl.MX_B_NN。它们标注 Ascend950 上 MX(microscaling)操作数的 GM scale 张量 —— MX_A_ZZ 对应左/A 侧 scale pack,MX_B_NN 对应右/B 侧 —— 使 Mat 到 scale 的 pl.move 能校验源布局,而不是把不兼容的数据按字节拷进 LeftScale / RightScale。这是唯一一处要求pl.Tensor 注解上写布局标记、而非不建议写的场景。MX 的 pl.load 可省略 target_memory;Python API 会选择 pl.Mem.Matmatmul_mx 操作数放置会插入所需 move。常规 MX 子视图(slicereshapetransposereinterpret_view)与 MX remote_load 会被拒绝。例外:FP8E8M0 的 pl.tensor.view 可在 packed ND backing 与 MX_A_ZZ / MX_B_NN 之间建立逻辑 rank-2 alias(layout=mx_*;PTOAS v0.60 负责物理打包)。矩阵乘本身是 pl.matmul_mx 及其 _acc / _bias 变体,每个操作数各接一块数据 tile 和一块 scale tile。进入算子的两块 data tile 必须都是 FP8E4M3FN。支持的 FP4 输入形式仅为左侧 FP4×右侧 FP8,并且必须在 matmul_mx 前显式写 pl.cast(fp4_tile, pl.FP8E4M3FN);A5 的 cast legalization pass 会将其展开为 FP4→BF16→FP32→FP8E4M3FN。原生 FP4×FP4 与反向 FP8×FP4 均不支持。独立 pl.quant_mx(本版本仅 MXFP8,以 group_axis 对齐 PTOAS grpAxis)已开放;在 Ascend950 上可与 pl.matmul_mx 共用一个 InCore mixed task,生成的 data 与 scale 直接经 V2C 传递,scale 会生成 Vec→Mat→scale memory 路径。

动态 shape

pl.dynamic(name) 用来标注编译时未知、且每次启动都可能不同的轴 —— 随请求变化的 batch、解码过程中不断增长的序列长度。该维的尺寸成为运行期的值,因此一份编译产物服务这个轴的所有取值:动态维在 JIT 缓存 key 里折叠为 None,尺寸变化不会触发重编译。

N = pl.dynamic("N")
TILE = 32                       # the physical tile the kernel moves per call


@pl.jit.incore
def rows(x: pl.Tensor[[N, 64], pl.FP32], out: pl.Out[pl.Tensor[[N, 64], pl.FP32]]):
    out = pl.store(pl.mul(pl.load(x, [0, 0], [TILE, 64]), 2.0), [0, 0], out)
    return out


@pl.jit
def drive(x: pl.Tensor[[N, 64], pl.FP32], out: pl.Out[pl.Tensor[[N, 64], pl.FP32]]):
    return rows(x, out)         # the entry is dynamic too, so both extents share it


# Two extents through one program: the dynamic dim collapses to None in the JIT
# cache key, so the second call is not a recompile.
for extent in (TILE, 3 * TILE):
    x = torch.randn(extent, 64, dtype=torch.float32)
    out = torch.zeros(extent, 64, dtype=torch.float32)
    drive(x, out, config=CFG)
    torch.testing.assert_close(out[:TILE], x[:TILE] * 2.0, rtol=1e-4, atol=1e-4)

入口必须保持 dynamic 才成立。给它一个具体形状会把程序钉死在一个 extent 上;而在 dynamic 形状的张量上做编排级运算(而不是交给 InCore kernel)会更早失败 —— 失败在 InitMemRef,它需要常量维。上面这个 kernel 搬的是固定的 TILE;要覆盖更大输入的全部,用 控制流 里的分块循环。

同一个 DynVar 对象在多处注解中使用时指的是同一维 —— 复用这个对象,不要在表示同一个值时再造一个同名的。

确实固定的维度就保持静态。静态尺寸是编译器能据以规划的数字 —— 分块选择、展开因子、静态边界检查 —— 动态维则把这个信息藏了起来。

参数方向

方向 语法 编译器据此断定
In(默认) x: pl.Tensor[...] 只读。排在生产者之后
Out x: pl.Out[pl.Tensor[...]] 只写不读。排在此前的读者与写者之后
InOut x: pl.InOut[pl.Tensor[...]] 都有。与一切触碰它的任务之间都有序

方向是编译器用来给任务之间定序的依据。把一个 InOut 缓冲区声明成 Out,等于告诉运行时"在本任务写它之前不需要等任何人" —— 这是一个竞态,不是一条诊断。

边界情况

致命陷阱: 把字节数写成元素个数会静默欠分配。pld.alloc_window_buffer(256) 预留的是 256 字节 —— 只够 64 个 FP32,不是 256 个。任何非字面量尺寸都必须写成 n * pl.<DTYPE>.get_byte()。没有任何告警;症状是前 64 个元素之后的数据被破坏。

症状 可能原因 修复
报 DN layout-only shorthand 的 ParserTypeError pl.Tensor[..., pl.DN] —— 已移除,它把两套坐标系压进了一条注解 写源 shape、不带标记;在使用处用 pl.transpose(x, -2, -1) 导出 DN;或让它从产生 DN 的算子经切片/reshape 继承
只有两个任务重叠时结果才出错 读写缓冲区声明成了 InOut 而非 InOut 按 kernel 实际行为声明方向
Out 参数读到垃圾 Out 承诺的是先写后读 若此前内容有意义,改用 pl.InOut[...]
本以为会隐式提升,却要求 pl.cast 没有隐式提升 补上 cast;多跳类型对见 LegalizeTileCast
两个本应相同的维度被当成互相独立 调了两次 pl.dynamic("M") 只创建一次 DynVar 并复用该对象

并非每个 pl.cast 都是一条指令。一对 (src, dst) 是映射到单条硬件 pto.tcvt 还是展开成一条链,取决于目标架构:INT32 -> FP16 在 Ascend910B 上是一条指令,在 Ascend950 上会降为 INT32 -> FP32 -> FP16。每一跳花费一次 tcvt;当中间类型比源类型更窄时,结果可能与直接舍入的转换相差目标类型的 1 ULP。这是预期行为,不是缺陷 —— 各架构的对照表见 LegalizeTileCast

TensorTile 输入,pl.cast 还接受仅关键字参数 saturation_mode"on" / "off",或 1 / 0)。"on" 把超出目标值域的结果钳制到该值域;"off" 保留目标平台的非饱和转换,其溢出行为由架构定义。当目标类型是整数时,默认值是 "on" —— 没有任何标准规定"转换到整数时溢出"应当产生什么,在两者之间"意外得到钳制"更安全,而且在 A2/A3 上它正是硬件原生支持的转换。浮点目标类型则保持原样:IEEE 规定窄化溢出产生无穷,除非你显式要求 "on",PyPTO 与之保持一致。多跳 cast 只把该模式作用于最后一跳,即真正到达你所指定 dtype 的那一跳。

See Also

  • 函数与程序 —— 这些注解出现在哪里,以及签名对调用方意味着什么。
  • 内存与数据搬运 —— 在这些类型所命名的空间之间搬运数据。
  • 算子 —— 哪些算子接受 Tensor、哪些接受 Tile
  • IR 类型 —— 这些注解所构建的 IR 层类型系统。
  • LegalizeTileCast —— 分架构的 cast 展开及其精度后果。