Skip to content

ppy

This page lists the signatures in the ppy package. The guide shows how to use them.

Importing ppy is cheap. It installs the .ppy import hook and exposes directives and annotation markers. It never initializes LLVM, loads compiler services, or starts background processes.

Each namespace a program writes against is a reference implementation under CPython that gives the same answer as the compiled one.

Directives

PPY directive objects and the inert decorators that record them.

pure = _flexible('pure') module-attribute

native = _flexible('native') module-attribute

jit = _flexible('jit') module-attribute

specialize = _flexible('specialize') module-attribute

dynamic = _Dynamic() module-attribute

parallel = _flexible('parallel') module-attribute

inline = _flexible('inline') module-attribute

noinline = _flexible('noinline') module-attribute

fastmath = _flexible('fastmath') module-attribute

reflective = _flexible('reflective') module-attribute

jax = _flexible('jax') module-attribute

Directive(name, options=None)

A recorded PPY directive. Carries no runtime behavior.

opt(level)

Select a per-function optimization level from 0 through 3.

attach(obj, directive)

Record a directive on obj and return obj unchanged.

The object is never wrapped: identity, descriptor behavior, coroutine behavior, and call behavior are all preserved (spec 6.1).

directives_of(obj)

Markers

PPY annotation markers: fixed-width numerics, containers, and refinements.

Every marker is an ordinary typing.Annotated alias, so a .ppy module keeps working under plain CPython and under any Python-aware editor.

IntWidth(bits, signed)

Bases: _Meta

Fixed-width integer representation request and range contract.

FloatWidth(bits)

Bases: _Meta

Explicit floating-point precision contract.

FloatFormat(name)

Bases: _Meta

A named floating-point format whose width alone is ambiguous.

TensorSpec(dtype, shape)

Bases: _Meta

The framework-neutral element dtype and shape of a tensor annotation.

Range(low, high)

Bases: _Meta

Refinement: low <= value <= high.

Length(size)

Bases: _Meta

Refinement: len(value) == size.

NoAlias

Bases: _Meta

Caller obligation: this argument does not alias any other argument.

Owned

Bases: _Ownership

Owned[T]: the callee takes the value and may keep, store, or return it.

Borrowed

Bases: _Ownership

Borrowed[T]: the callee reads the value for the call and no longer; it may not return it, store it where it outlives the call, or mutate it.

Mut

Bases: _Ownership

Mut[T]: a borrow the callee may write through, for the call and no longer.

Shape(*dims)

Bases: _Meta

Refinement: array shape, with str entries naming symbolic dimensions.

DType(name)

Bases: _Meta

Refinement: the array's element type, named as the library spells it.

Contiguous

Bases: _Meta

Refinement: the buffer is C-contiguous.

Array

Array[T, N]: fixed-length homogeneous value container (tuple-like).

Vector

Vector[T]: dynamic-length homogeneous mutable container (list-like).

Buffer

Buffer[T]: contiguous borrowed buffer view (buffer protocol).

Tensor

Framework-neutral tensor annotation: Tensor[dtype, shape].

Input and buffers

Typed input: lines the way input() reads them, tokens the way a scanner does (spec 28).

Two readers over one standard input, with one grammar between them:

line = ppy.input[str]()              # one line, the newline removed, as input() gives it
n = ppy.input[int]()                 # one line, read as int(input()) reads it
a, b = ppy.input[tuple[int, int]]()  # one line split into exactly two fields

word = ppy.scan[str]()               # one whitespace-delimited token, lines irrelevant
k = ppy.scan[int]()                  # one integer token
values = ppy.scan[Buffer[int]](n)    # n integer tokens, straight into a buffer

ppy.input is line-oriented and means what the builtin input() means, so a converted program reads what it read before. ppy.scan is the scanner: whitespace and newlines are the same thing to it, as they are to scanf, and reading goes into memory rather than through a Python object per field, which is what takes a million numbers quickly. ppy.read_ints and ppy.read_token are the scanner's low-level forms over a buffer the caller already has.

The scanner is C compiled once and cached (ppy_runtime.scanner, the same text a standalone binary links); without a C compiler the pure-Python fallback below reads exactly the same grammar, and the only difference is speed. Both own file descriptor 0 and buffer it, so a program that reads through them must not also read input() or sys.stdin.

reader_available()

Whether the compiled scanner is in use rather than the Python fallback.

read_ints(buffer)

Fill buffer with integer tokens from standard input.

Returns how many were read, which is fewer than len(buffer) only when the input ran out. A token that is not an integer -- anything but an optional sign and digits, or a value outside 64 bits -- raises ValueError naming it, with the tokens before it already stored. The buffer must be a writable contiguous buffer of 64-bit integers; array.array("q", ...) is the usual one.

This reads file descriptor 0 directly, so a program that calls it must not also read input() or sys.stdin.

read_token(buffer)

Read one whitespace-delimited token into buffer, one byte per slot.

The buffer may hold bytes (array.array("b", ...)) or 64-bit integers (array.array("q", ...)); the wider one is what a native kernel indexes, since Buffer[int] is 64-bit. Returns how many slots were written. The buffer's length is the capacity the caller chose: a longer token is cut there, and the rest of it is consumed and dropped. ppy.scan[str]() is the form with no such limit.

Allocating a buffer, in the one spelling every path understands.

ppy.buffer[int](n) is array.array("q", bytes(8 * n)) under CPython and a zeroed native allocation in a standalone build, where there is no array.array to make. Writing it this way is what lets the same source run both places.

buffer = _TypedBuffer() module-attribute

Collections

Collections that compile: Vec, Deque, Heap, MaxHeap, LinkedList, HashMap, HashSet, TreeMap, and TreeSet.

from ppy import Deque, Heap, Vec

adjacent = Vec[Vec[int]](n)     # n empty rows
adjacent[0].push(3)
q = Deque[int]()
q.push_back(0)
start = q.pop_front()
h = Heap[tuple[int, int]]()     # (distance, node), smallest first
h.push((0, start))

Each is a generic class. An element, or a map's value, is a number, a tuple of numbers, a dataclass, or another collection; a key is an int or a tuple of them. This module is the reference: it is what the types mean under CPython, and native code implements the same operations over machine memory.

The rules are the same on every path, which is why some differ from a list's:

  • An index runs from 0 to len - 1. A negative index is an IndexError, as is any other index out of range: there is no counting from the end.
  • Removing from an empty collection is an IndexError.
  • Iterating reads the length at every step, as iterating a list does, so a loop that pushes sees what it pushed.
  • A heap has no iteration order to depend on: it is read by peek and pop alone.

Vec(count=0)

Bases: _Elements

A growable array: push and pop at the end, any index in between.

push(value)

Add value at the end.

pop()

Remove the last element and return it.

last()

The last element, left in place.

clear()

Remove every element.

sort()

Sort in ascending order.

reverse()

Reverse the order in place.

Deque()

Bases: _Elements

A double-ended queue: push and pop at either end, any index in between.

push_back(value)

Add value at the back.

push_front(value)

Add value at the front.

pop_back()

Remove the back element and return it.

pop_front()

Remove the front element and return it.

front()

The front element, left in place.

back()

The back element, left in place.

clear()

Remove every element.

Heap()

Bases: _Elements

A priority queue: pop returns the smallest element.

push(value)

Add value.

pop()

Remove the element peek would return, and return it.

peek()

The smallest element (the largest, for a MaxHeap), left in place.

clear()

Remove every element.

MaxHeap()

Bases: Heap

A priority queue: pop returns the largest element.

LinkedList()

Bases: _Elements

A doubly linked list whose nodes are named by integers, not pointers.

push_back and the other insertions return the new node's id, which next, prev, value, set, insert_after, and remove take. Ids are handed out in order, and a removed node's id is the next one given out again. -1 stands for no node: the next of the last one, the head of an empty list.

push_back(value)

Add value at the back; its node id.

push_front(value)

Add value at the front; its node id.

insert_after(node, value)

Add value after node; its node id.

insert_before(node, value)

Add value before node; its node id.

remove(node)

Take node out and return its value; its id is given out next.

pop_front()

Remove the front element and return it.

pop_back()

Remove the back element and return it.

front()

The front element, left in place.

back()

The back element, left in place.

head()

The first node's id, or -1.

tail()

The last node's id, or -1.

next(node)

The id after node, or -1.

prev(node)

The id before node, or -1.

value(node)

What node holds.

set(node, value)

Replace what node holds.

clear()

Remove every element; ids start from 0 again.

HashMap()

Bases: _KeysAndValues

A hash map from int keys, in insertion order, as dict keeps it.

get(key, default)

The value of key, or default where there is none.

pop(key)

Remove key and return its value.

clear()

Remove every entry.

HashSet()

Bases: HashMap

A hash set of int, in insertion order.

add(key)

Add key; nothing changes if it is there.

remove(key)

Remove key, which must be there.

discard(key)

Remove key if it is there.

TreeMap()

Bases: _KeysAndValues

A map from int keys kept in order: the smallest, the largest, and the nearest key above or below any value.

get(key, default)

The value of key, or default where there is none.

pop(key)

Remove key and return its value.

clear()

Remove every entry.

min()

The smallest key.

max()

The largest key.

floor(key)

The largest key at most key.

ceiling(key)

The smallest key at least key.

lower(key)

The largest key below key.

higher(key)

The smallest key above key.

TreeSet()

Bases: TreeMap

A set of int kept in order.

add(key)

Add key; nothing changes if it is there.

remove(key)

Remove key, which must be there.

discard(key)

Remove key if it is there.

The import hook

Import support for .ppy modules under plain CPython.

Importing ppy installs a sys.meta_path finder so that sibling .ppy modules load as ordinary Python source -- or natively, when a build of them exists or can be made (_native). No compiler is involved here.

PPyAmbiguousModuleWarning

Bases: ImportWarning

Both name.py and name.ppy are importable under the same name.

PPySourceLoader

Bases: SourceFileLoader

Loads a .ppy file as ordinary Python source.

SourceFileLoader already does everything: the suffix only matters to the finder, so no method needs overriding.

PPyPathFinder

Meta-path finder for .ppy modules and packages.

add_import_root(path)

Confine .ppy precedence to the given root (spec 5.2).

With no root registered, any sys.path entry may provide .ppy modules.

install()

Install the .ppy finder ahead of the standard path finder.

ppy.native

ppy.native is an object rather than a module. It is both the directive and the namespace of typed native memory: native.ptr[T], native.const_ptr[T], native.stack_alloc[T](n), native.load, native.store, native.offset, native.cast[T], native.sizeof[T], native.extern(...), native.export(...). Native memory and FFI describes each.

ppy.native: the directive, and the namespace of typed native memory.

Under plain CPython every one of these has a reference implementation over array memory, so a program that uses them runs unchanged on every path; the compiler lowers them to pointer operations in native code. What the reference implementation cannot do -- reinterpret memory the machine could -- fails clearly rather than approximately.

from ppy import native

@native
def fill(p: native.ptr[float], n: int) -> None:
    for i in range(n):
        native.store(native.offset(p, i), 0.5 * i)

Pointer(memory, index, element, *, mutable=True)

A typed pointer into array memory: the reference implementation.

memory is the array, index the element the pointer names. A pointer from stack_alloc owns its memory; offset makes another pointer into the same memory.

address()

The machine address, for handing to C.

offset(count)

The pointer count elements on, into the same memory.

touch()

A store went through this pointer: memory that lives elsewhere too takes note.

ppy.ffi

ppy.ffi

ppy.ffi: binding C functions, one layer over ppy.native.extern.

from ppy import ffi, native

libm = ffi.library("m")


@ffi.bind(libm, symbol="sin", pure=True)
def sin(x: float) -> float: ...

A binding is a stub whose annotations are the C signature; under CPython the call goes through ctypes, in native code straight to the symbol. The markers say what a plain signature cannot: ffi.nullable[T] for a pointer that may be null, ffi.LengthOf("xs") for an integer that is the length of another parameter, and ppy.Owned/ppy.Borrowed for who keeps memory.

Library(name)

A shared library by name ("m"), or by path.

LengthOf(parameter)

Marker: this integer is the length of the named buffer parameter.

bind(lib=None, *, symbol=None, pure=False, convention='c')

@ffi.bind(lib, symbol="sin"): the stub is the C function.

ppy.simd

ppy.simd

ppy.simd: a few scalars operated on at once.

simd.Vector[T, N] is N lanes of T. splat[T, N](x) fills one, load[T, N](p) reads one from a native.ptr[T], store(v, p) writes it back, extract(v, i) and insert(v, i, x) reach one lane, shuffle(a, b, mask) builds a vector from the lanes of two, and reduce_add, reduce_min, reduce_max fold one to a scalar in lane order. + - * /, the comparisons, & | ^, and select(mask, a, b) work lane by lane; integer lanes wrap at their width, which is what the machine's lanes do.

from ppy import native, simd

def dot4(a: native.ptr[float], b: native.ptr[float]) -> float:
    return simd.reduce_add(simd.load[float, 4](a) * simd.load[float, 4](b))

Under CPython every operation runs here, lane by lane, on the same native.Pointer memory; the compiler lowers them to vector instructions.

Vector(element, lanes)

N lanes of one scalar type: the reference implementation.

store(vector, pointer)

Write the lanes to the count elements at pointer.

shuffle(a, b, mask)

Lanes mask[i] of a followed by b: 0..N-1 from a, N..2N-1 from b.

reduce_add(vector)

The lanes summed in order, first to last.

select(mask, a, b)

Lane by lane, a where mask is true, else b.

ppy.cpu

ppy.cpu

ppy.cpu: the CPU as a facade, with no instruction named.

from ppy import cpu

@cpu.target("avx2", "fma")
def dot(a: native.ptr[float], b: native.ptr[float], n: int) -> float: ...

if "avx2" in cpu.features():
    ...
lanes = cpu.vector_width[float]()
cpu.prefetch(p)
cpu.pause()

features() is what this machine has, in LLVM's names; the compiler folds a membership test on it to a constant, so a program takes the branch for the machine it is compiled on. vector_width[T]() is how many T a vector register holds here. prefetch and pause are hints: no value changes, and under CPython they do nothing. A function under @cpu.target(...) is compiled with those features on, and the boundary binds it only on a machine that has them; elsewhere its Python definition runs.

features()

This machine's CPU features, sorted, in LLVM's spelling.

prefetch(pointer, *, write=False, locality=3)

Ask for the cache line at pointer; under CPython, nothing to ask.

pause()

The spin-wait hint; under CPython, nothing to hint.

target(*names)

Compile the function with these CPU features on ("avx2", "fma").

ppy.atomic

ppy.atomic

ppy.atomic: shared memory, one operation at a time.

Every function takes a native.ptr[T] to the slot and a memory order, "seq_cst" unless said otherwise ("relaxed", "acquire", "release", "acq_rel"). The compiler lowers each to the atomic instruction of that order; the reference implementation here serializes them under one lock, which is what an atomic operation on an interpreter with a global lock already is.

from ppy import atomic, native

counter = native.stack_alloc[int](1)
atomic.fetch_add(counter, 1)
old, swapped = atomic.compare_exchange(counter, 1, 5)

compare_exchange(pointer, expected, desired, order='seq_cst')

(the value found, whether it was expected and is now desired).

ppy.concurrent

ppy.concurrent

ppy.concurrent: threads, and the memory that keeps them apart.

spawn(f, *args) starts f(*args) on a new thread and hands back its handle; join(handle) waits for it. The synchronization objects are memory the program owns, so they cross into native code as pointers: a mutex is one int slot (zero unlocked), a condition is one int slot counting notifications, a barrier is two int slots. lock, unlock, wait, notify, and barrier work on them the same way on every path -- spinning on atomic operations -- so a program synchronizes identically under CPython and compiled. thread_id() names the running thread.

from ppy import concurrent, native

def fill(p: native.ptr[int], begin: int, end: int) -> None:
    for i in range(begin, end):
        native.store(native.offset(p, i), i * i)

def main() -> int:
    data = native.stack_alloc[int](8)
    first = concurrent.spawn(fill, data, 0, 4)
    second = concurrent.spawn(fill, data, 4, 8)
    concurrent.join(first)
    concurrent.join(second)
    return native.load(native.offset(data, 7))

Thread(function, arguments)

A spawned thread: the handle join takes.

spawn(function, *arguments)

Run function(*arguments) on a new thread.

join(handle)

Wait for the thread; what it raised is raised here.

lock(mutex)

Take the mutex: spin until its slot goes from 0 to 1.

wait(condition, mutex)

Release the mutex, wait for a notification after this call, take it back.

notify(condition)

Wake every waiter on the condition.

barrier(slots, parties)

Wait until parties threads have arrived; slots is two ints.

Derivatives

ppy.autodiff

ppy.grad and ppy.value_and_grad: the derivative of a function.

import math
import ppy

def f(x: float, y: float) -> float:
    return math.sin(x) * y + x * x

df = ppy.grad(f)              # d f / d x
dy = ppy.grad(f, argnums=1)   # d f / d y
both = ppy.value_and_grad(f)  # (f(x, y), d f / d x)

Under CPython the derivative is made from the function's source: the body -- assignments and a return, over arithmetic, math, and NumPy arrays -- is restated one operation at a time, then each operation's adjoint is emitted in reverse, by the same rules in the same order the compiler's autodiff transform uses, so the Python path and the native path agree bit for bit. What the rules do not cover -- a branch, a loop, a call into code that is not one of the known operations -- is refused with the reason, as the compiler refuses it.

grad(function, argnums=0)

The gradient of function with respect to its argnums parameter(s).

value_and_grad(function, argnums=0)

function's value and its gradient, as a pair.

ppy.aio

ppy.aio

ppy.aio: coroutines that sleep and speak on sockets, the same on every path.

from ppy import aio, native


async def serve_one(listening: int) -> int:
    client = await aio.accept(listening)
    room = native.stack_alloc[ppy.u8](64)
    got = await aio.read(client, room, 64)
    sent = await aio.write(client, room, got)
    aio.close(client)
    return sent


aio.run(serve_one(aio.listen("127.0.0.1", 8000)))

sleep, accept, connect, read, and write are awaitables; spawn starts a coroutine as a task to await later; listen, port, and close are immediate. A socket is an int, and every socket operation answers a negative errno rather than raising, so a compiled coroutine and this one say the same thing. Under CPython the awaitables are asyncio's, and run is asyncio.run; the compiler lowers an async def whose awaits are these and other native coroutines to the async dialect (spec 76), the native runtime drives it (spec 77), and run given what a compiled coroutine hands back drives that. An await the compiler does not know keeps the coroutine in Python (spec 78); compiled(f) says which happened.

sleep(seconds) async

Wait seconds; other coroutines run meanwhile.

listen(host, port_number, backlog=16)

A listening TCP socket bound to host:port, or a negative errno.

port(fd)

The port a socket is bound to, or a negative errno.

accept(fd) async

The next connection on a listening socket, or a negative errno.

connect(host, port_number) async

A socket connected to host:port, or a negative errno.

read(fd, buffer, count) async

Up to count bytes into buffer: how many came, 0 at the end, negative errno on failure.

write(fd, buffer, count) async

All count bytes of buffer to the socket: count, or a negative errno.

spawn(awaitable)

Start awaitable now, as a task the loop runs alongside; await it for its value.

run(awaitable)

awaitable's value: the native loop for a compiled coroutine, asyncio otherwise.

compiled(function)

Whether calling function starts a native coroutine here.

ppy.cuda

ppy.cuda

ppy.cuda: kernels written in Python, lowered to the gpu dialect, written as CUDA.

from ppy import cuda, native


@cuda.kernel
def saxpy(n: int, a: float, x: native.const_ptr[float], y: native.ptr[float]) -> None:
    i = cuda.global_id()
    if i < n:
        slot = native.offset(y, i)
        native.store(slot, a * native.load(native.offset(x, i)) + native.load(slot))


def run(n: int, a: float, x: native.const_ptr[float], y: native.ptr[float]) -> None:
    cuda.launch(saxpy, (n + 255) // 256, 256, n, a, x, y)

@kernel marks a function of scalars and pointers that returns nothing and runs once per thread of a launch; @device one a kernel calls. Inside, thread_id(), block_id(), block_dim(), grid_dim(), and global_id() say where a thread is, syncthreads() and syncwarp() wait for the block and the warp, shared[T, N]() and local[T, N]() are memory of the block and of the thread, and the shfl family trades a scalar across the warp. launch(kernel, grid, block, *args) runs a kernel and waits, and device_alloc[T](n) is memory that lives on the device between launches, a native.ptr[T] the host reads and writes through a mirror. Under CPython the launch runs the grid here, on threads that know their position -- the reference, exact and slow (spec 72); the compiler lowers the same calls to the gpu dialect, and ppy emit cuda writes them as CUDA C++.

launch(function, grid, block, /, *arguments)

Run kernel function(*arguments) over grid blocks of block threads, and wait.

compiled(function)

Whether a launch of function runs on the device here: a kernel the build staged.

warp_size()

The lanes of a warp: 32.

ppy.hip

ppy.hip

ppy.hip: the vocabulary of ppy.cuda, spelled for HIP and written as HIP C++.

from ppy import hip, native


@hip.kernel
def saxpy(n: int, a: float, x: native.const_ptr[float], y: native.ptr[float]) -> None:
    i = hip.global_id()
    if i < n:
        slot = native.offset(y, i)
        native.store(slot, a * native.load(native.offset(x, i)) + native.load(slot))


def run(n: int, a: float, x: native.const_ptr[float], y: native.ptr[float]) -> None:
    hip.launch(saxpy, (n + 255) // 256, 256, n, a, x, y)

@kernel marks a function of scalars and pointers that returns nothing and runs once per thread of a launch; @device one a kernel calls. Inside, thread_id(), block_id(), block_dim(), grid_dim(), and global_id() say where a thread is, syncthreads() and syncwarp() wait for the block and the warp, shared[T, N]() and local[T, N]() are memory of the block and of the thread, and the shfl family trades a scalar across the warp. launch(kernel, grid, block, *args) runs a kernel and waits; a kernel marked by either module is one kernel, and either launch runs it. Under CPython the launch runs the grid here, on threads that know their position -- the reference, exact and slow (spec 72); the compiler lowers the same calls to the same gpu dialect a CUDA kernel reaches, and ppy emit hip writes them as HIP.

launch(function, grid, block, /, *arguments)

Run kernel function(*arguments) over grid blocks of block threads, and wait.

compiled(function)

Whether a launch of function runs on the device here: a kernel the build staged.

warp_size()

The lanes of a wavefront: 64.

ppy.xla

ppy.xla

ppy.xla: a function's arithmetic compiled by XLA and run on a PJRT device.

from ppy import xla

@xla.jit
def f(x: float, y: float) -> float:
    return math.sin(x) * y + x * x

xla.devices()        # ["cpu:0"] where a device is present

@xla.jit marks a function of floats, ints, and bools whose body is one block of arithmetic and math. The compiler lowers it to the IR, emits StableHLO, and the build stages it; at run time the staged module is compiled by XLA once and each call runs on the device. Under plain CPython -- and wherever no device is present -- the function runs as written: the directive is inert, like every ppy directive (spec 61, 64). compile(f) is the same as jit(f); devices(), default_device(), and device_put() ask the PJRT bridge, and answer nothing, None, and the value itself when there is no bridge to ask.

compile(function)

jit, as a call: the function marked for XLA.

devices()

The XLA devices a program can run on; none without the bridge.

device_put(value)

value placed on the default device, or itself when there is none.