Types¶
Every value in a PyPTO program carries a type that says where it lives and how wide its elements are. Getting the annotation right is how you tell the compiler what to allocate and what it is allowed to do.
Prerequisites: Programming Model § memory hierarchy.
Concept¶
Three things are encoded in a type annotation, and it is worth separating them because they fail differently.
Where the value lives. pl.Tensor is in DDR, pl.Tile is an on-chip buffer,
pl.Scalar is a register-width value. This is not a hint — a tensor operation on the
execution plane and a tile operation on the control plane are both rejected, and the
container type is how the compiler tells which is which.
How wide the elements are. The dtype constants (pl.FP16, pl.INT32, …) name a
hardware element format. Mixing them is legal but never implicit: there is no promotion,
so a pl.cast is required wherever the widths differ.
How the caller may use it. Parameter direction — In (the default), pl.Out[...],
pl.InOut[...] — is part of the signature, not a convention. The compiler derives task
dependencies from directions, so a mis-declared direction produces a wrong dependency
graph rather than a compile error.
Shapes are static by default and checked at parse time. pl.dynamic() opts a dimension
out of that, at the cost of everything the compiler could have concluded from knowing it.
Quickstart: reading a signature¶
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
| Element | Reads as |
|---|---|
pl.Tensor[[M, 128], pl.FP16] |
2D DDR array, M rows (runtime value), 128 columns, half precision |
pl.InOut[...] |
The kernel both reads and writes it — the compiler orders it against both earlier writers and earlier readers |
pl.Out[...] |
The kernel only writes it. Reading an Out parameter before writing it reads undefined memory |
pl.Scalar[pl.FP32] |
A single value, not a buffer |
M = pl.dynamic("M") |
The dimension is unknown at compile time and bound per launch |
Mechanics¶
Data types¶
| Constant | Bits | Notes |
|---|---|---|
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 half |
pl.BF16 |
16 | Brain float |
pl.FP32 |
32 | IEEE single |
pl.FP4 |
4 | Packed MXFP4 E2M1×2 |
pl.FP8E4M3FN / pl.FP8E5M2 |
8 | MXFP8 data formats |
pl.FP8E8M0 |
8 | MX block-scale exponent |
pl.HF4 / pl.HF8 |
4 / 8 | Hisilicon float formats |
pl.INDEX |
64 | Index arithmetic — loop variables, dimensions |
pl.TASK_ID |
— | Producer handle for a launched task |
dtype.get_byte() returns the byte-addressable element size, rounded up to one byte.
Use it for byte-addressable dtypes whenever a byte count is computed rather than written
as a literal. Do not use logical_elements * dtype.get_byte() for a 4-bit buffer: PyPTO
packs every semantic 4-bit dtype two logical elements per byte, so the physical size is
ceil(logical_elements / 2).
FP4 shapes inside PyPTO IR are logical nibble shapes, and valid_shape uses the same
logical units. At the Torch/runtime boundary, torch.float4_e2m1fn_x2 uses a physical x2
carrier shape: its last dimension contains one byte per two logical FP4 values. JIT expands
that last dimension on entry, while compiled-call metadata and orchestration allocations
contract it by two; no separate storage_shape is stored in TensorType or TileType. Packed
FP4 requires a positive even logical last dimension, including static allocation and view
shapes; dynamic widths are checked before conversion. A 4-bit slice origin must land on a byte
boundary, so an odd linear nibble offset is rejected.
End-to-end 4-bit execution is backend-gated. Ascend950 supports pl.FP4; INT4, UINT4,
and HF4 remain storage-accounted but are rejected by in-core codegen. Ascend910B/A2A3
rejects every 4-bit in-core dtype because its isolated FP16↔INT4 conversion has no matching
packed load/store carrier ABI.
Container types¶
| Type | Lives in | Written as |
|---|---|---|
pl.Tensor[[shape], dtype] |
DDR | x: pl.Tensor[[64, 128], pl.FP32] |
pl.Tile[[shape], dtype] |
On-chip buffer (Vec by default) | t: pl.Tile[[64, 64], pl.FP32] |
pl.Scalar[dtype] |
Value, not a buffer | s: pl.Scalar[pl.FP32] |
pl.Array[extent, dtype] |
On-core array | a: pl.Array[16, pl.INT32] |
pl.Tuple[T1, T2] |
— | Multi-value return annotation |
pl.TaskId is a convenience alias for pl.Scalar[pl.TASK_ID].
pl.Array is normally created rather than annotated — arrays do not cross function
boundaries, so the annotation form is rare. The update is functional — it produces a new
array value and rebinds the name, so an array assignment inside a loop is a carried value
like any other.
arr = pl.array.create(16, pl.INT32)
arr[i] = value # array.update_element — functional, rebinds arr
x = arr[i] # array.get_element
Layouts¶
Write pl.Tensor annotations with the runtime row-major shape and no layout marker.
Layout is an IR-internal concern; passes derive it from the operations that produce and
consume each view.
The layout-only shorthand pl.Tensor[..., pl.DN] is not supported: it raises
ParserTypeError. For a transposed matmul operand, pass a_trans=True / b_trans=True to
pl.matmul, or derive the transposed view at the use site with pl.transpose(x, -2, -1).
A slice or reshape of a DN-producing operation inherits DN automatically.
pl.ND is the default row-major layout and never needs writing. pl.NZ asserts that the
tensor's bytes in global memory are already stored in PTO-native NZ fractal order, so a
matmul weight load can skip the online ND→NZ conversion. It is an assertion about existing
bytes, not a request to convert: the shape and slicing you write stay logical, and the
compiler derives the blocked physical descriptor. It currently requires a statically shaped,
fractal-aligned tensor with a whole-byte dtype (shape[-2] % 16 == 0,
shape[-1] % (256 / dtype bits) == 0) of logical rank 2 or 3 — [R, C] or [B, R, C],
since the underlying NZ descriptor has exactly one batch slot — read
into a matmul operand; anything else is rejected with a diagnostic.
When a tensor's rows are not contiguous — a window into a larger buffer, a strided slice
handed in from outside — describe it with pl.TensorView, which makes the strides explicit
instead of leaving them to be inferred:
layout= is required whenever any of stride, valid_shape, or pad is given.
pl.TensorLayout is the enum those layout constants come from — pl.ND is
pl.TensorLayout.ND.
pl.MX_A_ZZ and pl.MX_B_NN are the two remaining layout constants. They tag the GM
scale tensor of an MX (microscaling) operand on Ascend950 — MX_A_ZZ for the left/A
scale pack, MX_B_NN for the right/B one — so that a Mat-to-scale pl.move can check the
source layout instead of byte-copying incompatible data into LeftScale / RightScale.
They are the one case where a layout marker on a pl.Tensor annotation is required rather
than discouraged. An MX pl.load may omit target_memory; the Python API selects
pl.Mem.Mat, and matmul_mx operand placement inserts the required moves. Ordinary MX
subviews (slice, reshape, transpose, reinterpret_view) and MX remote_load are
rejected. Exception: FP8E8M0 pl.tensor.view may alias packed ND
backing to MX_A_ZZ / MX_B_NN as a logical rank-2 view (layout=mx_*; PTOAS v0.60 packs
physically). The matmul itself is pl.matmul_mx and its _acc /
_bias variants, which take a data tile and a scale tile per operand. Both data tiles reaching
the op must be FP8E4M3FN. The supported FP4-input form is a left FP4 operand multiplied by a
right FP8 operand: write pl.cast(fp4_tile, pl.FP8E4M3FN) before matmul_mx. On A5 the cast
legalization pass expands that request to FP4→BF16→FP32→FP8E4M3FN. Native FP4×FP4 and the
reverse FP8×FP4 form are not supported. Standalone pl.quant_mx (MXFP8-only in this release, with
group_axis matching PTOAS grpAxis) is available. On Ascend950 it can share one InCore mixed
task with matmul_mx; the generated data and scale cross directly over V2C, with a generated
Vec-to-Mat-to-scale-memory path for the scale.
Dynamic shapes¶
pl.dynamic(name) marks an axis whose extent is not known when the kernel is compiled
and may differ from one launch to the next — a batch that varies with the request, a
sequence length that grows as decoding proceeds. The extent becomes a run-time value, so
one compiled program serves every size that axis takes: dynamic dimensions collapse to
None in the JIT cache key, and changing the extent does not trigger a recompile.
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)
The entry has to stay dynamic for that to hold. Giving it a concrete shape pins the program
to one extent, and doing orchestration-level work on a dynamic-shaped tensor (rather than
handing it to an InCore kernel) fails earlier still, in InitMemRef, which needs a constant
dim. The kernel above moves a fixed TILE; covering the whole of a larger input is the
chunking loop from Control Flow.
The same DynVar object used in several annotations refers to the same dimension — reuse
the object, do not create a second one with the same name if you mean the same value.
Keep a dimension static when it really is fixed. A static extent is a number the compiler can plan around — tiling choices, unroll factors, static bound checks — and a dynamic one withholds it.
Parameter directions¶
| Direction | Syntax | The compiler concludes |
|---|---|---|
| In (default) | x: pl.Tensor[...] |
Read-only. Orders after producers |
| Out | x: pl.Out[pl.Tensor[...]] |
Written, not read. Orders after prior readers and writers |
| InOut | x: pl.InOut[pl.Tensor[...]] |
Both. Orders against everything touching it |
Directions are what the compiler reads to order tasks against each other. Declaring an
InOut buffer as Out tells the runtime nothing needs to finish before this task writes
it — which is a race, not a diagnostic.
Edge Cases¶
Fatal pitfall: a byte count written as an element count silently under-allocates.
pld.alloc_window_buffer(256)reserves 256 bytes — room for 64 FP32 values, not 256. Any non-literal size must be spelledn * pl.<DTYPE>.get_byte(). Nothing warns; the symptom is corrupted data past the first 64 elements.
| Symptom | Likely cause | Fix |
|---|---|---|
ParserTypeError about the DN layout-only shorthand |
pl.Tensor[..., pl.DN] — removed, it forced two coordinate systems onto one annotation |
Write the source shape with no marker; derive DN at the use site with pl.transpose(x, -2, -1); or inherit it through a slice/reshape of a DN-producing op |
| Results wrong only when two tasks overlap | A read-write buffer declared In or Out instead of InOut |
Declare the direction the kernel actually performs |
Reading an Out parameter returns garbage |
Out promises write-before-read |
Use pl.InOut[...] if the prior contents matter |
pl.cast where you expected implicit promotion |
There is no implicit promotion | Insert the cast; check LegalizeTileCast for multi-hop pairs |
| Two dimensions that should match are treated as independent | Two separate pl.dynamic("M") calls |
Create the DynVar once and reuse the object |
Not every pl.cast is one instruction. Whether a (src, dst) pair maps to a single
hardware pto.tcvt or expands into a chain depends on the target: INT32 -> FP16 is one
instruction on Ascend910B and lowers to INT32 -> FP32 -> FP16 on Ascend950. Each hop
costs a tcvt, and where an intermediate is narrower than the source the result can
differ from a directly rounded conversion by one ULP of the destination. This is expected
behaviour, not a defect — see
LegalizeTileCast for the per-architecture
tables.
pl.cast also takes a keyword-only saturation_mode ("on" / "off", or 1 / 0) for
Tensor and Tile inputs. "on" clamps an out-of-range result to the destination range;
"off" keeps the target's non-saturating conversion, whose overflow behaviour is
architecture-defined. "on" is the default when the destination is an integer type —
nothing standard fixes what an overflowing conversion to an integer produces, clamping is
the safer thing to get by accident, and on A2/A3 it is also the conversion the hardware
performs natively. A float destination is left alone: IEEE says an out-of-range
narrowing yields an infinity, and PyPTO matches that unless you ask for "on". A
multi-hop cast applies the mode to its final hop, which is the one that reaches the dtype
you named.
See Also¶
- Functions and Programs — where these annotations appear, and what a signature means to the caller.
- Memory and Data Movement — moving data between the spaces these types name.
- Operations — which operators accept
TensorversusTile. - IR Types — the IR-level type system these annotations build.
- LegalizeTileCast — per-architecture cast expansion and its precision consequences.