Getting Started with PyPTO¶
What is PyPTO?¶
PyPTO is a Python-based kernel programming framework for Ascend NPUs. You write compute kernels in Python using the pypto.language module, and PyPTO compiles them into optimized device code.
All kernel code uses the pl namespace. The ir module provides compilation and IR utilities.
Hello World: Vector Add (Tensor Level)¶
The simplest kernel operates on Tensors — high-level arrays in DDR memory. PyPTO automatically handles data movement and memory allocation.
import pypto.language as pl
from pypto import ir
@pl.function
def vector_add(
a: pl.Tensor[[64], pl.FP32],
b: pl.Tensor[[64], pl.FP32],
) -> pl.Tensor[[64], pl.FP32]:
result: pl.Tensor[[64], pl.FP32] = pl.add(a, b)
return result
Line by line:
| Line | What it does |
|---|---|
@pl.function |
Parses the Python function body into PyPTO IR |
a: pl.Tensor[[64], pl.FP32] |
Input: 1D tensor, 64 elements, 32-bit float |
pl.add(a, b) |
Element-wise addition (dispatches to tensor add) |
return result |
The function returns a tensor |
After decoration, vector_add is an ir.Function object — not a Python callable. Print the IR:
Tile Kernel: Load-Compute-Store¶
For hardware-level control, use Tiles — on-chip memory buffers. You explicitly load data from DDR, compute on-chip, and store results back.
@pl.function
def vector_add_tile(
a: pl.Tensor[[64], pl.FP32],
b: pl.Tensor[[64], pl.FP32],
output: pl.Out[pl.Tensor[[64], pl.FP32]],
) -> pl.Tensor[[64], pl.FP32]:
# Load from DDR → on-chip (Vec memory)
a_tile: pl.Tile[[64], pl.FP32] = pl.load(a, [0], [64])
b_tile: pl.Tile[[64], pl.FP32] = pl.load(b, [0], [64])
# Compute on-chip
result: pl.Tile[[64], pl.FP32] = pl.add(a_tile, b_tile)
# Store back to DDR
out: pl.Tensor[[64], pl.FP32] = pl.store(result, [0], output)
return out
Key differences from the Tensor version:
| Concept | Tensor level | Tile level |
|---|---|---|
| Data location | DDR (automatic) | Explicit load/store |
| Type | pl.Tensor |
pl.Tile (on-chip) |
| Output parameter | Return value | pl.Out[pl.Tensor[...]] |
| Memory control | Compiler decides | You decide |
pl.load(tensor, offsets, shapes) copies a region from a DDR Tensor into an on-chip Tile.
pl.store(tile, offsets, output_tensor) copies a Tile back to DDR.
Loops and Accumulation¶
Use pl.range() for loops. With init_values, you get loop-carried values (accumulators):
@pl.function
def sum_elements(
a: pl.Tensor[[64], pl.FP32],
) -> pl.Tensor[[1], pl.FP32]:
zero: pl.Tensor[[1], pl.FP32] = pl.create_tensor([1], dtype=pl.FP32)
for i, (acc,) in pl.range(64, init_values=(zero,)):
elem: pl.Tensor[[1], pl.FP32] = pl.slice(a, [1], [i])
new_acc: pl.Tensor[[1], pl.FP32] = pl.add(acc, elem)
acc_out: pl.Tensor[[1], pl.FP32] = pl.yield_(new_acc)
return acc_out
How init_values works:
init_values=(zero,)— initial value for the accumulatorfor i, (acc,)—iis the loop variable,accis the current accumulatorpl.yield_(new_acc)— passesnew_accas the accumulator to the next iteration- After the loop,
acc_outholds the final value
Simple loops without accumulators:
for i in pl.range(10):
# i goes from 0 to 9
...
for i in pl.range(0, 100, 2):
# i goes from 0 to 98, step 2
...
Multi-Function Programs¶
Use @pl.program to group multiple functions that call each other:
@pl.program
class VectorAddProgram:
@pl.function(type=pl.FunctionType.InCore)
def kernel_add(
self,
a: pl.Tensor[[128, 128], pl.FP32],
b: pl.Tensor[[128, 128], pl.FP32],
output: pl.Out[pl.Tensor[[128, 128], pl.FP32]],
) -> pl.Tensor[[128, 128], pl.FP32]:
a_tile: pl.Tile[[128, 128], pl.FP32] = pl.load(a, [0, 0], [128, 128])
b_tile: pl.Tile[[128, 128], pl.FP32] = pl.load(b, [0, 0], [128, 128])
result: pl.Tile[[128, 128], pl.FP32] = pl.add(a_tile, b_tile)
out: pl.Tensor[[128, 128], pl.FP32] = pl.store(
result, [0, 0], output
)
return out
@pl.function(type=pl.FunctionType.Orchestration)
def main(
self,
a: pl.Tensor[[128, 128], pl.FP32],
b: pl.Tensor[[128, 128], pl.FP32],
) -> pl.Tensor[[128, 128], pl.FP32]:
c: pl.Tensor[[128, 128], pl.FP32] = pl.create_tensor(
[128, 128], dtype=pl.FP32
)
c = self.kernel_add(a, b, c)
return c
Key concepts:
| Concept | Description |
|---|---|
@pl.program |
Decorates a class → becomes an ir.Program |
self |
Required first parameter; stripped from IR |
self.kernel_add(...) |
Cross-function call within the program |
FunctionType.InCore |
Runs on AICore (compute kernel) |
FunctionType.Orchestration |
Runs on host (task graph coordinator) |
Function types:
Opaque(default) — no specific execution contextInCore— AICore compute kernel; uses load/store for data movementOrchestration— host-side function that creates tensors and dispatches InCore tasks
Compiling¶
Compile a program to generate device code:
from pypto.backend import BackendType
output_dir = ir.compile(
VectorAddProgram,
strategy=ir.OptimizationStrategy.Default,
dump_passes=True,
backend_type=BackendType.Ascend910B,
)
print(f"Generated code in: {output_dir}")
ir.compile() parameters:
| Parameter | Default | Description |
|---|---|---|
program |
(required) | The ir.Program to compile |
output_dir |
None → <base>/<name>_<timestamp> |
Directory for codegen, reports, and (when dumping) pass IR. <base> is the PYPTO_PROG_BUILD_DIR env var, or build_output if unset |
strategy |
OptimizationStrategy.Default |
Pass pipeline preset (Default or DebugTileOptimization) |
dump_passes |
True |
When True, write IR snapshots under output_dir/passes_dump/ after each pass |
backend_type |
BackendType.Ascend910B |
Target hardware for passes and codegen (Ascend910B or Ascend950) |
skip_ptoas |
False |
If True, skip the ptoas step and emit raw .pto (MLIR) instead of compiled C++ wrappers |
verification_level |
None |
Optional ir.VerificationLevel override; None uses defaults (or PYPTO_VERIFY_LEVEL) |
DebugTileOptimization is a debug-only shortcut for inspecting the PTO tile
pipeline. Prefer Default unless you are explicitly debugging strategy
selection or pass ordering.
Inspect IR without compiling:
# Print a single function
print(vector_add.as_python())
# Print an entire program
print(VectorAddProgram.as_python())
# Print without intermediate type annotations (concise mode)
print(vector_add.as_python(concise=True))
Reusing weights on the worker (DeviceTensor)¶
When the same large tensor is consumed by many kernel invocations — e.g. a
weight matrix used across batches of a forward pass — uploading it on every
call wastes bandwidth. ChipWorker.alloc_tensor allocates persistent device
memory and returns a DeviceTensor handle that CompiledProgram accepts in
place of a torch.Tensor. The runtime treats the buffer as already resident
and skips both H2D and D2H copies for that argument.
import torch
from pypto import ir
from pypto.runtime import ChipWorker, RunConfig
compiled = ir.compile(MyKernel)
with ChipWorker(config=RunConfig(platform="a2a3sim")) as w:
weight = w.alloc_tensor((1024, 4096), torch.float16, init=host_weight)
for batch in batches:
out = torch.empty(batch.shape[0], 4096, dtype=torch.float16)
compiled(batch, weight, out)
w.free_tensor(weight)
Caveats¶
- A
DeviceTensoris never copied back to the host. If a kernel writes to one, callw.copy_from(host_ptr, t.data_ptr, t.nbytes)on the same ChipWorker instance to read the result. - Free the handle with
w.free_tensor(t)before the ChipWorker is closed, otherwise the memory leaks for the lifetime of the ChipWorker. - Only the ChipWorker instance that allocated the buffer can use it.
Explicit dispatch (worker.run, worker.register)¶
The implicit with ChipWorker(): compiled(...) pattern shown above relies on
ContextVar discovery: any compiled(...) call inside the block finds the
active worker and reuses it. That's convenient for scripts but leaves the
worker hidden — library code that needs to pass the worker around, or a
serving runtime that wants to pre-register many kernels, should drive
dispatch explicitly:
worker = ChipWorker(config=RunConfig(platform="a2a3sim"))
try:
out = worker.run(compiled, a, b) # one-shot
handle = worker.register(compiled) # eager registration
for _ in range(1000): # hot loop, no cid lookup
handle(a, b, out)
finally:
worker.close() # cids + DeviceTensors released
worker.register(compiled) triggers compile_and_assemble + simpler
register immediately, so configuration errors surface here rather than on
first dispatch. The returned RegistrationHandle is callable, supports
with handle: for scoped cleanup, and exposes handle.unregister() for
explicit early release. Multiple register calls for the same
compiled.chip_callable return aliases of the same cid; the underlying
simpler unregister runs once, in worker.close().
For @pl.jit kernels, the same flow works via JITFunction.compile():
@pl.jit
def add_kernel(a, b, out): ...
compiled = add_kernel.compile(sample_a, sample_b, sample_out)
handle = worker.register(compiled)
for batch in stream:
handle(batch.a, batch.b, batch.out)
compile() only reads each tensor argument's shape/dtype — contents are never
touched — so the sample tensors are pure metadata carriers.
Compiling from the signature (no sample tensors)¶
When every tensor parameter is fully annotated with its shape and dtype,
compile() can read the whole shape contract straight from the signature — call
it with no positional arguments and skip the sample tensors entirely:
HIDDEN, VOCAB = 4096, 152064
M = pl.dynamic("M") # runtime-dynamic dim
@pl.jit
def prefill_fwd(
hidden: pl.Tensor[[M, HIDDEN], pl.BF16],
lm_head: pl.Tensor[[VOCAB, HIDDEN], pl.BF16],
out: pl.Out[pl.Tensor[[M, VOCAB], pl.FP32]],
): ...
# No torch.empty(...) dummies — shapes come from the annotations.
compiled = prefill_fwd.compile()
This is the ergonomic path for kernels with large signatures: the shape contract
lives in one place (the signature) instead of being re-declared as a list of
throwaway torch.empty(...) buffers. Details:
- Static dims (
HIDDEN,VOCAB, …) come from the annotation constants. - Dynamic dims (
pl.dynamic/bind_dynamic) need no value — the compiled artifact is extent-independent, andcompile()shares one cache entry with an equivalentcompile(sample_tensors)call. - Scalar parameters carry no value in the signature — pass them as keyword
args, e.g.
kernel.compile(num_tokens=128). - A bare
pl.Tensorparameter (no shape) has nothing to read and raises a clear error; give it a fullpl.Tensor[[...], dtype]annotation, or fall back tocompile(*sample_tensors).
See examples/runtime/explicit_dispatch.py for three end-to-end patterns
(inference service, training loop, register/dispatch overhead check).
Reading per-launch timing¶
worker.run / handle(...) return tensor outputs only and no longer surface
a per-launch timing object. The runtime emits per-run host/device timing as
[STRACE] log markers (simpler PR #1177, on by default under
SIMPLER_DFX); parse them with simpler's strace_timing /
device_log_timing tools rather than reading a return value. For per-task
device timing, enable the L2 swimlane DFX (RunConfig(enable_l2_swimlane=True))
and read l2_swimlane_records.json.
Benchmarking (benchmark)¶
For the register-once + rounds pattern, pypto.runtime.benchmark owns the loop
and aggregation: it registers compiled once and dispatches rounds cheap
launches (no per-round register/load), reads each launch's [STRACE] markers,
and returns a BenchmarkStats:
from pypto.runtime import benchmark
stats = benchmark(compiled, [a, b, c], rounds=100, warmup=3,
platform="a2a3", device_id=0)
print(stats.device_wall_us_median, stats.device_wall_us_min, len(stats.samples))
Pass platform= / device_id= for the common case, or a full RunConfig via
config= for aicpu_thread_num control (not both). Aggregates are
exposed under both device_wall_us_* and shorter device_us_* names, with
samples aliasing the raw device_wall_us list.
benchmark reads timing from the [STRACE] markers (simpler PR #1177): it
raises the runtime log level to v9 for the worker's lifetime and captures
stderr at the fd level around the measured loop, so stderr emitted during the
loop is diverted into a temp file rather than shown live. device_wall_us is a
real on-NPU wall for L2 single-chip runs (see the L3 note below for distributed
programs); it is 0 on runtimes built without SIMPLER_HOST_STRACE or on *sim
platforms (check stats.all_zero_device).
Beyond the aggregates, each measured launch keeps its full [STRACE] span tree
on stats.invocations (a list of TraceInvocation; warmup excluded). Render it
with branch connectors — one launch, or averaged across all launches with a
per-node spread (spread is "stdev" (default), "minmax", "both", or
"none"):
stats.print_tree(launch=0) # one launch's nested span tree
stats.print_mean_tree(spread="both") # mean per node, with ±stdev and [min..max]
mean of 20 launches (warmup 5 excluded); each node: mean ±stdev [min..max]:
simpler_run 71784.1us ±6797.5 [66482.4..89832.6]
|- bind 27943.6us ±4163.7 [24836.7..37713.3]
|- runner_run 3030.8us ±184.4 [2822.3..3694.7]
| `- device_wall [dev] 2005.2us ±74.6 [1875.1..2173.2]
| `- graph_build [dev] 1634.8us ±64.6 [1490.2..1777.6]
`- validate 40697.7us ±3063.5 [38606.3..48200.6]
Nesting is reconstructed from the dotted span names, so device-domain spans
(...device_wall.*, tagged [dev]) nest under their host parent. Each node is a
wall-clock window, not a partition: children may overlap (e.g. orch/sched
run concurrently) or sit in a different clock domain (runner_run host wall vs
device_wall on-NPU), so child durations need not sum to the parent. Drill into
raw spans via stats.invocations[i].by_name()[<name>].dur_us.
benchmark also accepts an L3 DistributedCompiledProgram (opened via
compiled.prepare()): pass shared-memory host tensors (or DeviceTensors) and
omit platform= / device_id= (the device set is fixed at compile time via
distributed_config). L3 has no single DAG-level device wall, so timing is
folded from the per-rank chip-child markers into per-round samples — the headline
device_wall_us[k] is the max across ranks of each rank's summed dispatch device
walls. Query the four metrics uniformly:
stats.per_round("device" | "host" | "effective" | "union") # -> [one value per round]
stats.per_rank("device" | "host" | "effective") # -> {pid: [one per round]}
Both views aggregate per rank per round: each entry sums that rank's
dispatches within the round (a card runs its dispatches serially), so they are
per-round-per-rank figures, not per-dispatch. When a rank runs exactly one
dispatch per round the sum is that single dispatch's value; for the individual
dispatches in any case, read stats.rounds_dispatches[k][pid] (see below).
effective is the orch∪sched on-device window (per-card L2 Effective); union
is the cross-rank host-timeline window (captures start skew — host-domain, so it
includes dispatch overhead). The navigable round -> rank -> [dispatch] grid is
stats.rounds_dispatches, where each TraceInvocation exposes .task (callable
id), .device_wall_us, .host_wall_us, .effective_us. A pure-device
cross-rank end-to-end wall is not recoverable from the markers today. If the
dispatch shape is non-deterministic, stats.fallback_flattened is set and the
per-rank / union views are empty.
Distributed (L3+) programs¶
L3+ distributed programs returned by ir.compile (a DistributedCompiledProgram)
accept DeviceTensor arguments the same way as CompiledProgram: pass a
worker-resident buffer in place of a torch.Tensor and the runtime skips H2D/D2H
for that argument. This is the recommended way to keep large static weights
resident across the many dispatches of a generate loop.
import torch
from pypto.runtime import DeviceTensor
compiled = ir.compile(MyDistributedProgram) # returns DistributedCompiledProgram
weight = DeviceTensor(dev_ptr, (1024, 4096), torch.float16) # caller-managed buffer
compiled(x, weight, out) # weight: no H2D/D2H copy
Reusing setup across dispatches (prepare())¶
compiled(*args) runs the full distributed setup (per-chip assembly, simpler
Worker construction + fork) on every call. For a resident service that
dispatches the same program many times (e.g. a generate loop), call
compiled.prepare() once to get a DistributedWorker handle that runs setup
once and dispatches many times on the same worker.
Per-call IO buffers are shared-memory host tensors allocated before prepare() and reused in
place, so child writes are visible to the parent. Large static weights may remain ordinary contiguous
CPU tensors when registered through DistributedWorker(..., inherited_host_tensors=[...]) before
fork. Registration retains their storage only as a read-only H2D source for rt.alloc_tensor and
rt.alloc_stacked_tensor; registered tensors cannot be passed directly to a dispatch. Call
release_inherited_host_tensor_refs() after the final upload to drop the runtime's parent-process
references. Existing forked child mappings remain until the worker closes.
from pypto.runtime import DistributedWorker
compiled = ir.compile(MyDistributedProgram)
# shared-memory host buffers — allocated BEFORE prepare()
host_x = torch.zeros((seq, 4096), dtype=torch.float16).share_memory_()
host_out = torch.zeros((seq, 4096), dtype=torch.float16).share_memory_()
host_weight = load_weight().contiguous() # immutable ordinary CPU tensor
with DistributedWorker(compiled, inherited_host_tensors=[host_weight]) as rt:
weight = rt.alloc_tensor(host_weight.shape, host_weight.dtype, init=host_weight)
rt.release_inherited_host_tensor_refs() # drop the runtime's parent-process Host reference
del host_weight # drop the caller's final parent-process reference
for step in generate_steps:
host_x.copy_(next_input(step)) # refresh input in place
rt(host_x, weight, host_out) # host shm IO + resident weight
consume(host_out) # read output directly
rt.free_tensor(weight)
# rt.close() runs on exit
Sharding a weight across cards (alloc_stacked_tensor)¶
When a HOST orchestrator slices a [B, N, M] weight along its leading dimension
and dispatches a per-rank child — the canonical
for r in range(world_size): child(x[r], device=r) — passing the whole host
tensor re-uploads each x[r] slice to its card on every dispatch. To upload
each shard once and keep it resident on its card, build a
StackedDeviceTensor with rt.alloc_stacked_tensor:
host_w = load_weight().contiguous() # [B, N, M], B == world_size
host_a = torch.zeros((B, N, M), dtype=...).share_memory_()
host_out = torch.zeros((B, N, M), dtype=...).share_memory_()
with DistributedWorker(compiled, inherited_host_tensors=[host_w]) as rt:
w = rt.alloc_stacked_tensor(host_w) # shard i uploaded to card i, once
rt.release_inherited_host_tensor_refs()
del host_w
for step in steps:
host_a.copy_(next_input(step))
rt(host_a, w, host_out) # x[r] resolves to the resident shard r
consume(host_out)
rt.free_stacked_tensor(w)
Internally each shard host_w[i] becomes a worker-resident DeviceTensor, so the
generated x[r] indexing skips the H2D upload (child_memory). Shards are
auto-freed on close() if not released earlier via free_stacked_tensor.
Like a single DeviceTensor, a StackedDeviceTensor is never copied back
automatically. To read the current device contents of every shard back to the
host in one call — e.g. a resident KV cache at the end of a step — use
rt.copy_stacked_from(w, host_out), the read-back symmetric of
alloc_stacked_tensor. host_out is filled in place (host_out[i] receives
shard i) and must be a CPU, contiguous, shared-memory
[B, *tail] tensor matching the stack's shape and dtype, allocated before
prepare() (call .share_memory_()): the D2H copy runs in the forked chip worker,
which can only write host memory it inherited at fork.
The leading dimension is the shard dimension and B must equal the number of
cards the program dispatches to. By default shard i lands on worker i
(matching device=r). If the program uses a non-identity placement — a
permutation or a subset of cards (e.g. device=2*r, or literal device=1 /
device=0) — pass the matching worker_ids, where worker_ids[i] is the worker
the program submits x[i]'s task to:
# orchestrator dispatches x[0] to card 1 and x[1] to card 0
w = rt.alloc_stacked_tensor(host_w, worker_ids=[1, 0])
worker_ids must be distinct and within [0, world_size); a mismatch with the
program's device= would leave a shard on the wrong card and read garbage.
rt.alloc_tensor(..., worker_id=r) similarly accepts a non-default worker_id
to place a single resident DeviceTensor on any card (pass the same worker_id
to free_tensor).
Dispatching several programs on one worker (multi-program)¶
Serving needs prefill and decode as separate HOST programs that share one L3
worker and one device-resident KV cache. Pass a list of compatible
DistributedCompiledProgram objects to DistributedWorker, or equivalently
prefill.prepare(extra_compiled=[decode]) — they are prepared once on the same
worker, and rt.run(compiled, *args) selects which one to dispatch. Programs
must agree on platform, runtime, and device ids. In multi-program mode the
rt(*args) shortcut is disabled (the target is ambiguous) — always dispatch via
rt.run(...). A worker-resident DeviceTensor (e.g. the KV cache) stays valid
across dispatches from either program.
A runnable end-to-end skeleton is in
examples/runtime/multi_program_kv_cache.py.
from pypto.runtime import DistributedWorker, RunConfig
cfg = RunConfig(platform="a2a3", distributed_config=dc)
prefill_c = prefill.compile(host_prompt, kv_sample, config=cfg) # @pl.jit.host kernels:
decode_c = decode.compile(host_token, kv_sample, host_logits, config=cfg) # compile, no dispatch
with DistributedWorker([prefill_c, decode_c]) as rt: # one worker, one fork
kv_cache = rt.alloc_tensor(kv_shape, torch.float16) # resident across both
rt.run(prefill_c, host_prompt, kv_cache) # writes the KV cache
for _ in range(max_new_tokens):
rt.run(decode_c, host_token, kv_cache, host_logits) # reads/updates it
What's Next¶
- Language Guide — complete reference for types, operations, control flow, memory, and compilation
- Operation Reference — lookup tables for every
pl.*operation