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
0tolen - 1. A negative index is anIndexError, 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
listdoes, so a loop that pushes sees what it pushed. - A heap has no iteration order to depend on: it is read by
peekandpopalone.
Vec(count=0)
¶
Bases: _Elements
A growable array: push and pop at the end, any index in between.
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()
¶
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()
¶
HashSet()
¶
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.
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.
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.
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.
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++.
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.
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.