Skip to content

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_() and pl.range()

Module Structure

# pypto.program: program_name
import pypto.language as pl

For unnamed programs: # pypto.program

Note: Module prefix is configurable (default pl, legacy ir, custom allowed).

Type System

Scalar Types

x: pl.INT64
y: pl.FP32
z: pl.BOOL

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_shape defaults 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 rank implies trailing :C[i] on a 4D tensor is C[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

-x              # Neg
~x              # BitNot
not x           # Not
abs(x)          # Abs
min(a, b)       # Min
max(a, b)       # Max

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

x: pl.INT64 = expr
y: pl.Tensor[[4], pl.FP32] = tensor_op(a)

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 reading pl.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 a self.<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 keyword level, with or without as tid / name_hint=). This is the form the printer emits for Spmd(InCore(...)), so it is what makes that IR round-trip. When the body provides a carrier, optimizations= must go on that pl.at(...) — putting it on the pl.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: mirrors with pl.at(...) as tid:. Same body shapes as the plain form above, and additionally captures the dispatch's grid-wide producer pl.Scalar[pl.TASK_ID] in tid (usable as a deps= edge, stored into a pl.array.create(N, pl.TASK_ID), or crossed into pl.manual_scope). TaskId capture is orthogonal to the inline body — it is the only thing this form adds over the plain form. Lowers to an ir.Submit whose trailing tuple element is the grid TaskId; core_num / sync_start ride on the outlined Spmd Function attrs. See Manual dependency primitives.
  • out, tid = pl.spmd_submit(kernel, *args, core_num=N)submit form: dispatches the kernel across N blocks and captures the dispatch's producer pl.Scalar[pl.TASK_ID] (the pl.submit sibling 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_invalidPTO2TaskId::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 for pl.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 on Submit.predicate, never in a statement position, so the tensor.read is not executed in orchestration: doing so would stall on wait_for_tensor_ready, exactly what the predicate exists to avoid. Orchestration codegen decomposes the Expr into the runtime's operand OP target triple, 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, opPredicateOp::*, 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 the as tid form. deps= is only accepted on with pl.spmd(...) as tid:. A predicate over a tensor produced elsewhere in the same function therefore needs that form — the plain with and for forms can only carry a predicate over a tensor with no in-function producer (typically a function parameter), which the contract check permits.
  • No as tid is required otherwise. Like allow_early_resolve=True, a predicate forces the scope to lower to a Submit; when the scope has no as tid the 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.parallel trip count must be a Python literal (statically known). A dynamic trip count under pl.parallel carrying 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

yield            # No values
yield x          # Single value
yield x, y       # Multiple values

Break and Continue

break              # Exit innermost loop
continue           # Skip to next iteration

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_print accepts 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_assert supports closure variable expressions (e.g. N > 32) and IR constants; message must be a string literal
  • dump_tag takes 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 explicit self.kernel(...) site it records the tensor in the consuming Call's dump_vars on 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's dump_vars and the outliner maps it onto the synthesised dispatch arg (see Runtime DFX). To list dump targets explicitly at a single task launch, use the dumps=[...] kwarg on pl.submit(...) / pl.at(...) (symmetric with deps=)
  • Output appears even if parsing fails later — useful for debugging parse errors

Statement Sequences

stmt1            # Natural Python sequencing
stmt2
stmt3

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 .name as GlobalVar (aliases are transparent)
  • External and internal function names must not conflict
  • Two different externals with the same .name is 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