Python IR Syntax Specification¶
Overview¶
Python-style syntax for PyPTO IR:
- Complete: All information needed to reconstruct IR
- Parseable: Can be parsed back into IR (see IR Parser)
- Pythonic: Follows Python style, passes most linters
- SSA-style: Uses SSA with
pl.yield_()andpl.range()
Module Structure¶
For unnamed programs: # pypto.program
Note: Module prefix is configurable (default pl, legacy ir, custom allowed).
Type System¶
Scalar Types¶
Available types:
| Category | Types |
|---|---|
| Integers | INT4, INT8, INT16, INT32, INT64 |
| Unsigned | UINT4, UINT8, UINT16, UINT32, UINT64 |
| Float | FP4, FP8, FP16, FP32 |
| Brain Float | BF16 |
| Hisilicon | HF4, HF8 |
| Boolean | BOOL |
Tensor and Tile Types¶
# Tensor (subscript notation)
a: pl.Tensor[[4, 8], pl.FP32] # Fixed shape
b: pl.Tensor[[n, m], pl.INT64] # Symbolic shape
# Tile (block in unified buffer)
t: pl.Tile[[16, 16], pl.FP16]
Memory References (MemRef)¶
# Create MemRef
addr_expr = pl.ConstInt(0x1000, pl.INT64, span)
memref = pl.MemRef(addr_expr, 1024, 0)
# Memory spaces: DDR, Vec, Mat, Left, Right, Acc
# Note: pl.Mem is a short alias for pl.MemorySpace
# Tensor with memref
tensor: pl.Tensor[[64, 128], pl.FP32, pl.MemRef(addr_expr, 8192, 0)]
# Tiles keep memory space on the tile annotation, not inside MemRef
tile: pl.Tile[[16, 16], pl.FP16, pl.MemRef(addr_expr, 512, 0), pl.Mem.Left]
Tile Views (TileView)¶
# Create TileView
valid_shape = [pl.ConstInt(16, pl.INT64, span)] * 2
stride = [pl.ConstInt(1, pl.INT64, span), pl.ConstInt(16, pl.INT64, span)]
start_offset = pl.ConstInt(0, pl.INT64, span)
tile_view = pl.TileView(valid_shape=valid_shape, stride=stride, start_offset=start_offset)
# Tile with memref and tile_view
tile: pl.Tile[
[16, 16], pl.FP16,
pl.MemRef(addr_expr, 512, 0), pl.Mem.Left,
pl.TileView(valid_shape=..., stride=..., start_offset=...)
]
Notes:
- Omitting
pl.TileView(...)does not mean "no TileView semantics". The DSL infers an implicit TileView from the tile shape and, when present, the tile memory space. - In that implicit form,
valid_shapedefaults to the tile shape. Layout/fractal defaults are also inferred from the shape / memory-space combination. - An explicit
pl.TileView()(or one that only repeats those implicit defaults) is treated as semantically equivalent to the omitted form. Parser / printer roundtrips may canonicalize both forms to the same printed syntax.
Expressions¶
Variables and Constants¶
x # Variable reference
tensor_a # Tensor variable
42 # Integer literal — INDEX-typed
3.14 # Float literal
pl.const(42, pl.INT64) # Typed integer literal (any non-INDEX dtype)
A bare integer literal is always INDEX-typed. To carry any other integer
dtype (e.g. INT64), use pl.const(value, dtype) — this is also how the
printer renders such constants so printed IR round-trips through the parser.
Inside composite shape dimensions and pure-constant arithmetic (e.g.
pl.const(32, pl.INDEX) + pl.const(32, pl.INDEX)), the printer emits typed
leaves even for INDEX so the parser rebuilds the tree verbatim instead of
constant-folding it; simplification stays the Simplify pass's job.
Closure variables: Names not found in the DSL scope are resolved from the enclosing Python scope. Supported types: int, float, bool, list, tuple, and IR expressions.
OFFSET = [0, 0]
TILE_SHAPE = [64, 64]
@pl.function
def func(t: pl.Tensor[[128, 128], pl.FP32], out: pl.Tensor[[128, 128], pl.FP32]) -> pl.Tensor[[128, 128], pl.FP32]:
a: pl.Tile[[64, 64], pl.FP32] = pl.tile.load(t, OFFSET, TILE_SHAPE) # closure vars as positional args
...
Subscript Indexing¶
Tensor and Tile subscripts use numpy/torch-style semantics:
- A scalar index removes its dimension; a slice keeps it.
- Fewer indices than
rankimplies trailing:—C[i]on a 4D tensor isC[i, :, :, :]. - Chained indexing composes —
C[i][j]is two rank-reducing views. - An all-scalar, full-rank index reads a scalar (
A[i, j]on a 2D tensor →tensor.read/tile.read).
C[i, j, k, l] # all scalar, full rank -> scalar
C[i, j] # partial, all scalar -> 64×64 view (dims 0,1 dropped)
C[i] # partial -> 64×64×64 view (dim 0 dropped)
C[i][j] # chained -> works (C[i] is 3D, then [j])
C[i:i+8, j] # mixed slice + scalar -> 8×64×64 view (dim 1 dropped)
C[i:i+8, :, :, :] # all slices -> 8×64×64×64 view
Restrictions (v1): no slice step, tile slice lower bounds must be static-foldable, no ellipsis / None / negative / advanced indexing. Tiles are physically 2D, so a tile result that would naturally be < 2D is auto-promoted to 2D ([N] → [1, N]) with a non-fatal warning — pass an explicit pl.tile.reshape if you want a different layout.
Mechanism: a non-trivial subscript lowers to tensor.slice / tile.slice with full-rank shape/offset plus a drop_dims list of the scalar-indexed axes (see the IR operator docs). The same rules apply on the assignment LHS — C[i, j] = rhs reshapes rhs back to the full-rank window before tensor.assemble (chained writes C[i][j] = rhs are not yet supported).
Binary Operations¶
| Python Operator | PyPTO IR | Category |
|---|---|---|
+ |
Add | Arithmetic |
- |
Sub | Arithmetic |
* |
Mul | Arithmetic |
// |
FloorDiv | Arithmetic |
% |
FloorMod | Arithmetic |
/ |
FloatDiv | Arithmetic |
** |
Pow | Arithmetic |
==, !=, <, <=, >, >= |
Eq, Ne, Lt, Le, Gt, Ge | Comparison |
and, or |
And, Or | Logical |
^ |
Xor | Logical |
& |
BitAnd | Bitwise |
\| |
BitOr | Bitwise |
<<, >> |
BitShiftLeft, BitShiftRight | Bitwise |
Note: and/or are parsed from Python's ast.BoolOp syntax. Chained expressions like a and b and c are folded left-to-right into And(And(a, b), c). Unlike Python, IR And/Or nodes evaluate both operands (no short-circuit semantics). The corresponding IR factory functions are ir.and_(lhs, rhs) and ir.or_(lhs, rhs).
Unary Operations and Functions¶
Function/Op Calls¶
# Explicit namespace
pl.tensor.add(a, b) # Tensor addition
pl.tile.load(t, [0, 0], [64, 64]) # Tile load
# Unified dispatch (auto-selects tensor/tile based on input type)
pl.add(a, b) # Tensor or Tile — dispatched automatically
pl.mul(tile, 2.0) # Tile + scalar → tile.muls
pl.exp(tile) # Tile → tile.exp
# Promoted ops (single-module ops accessible at pl.*)
pl.load(t, [0, 0], [64, 64]) # Promoted from block
pl.create_tensor([64], dtype=pl.FP32) # Promoted from tensor
# System operations (synchronization primitives)
pl.system.sync_src(set_pipe=pl.PipeType.MTE2, wait_pipe=pl.PipeType.V, event_id=0)
pl.system.sync_dst(set_pipe=pl.PipeType.MTE2, wait_pipe=pl.PipeType.V, event_id=0)
pl.system.bar_v() # Vector barrier
pl.system.bar_m() # Matrix barrier
pl.system.bar_all() # Global barrier
# Cross-core operations (TPUSH/TPOP protocol)
pl.tpush_to_aic(tile0, split=0, id=0) # Vector → Cube push on pipe 0
pl.tpush_to_aic(tile1, split=0, id=1) # Vector → Cube push on pipe 1
tile0 = pl.tpop_from_aiv(split=0, id=0) # Cube pops from Vector pipe 0
tile1 = pl.tpop_from_aiv(split=0, id=1) # Cube pops from Vector pipe 1
pl.tfree_to_aiv(tile0, id=0) # Release slot to Vector pipe 0
pl.tfree_to_aiv(tile1, id=1) # Release slot to Vector pipe 1
# Cross-core pipe initialization and buffer management
buf = pl.reserve_buffer(name="slot_buf", size=4096, base=pl.AUTO)
peer = pl.import_peer_buffer(name="slot_buf", peer_func="other_func")
pl.aic_initialize_pipe(pl.const(0, pl.INT32), buf, dir_mask=2, slot_size=512, id=0)
pl.aiv_initialize_pipe(pl.const(0, pl.INT32), peer, dir_mask=2, slot_size=512, id=0)
# Optional: pin the GM ring-buffer slot count (default 8 unidirectional / 4
# bidirectional) and, on a2/a3, the local slot count (must be <= slot_num).
# Size the reserved buffer yourself: a3 -> slot_size * local_slot_num,
# a5 -> slot_size * slot_num.
pl.aic_initialize_pipe(pl.const(0, pl.INT32), buf, dir_mask=2, slot_size=512, slot_num=16, local_slot_num=4)
Statements¶
Assignment¶
If Statement (SSA-style)¶
# If with both branches
if condition:
y1 = pl.yield_(value1)
else:
y1 = pl.yield_(value2)
# Multiple return values (no inline type annotations)
if condition:
y1, y2 = pl.yield_(value1, value2)
else:
y1, y2 = pl.yield_(value3, value4)
Key points:
pl.yield_()assigns to SSA phi nodes- Variables defined in yield become accessible after if
- Both branches must yield the same variables
- Type annotations cannot be used inline with tuple unpacking
For Loop (SSA-style with iter_args)¶
# Simple loop (1-3 positional args, like Python's range())
for i in pl.range(stop): # start=0, step=1
for i in pl.range(start, stop): # step=1
for i in pl.range(start, stop, step): # explicit
# Loop with iter_args (loop-carried values)
sum_init: pl.INT64 = 0
for i, (sum,) in pl.range(n, init_values=(sum_init,)):
sum = pl.yield_(sum + i)
sum_final = sum
# Parallel for loop (same 1-3 arg forms)
for i in pl.parallel(stop):
for i in pl.parallel(start, stop, step):
body_statements
Key points: Loop-carried values use pl.range() or pl.parallel() with init_values, tuple unpacking (sum,) declares iter_args, pl.yield_() updates values for next iteration, after loop iter_args contain final values. pl.parallel() produces a ForKind.Parallel loop while pl.range() produces ForKind.Sequential (default).
While Loop (SSA-style with iter_args)¶
# Natural while: condition is the while-header expression
i: pl.Scalar[pl.INT64] = 0
while i < n:
i = i + 1
# SSA form with init_values: header tuple = iter_args, first stmt is pl.cond().
# yield-LHS supplies the post-loop binding name (mirrors pl.range).
x_init: pl.Scalar[pl.INT64] = 0
for (x,) in pl.while_(init_values=(x_init,)):
pl.cond(x < n)
x_next = pl.yield_(x + 1)
# `x_next` is bound here (from the yield-LHS); `x` is loop-scoped only.
# Pre-SSA: no pl.yield_ at all; ConvertToSSA synthesizes it later.
for (x,) in pl.while_(init_values=(x_init,)):
pl.cond(x < n)
x = x + 1
# ❌ Bare pl.yield_(...) with non-empty init_values is rejected at parse time:
# for (x,) in pl.while_(init_values=(x_init,)):
# pl.cond(x < n)
# pl.yield_(x + 1) # ParserSyntaxError: requires assignment-form pl.yield_
Key points: pl.while_(init_values=(...,)) reuses the for ... in header for SSA-style loops; the first body statement must be pl.cond(<bool>). The post-loop binding name comes from the yield-LHS (x_next above), not the header tuple — header-tuple names are scoped to the loop body only. This convention is uniform with pl.range: assignment-form yield is required whenever init_values is non-empty AND the body contains a pl.yield_(...) call. Pre-SSA loops with no yield at all are still valid (last form above).
Scope Context Managers¶
| Form | Scope Kind | Notes |
|---|---|---|
pl.at(level=pl.Level.CORE_GROUP) |
InCore |
Fixed-boundary outline at CORE_GROUP |
pl.at(level=pl.Level.CORE_GROUP, optimizations=[pl.split(MODE)]) |
InCore |
InCore + cross-core split hint |
pl.at(level=pl.Level.CORE_GROUP, optimizations=[pl.cross_core_slot(slot_num=N)]) |
InCore |
InCore + cross-core pipe slot count |
pl.at(level=pl.Level.HOST) (or any non-CORE_GROUP level) |
Hierarchy |
Distributed hierarchy scope |
pl.cluster() |
Cluster |
Co-scheduled AIC+AIV group |
with pl.spmd(N) / for i in pl.spmd(N) |
Spmd (for-form wraps inner InCore) |
SPMD multi-block dispatch — see pl.spmd |
pl.spmd(N, optimizations=[pl.split(MODE)]) |
Spmd(InCore(split=MODE)) |
Split hint applies to the inner InCore (both forms) |
pl.spmd(N, optimizations=[pl.cross_core_slot(slot_num=N)]) |
Spmd(InCore(slot_num=N)) |
Slot count applies to the inner InCore (both forms); combinable with pl.split(MODE) |
pl.scope(mode=pl.ScopeMode.MANUAL) / pl.manual_scope() |
Runtime(manual=true) |
Orchestrator MANUAL scope — user manages task ordering. Allowed in either auto_scope mode (it is a dependency-semantics choice). See Manual dependency primitives |
pl.scope() |
Runtime(manual=false) |
Orchestrator AUTO scope (PTO2_SCOPE()). Hand-placing one requires @pl.function(auto_scope=False) (in the default auto_scope=True the compiler owns AUTO placement). See MaterializeRuntimeScopes |
See Language Guide for examples.
pl.spmd multi-block dispatch¶
pl.spmd(N) dispatches a kernel across N blocks. Forms:
with pl.spmd(N): ...— body is either a dispatch body calling a pre-defined InCore kernel (SpmdScopeStmt(body=<stmts>), no inner InCore wrapper) or an inline block auto-outlined into a synthetic InCore region (like the for-form, minus the auto-bound index). Decided semantically, not by statement count: a body readingpl.tile.get_block_idx()is inline and gets wrapped; otherwise it is a dispatch body and stays unwrapped, however many statements it holds. A body that neither reads the index nor dispatches aself.<kernel>(...)call is rejected. Captures no producer TaskId.- An explicit
with pl.at(<CORE_GROUP level>, ...):as the sole body statement is the InCore carrier: parsed as an ordinary nested scope, not wrapped a second time (positional or keywordlevel, with or withoutas tid/name_hint=). This is the form the printer emits forSpmd(InCore(...)), so it is what makes that IR round-trip. When the body provides a carrier,optimizations=must go on thatpl.at(...)— putting it on thepl.spmd(...)line is rejected, whether or not the carrier also carries one. - A dispatch body may launch one kernel. It is lowered via
FindFirstInnerCall, which stops at the first call, so a second dispatch would be silently dropped rather than launched; the parser rejects it instead. Hoisted temporaries and tuple projections are not dispatches and do not count. for i in pl.spmd(N): ...— loop variable binds the per-block index (pl.tile.get_block_idx()); the body is auto-outlined into a synthetic InCore region.with pl.spmd(N, deps=[...]) as tid: ...— capture form: mirrorswith pl.at(...) as tid:. Same body shapes as the plain form above, and additionally captures the dispatch's grid-wide producerpl.Scalar[pl.TASK_ID]intid(usable as adeps=edge, stored into apl.array.create(N, pl.TASK_ID), or crossed intopl.manual_scope). TaskId capture is orthogonal to the inline body — it is the only thing this form adds over the plain form. Lowers to anir.Submitwhose trailing tuple element is the grid TaskId;core_num/sync_startride on the outlinedSpmdFunction attrs. See Manual dependency primitives.out, tid = pl.spmd_submit(kernel, *args, core_num=N)— submit form: dispatches the kernel acrossNblocks and captures the dispatch's producerpl.Scalar[pl.TASK_ID](thepl.submitsibling for a pre-defined kernel). See Manual dependency primitives.
All three pl.spmd(...) scope forms also accept allow_early_resolve=True (a boolean literal; same early-dispatch opt-in as pl.submit / pl.at). It forces the dispatch to lower to an ir.Submit even without as tid and lowers to Arg::set_allow_early_resolve(true). Rejected on a pl.cluster()-nested pl.spmd (such a scope is unwrapped into the Group function and never produces a Submit, so the hint would be lost).
Optional optimizations=[...]. The entries are orthogonal and may be combined
in one list (e.g. [pl.split(MODE), pl.cross_core_slot(slot_num=4)]):
| Entry | Form | Effect |
|---|---|---|
pl.split(MODE) |
both | Sets the inner InCore's split_ field (cross-core transfer hint, consumed by ExpandMixedKernel / MemoryReuse). The with-form gains an inner InCoreScopeStmt wrapper around the call. |
pl.cross_core_slot(slot_num=N) |
both | Sets the inner InCore's slot_num attr — the slot count (ring depth) of the automatic cross-core pipe, consumed by ExpandMixedKernel. Sizes a data channel only; it does not partition work, so it coexists with pl.split_aiv regions where pl.split(...) does not. Omit to keep the PTOAS default (8 one-directional, 4 per direction bidirectional). |
pl.split(MODE, slot_num=N)is a deprecated alias for the slot count and warns — see ExpandMixedKernel.
Manual dependency primitives¶
By default the runtime auto-derives task→task dependencies from buffer
read/write overlap (the OverlapMap). The DSL exposes two orthogonal
mechanisms the user can combine:
The two mechanisms are independent. Opting a buffer / region / arg out of auto-tracking does not require declaring explicit edges, and declaring explicit edges does not require turning auto-tracking off. The final task fanin is
auto-tracked deps ∪ explicit deps— they compose, they don't substitute for each other.
Mechanism A — opt out of auto-dep tracking (3 granularities)¶
All three granularities are independent of each other. Pick the smallest unit that fits your use case; combine if needed.
| Surface | Granularity | Effect |
|---|---|---|
with pl.manual_scope(): |
per-region | Lowers to PTO2_SCOPE(PTO2ScopeMode::MANUAL). Inside, the runtime never auto-tracks; the user must declare every required ordering edge explicitly (see Mechanism B). |
pl.create_tensor([...], dtype=..., manual_dep=True) |
per-tensor lifetime | Every task that reads or writes this tensor skips OverlapMap lookup and insert for its entire lifetime, regardless of scope. Useful for scratch buffers that are managed entirely by explicit edges. |
pl.no_dep(arg) |
per-call argument | At a kernel call site, the wrapped argument's ArgDirection becomes NoDep — auto-tracking ignores that slot for this submission only. Legal regardless of whether the callee declares the slot as In, Out, or InOut: the user asserts out-of-band that there is no RaW / WaW / WaR conflict on this slot (e.g. paged-attention writes whose offset is data-dependent but disjoint by allocation protocol). No effect inside pl.manual_scope (the scope already disables auto-tracking). |
with pl.at(..., no_dep_args=[t1, t2]): |
per-arg, on a pl.at-block |
The pl.at-block analogue of pl.no_dep(arg). The outliner makes the listed tensors arguments of the synthesised kernel call; DeriveCallDirections then forces those arg slots to NoDep — same effect as wrapping the tensors with pl.no_dep(...) at an explicit call site. Each entry must be a bare tensor name visible to the enclosing scope. Same In / Out / InOut applicability as pl.no_dep(arg): a captured tensor that the scope body mutates via pl.assemble becomes InOut on the synthesised kernel, and no_dep_args= overrides it to NoDep just as it overrides In. Note: no_dep_args= takes tensors, while deps= takes TaskIds — same word "dep", different layer. |
Mechanism B — declare explicit task→task edges (deps=)¶
These surfaces all produce set_dependencies codegen; choose by producer
shape (single kernel call, outlined pl.at region, or dependency-only fan-in).
| Surface | Producer shape | Notes |
|---|---|---|
result, tid = pl.submit(kernel, *args, deps=[...], allow_early_resolve=False) |
single kernel call | The trailing tid is the producer pl.Scalar[pl.TASK_ID]. A parser construct (like pl.range), not a runtime function. allow_early_resolve=True opts this task in as a speculative early-dispatch producer (lets the scheduler pre-stage its consumers; lowers to Arg::set_allow_early_resolve(true)). Also accepts predicate=(t[i] > 0) — a dispatch predicate the scheduler evaluates at the dispatch point (see Dispatch predicate). |
result, tid = pl.spmd_submit(kernel, *args, core_num=N, sync_start=False, deps=[...]) |
single SPMD task launch | The SPMD sibling of pl.submit: dispatches the kernel across N blocks (one orchestration task → one tid). core_num is a required keyword (positive int expr); sync_start=True forces atomic launch of all blocks. Callee may be InCore / AIC / AIV / Group. Records the launch spec on Submit.core_num / Submit.sync_start. Also accepts allow_early_resolve=True (same early-dispatch opt-in as pl.submit) and predicate=(t[i] > 0) (see Dispatch predicate). |
with pl.at(level=pl.Level.CORE_GROUP, deps=[...]) as tid: |
outlined pl.at-block |
The whole block is outlined into an InCore kernel + Submit; tid captures the synthesized Submit's TaskId, usable as a dep for later pl.submit / pl.at sites. Without as tid the outliner synthesizes an unused TaskId Var — deps always travel on Submit::deps_. Also accepts allow_early_resolve=True (same early-dispatch opt-in as pl.submit); it forces the Submit shape even without as tid and lowers to Arg::set_allow_early_resolve(true). |
with pl.spmd(N, deps=[...]) as tid: |
outlined SPMD dispatch | The SPMD sibling of the pl.at ... as tid form. The inline body is auto-outlined into an InCore kernel and dispatched across N blocks; tid captures the grid-wide producer TaskId. deps= accepted only with as tid. core_num / sync_start ride on the outlined Spmd Function attrs (the lowered Submit.core_num is None); codegen reads them via the launch-function fallback. Also accepts allow_early_resolve=True (same early-dispatch opt-in as pl.submit / pl.at; valid on all three pl.spmd forms, forcing the Submit shape even without as tid) and predicate=(t[i] > 0) (see Dispatch predicate; also valid on all three forms and also forces the Submit shape). Cannot nest inside pl.cluster(). |
barrier = pl.system.task_dummy(deps=[...]) |
dependency-only barrier | Submits no kernel. The returned TaskId is a compact fan-in point for later deps=[barrier]. |
None (Python literal) |
seed / dep entry | The "no producer yet" sentinel. prev_tid = None seeds a TaskId loop iter_arg; None in deps=[None] is dropped (contributes no edge). Lowers to system.task_invalid → PTO2TaskId::invalid(). |
These surfaces work regardless of Mechanism A state. Use explicit deps in
plain auto-tracked orchestration, inside pl.manual_scope(), or with a
manual_dep=True tensor; explicit edges are added on top of auto-tracking.
The earlier "deps= only inside pl.manual_scope" restriction no longer applies.
Plain out = self.kernel(...) is fire-and-forget: it returns no task
id, and deps= is rejected on it (the parser raises, hinting "use
pl.submit"). Each deps=[...] entry must be a TaskId value: a tid
bound by a prior pl.submit(...) / pl.at(..., deps=) as tid, the result of
pl.system.task_dummy(deps=[...]), a TaskId loop iter_arg carry, a
Scalar[TASK_ID] read from a TaskId array slot (prev = tids[k]), an
Array[N, TASK_ID] from pl.array.create(N, pl.TASK_ID), or the literal
None. Tensors are not accepted in deps=[...].
# Example 1 — both mechanisms together: scope-wide opt-out + explicit edge.
@pl.function(type=pl.FunctionType.Orchestration)
def main(self, x: pl.Tensor[[64], pl.FP32],
scratch: pl.Out[pl.Tensor[[64], pl.FP32]],
out: pl.Out[pl.Tensor[[64], pl.FP32]]) -> pl.Tensor[[64], pl.FP32]:
with pl.manual_scope(): # Mechanism A: scope-wide
scratch, stage1_tid = pl.submit(self.stage1, x, scratch)
out, _ = pl.submit(self.stage2, scratch, out, deps=[stage1_tid]) # Mechanism B
return out
# Example 2 — Mechanism B alone, NO manual_scope. Auto-tracking stays on
# for everything else; the explicit edge is *added on top* of whatever
# auto-tracking decided. Note the absence of `with pl.manual_scope():`.
@pl.function(type=pl.FunctionType.Orchestration)
def main(self, x: pl.Tensor[[64], pl.FP32],
out: pl.Out[pl.Tensor[[64], pl.FP32]]) -> pl.Tensor[[64], pl.FP32]:
tmp, prep_tid = pl.submit(self.preprocess, x)
out, _ = pl.submit(self.consume, tmp, out, deps=[prep_tid])
return out
# Example 3 — pl.at-block as the producer, with deps= on a downstream block.
# `as tid` captures the synthesized outlined-Call's TaskId.
@pl.function(type=pl.FunctionType.Orchestration)
def main(self, x: pl.Tensor[[64], pl.FP32],
out: pl.Out[pl.Tensor[[64], pl.FP32]]) -> pl.Tensor[[64], pl.FP32]:
with pl.at(level=pl.Level.CORE_GROUP) as tid_a:
# body becomes an outlined InCore kernel
...
with pl.at(level=pl.Level.CORE_GROUP, deps=[tid_a]) as tid_b:
# explicit edge — runs strictly after the `tid_a` block
...
return out
# Example 4 — Mechanism A tensor-lifetime: scratch buffer opted out for its
# whole lifetime; explicit edge still pins the ordering between producer
# and consumer.
scratch = pl.create_tensor([N], dtype=pl.FP32, manual_dep=True)
scratch, prod_tid = pl.submit(self.fill, x, scratch)
out, _ = pl.submit(self.consume, scratch, out, deps=[prod_tid])
pl.submit desugars to a single ir.Submit whose return type is the flat
augmented TupleType([*<kernel return types>, ScalarType(TASK_ID)]) —
elements 0..N-1 are the kernel results, element N is the producer
TaskId. The parser writes each deps=[...] list directly into the typed
Submit::deps_ field (no plain Call ever carries manual_dep_edges —
the ManualDepsOnSubmitOnly invariant). pl.at(..., deps=) follows the same
path: the outliner reads attrs["task_id_var"] and attrs["manual_dep_edges"]
on the ScopeStmt and lifts them onto a synthesized Submit (a scope with
deps but no as tid gets a synthetic unused TaskId Var so the dispatch is
still a Submit). Codegen fills a fixed-size stack array sized to the
exact dep count and emits one params.set_dependencies(arr, count);
call per task. The runtime's Arg::set_dependencies(ptr, count) accepts a
caller-owned array of arbitrary size, so there is no per-call edge cap.
For explicit fan-in, write barrier = pl.system.task_dummy(deps=[tids]) and
then pl.submit(..., deps=[barrier]); it uses the same dependency parser,
lowers to rt_submit_dummy_task(...), returns invalid without submitting when
all deps are invalid, and coexists with automatic ExpandManualPhaseFence
barriers for profitable full-array phase fences.
pl.no_dep(arg) is an auto-scope primitive; inside pl.manual_scope it
has no effect (the whole region already skips auto-tracking).
Dispatch predicate (predicate=)¶
pl.submit / pl.spmd_submit accept an optional
predicate=(tensor[indices] <op> target). The scheduler evaluates the
comparison at the dispatch point — after the task's dependencies are
satisfied, so the value is current without an orchestration-time
wait_for_tensor_ready stall. When the comparison is false the task is
retired inline (never dispatched to a core) while still settling
fanin/fanout, so downstream consumers still unlock — it does not vanish from the
task graph. When true it dispatches normally.
The canonical use is MoE "skip empty experts": submit every expert statically,
each carrying predicate=(row_count[e] > 0) and depending on the gather
producer — the scheduler dispatches only the non-empty experts, without stalling
orchestration to read the per-expert count.
The comparison is parsed as an ordinary expression, but never evaluated.
rc[0, 0]is the usual sugar forpl.read, so the kwarg lowers to plain IR —Gt(Cast(tensor.read(rc, [0, 0])), 0)— reusing the IR's existing comparison nodes rather than any bespoke encoding. It is stored onSubmit.predicate, never in a statement position, so thetensor.readis not executed in orchestration: doing so would stall onwait_for_tensor_ready, exactly what the predicate exists to avoid. Orchestration codegen decomposes the Expr into the runtime'soperand OP targettriple, so only the shape below is accepted.
| Part | Meaning | Constraint |
|---|---|---|
tensor |
operand tensor read at the dispatch point | must be a named tensor (a parameter or a variable bound to one), subscripted to one element |
indices |
element locator into tensor |
each index an integer scalar (ConstInt or an int/index Var); one index per tensor dimension |
<op> |
comparison | one of == != > < >= <= (a single, unchained comparison) |
target |
right-hand side | an integer literal (may be negative) |
The mirrored order is accepted — 0 < rc[e] means the same as rc[e] > 0. The
IR keeps the comparison as written; orchestration codegen flips the operator so
the tensor is always the runtime's operand.
Lowers to the runtime L0TaskPredicate + Arg::set_predicate(...) in
orchestration codegen (operand → its ext_<name> reference, op →
PredicateOp::*, target verbatim; elem_size is derived by the runtime from
the tensor dtype).
Contract: the predicate operand tensor's producer must be one of the
submit's deps=, so the dispatch-point read observes the current value. Omitting
it lets the scheduler evaluate the predicate before the producer has written the
tensor, deciding from stale data.
The parser makes a best-effort spot check, not a guarantee: it tracks the
result variables a pl.submit(...) binds via tuple unpacking, and rejects a
predicate whose operand is one of them when the producing TaskId is absent from
deps=. Treat a clean parse as "no obvious mistake found", not as proof.
It does not see through, and therefore silently accepts:
| Not covered | Why |
|---|---|
rc2 = rc then rc2[0, 0] |
the alias is a fresh variable with no recorded producer |
a tensor passed as an pl.Out argument and rebound under a new name |
only the returned binding is tracked, not the argument alias |
rc3 = self.helper(rc) |
any intervening call launders the association |
res = pl.spmd_submit(...) (single-target form) |
the single-target path records nothing |
any deps= list containing an Array[N, TASK_ID] entry — including the common deps=[tids[i]] |
array entries do not name their producers individually, so the whole check is skipped for that submit |
a producer written later in the source, e.g. a loop-carried rc written by the previous iteration |
the lookup happens while parsing the predicate, so producers that follow it are not yet recorded |
Getting deps= right therefore remains the author's responsibility.
Expressiveness is fixed to tensor[indices] OP const — one comparison,
matching the runtime's single-comparison DispatchPredicate. Chained
comparisons (0 < t[i] < 8), arithmetic (t[i] % 8 == 0), boolean combination
(a[0] > 0 and b[0] > 0), and a non-literal right-hand side (t[i] > u[i]) are
all rejected at parse time; reduce anything richer to a single gate value in a
prior kernel and predicate on that.
with pl.manual_scope():
rc, g_tid = pl.spmd_submit(self.gate, rc, core_num=1) # producer of rc
out, _ = pl.spmd_submit(
self.expert, x, out, core_num=1,
deps=[g_tid], # producer is a dep
predicate=(rc[0, 0] > 0),
)
Scope: predicate= is accepted on pl.submit / pl.spmd_submit (the
direct-Submit forms) and on the with pl.spmd(...) scope form — all three
spmd spellings (plain with, with ... as tid, and for i in pl.spmd(...)).
It is not accepted on pl.at(...).
Scope form¶
The scope form takes the same expression and the same validation; it differs
only in how the predicate reaches the IR. It rides on SpmdScopeStmt.attrs
until the scope is outlined, at which point it moves onto Submit.predicate —
so the lowering, the codegen output, and the contract are identical.
with pl.spmd(1) as g_tid: # producer of rc
rc = self.gate(rc)
with pl.spmd(4, deps=[g_tid], predicate=(rc[0, 0] > 0)) as tid: # producer is a dep
out = self.expert(x, out)
Two things follow from that route:
deps=needs theas tidform.deps=is only accepted onwith pl.spmd(...) as tid:. A predicate over a tensor produced elsewhere in the same function therefore needs that form — the plainwithandforforms can only carry a predicate over a tensor with no in-function producer (typically a function parameter), which the contract check permits.- No
as tidis required otherwise. Likeallow_early_resolve=True, a predicate forces the scope to lower to aSubmit; when the scope has noas tidthe outliner synthesises an unused TaskId Var.
A cluster-nested pl.spmd is unwrapped into the Group function and never
produces a Submit, so predicate= (like allow_early_resolve=) is rejected
there at parse time rather than silently dropped.
The contract check covers scope producers too: a tensor assigned inside a
with pl.spmd(...) as tid: body is recorded as produced by that scope, so
omitting it from a later deps= is rejected. The same best-effort limits in the
table above still apply (aliases, intervening calls, Array[N, TASK_ID] deps).
pl.parallel under manual scope: array-carry fence¶
When a manual-dep edge is carried through a pl.parallel loop (i.e. the
loop's iter_arg holds the TaskId being depended on), the orchestration codegen
treats the corresponding TaskId iter_arg as an array of size equal to the
parallel loop's trip count. Each parallel iteration writes its own slot,
and downstream consumers depend on every slot (not just the
last-dispatched task). This guarantees the user-declared fence semantics
even when iters finish out of dispatch order.
Requirements for the array-carry path:
- The
pl.paralleltrip count must be a Python literal (statically known). A dynamic trip count underpl.parallelcarrying a manual dep is rejected at codegen with a "statically-known trip count" message.
with pl.manual_scope():
prev_tid = None # seed: no producer yet
for phase in pl.range(N_PHASES):
for branch in pl.parallel(N_BRANCHES): # const trip count
row = (phase * N_BRANCHES + branch) * TILE_M
out, prev_tid = pl.submit(self.kernel_stripe, data, row, 1.0, out, deps=[prev_tid])
prev_tid is rebound inside pl.parallel, so codegen lowers the carry as
a PTO2TaskId[N_BRANCHES] array. Each task in phase N+1 waits for all
N_BRANCHES tasks of phase N, not just the last-dispatched one.
Yield Statement¶
Break and Continue¶
Restrictions: Only valid when the innermost enclosing loop is sequential (pl.range) or while. Not supported when the innermost loop is pl.parallel() or pl.unroll(). A break in an inner pl.range loop nested inside an outer pl.parallel loop is valid. Note: Codegen backend support for break/continue is tracked in #448.
Compile-Time Debugging¶
pl.static_print() and pl.static_assert() are parse-time-only constructs for inspecting IR state and asserting conditions during parsing. They produce no IR.
@pl.function
def func(x: pl.Tensor[[128, 64], pl.FP16]) -> pl.Tensor[[128, 64], pl.FP16]:
pl.static_print("input:", x) # → static_print [file:line]: input: x: pl.Tensor[[128, 64], pl.FP16]
pl.static_print(f"input: {x}") # → static_print [file:line]: input: x: pl.Tensor[[128, 64], pl.FP16]
pl.static_assert(True) # passes silently
pl.static_assert(N > 32, "N too small") # checks closure variable N at parse time
return x
| Function | Purpose | On failure |
|---|---|---|
pl.static_print(*args) |
Print variable types/values to stdout | Requires ≥1 argument |
pl.static_assert(cond, msg="") |
Assert compile-time condition | Raises ParserError |
pl.dump_tag(tensor) |
Mark a tensor for selective runtime tensor dump — declarative per-tensor marker (valid in Orchestration scope, or in an Inline helper that the orch inlines — see Runtime DFX) | Raises ParserSyntaxError outside an Orchestration or Inline function, or for non-Name arguments |
Key points:
- All three are statement-only (cannot be used in expressions)
static_printaccepts variables, constants, string labels (printed as-is), and f-strings with plain{expr}placeholders (formatted as IR). Conversions (!r,!s,!a) and format specs (:...) are not supported.static_assertsupports closure variable expressions (e.g.N > 32) and IR constants; message must be a string literaldump_tagtakes one bare tensor variable name bound in the enclosing Orchestration (or Inline) scope; it is consumed at parse time and tracked by Var identity (not name) all the way to codegen. At an explicitself.kernel(...)site it records the tensor in the consuming Call'sdump_varson every subsequent consuming call; in the@pl.jit/with pl.at(level=...)style (where the dispatch is synthesised by the outline passes) it instead seeds the enclosing scope'sdump_varsand the outliner maps it onto the synthesised dispatch arg (see Runtime DFX). To list dump targets explicitly at a single task launch, use thedumps=[...]kwarg onpl.submit(...)/pl.at(...)(symmetric withdeps=)- Output appears even if parsing fails later — useful for debugging parse errors
Statement Sequences¶
Functions¶
# Single return type
def function_name(param1: pl.INT64, param2: pl.FP32) -> pl.INT64:
x: pl.INT64 = param1 + 1
return x
# Multiple return types
def function_name(x: pl.INT64) -> tuple[pl.INT64, pl.INT64]:
y: pl.INT64 = x + 1
z: pl.INT64 = x * 2
return y, z
# No return types
def function_name(x: pl.INT64):
y: pl.INT64 = x + 1
# With function type
@pl.function(type=pl.FunctionType.Orchestration)
def orchestrator(n: pl.INT64) -> pl.INT64:
return n + 1
@pl.function(type=pl.FunctionType.InCore)
def aicore_kernel(x: pl.INT64) -> pl.INT64:
return x * 2
Function Types¶
| Type | Usage | Description |
|---|---|---|
pl.FunctionType.Opaque |
Default | Unspecified function type |
pl.FunctionType.Orchestration |
Host/AICPU | Control flow and dependency analysis |
pl.FunctionType.InCore |
AICore | Sub-graph on specific AICore (unspecialized) |
pl.FunctionType.AIC |
Cube core | Cube core kernel (specialized InCore) |
pl.FunctionType.AIV |
Vector core | Vector core kernel (specialized InCore) |
pl.FunctionType.Group |
Multi-core | Co-scheduled group of AIC + AIV kernels |
When no type is specified, functions default to Opaque.
Parameter Directions¶
Parameters can have In (default), Out, or InOut directions using wrapper types:
@pl.function(type=pl.FunctionType.InCore)
def kernel(
qi: pl.Tensor[[16, 128], pl.BF16], # In (default)
output: pl.InOut[pl.Tensor[[16, 128], pl.FP32]], # InOut
result: pl.Out[pl.Tensor[[16, 128], pl.FP32]], # Out
scale: pl.Scalar[pl.FP32], # In (default)
) -> pl.Tensor[[16, 128], pl.FP32]:
...
| Direction | Wrapper | Description |
|---|---|---|
In |
None (default) | Read-only input parameter |
Out |
pl.Out[type] |
Write-only output parameter |
InOut |
pl.InOut[type] |
Read-write input/output parameter |
Constraint: Scalar parameters cannot have InOut direction (raises ParserTypeError).
Complete Example¶
Tensor Operations (Loop with iter_args)¶
# pypto.program: my_program
import pypto.language as pl
def loop_sum(n: pl.INT64) -> pl.INT64:
sum_init: pl.INT64 = 0
for i, (sum,) in pl.range(n, init_values=(sum_init,)):
sum = pl.yield_(sum + i)
return sum
Tile Operations (Tile-based computation)¶
import pypto.language as pl
@pl.program
class BlockExample:
@pl.function
def tile_add(
self,
input_a: pl.Tensor[[64, 64], pl.FP32],
input_b: pl.Tensor[[64, 64], pl.FP32],
output: pl.Tensor[[64, 64], pl.FP32],
) -> pl.Tensor[[64, 64], pl.FP32]:
tile_a: pl.Tile[[64, 64], pl.FP32] = pl.load(input_a, [0, 0], [64, 64])
tile_b: pl.Tile[[64, 64], pl.FP32] = pl.load(input_b, [0, 0], [64, 64])
tile_c: pl.Tile[[64, 64], pl.FP32] = pl.add(tile_a, tile_b)
result: pl.Tensor[[64, 64], pl.FP32] = pl.store(tile_c, [0, 0], output)
return result
SSA-Style Control Flow¶
pl.yield_() creates SSA phi nodes for if/for statements:
# If: phi node at merge point
if condition:
y1 = pl.yield_(x + 1)
else:
y1 = pl.yield_(x + 2)
# y1 = phi(x + 1, x + 2)
# For: loop-carried values via iter_args
sum_init: pl.INT64 = 0
for i, (sum,) in pl.range(10, init_values=(sum_init,)):
sum = pl.yield_(sum + i)
sum_final: pl.INT64 = sum # captures final value
Cross-Module Function Reuse¶
Functions defined outside a @pl.program class can be reused via two mechanisms.
External @pl.function Calls¶
An externally-defined @pl.function can be called by name inside @pl.program. The function is automatically added to the Program and an ir.Call(GlobalVar, args) is emitted.
@pl.function
def softmax(x: pl.Tensor[[64], pl.FP32]) -> pl.Tensor[[64], pl.FP32]:
...
@pl.program
class MyModel:
@pl.function
def main(self, x: pl.Tensor[[64], pl.FP32]) -> pl.Tensor[[64], pl.FP32]:
y: pl.Tensor[[64], pl.FP32] = softmax(x) # ir.Call(GlobalVar("softmax"), [x])
return y
Rules:
- Uses the function's
.nameas GlobalVar (aliases are transparent) - External and internal function names must not conflict
- Two different externals with the same
.nameis an error - Same external called from multiple methods is added once
@pl.inline Decorator¶
@pl.inline captures a function for statement-level inlining. No function is added to the Program — the body is expanded at each call site.
@pl.inline
def normalize(x: pl.Tensor[[64], pl.FP32]) -> pl.Tensor[[64], pl.FP32]:
result: pl.Tensor[[64], pl.FP32] = pl.mul(x, 2.0)
return result
@pl.program
class MyModel:
@pl.function
def main(self, x: pl.Tensor[[64], pl.FP32]) -> pl.Tensor[[64], pl.FP32]:
y: pl.Tensor[[64], pl.FP32] = normalize(x) # statements inlined in-place
return y
Rules:
- Argument count must match parameter list exactly
- Closure variables from the inline definition site are available
- Inline functions can be called multiple times (each expansion is independent)
- Nested inline calls are supported
Printing IR Nodes¶
Use as_python() on any IR node to get its Python representation:
print(stmt.as_python()) # "x: pl.Scalar[pl.INT64] = a + b" (default "pl" prefix)
print(stmt.as_python("ir")) # "x: ir.Scalar[ir.INT64] = a + b" (custom prefix)
Concise Mode¶
Pass concise=True to omit intermediate type annotations. Function signature types (parameters and return) are always preserved:
print(func.as_python()) # verbose (default): type on every assignment
print(func.as_python(concise=True)) # concise: omits intermediate type annotations
Verbose output:
def main(self, x: pl.Tensor[[64, 128], pl.FP32]) -> pl.Tensor[[64, 128], pl.FP16]:
y: pl.Tensor[[64, 128], pl.FP32] = pl.some_op(x)
result: pl.Tensor[[64, 128], pl.FP16] = pl.cast(y, pl.FP16)
return result
Concise output:
def main(self, x: pl.Tensor[[64, 128], pl.FP32]) -> pl.Tensor[[64, 128], pl.FP16]:
y = pl.some_op(x)
result = pl.cast(y, pl.FP16)
return result
The free function ir.python_print(node) is also available and supports the same parameters.
References¶
- IR Overview - Core IR structures
- IR Parser - Parsing Python syntax back to IR
- Operator Registration - Op system and type inference