Functions and Programs¶
How a Python function becomes an IR function, which decorator to reach for, and how functions call each other.
Prerequisites: Types.
Concept¶
A decorator does not wrap your function — it parses its source. The body never
executes as Python. That single fact explains most of what follows: why closure variables
behave the way they do, why pl.yield_ is meaningful only inside one, and why an error in a kernel body is reported at parse time with a line number
rather than at call time with a traceback.
@pl.jit is how you write PyPTO kernels. Types come from the arguments at the first
call, the function specializes, and sub-functions are discovered automatically — you call
them by name and the decorator finds them. It is what examples/ uses and what the rest
of this manual uses.
You will also meet @pl.function inside @pl.program: a class where each method is one
IR function and calls between them are written self.other(...). That form is a
one-to-one transcription of the IR, and it exists mainly for writing compiler test cases,
where a test needs to state a program's exact shape without ever running it. As a user you
do not need it — the section below is there for when you
read a compiler test or a piece of printed IR.
Quickstart: an entry point and a device kernel¶
import pypto.language as pl
@pl.jit.incore
def add_kernel(
a: pl.Tensor[[128, 128], pl.FP32],
b: pl.Tensor[[128, 128], pl.FP32],
out: pl.Out[pl.Tensor[[128, 128], pl.FP32]],
):
out[:] = pl.add(a, b)
return out
@pl.jit
def entry(
a: pl.Tensor[[128, 128], pl.FP32],
b: pl.Tensor[[128, 128], pl.FP32],
out: pl.Out[pl.Tensor[[128, 128], pl.FP32]],
):
out = add_kernel(a, b, out) # sub-function discovered automatically
return out
| Line | What it does |
|---|---|
@pl.jit.incore |
Marks a device kernel — the execution plane, where operators live |
@pl.jit |
Marks the chip-level entry point — the control plane, which dispatches |
add_kernel(a, b, out) |
A plain call; the decorator discovers the callee and wires it in |
out: pl.Out[...] |
Declares the direction, which is how the compiler orders this task |
To read the IR this becomes, call entry.lower(*args) for the post-pass ir.Program, or
entry.compile(*args) and print compiled.program.as_python().
Mechanics¶
The @pl.jit family¶
Five variants, one per IR function kind, so a single program can span host, chip, and core levels:
| Decorator | IR target | Use for |
|---|---|---|
@pl.jit |
Orchestration |
Chip-level entry point that dispatches InCore work |
@pl.jit.host |
level=HOST, role=Orchestrator |
HOST entry — allocates window buffers, dispatches chip orchestrators per rank |
@pl.jit.incore |
InCore |
A device kernel (accepts level= to target a specific hierarchy level) |
@pl.jit.inline |
Inline |
Helper spliced into every call site by InlineFunctions |
@pl.jit.opaque |
Opaque |
A separate IR function that may hold orchestration loops and pl.at scopes |
@pl.jit.graph |
Graph |
A recordable orchestration fragment — the host_build_graph runtime records its task topology on the first call and replays it after, so N calls cost one graph build rather than N. Requires compiling under RuntimeKind.HOST_BUILD_GRAPH |
@pl.jit.graph has a scope form: with pl.graph("name"): marks a region in place
instead of splitting it into a function. The two compile to the same thing — the region is
outlined into a Graph function named after name — so the choice is ergonomic. Use the
decorator when the layer is already its own function; use the scope when the region is a
slice of a larger orchestration body you would rather not split up:
@pl.jit
def decode(w: pl.Tensor, hidden: pl.InOut[pl.Tensor]):
for layer in pl.range(40):
with pl.graph("decoder_layer"): # recorded once, replayed 39 times
...
return hidden
The name is required and becomes the recorded graph's identity, so keep it stable. A Graph
region cannot nest inside another Graph region, nor inside pl.at / pl.cluster /
pl.spmd — those become a single device task, whereas a Graph region records a topology of
them. All three are compile errors.
Sub-function dependencies (.incore / .inline / .opaque / .graph) are auto-discovered from
the entry's body — call them by name. The name you call is resolved in the entry's own
namespace, so an aliased import (from kernels import matmul as mm, or a plain
mm = matmul rebinding) is discovered like any other binding; the generated program still
names the function after its def. When two distinct sub-functions share that name — two
modules each defining helper, or two kernels from one factory — the second one generated
is suffixed (helper, helper__2), so both specializations survive; the entry keeps its
own name. A @pl.jit.host entry additionally discovers
@pl.jit chip-orchestration dependencies, so a full distributed program needs no
@pl.program class.
The fragment below shows only the discovery structure — the kernel bodies are elided, and the distributed types it names are covered in the distributed chapter, which is not written yet:
import pypto.language.distributed as pld
@pl.jit.inline
def reduce_step(local, peer, out): ...
@pl.jit
def chip_orch(inp: pl.Tensor, out: pl.Out[pl.Tensor],
data: pl.InOut[pld.DistributedTensor], peer: pl.Scalar[pl.INT32]):
return reduce_step(inp, peer, out) # auto-discovered sub-function
@pl.jit.host
def host_orch(
inputs: pl.Tensor[[2, 1, 256], pl.FP32],
outputs: pl.Out[pl.Tensor[[2, 1, 256], pl.FP32]],
):
data_buf = pld.alloc_window_buffer(256 * pl.FP32.get_byte())
for r in pl.range(pld.world_size()):
data = pld.window(data_buf, [1, 256], dtype=pl.FP32)
chip_orch(inputs[r], outputs[r], data, (r + 1) % pld.world_size(), device=r)
return outputs
Plain @pl.jit entries do not discover other @pl.jit entries — only .host
reaches across the chip boundary. That keeps two unrelated top-level kernels from
silently folding into one program.
@pl.jit.host rejects level= (HOST is implicit).
What a sub-function hands back keeps its shape and dtype¶
Specialization stamps every generated parameter with a concrete shape and dtype, so a tensor a sub-function returns must be traceable to one. Both conventions work, and you can mix them in the same entry:
@pl.jit.inline
def make_pair(x: pl.Tensor[[1, 8], pl.FP32]):
a = pl.create_tensor([1, 8], dtype=pl.FP32) # helper allocates its own results
b = pl.create_tensor([1, 8], dtype=pl.FP32)
with pl.at(level=pl.Level.CORE_GROUP):
a[:, :] = x[:, :]
b[:, :] = pl.mul(x[:, :], 2.0)
return a, b
@pl.jit.incore
def relu_kernel(x: pl.Tensor, out: pl.Out[pl.Tensor]): # caller allocates, kernel fills
...
@pl.jit
def entry(x: pl.Tensor[[1, 8], pl.FP32], out: pl.Out[pl.Tensor[[1, 8], pl.FP32]]):
a, b = make_pair(x) # metas read out of make_pair's own body
buf = pl.create_tensor([1, 8], dtype=pl.FP32)
mid = relu_kernel(a, buf) # mid aliases buf, so it inherits buf's meta
...
A device kernel (@pl.jit.incore) cannot allocate, so it always takes the second form —
pl.create_tensor belongs on the control plane. An @pl.jit.inline helper is spliced into
the caller, so either form is available to it.
A complete one-dimensional view of an NZ tensor also keeps inferable metadata. For
w: pl.Tensor[[256, 512], pl.INT8, pl.NZ], w_flat = pl.reshape(w, [131072])
can be passed to a helper declared with src: pl.Tensor; specialization supplies
src: pl.Tensor[[131072], pl.INT8] (ND). The reshape creates a view of the existing physical
bytes without layout conversion or copying. Every source dimension and the target length must be
static, and the target length must equal the source element count. Other NZ reshape
targets and symbolic NZ flattening remain unsupported.
An extent the specializer cannot compute statically is not by itself a problem. A
pl.create_tensor sized from a value only the device knows — pl.tensor.read(cfg, [0]),
pld.world_size() — becomes a dynamic dimension and keeps flowing, and the shared pass
pipeline judges whatever the program then does with it (loading the whole tensor as a tile,
say, gets you InitMemRef requires static shape — the same error the equivalent
@pl.program earns).
pl.reshape also preserves metadata when its target dimensions use known tensor
dimensions and integer arithmetic (+, -, *, //, and unary -). For example,
pl.reshape(src, [src.shape[0] * src.shape[1]]) keeps a dynamic extent when either
source dimension is dynamic. The consuming JIT function reads the actual extent from
the reshaped tensor's descriptor. The original reshape expression remains in IR,
and existing reshape validation is unchanged.
Calls to pl.tensor.dim support positional or keyword tensor/axis arguments
and static constant or negative axes. Unknown runtime axes remain unresolved.
Shape subscripts use nonnegative integer literal indices; use pl.tensor.dim(src, -1) for
a negative axis. Dynamic unary + remains unsupported.
Intermediate scalar aliases work as well: total = src.shape[0] * src.shape[1]
followed by flat = pl.reshape(src, [total]) preserves the dynamic extent.
Scalar calculations stay scalar: inference records immutable expression snapshots
and creates dynamic-axis symbols when recording a tensor shape. Rebinding a source
name does not change an already computed alias. Chains of simple names such as
total = half = total // 2 evaluate the right-hand side once and are lowered
to individual assignments before parsing. Static integer aliases fold normally;
unknown runtime values and invalid arithmetic remain unresolved. Aliases rebound
under conditionals or loops are handled conservatively across control-flow
boundaries, rather than assuming the value from one syntactic execution.
After a tensor variable is rebound, shape reads use its current value, including
whole-shape unpacking and results of tensor operations. Shape and dtype arguments
such as shape=src.shape and dtype=src.dtype also use the current tensor type.
Unrelated branches and loops keep existing aliases; a loop-carried alias becomes
unknown until a definite assignment establishes its value in the current iteration.
Mutually exclusive if/elif/else arms start from the same incoming scalar
bindings and tensor metadata. Conditional scalar writes remain unknown after
the branches join. Tensor metadata established identically in both arms remains
usable; conflicting or missing tensor metadata remains unknown.
Metadata keeps independent derived dimensions separate when a Python variable
is reused or a helper is called repeatedly. Use explicit assignments such as
n = n + step; augmented assignment (n += step) remains unsupported.
What does not resolve is a returned tensor whose shape the specializer cannot reach at
all: a pl.reshape sized from an unknown runtime scalar, such as pl.tensor.read(cfg, [0]),
or a result rebound through an operation the specializer does not model. That surfaces as
missing inferred tensor metadata for parameter '<name>' at the next call that consumes
it — the error names the consumer, but the fix belongs at the producer: give the producing
statement a statically inferable shape, or pass the buffer in as a pl.Out[...] parameter.
Three constraints that decide whether a jit kernel compiles¶
These are the failures new @pl.jit code hits, in the order it hits them.
1. A @pl.jit entry body cannot hold operators. It is an Orchestration function —
the control plane. Put the operators inside with pl.at(level=pl.Level.CORE_GROUP):, or
move them into a @pl.jit.incore sub-function.
@pl.jit
def bad(x: pl.Tensor[[64, 64], pl.FP32], out: pl.Out[pl.Tensor[[64, 64], pl.FP32]]):
out[:] = pl.add(x, x) # ✗ Misplaced tensor op ... should be inside InCore block
return out
@pl.jit
def good(x: pl.Tensor[[64, 64], pl.FP32], out: pl.Out[pl.Tensor[[64, 64], pl.FP32]]):
with pl.at(level=pl.Level.CORE_GROUP):
out[:] = pl.add(x, x) # ✓
return out
2. JITFunction has no as_python(). The IR does not exist until a specialization
does. Call lower(*args) for the post-pass ir.Program, or compile(*args) and read
compiled.program.as_python() for the specialized, pre-pass IR.
3. compile() takes the kernel's own arguments, not compile options. Compile-time
knobs go through config=RunConfig(...). A stray compile(skip_ptoas=True) is bound
against the kernel's signature and raises TypeError: got an unexpected keyword argument.
@pl.jit detects whether ptoas is available on its own, so skip_ptoas is not something
you need to pass.
A device kernel cannot return a scalar. The runtime's two task-argument channels are
disjoint: scalars go in by value, and only tensors come back. So a value computed on
device and needed by the caller — the orchestration function that launched the kernel, and
the host beyond it — has to travel in a tensor. That covers a scalar tucked inside a
pl.Tuple[...] return, too.
@pl.jit.incore
def bad(x: pl.Tensor[[64], pl.FP32]) -> pl.Scalar[pl.INDEX]: # ✗ no carrier for the return
...
@pl.jit.incore # ✓ hand it back in a [1] tensor
def good(x: pl.Tensor[[64], pl.FP32], n_out: pl.Out[pl.Tensor[[1], pl.INT32]]):
...
# then, in the entry body, after the launch:
n = pl.tensor.read(n_out, [0])
The same rule applies to a scalar you assign inside with pl.at(...) and read after it —
that is a kernel return in disguise. When the value only depends on things the entry body
already has (a loop variable, a scalar parameter), the compiler moves the computation out
of the scope for you; when it depends on device data, it asks you to route it through a
tensor as above.
A helper that computes a scalar for the kernel is fine — write it @pl.jit.inline
(FunctionType.Inline). It is spliced at the call site, so it is not a task and the rule
does not apply to it.
@pl.function and @pl.program¶
You reach for this form when writing a compiler test case, not when writing a kernel. It
describes the IR one-to-one: the class is the program, each method is a function, and the
call graph is written out rather than discovered. @pl.jit specializes into exactly this
shape — printing a compiled program shows you @pl.program source.
@pl.program
class Adder:
@pl.function(type=pl.FunctionType.InCore)
def add_kernel(self, a, b, out): ...
@pl.function(type=pl.FunctionType.Orchestration)
def entry(self, a, b, out):
out = self.add_kernel(a, b, out) # explicit cross-function call
return out
Every method takes self (it is stripped from the IR), and Adder becomes an
ir.Program — not a Python class you can instantiate. Adder.as_python() prints it.
type= names the plane each function belongs to:
| Function type | Plane | Typical use |
|---|---|---|
Opaque (default) |
none yet | Standalone building block; takes its plane from where it is used |
InCore |
Execution | Load / compute / store kernel |
Orchestration |
Control | Creates tensors, dispatches InCore tasks |
Inline |
none | Spliced at every call site; leaves no function behind |
A standalone @pl.function called from inside a @pl.program is added to the program as
a separate function. @pl.inline (and @pl.jit.inline) instead expand at the call site
and leave no function behind.
@pl.inline
def normalize(x: pl.Tensor[[64], pl.FP32]) -> pl.Tensor[[64], pl.FP32]:
return pl.mul(x, 2.0)
The decorated object is a pl.InlineFunction — a template the parser splices, not a
function you can call from Python.
Function attributes: pl.func_attr¶
Metadata about the function as a whole is declared with pl.func_attr({...}) as the
first statement of the body:
@pl.program
class Kernels:
@pl.function(type=pl.FunctionType.InCore)
def kernel(self, x: pl.Tensor[[64, 64], pl.FP32], w: pl.Tensor[[64, 64], pl.FP32],
out: pl.Out[pl.Tensor[[64, 64], pl.FP32]]):
pl.func_attr({"stationary": w, "split": pl.SplitMode.UP_DOWN})
...
It reads oddly for a function-level declaration to sit inside the body, so it is worth
saying why. A decorator is evaluated before the signature binds any name, so
@pl.function(attrs={"stationary": w}) cannot be written at all — w does not exist
yet. Body position places the declaration after the parameters are bound, which is what
makes an attribute that references a parameter expressible. The alternatives are a
positional index ({"stationary_param": 1}, which breaks the moment a pass reorders
parameters) or a naming convention nothing enforces.
Rules worth knowing:
| Rule | Why |
|---|---|
| Must precede every other statement | An attribute describes the whole function; it must not appear to start applying partway down a body. This also bounds what it can reference to the parameters. |
| A bare name is always a parameter | pl.func_attr({"n": k}) records the parameter k, never a same-named Python variable from the enclosing scope. Write Python constants as literals. |
| Multiple calls merge | A key declared twice is an error naming the key, so which value wins is never a matter of parse order. |
auto_scope= and external_source= stay on the decorator |
The parser reads them before it walks the body, so a body-position declaration would arrive too late to take effect. |
@pl.function(attrs={...}) is deprecated and emits a DeprecationWarning. It still
parses and behaves identically, but it can only ever carry values that reference nothing.
Printed IR always uses a non-deprecated spelling — the pl.func_attr prologue, or the
dedicated auto_scope= / external_source= keywords — so reparsing compiler output never
warns.
Splitting compile from dispatch¶
@pl.jit kernels normally fuse specialize + compile + dispatch into one kernel(*args)
call. JITFunction.compile(*sample_args) stops after compilation and hands back the
CompiledProgram — for driving ChipWorker yourself, inspecting artifacts under
compiled.output_dir, or validating codegen ahead of time.
compiled = my_kernel.compile(sample_x, sample_w, sample_out)
print("artifacts in:", compiled.output_dir)
The returned object is the same one the JIT cache holds, so a later call with the same specialization key returns the identical instance.
lower(*sample_args) stops one stage earlier: it runs the passes and returns the
post-pass ir.Program, with no code generation, no ptoas, no artifacts, and no cache
write. Use it to read lowered IR; use compile() when codegen itself is what you want to
check. Both accept config=RunConfig(...), but lower() ignores the runtime and artifact
fields. Compile options are in Compiling and the runtime surface in
Running.
specialize(*sample_args) stops one stage earlier still: entry and deps are specialized
into @pl.program source and parsed, and the pre-pass ir.Program comes back
untransformed. Reach for it only when something downstream runs the pass pipeline itself
— most of all ir.compile(program, output_dir=...), which runs passes and code
generation, so handing it lower()'s output would run the pipeline twice.
program = my_kernel.specialize(sample_x, sample_w, sample_out)
ir.compile(program, output_dir="build/out", backend_type=BackendType.Ascend910)
It takes no config=: no pass runs here, so a RunConfig would have nothing to
configure. Sample arguments may be omitted only when every tensor parameter is annotated
with full shapes — a bare pl.Tensor has none to read.
Two kernels that specialize to the same program compare equal after passes, not before: the specializer renames SSA-rebound locals (
outbecomesout_v1), and canonicalization removes the difference. Comparelower()output when asserting equivalence against a hand-written@pl.program.
Three accessors describe the signature without specializing anything: param_names (in
declaration order), output_param_names (the pl.Out[...] and pl.InOut[...] params,
also in declaration order), and __name__.
External C++ kernels¶
A hand-written C++ kernel can be called like any other function. See Integrating Hand-Written C++ Kernels.
Edge Cases¶
Fatal pitfall: verify a new
@pl.jitexample with a fullcompile(), never withlower()alone.lower()stops after the passes, so the "operators in an Orchestration body" error above never fires — the kernel appears to pass and fails only when someone runs it for real.
| Symptom | Likely cause | Fix |
|---|---|---|
Misplaced tensor op ... should be inside InCore block |
Operators directly in a @pl.jit body |
Wrap in with pl.at(level=pl.Level.CORE_GROUP): or move to @pl.jit.incore |
AttributeError: 'JITFunction' object has no attribute 'as_python' |
Printing IR that does not exist yet | f.lower(*args), or f.compile(*args) then compiled.program.as_python() |
lower() passes but compile() fails |
lower() runs no code generation |
Expected — use compile() to check codegen |
TypeError: got an unexpected keyword argument |
A compile option was passed to compile(), which binds against the kernel's signature |
Pass config=RunConfig(...) |
| A second top-level kernel is missing from the program | Plain @pl.jit does not discover other @pl.jit entries |
Use @pl.jit.host, or make the callee .incore / .opaque |
auto_scope=False rejected |
Used on .incore / .opaque |
Put it on the entry or on an .inline helper |
self missing from a @pl.program method |
Every method needs it | Add self; it is stripped from the IR |
A task cannot return a scalar |
A device kernel declares a pl.Scalar return |
Write it into a [1] tensor output and pl.tensor.read(t, [0]) after the launch |
cannot return a scalar naming a variable in a scope |
A scalar assigned inside pl.at(...) is read after it, and depends on device data |
Route it through a [1] tensor, or move the computation out of the scope |
Worked example¶
examples/utils/cross_function_calls.py — @pl.jit.inline helpers auto-discovered as
deps of a @pl.jit entry and spliced at the call site.
See Also¶
- Control Flow — loops and conditionals inside these bodies.
- Scopes and Placement —
pl.atand the other placement scopes. - Quickstart — the same decorators in a worked example.
- InlineFunctions — how
Inlinebodies are spliced. - Integrating Hand-Written C++ Kernels — calling external kernels.