Statements and Control Flow¶
Statement forms the parser accepts, and the SSA phi-node semantics behind
pl.yield_. See Python IR Syntax Specification for the
type system and expression grammar.
Statements¶
Assignment¶
Augmented assignments such as acc += ... and acc[...] += ... are not
supported. The parser reports the source statement and an explicit-assignment
hint. For matrix reductions, use matmul_acc with init_cond:
acc[t0 : t0 + R, :] = pl.matmul_acc(
acc[t0 : t0 + R, :], x_k, w_k, b_trans=True, init_cond=(k0 == 0)
)
The first K step overwrites only that window; later steps accumulate into it. Other windows retain their values. The compiler can pack equal-size row windows of a local accumulator into contiguous L0C windows; see accumulator row windows.
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 |
pl.graph("name") |
Graph |
Recordable orchestration region; the name is required and becomes the outlined function's — see OutlineGraphScopes |
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 (SIMPLER_SCOPE()). Hand-placing one requires @pl.function(auto_scope=False) (in the default auto_scope=True the compiler owns AUTO placement). See MaterializeRuntimeScopes |
See Scopes and Placement 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 thatSubmit's own fields (the launch spec belongs to the launch site, not the outlined callee). 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 default depth of 2 per active direction. |
pl.split(MODE, slot_num=N)is a deprecated alias for the slot count and warns — see ExpandMixedKernel.
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¶
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
See Also¶
- Python IR Syntax Specification — types and expressions
- Manual Dependency Primitives — opting out of auto-dep tracking
- Functions and Program Structure — function types and parameter directions