Skip to content

Changelog

0.3.7 — 2026-09-25

  • A standalone binary reads more than int. ppy.input and ppy.scan of float, of the fixed widths (ppy.i8 through ppy.u64, ppy.f32, ppy.f64), and of tuples of them lower to the C runtime, and so do ppy.input[Buffer[int]](), ppy.input[Buffer[float]](), ppy.input[list[int]](), ppy.input[list[float]](), and ppy.scan[Buffer[float]](https://github.com/franknoh/PPy/blob/main/n). The float grammar is float()'s, and where CPython raises the binary names the same exception and stops.
  • A standalone print writes a float the way repr does: the shortest digits that read back as the same double.
  • ppy.input[ppy.i32]() and the other fixed widths read under CPython too, where they used to be a TypeError: the value is read as int and must fit, or the read raises OverflowError.
  • ppy.input[Model]() reads one line of JSON into a dataclass, a pydantic model, or a TypedDict, and ppy.input[list[Model]]() a JSON array of them. ppy.scan[Model]() reads the next JSON value over as many lines as it spans. A dataclass or TypedDict is checked field by field and a mismatch is a ValueError naming where ($.items[2].price); a pydantic model is validated by pydantic.
  • Collections that compile: ppy.Vec, ppy.Deque, ppy.Heap, ppy.MaxHeap, ppy.LinkedList (nodes named by integer ids), ppy.HashMap, ppy.HashSet (insertion order), ppy.TreeMap, and ppy.TreeSet (key order, with floor, ceiling, lower, higher). Under CPython each is a Python class, the reference. A function that makes and uses them lowers to calls into a small C runtime under ppy run, in a standalone binary, and in emitted C and C++, and frees what it made before it returns. A native function may take one as a parameter; native callers pass its handle. examples/47_collections runs five problems with them: 4.3 s under CPython, 0.39 s under ppy run.
  • Collections hold anything with a native form: numbers, tuples of numbers, dataclasses (ordered with order=True), and other collections, so Vec[Vec[int]], HashMap[int, Vec[int]], Heap[tuple[int, int]], and Vec[Edge] compile. Keys may be tuples of int. The runtime works on words and the compiler writes the typed access per element type. Memory is reference counted, so collections can be aliased, nested, returned from native functions, and passed as temporaries; the emitted C of the tests runs clean under AddressSanitizer with leak detection.
  • Generic functions take, make, and return collections of their type parameter (def smallest[T: int | float](https://github.com/franknoh/PPy/blob/main/v: Vec[T], k: int) -> Vec[T]), one native instance per type, standalone builds included. A generic function's body may annotate locals with its type parameters.
  • Classes in native code. A class whose methods change its fields, or whose fields hold objects or collections, is an object class: native code holds an instance by handle, with reference counting, so trees, linked nodes (left: "Node | None"), and objects in collections lower. Methods lower with self as a handle, x is None compares with the null handle, and len(obj) and if obj: call __len__ and __bool__. Reference cycles are not freed.
  • Generic classes: class Stack[T] with fields and methods over T. The checker substitutes a receiver's type arguments into its fields and methods, so Stack[int]().push(2.5) is E1301, and native code instantiates the class and its methods per type argument.
  • Standalone programs may define classes (no bases, @dataclass or none, methods and annotated fields) and use len and truth tests on them.
  • Emitted C reserves the C library's names, so a function called remove no longer collides with stdio.h.
  • Emitted C no longer moves a store above a load of the same slot when it writes a value straight into its destination.
  • The standalone runtime's ppy_rt_alloc returns int8_t *, the type the IR gives it, so emitted C no longer warns about incompatible pointers (an error from GCC 14 on).

0.3.6 — 2026-09-21

  • Unsafe standalone C/C++ source emits integer ppy.input and ppy.scan as plain scanf(...); calls without conversion checks, assuming valid input within the selected 32-bit or 64-bit range. Safe emission and builds retain their input validation.
  • Explicit print(..., flush=True) continues to flush stdout; omitted or false flush does not emit fflush. Regression tests cover both languages and integer widths, normal reads, and safe-mode rejection of invalid input.

0.3.5 — 2026-09-16

  • Unsafe standalone C/C++ source accepts --int-width 32, using actual 32-bit arithmetic, plain int declarations, and %d input/output. The default remains 64 bits; constants outside the selected range and fixed runtime ABIs that cannot be narrowed are rejected.
  • Readable source places callees before callers and local declarations near their first write, removes redundant arithmetic casts, and uses plain division when branch bounds prove Python's floor correction unnecessary. Large positive divisors no longer overflow an intermediate remainder correction in the 32-bit model.

0.3.4 — 2026-09-16

  • ppy emit c/cpp --standalone --unsafe [--format] emits readable standalone source with direct scalar/void returns, one main, source names, C++ module namespaces, inline literals, fused stdio output, and checked scanf reads. Native arithmetic is selected in IR; signed overflow follows C/C++ semantics. Safe builds and ordinary emission retain their existing ABI and semantics.
  • Standalone source links reachable functions across project modules while rejecting imported initialization that requires Python.

  • A region may write through a parameter. narrow and copy_ joined the curated set, so a slot of a preallocated tensor can be filled in place -- the other way to keep a KV cache, and the one a long context wants, since growing by cat copies the whole cache per token. The write is not silent: copy_ carries WriteMemory, which @ppy.pure forbids, so a function that fills a caller's buffer is refused the claim by name.

  • An ATen region can hand back several tensors, which is what a KV cache needs. A region returned one at::Tensor, so a block could not give back the key and value it had just grown beside its output and incremental decoding stayed in Python -- the place the Python between operators costs the most. A return annotation of tuple[torch.Tensor, ...] now becomes a std::tuple, which pybind11 hands Python as an ordinary tuple, and with causal a bool parameter and length an int64_t rather than constants folded in, one compiled function prefills a prompt under a causal mask and then decodes a token at a time out of the cache it built. examples/46_gpt2 measures that: 128 tokens through forty-eight blocks is 6144 region calls with almost no arithmetic in each, the row where the interpreter is most of the wall clock. It is also the row where torch.compile's CUDA-graphs mode refuses to run at all until each invocation is announced and the cache is cloned, since a cache is exactly the memory those graphs reuse.
  • An ATen region can hold a whole transformer block. What a region was allowed to emit was a table of (C++ function, arity) pairs, which is enough for relu(add(matmul(x, w), b)) and not enough for anything with a dimension in it: softmax, transpose, reshape, layer_norm, and scaled_dot_product_attention had no entry, keyword arguments were refused outright, and an int parameter of the region was declared double. Each operation is now described by the C++ signature it is called through -- a dimension is an int64_t, a shape an at::IntArrayRef written as a tuple, keepdim and is_causal a bool, gelu's approximation a mode string -- so an argument is rendered by the slot it fills. Keywords are matched against the C++ parameter names, optional arguments take the C++ defaults when a later one is given, and an operation with two signatures (mean over everything, or over named dimensions) takes the one the call fills. Thirteen operations were added to the curated set for it, and examples/46_gpt2 is GPT-2 XL with each of its forty-eight blocks compiled to one region, measured against PyTorch eager and torch.compile on an RTX 4090.

0.3.3 — 2026-09-15

  • ppy.Tensor[dtype, shape] carries common tensor contracts through type checking and IR, with ppy.bf16 distinct from IEEE float16.
  • Plugins can declare call-scoped read-only and mutable argument borrows, allowing ppy.Mut[ppy.Tensor[...]] without a separate plugin-owned type.
  • Common tensors retain a shared IR representation and can use an explicit backend's physical type lowering without depending on plugin order.
  • Plugin rejection diagnostics include the supplied reason at the call site.

0.3.2 — 2026-09-15

  • Plugins can lower analyzed types and facts into IR types, preserving tensor dtype and shape across function parameters, results, local values, and calls. Canonical IR generation no longer depends on the CPU native ABI.
  • Plugin dialect calls carry operands, constant keyword attributes, effects, guards, and source locations into the IR, including operations without a result. Optimizations preserve operations with write effects.
  • Explicit external-backend compilation reports lowering failures with the function's location and reason, while ordinary Python fallback remains available.
  • Project dialect registries govern verification, control-flow analysis, transformations, and IR-file builds without process-global registration.
  • Source emission formats can declare requires_toolchain=False to work without an installed SDK. Existing formats and artifact builds retain their toolchain checks.

0.3.1 — 2026-09-15

  • Standalone builds and C emission support print with string literal end and sep, boolean literal flush, and basic integer and boolean f-strings. Converted input prompts retain their output and flush before reading.

0.3.0 — 2026-09-13

The release that makes the compiler an open one: a public backend interface behind the canonical IR, so a code generator for another target is a Python package with an entry point rather than a fork of this repository. Around it, the semantics that surround such a boundary were tightened -- what ppy.check may claim about a value, what ppy.input and ppy.scan read, what a backend may be asked for -- and the accelerator paths were validated on real hardware. The entries below are in the order the work landed.

  • ppy build --backend python is held to the same policy as every other backend that is not LLVM. It shared the LLVM branch of the dispatch, so ppy build foo.ppyir --backend python built an object, a shared library, and a manifest through the LLVM road, and --unsafe, --sanitize, --pgo, --prover, --host-cpu, --standalone, --python-extension, --library, --report-opt, --report-opt-json, and --target were accepted and dropped. The backend is now chosen first and what was asked for is judged against it: one refusal table serves the Python backend and installed backends alike, a .ppyir target is refused for the Python backend, which reads PPY source, and -o is refused rather than ignored, since its modules go to the project cache. --warm is refused at dispatch for any backend but LLVM, so the warm command no longer carries a second, differently worded rule of its own. The LLVM road keeps every one of those options, --warm, and .ppyir.
  • ppy.check[T] refuses a protocol. isinstance against a @runtime_checkable protocol answers whether the attributes are there and nothing of what they take or answer, so a class whose f(self) returns a string satisfies a protocol declaring f(self, x: int) -> int; handing that value back as the protocol was the false typed value ppy.check exists to refuse. Plain and runtime-checkable protocols are refused alike, wherever they appear in the target -- nested in a list, tuple, set, or dict, behind Annotated, or as a union arm in either order -- and ppy.assume[T] remains the unchecked crossing. The guide already said protocols were refused; now they are.
  • The backend interface, tightened while it is young:
  • an external backend declares the interface version it implements as a literal of its own (api_version = 1) and is refused if it declares none. Inheriting the base class's number would have made every installed backend call itself current the moment a later compiler imported it, which is the compatibility break the number exists to catch; the base class now declares nothing.
  • a backend's passes reach the backend stage and no other. What register_passes receives is a BackendPassRegistrar (passes.add(MyPass)), not the PassManager, so a backend can no longer hang a pass among the shared pipeline's or the plugins' stages and decide for every other backend what the canonical IR is; one that asks is refused by name.
  • an artifact's identity carries the distribution the backend came from and that distribution's version, so a new release of a backend package is new artifacts whether or not its author touched fingerprint().
  • an EmitFormat says whether it is written per module (the default, through emit) or per program (through emit_program, every module at once). A per-module format whose target resolves to several modules needs -o DIR and writes one file for each: two artifacts are no longer written end to end into one file, which for two object files or two device images made a thing that was neither.
  • ppy build --backend NAME chooses the backend before anything runs. A .ppyir target builds through that backend -- the file is canonical IR, so its passes, validation, and build run over it -- where it used to be answered by the LLVM road whatever --backend said, and --warm is refused for a backend other than LLVM's rather than silently building the LLVM artifact. Every LLVM-road option (--unsafe, --sanitize, --pgo, --prover, --host-cpu, --standalone, --python-extension, --library, --report-opt, --report-opt-json, --target) is refused by name for another backend instead of being accepted and ignored.
  • a distribution may declare which formats its backend owns in the optional ppy.backend-formats entry-point group, so ppy emit <format> imports the one backend that owns it rather than every installed backend to ask. Undeclared formats are still found the slower way.
  • A backend can be a package of its own. ppy_compiler.backend is the interface (Backend, BackendContext, EmitFormat, BuildResult, ToolchainStatus, BackendValidationError; BACKEND_API_VERSION 1, a version of the interface, not of the compiler): a backend consumes the canonical IR after the shared passes, hangs its own passes at the new backend stage, refuses in validate what it cannot take, and emits text or bytes or builds. A distribution registers one through the ppy.backends entry-point group; discovery reads the entry points without importing, and the package is imported when its backend is asked for. ppy emit <format> takes any format an installed backend registers under the rules the builtin kinds follow; ppy build --backend NAME builds through it; ppy doctor lists every backend with its toolchain, formats, and fingerprint; [tool.ppy.backends.<name>] is the backend's own configuration. The artifact key carries the backend's name, fingerprint, configuration, and target (the LLVM road's now carries the llvmlite under it too). Refusals are E1903 (a backend that cannot be used: unknown, registered twice, another interface version, a failing factory), E1801 (toolchain missing), E1802 (IR refused, with what and where), and E1904 (a backend pass that broke the IR, named). The shared IR pipeline moved out of the LLVM package into driver/ir_pipeline.py (canonical_ir_modules, optimize_shared_ir); the builtin backends stand in the same registry with their formats, fingerprints, and toolchain status.
  • ppy.input[T]() reads one line and means what the builtin input() means: ppy.input[str]() is the line with its newline removed, spaces kept, "" for an empty line, EOFError at the end; ppy.input[int]() and [float] parse the whole line as int(input()) and float(input()) do, so 1 2 is ValueError; ppy.input[tuple[int, int]]() splits one line and requires exactly its fields, never reaching into the next. The scanner the old ppy.input was is ppy.scan[T](): tokens wherever they fall, ppy.scan[Buffer[int]](https://github.com/franknoh/PPy/blob/main/n) for n of them into a buffer. A converted program therefore reads what it read as Python -- ppy convert no longer turns a loop of int(input()) into a bulk scan that would read across lines. A line of fields is a line read too: ppy.input[list[int]]() is list(map(int, input().split())), and ppy.input[Buffer[int]]() reads a line of integers straight into a buffer, as array.array("q", map(int, input().split())) would, with no count to give and no Python object per field; the converter writes both idioms so. A tuple of int is read the same way, in C; a typed read is planned once per type, so a read in a loop costs the read and nothing else. The C parser reads the ASCII forms int() reads within 64 bits and hands any other field -- a wider integer, non-ASCII digits, no integer at all -- to int() itself, so a typed line read means exactly what the idiom it stands for means: a, b = ppy.input[tuple[int, int]]() reads 9223372036854775808 as map(int, input().split()) does, and a converted program keeps Python's input semantics on every field. A differential test runs the original and the converted program as subprocesses on the same input and holds them to one output and one exception. ppy.read_ints and ppy.read_token stay the buffer-oriented forms; read_token cuts a token at the buffer's capacity and says so.
  • One scanner grammar, implemented once in C (ppy_runtime.scanner) for the runtime's compiled reader and for a standalone binary's runtime alike, and once more in the pure-Python fallback: whitespace is ASCII, an integer token is [+-]?[0-9]+ within 64 bits, and a token that is not an integer where one is expected is ValueError naming it -- the compiled reader no longer skips over abc to find the 3 after it while the fallback raised. A high-level string read is never cut: ppy.input[str]() and ppy.scan[str]() read a line or a token of any length, where the old scalar read cut at 4096 bytes and dropped the rest.
  • ppy.scan[Buffer[int]](https://github.com/franknoh/PPy/blob/main/n) reads exactly n integers or raises: the input ending first is EOFError with the tokens before it read, where it used to hand back a buffer padded with zeros that were never in the input; a negative n is ValueError. A standalone binary ends with the same message. ppy.read_ints(buffer) stays the partial read.
  • ppy.check[T](https://github.com/franknoh/PPy/blob/main/value) checks every PPy refinement in an Annotated, not only the type under it: ppy.check[ppy.i8](https://github.com/franknoh/PPy/blob/main/300) is refused, as are ppy.check[ppy.Array[int, 3]](https://github.com/franknoh/PPy/blob/main/(1, 2)), a Vector[T] with a wrong element, a Buffer[T] whose buffer holds another format or is not one contiguous dimension, a value outside its Range, a Length, a Shape (symbolic dimensions bound consistently), a DType, or a Contiguous the value's own metadata contradicts; an f32 wider than a float32 holds is refused too. A contract no single value can bear witness to -- Owned[T], Borrowed[T], Mut[T], NoAlias, a symbolic array length -- is rejected outright rather than stripped, and ppy.assume[T] stays the unchecked crossing. The guide said validation was shallow; it is not, and the guide says what the code does.
  • The PJRT bridge behind ppy.xla compiles for the platform JAX would pick -- the GPU where a CUDA or ROCm plugin is installed -- where it used to compile for cpu unless PPY_XLA_PLATFORM said otherwise, so an @xla.jit function on a GPU machine ran on a cpu:0 it never asked for. It compiles for one device of that platform (the first; PPY_XLA_DEVICE names another): compiled for every device, its executables expected one argument shard per device, and a machine with two GPUs refused the single buffers the bridge places -- found on a two-GPU RunPod machine, held by a test over two virtual CPU devices.
  • ppy.check[T](https://github.com/franknoh/PPy/blob/main/value) validates all the way down: a list[int] element by element, a dict[str, float] key and value, a tuple field by field, a dataclass field by field, a union member by member. A T it cannot validate soundly -- a callable, an iterator, a protocol -- is refused rather than checked in part. ppy.assume[T](https://github.com/franknoh/PPy/blob/main/value) is the unchecked crossing, typed on the programmer's word alone; the checker types both and gives only check the TypeError it may raise.
  • ppy run and ppy build mean the same program: both keep Python's integer semantics by default, and --unsafe on either is the one spelling of 64-bit wrap semantics. build --safe is gone, having become the default. A standalone build keeps the guards too, and a guard that fails ends the process with a message rather than wrapping in silence; the benchmarks that want wrap semantics build with --unsafe and say so.
  • examples/compare.py holds every counterpart to one answer before it prints a timing: a tool that fails, answers differently from itself between runs, answers differently from the reference, or prints no timing for a kernel is an error and the exit status is 1.
  • The guide splits into "Reading input" and "Native lowering"; the example count on the landing pages is generated from the tree; the native-memory guide names every pointer element width the runtime supports; ppy.buffer, ppy.scan, and ppy.assume are declared in the package's public surface.

  • cuda.device_alloc[T](https://github.com/franknoh/PPy/blob/main/n): memory that lives on the device between launches. It is a native.ptr[T] like stack_alloc's -- the same loops fill and read it, native.offset keeps its kind -- and the host sees it through a mirror that whichever side wrote last keeps current: a launch passes the device address and copies nothing, a host read after a launch brings the array back once, a host write before a launch sends it once. Without a device there is only the mirror. hip.device_alloc is the same surface; the checker types both (E1644 for a misuse) and a function that allocates stays in Python under ppy run, as one that launches does, while ppy emit cuda and ppy emit hip write it into the host function as a gpu.device_alloc -- a managed allocation the host reads and writes, freed when the function returns. The CUDA example makes its saxpy arrays this way, and its comparison with CuPy and Numba gained the device-resident rows.

  • ppy explain reports a body that lowered as llvm backend: native, whatever the effects suggested: a parallel loop's Thread effect was reported as boxing the function while the function ran natively. Thread and sync effects no longer count against lowering in the contract report either.

  • ppy.tile: kernels over tiles. @tile.kernel runs once per program of a launch; tile.arange(BLOCK) is the lane index, tile.load and tile.store gather and scatter through a native.ptr with a mask, arithmetic and comparisons are lane by lane with a scalar broadcast, tile.where chooses per lane, and tile.sum, tile.max, tile.min reduce a tile to one number. The compiler lowers a program to a block of threads that each hold a slice of every tile as a vector, gathers and scatters lane by lane, and reduces across the block with a shuffle tree and shared memory; the CUDA source backend writes the same kernels, and the reference launch runs the programs in order under CPython. The new 44_tile example is compared with Triton and Taichi, the tools that program tiles, and the CUDA example keeps to thread-level kernels: CuPy, Numba, Mojo, CUDA C.

  • The comparison tables are measured and written by scripts/compare_docs.py from a manifest of every counterpart program, between markers in each README, with the run recorded beside the programs; bench.yml runs it and scripts/refresh.py on a self-hosted runner for every push to dev that touches code and commits what moved, and a change that touches only prose skips the test matrix for the strict docs build. The comparison sections themselves now show each tool's kernel side by side and say what the numbers mean, rather than that they agree.
  • Three hot paths in the runtime, found by the comparisons. A CUDA launch over host arrays uploaded each array from a copy of it, and the copy cost more than the transfer -- 90 ms against 12 for 128 MB -- so the driver now reads the array itself; the copying rows of the CUDA example fall from 172 ms to 38 ms, beside CuPy. A fused NumPy kernel confirmed its result finite by rereading the output and every input after the loop; the map kernel now counts non-finite elements in the loop itself, one add reduction the vectorizer keeps in a register, and guards on it once, and a reduction checks only its number -- the inputs never needed a pass, since any condition NumPy would report leaves a non-finite result. The fused kernel of the parallel example runs in 4 ms where it took 12.5, its serial form in 12 where it took 20, and an ordered np.sum(x * x) costs what NumPy's does. The chunk a worker wrote is also what it checks, where a check is still made.
  • Four more examples measure themselves against their neighbours, with the counterpart programs in each compare/ folder and the site showing them side by side on the example's page and on one comparisons page: regular expressions against CPython's re and Rust's regex, the buffer kernels against Numba, Cython, NumPy, and C, the fused NumPy kernel against NumPy, numexpr, Numba, and JAX, and Newton's method on a ppy.grad derivative against JAX and PyTorch; the numerics example shows what Numba, Codon, Mojo, C, and Rust print where Python keeps the integer. The CUDA example's table gains Mojo 1.0 GPU kernels and a CUDA C reference beside CuPy and Numba -- thread-level kernels against thread-level kernels; Triton and Taichi, which program tiles, are the counterparts of PPY's tile kernels instead. examples/compare.py prints microseconds to four places where a kernel is that small.
  • Three examples compare PPY with the tools that do the same job, code and numbers side by side: the parallel ranges against Numba, Taichi, Mojo, and NumPy; the CUDA kernels against CuPy and Numba; the eight algorithm kernels against Numba, Mojo, and Codon. Each counterpart is in the example's compare/ folder, written the way its tool wants it, and examples/compare.py holds them all to one answer and tabulates the timings. The CUDA table says plainly what PPY lacks: an array that lives on the device between launches.
  • A parallel body that writes a buffer element, out[i] = i * i under parallel.range, lowers: the store writes through out and binds nothing outside the loop, which is what the guide promised and what the check mistook for an assignment of out.
  • Regular expressions run natively. A pattern compiled from a bytes literal at module level -- WORD = re.compile(rb"[A-Za-z]+") -- or written into re.search(rb"...", buf) becomes a regex.search, regex.match, or regex.fullmatch operation over a Buffer[ppy.u8], and the new lower-regex pass compiles each pattern into a matcher function of core operations, so the LLVM and C backends run it as they run anything else. The matcher backtracks the way CPython's does and answers exactly what re answers -- ordered alternation, greedy and lazy repeats, the group's last iteration, $ before a trailing newline, \b at ASCII word edges, pos and endpos -- on random inputs across both backends. A match is a local whose start, end, and span are native; m is None and if m: narrow it; group() stays on Python. Backreferences, lookaround, atomic groups, possessive repeats, and locale categories are refused with the reason, and a match that would need more than the matcher's stack falls back to re. The checker types re.compile, re.Pattern, re.Match, and the flags. A while True: loop that only leaves by returning lowers too, which is how a search loop is written.
  • The project scan skips a virtual environment by any name -- a directory holding pyvenv.cfg -- not only .venv and venv; a second environment kept beside the first no longer costs a scan of every package in it.
  • ppy.input[T]() takes no argument, and ppy.scan[Buffer[int]](https://github.com/franknoh/PPy/blob/main/n) takes only how many values to read: reading and printing are two things, so a prompt is a print before the read rather than an argument that meant a prompt for one type and a count for another. The checker says so (E1305 for an argument to a scalar read or a missing count, E1301 for a count that is not an int), and the converter writes input("p")'s prompt as print("p", end="", flush=True) before the statement that reads -- or, inside a loop's test or a comprehension, as the one-expression print(...) or ppy.input[T]() so it still prints each time.
  • The C backend writes structured code. Loops are while, branches are if/else with break, continue, and return, rebuilt from the IR's dominator tree and loops; a stack slot that is only loaded and stored is a variable named after it, a parameter keeps its Python name and is the variable its slot was, and a value read once is written where it is read with the parentheses C's precedence needs and no others. Python's floor division by a positive constant is (a % b + b) % b, a failed guard is if (b == 0) return 1;, a checked addition into a variable writes the variable itself. A graph the reconstruction cannot express falls back, for that function alone, to the labels-and-goto writer, so every unit is still correct; the tests hold both writers to the LLVM road's answers. CUDA and HIP get the same treatment, with C++'s int64_t(x) casts. ppy emit --format runs c, cpp, cuda, hip, and header output through clang-format, with the project's .clang-format where it has one.
  • A pandas or PyArrow expression fuses into one loop in fact, not only in name: the kernel is a columnar.map that evaluates the whole tree row by row, where it was one loop and one heap temporary per operation plus a copy at the end (s * t + s.fillna(0.0) over eight million rows: 109 ms to 35 ms, from slower than pandas to level with it). A NumPy-backed Series now runs under NumPy's own null model, in the kernel's twin for it: a NaN is the null fillna fills and isna finds -- before, the kernel read a NaN as a value behind a bitmap of ones, so s.fillna(0.0) under ppy run handed the NaN back -- a bool mask is a byte per row, a bool answer over NumPy storage no longer falls back to pandas, and the answer is written straight into the NumPy array its Series wraps. != is IEEE's on every path: a NaN differs from everything, as pandas and Arrow have it.
  • & and | on pandas Series lower to the columnar dialect's new and_kleene and or_kleene, the three-valued logic pandas computes over an Arrow-backed Series: (s > t) & t.notna() is false, not null, where t is null. PyArrow's and_/or_ keep the two-valued and/or, and pc.and_kleene/pc.or_kleene name the Kleene forms. A fused answer no null can reach -- isna(), notna(), logic over them -- comes back as the NumPy bool Series pandas gives, whatever the inputs' backing; the nullability of a fused tree is derived from the tree, not from its root alone. An end-to-end test holds ppy run to what python prints for finite values, NaNs, fillna, nested trees, and mixed arithmetic and null handling on both backings.
  • A fused library expression inside a wider call is replaced, and the call kept: the fusion plan is keyed by the expression's whole source span, where it was keyed by the start alone, so s.isna().sum() -- which begins where s.isna() does -- had the whole call rewritten to the kernel and the .sum() lost. It went unnoticed while the kernel fell back to pandas for that case, whose fallback was the whole expression.
  • examples/45_multi_gpu_jax: a data-parallel MLP over a mesh of every accelerator in the machine, the batch sharded and the parameters replicated, the gradient summed across devices, held to a single-device run; fewer than two accelerators is reported, never worked around with a CPU or a virtual device. scripts/cloud/runpod_matrix.py validates the accelerator stack on rented hardware on demand -- one NVIDIA GPU, two or more in one Pod, an AMD Instinct where one is in stock -- with accelerator_check.py failing a GPU run whose JAX sees only a CPU, the CUDA, tile, and XLA examples under ppy run with ppy explain beside them, the multi-GPU trainer, a one-process-per-GPU distributed smoke test, and a few benchmarks as validation numbers; every Pod it makes it deletes. The docs' internals/hardware-validation.md records the last run.
  • The docs' example counts come from the tree: 18 joins the two existing markers, the architecture page's program count is a marker, and a test holds examples/README.md's own count to the same functions, so no page spells a number the tree has moved past.
  • ppy convert keeps Python's unpacking where the target is starred: a, *rest = map(int, input().split()) becomes a, *rest = ppy.input[list[int]](), a list of however many fields the line holds, never a fixed-width tuple that would refuse a longer line; float and str fields alike, and a target with anything but names in it (a nested tuple) is left as it was written. A differential test runs the Python and the conversion on the least arity, more, too few, an empty line, integers past 64 bits, and non-ASCII digits.
  • ppy.check[T]: a Literal is its value and its type, so Literal[1] refuses True and Literal[False] refuses 0 though the pairs compare equal; a fixed-width integer (i8, u8, ...) refuses a bool; and the whole of T is judged before any value is looked at, so a union with an arm no value can be checked against (int | Callable[..., int]) is refused whichever arm comes first and whatever the value.
  • The PJRT bridge no longer reads a JAX that will not initialize as the CPU: the error is raised, and a JAX that came up on the CPU while an accelerator plugin is installed (and JAX_PLATFORMS did not ask for the CPU) is raised too, naming the plugin. Without JAX the platform is the CPU available() already qualifies; PPY_XLA_PLATFORM still wins.
  • The hardware harness: a Pod that is not gone after a run fails the run (cleanup_ok, and the exit status with it), --keep excepted; an AMD type the stock list lacks is asked for in every datacenter whose inventory names it, and NOT RUN is recorded only when every request was refused; the ROCm environment is AMD's own JAX image, started under an sshd for the injected key since the image runs none, with PPy installed beside its JAX under version constraints and every jax* distribution checked to be the one that was there; and ppy emit hip is a step of a ROCm run, the source compiled by the image's hipcc. An MI300X run passed on every step. Unit tests with a fake runpodctl hold the judgement.
  • The docs' landing page carries no count of folders or programs as digits: the "by the numbers" row uses the markers, and the guard test sees through Markdown emphasis. examples/45_multi_gpu_jax times its steps without a host synchronization between them.
  • ppy.native.compiled(f) says whether calling f runs its native form here. Under ppy run the object in a module's namespace may be the generated C entry point itself, which carries no attribute; the runtime now knows those by identity, aio.compiled answers through the same probe, the training examples print # native prep: True under ppy run as they should, and the entry point bears the function's qualified name rather than call_0.
  • The landing page's test and diagnostic-code counts come from the tree through markers (1264, 78), and the guard refuses those spelled as digits. The GPU guide says what a ROCm machine gets: no launch, compiled false, ppy emit hip for hipcc.
  • The ROCm harness installs every group when the image's JAX allows it and every group but jax otherwise, and says which.
  • The lowering cache dropped a coroutine's future kind from its signature, so the second ppy run of a program whose entry coroutine was served from the cache bound it through the plain boundary and handed aio.run a bare handle instead of a future (TypeError from asyncio). The cache carries the kind now, its schema moved to 7 so no stale entry is served, and a test holds the round trip. Found by recording the examples' outputs twice.
  • Every example README is rewritten in a plain voice with its detail kept, and ends with the commands and what each one prints. A short output is inline, a longer one is folded into a <details> block, a very long one is a file under the folder's outputs/ that the documentation site embeds; each exists once. The six judge problems gained a small input.txt so their commands run as written.

0.2.0 — 2026-09-08

The release that turns the compiler into a platform: a typed, multi-dialect IR between analysis and every backend, a pass and pattern infrastructure over it, and plugins that extend it through explicit APIs. The entries below are in the order the work landed.

  • One version, 0.2.0, in every place that states it: the compiler constant the packaging build reads, ppy_runtime.version, and ppy.__version__, held together by a test. Every cache and artifact schema moved with it -- the frontend cache, the lowering cache, the project scan, and the binding manifest's ABI, now 2 -- so nothing a 0.1.x compiler produced is served by this one; a 0.1.x manifest is refused with the rebuild message.
  • The typed canonical IR, ppy_compiler.ir: SSA values with one type each, blocks with arguments and one terminator, an explicit control-flow graph, and operations named in dialects. The core dialect spells overflow and rounding on the operation rather than leaving a backend to guess. A verifier checks structure, dominance, symbols, and every dialect's own rules and returns a list of errors with their positions; the printer writes one deterministic text per module, the parser reads it back, and .ppyir is that text with a schema and dialect-version header a reader refuses rather than guesses at. docs/internals/ir.md is the reference.
  • Passes and patterns over the IR. A pattern rewrites one operation through a rewriter that records every change and revisits what it touched; the greedy driver runs a pattern set to a fixed point and names a pattern that never settles. The core dialect's patterns fold constants by the operation's own overflow and rounding attributes, remove identity elements, double negations, lossless cast round trips, and settled selects and comparisons, and leave alone what floating point or checked semantics forbid. A pass manager runs passes that declare what analyses they require, preserve, and invalidate, caches those analyses accordingly, verifies the module after every pass on request and names the pass that broke it, and runs plugin passes at named stages. The shared passes are canonicalize, constant-fold, simplify-cfg, and dce.
  • The LLVM backend has a second road: Python AST to canonical IR (ppy_compiler.lowering), the shared passes, and IR to LLVM (backend/llvm/from_ir), which reads the IR and nothing else. It covers the whole native subset the direct road covers -- scalars, fixed tuples, value classes, borrowed buffers and their loops, calls between native functions, math intrinsics, the standalone shims, specialization with pinned constants -- with the same guard hoisting and the same solver proofs, and answers alike on every input, fallbacks included: the whole suite and every example pass on it. [tool.ppy.llvm] pipeline = "ir" selects it, PPY_LOWERING=ast|ir overrides for one process, every cache is keyed on the road, and CI runs the suite on both. One thing the IR road does that the direct road did not: MIN // -1 takes the fallback instead of trapping in the division.
  • Plugin interface 2. Plugin is a base class with a no-op default for every hook, so the compiler calls operator, subscript, instance_attribute, call_alias, decorator_semantics, and adjust_call directly instead of probing for them; the builtin plugins extend it. What a call answers about lowering is a typed spec -- IntrinsicSpec, DialectOperationSpec, DirectCallSpec, GraphRegionSpec, FallbackSpec, RejectSpec -- never backend code. A plugin registers dialects, patterns, and passes for the IR, and the pipeline runs its passes at their stages, verified, naming one that breaks the IR (E1902). External plugins are discovered through the ppy.plugins entry-point group without being imported, and load only for a project that names them; two plugins claiming one module are a reported problem (E1901), never a question of who registered last.
  • Three builtin plugins: scipy (special functions, transforms, dense linear algebra, sparse matrices, typed and named as dialect operations; the callback-driven families carry their effect), pandas (frames, series, and indexes typed as what they are, the curated surface named as columnar operations, everything the model does not capture exactly left to pandas), and pyarrow (Arrow typed as Arrow, the curated compute named as the same columnar operations).
  • Effect system v2. The vocabulary every consumer shares -- purity, native and GPU eligibility, code motion, fusion, the async lowering, plugin contracts -- now names native memory apart from Python objects (read_memory/write_memory) and adds network, atomic, python_dynamic, gpu_launch, and device_memory; the socket and urllib surface carries network. Every IR function carries its effects and the passes read them: an unused call to a callee with none but allocation and reads is dead code.
  • A light ownership model: ppy.Owned[T], ppy.Borrowed[T], ppy.Mut[T]. A borrow lasts the call -- not returned (E1611), not stored where it outlives the call (E1612), not written unless Mut (E1613) -- and a Buffer[T] is borrowed unless the program says otherwise. The IR carries ownership and noalias on parameters, and its verifier refuses a borrowed parameter in a return or a store.
  • Generics. def f[T: Bound](https://github.com/franknoh/PPy/blob/main/...) declares type parameters; a call infers the arguments, checks the bounds (E1721), and substitutes them into the result. Native code monomorphizes: a generic called from native code is lowered once per tuple of type arguments under a name that spells them, and the call goes straight to the instance. [tool.ppy.generics] bounds the specializations (E1722), and a generic that feeds its own type parameter back into itself wrapped is refused (E1723). Inside native code a + b on a value class dispatches statically to the class's own __add__; native code never falls back to dynamic dispatch.
  • The math dialect: elementary functions named once for every backend, pure, folding on constants, floor(floor(x)) once. The frontend writes math.sqrt where the source says so; the LLVM backend lowers it to the intrinsic of that name.
  • ppy.native is the directive it was and a namespace of typed native memory: ptr[T]/const_ptr[T], load, store, offset, cast[U], sizeof[T](), alignof[T](), stack_alloc[T](https://github.com/franknoh/PPy/blob/main/n), with a reference implementation over array memory under CPython so the three paths agree, and pointer operations in native code. @native.extern binds a stub to a C symbol -- ctypes under CPython, a direct call in native code, the library linked and loaded -- and @native.export gives a function a public C symbol, with a header written beside the built library and a trap where Python would have taken the fallback. ppy.ffi is the binding layer over it: library, bind, nullable, LengthOf.
  • ppy emit ir|llvm-ir TARGET [-o] prints a compiler stage as text, one rule for every kind; .ppyir is the IR's on-disk form, self-describing down to each function's ABI, and ppy build foo.ppyir builds an object, a library, and a manifest from it alone.

Speed of the compiler itself, measured before being changed.

  • A warm ppy run no longer imports the compiler. The first run of a program builds its artifact into the cache and launches it; the next run finds the artifact by a key over everything that could change it and goes straight to ppy_runtime. On a small program that was 1.65 s of imports, LLVM initialization, and re-analysis before the first line ran; it is now the launcher's few dozen milliseconds plus the key. Programs that specialize at runtime, fuse NumPy kernels, or use the JAX plugin keep the in-process JIT, and say so in a needs-jit note.
  • A built artifact carries its torch ATen regions. The extension compiled against the installed PyTorch is copied beside the manifest and recorded under regions, and ppy_runtime loads it with no compiler in the process; a region library that has gone missing is the Python body. So a program that imports torch is cached and launched like any other, and a .ppy kernel that imports torch is served natively by import ppy.
  • ppy build --warm TARGET builds ahead of time what ppy run and import ppy build on first use, for one module or every .ppy under a directory, under the project configuration alone -- flags that would key a different artifact are refused. It is the step before a launch that starts many ranks at once: each finds the build instead of making its own.
  • examples/31_torchrun: a trainer with two .ppy kernels -- native preprocessing loops and an ATen-region model -- that runs the same under python, torchrun, and accelerate launch, with import ppy as the whole integration.
  • A development tree fingerprints the compiler by the sizes and mtimes of its sources rather than by reading them all, which was a third of a second on every command.
  • Inference stops re-checking what nothing moved. Each round used to seed every function's summary again and then check every module twice to confirm it; now the seed runs once per project, and a module whose inputs -- its own signatures and fields, and those of everything it imports -- have the digest they had when it was last checked keeps its analysis instead of being checked again. The answers are byte for byte what they were.
  • ppy convert and ppy migrate stop repeating themselves on large files. The reflection index walked a module's whole tree once per function it held; the write index and the reflection index each walked, parsed, and lexically scanned the whole project on their own (the latter twice); the lexical scan snapshotted every statement's environment onto the two Load/Store singletons and spent most of its time joining them; and every migration pass walked every module whether or not the module spelled anything it rewrites. The alias analysis, which runs for every function on every inference round, did the same snapshot-and-join on the Load/Store singletons inside its loop fixpoint -- a loop inside a loop multiplied it -- and now records without joining and joins only what is asked about. The alias map is also computed once per function per project rather than once per checker pass -- it depends on the body and on which parameters are immutable, and on nothing else, while the checker runs at least twice per analysis and once per inference round. A 3270-line module migrates in 5.6 s where it took 10.5 s, with byte-identical output; the compiler's own 26,600 lines migrate in 50 s where 85 s used to end in a crash.
  • An error that would have to say <unknown> is not reported. It only restates a type that was never resolved, and that unresolved type already has its line -- the untyped parameter (E1201, E1304) or the call with no signature (E1306). A 3270-line module went from 332 reported errors to 35, and the 35 are the findings; one W2006 line says how many were withheld and names the unknown signatures behind them.
  • A field is typed by everything its class assigns to it, joined, rather than by the first assignment in __init__ alone: self.buffer = None there and a tensor in setup() is Tensor | None, where it used to be None and every later assignment an error. A field the class body annotated keeps the annotation.
  • Three checker gaps the compiler's own source exposed are closed: the set algebra (a | b, a & b, a - b, a ^ b on sets and frozensets) was "not defined", frozenset(...) was typed as a set, and a project class's own __or__, __add__ and the other operator methods were never consulted. Together they were 193 of the compiler's 627 self-reported errors, and none of them was a finding.
  • A name imported from a library module is what the library says it is: from pathlib import Path then Path(p) was an unknown signature while pathlib.Path(p) was not. pathlib.Path is modeled -- construction, the filesystem calls with their IO effect, the spellings of other paths, the string parts -- and so are the ast functions (parse, walk, unparse, and the rest). x: type is an annotation, and what a class held as a value exposes is known. libcst's node classes are opaque types.
  • f(*pair) is as many arguments as the pair holds, and [first, *rest] holds what rest holds. type[Base] is the class object of Base or a subclass, a class is callable, and dict[str, dict[str, int]] is a dict[str, Any]: Any is anything at any depth of an invariant argument. str.partition and the rest of the str methods with a known result are modeled; an external class's bases are spelled the way the annotation spells them, so a libcst.Call is a libcst.BaseExpression; sqlite3's classes are annotations.
  • A class whose members cover a project Protocol's is an instance of it, and one that defines __iter__ is an Iterable. A function, a class, a module: everything is an object. type(x) of something unresolved is some class and fits type[Base]; list | dict in an isinstance is a type; table.get(type(node.op)) may ask with a wider key than the table holds. A library exception is a BaseException.
  • program or {} is never None: an operand of or that is not the last is the result only when it is truthy.
  • A constant subscript is a place a check can be about: after if args[0].facts is not None, args[0].facts is not None, until args[0] is written. A union of tuples unpacks position by position, so count, first = seen.get(key, (0, node)) gives an int count. Being one of the constants in x in {"a", "b"} is being of their type.
  • ppy check, run, and build know the fields __init__ assigns. self.width = width says as much about the field as an annotation would, and only convert and migrate used to hear it: the single-pass path reported has no attribute on every read of such a field. The analysis now settles those fields in its own fixpoint, from what its seed pass saw assigned, so every path starts from the same fields and the reporting pass reads fields that are known. No extra pass: ppy check costs what it did.
  • Every whole-tree scan iterates a node tuple the tree's owner walked once: a conversion made a dozen passes over the same trees through ast.walk, which was a tenth of its time. A branch merge keeps a binding both sides share instead of joining it with itself, and the alias snapshot's revisit check is a set lookup rather than a scan of a list that grew with every loop iteration the fixpoint needed.
  • A project class that subclasses a builtin has the builtin's methods: class Reached(list) may call self.append, and super().__init__(...) past a base the project does not define is a call that returns nothing.
  • A migration pass runs only over a module that has the shape it rewrites, decided on the syntax tree rather than on a substring: getattr is in most files, getattr(x, "name") in few, and a pass that found nothing still paid for a position-annotated traversal of the whole module. The builtin method table is built once per receiver type and attribute rather than on every attribute read; an annotation's expression is parsed once per spelling; the rewriter hands its module to the normalizer instead of printing and parsing its own output. A path is resolved once per directory and a module's file is probed for once per graph build: every importer of pkg.util used to stat the same four paths, and resolving walked every component with an lstat. A migration pass resolves positions once, after it ran, and only for what it rewrote; and each function's environment starts from the module's imports and classes seeded once per pass rather than rebuilt per function. Signatures are wrapped only when some line of the module is past the limit.
  • The whole-project scan behind Final and annotation materialization keeps, per file, only what the two indexes need -- the attribute writes it makes on other modules, the annotation readers it holds and calls -- and serves that record from the cache store when the file's path, size, and modification time match one written by this compiler. Converting one file of a large project costs a stat per file, not a parse.
  • A class's MRO is derived from its name, not part of a type's identity: pathlib.Path reached through the analyzer's own table and through the library's __mro__, which Python 3.13 spells with more bases than 3.14, was a union of two types that printed alike, and path / "x" was an error on one Python and not the other.
  • A module the analysis did not need to recheck still reports what it found. The fixpoint keeps a module whose inputs have not moved between rounds, and the kept module's diagnostics lived in the earlier round's discarded bag: ppy check b.ppy, with b importing an a whose error was already settled, said "no errors". Each module now keeps its own report, and the round that settles reports every module.
  • llvm.prover = "z3", or --prover z3 on ppy run and ppy build: the solver proves overflow guards away where the analysis allows it. A chain of +, -, * is stated as an obligation over integers -- each load a variable with the range the analysis recorded, each induction variable carrying start <= i <= stop - 1 from its range() -- settled by intervals when they suffice and by Z3 otherwise; a proven chain lowers to the plain nsw instruction, an unproven one keeps its guard exactly as before. A function whose guards a proof may leave out checks its parameters' declared ranges once on entry, and a call outside them takes the fallback, so the three paths agree on every call. Needs ppy-lang[solver]; ppy doctor reports the solver; the artifact and the warm run directory are keyed by the prover and its version. docs/internals/solver.md says where a solver fits, where it does not, and what is next.
  • A type alias imported from another module is read in the module that defines it: from .obligations import Term with Term = Union[Var, Const, BinOp] was "not a type the project can analyze", because the alias table was the importing module's. Arithmetic on a union of numbers is arithmetic on the widest of them: x / 2 with x: int | float is a float, not an undefined operator.
  • A dataclass is built the way dataclasses builds it: the fields of each dataclass base first, a field with a default or a field(default=...) optional, kw_only=True, field(kw_only=True) and everything after a KW_ONLY marker by keyword only, InitVar[T] a constructor parameter and not an attribute, and a required field left out is an error. Stats(*totals) is checked by the list, not by position.
  • PAGE = 8 in a class body is a class attribute, read through the class and through every instance, and not a dataclass field.
  • self.a, self.b = x, y sets two fields. mask | other on arrays is bitwise_or, with &, ^ and the shifts. Being one of the constants in x in {"small", "medium", "large"} is being those literals, which a Literal[...] return type accepts.
  • An instance of a project class is callable through its __call__, or through the method a plugin names for an external base: an nn.Module subclass is called through forward. torch.nn.Module's members are modeled -- parameters, state_dict, to, eval, and the rest -- and a method that returns the module returns the subclass it was called on. A bare container iterates: isinstance(x, tuple) leaves a tuple that a for can walk. sys.path, sys.modules, sys.version_info are known. --no-strict is accepted after the subcommand as well as before it, except on convert, which has no strictness escape hatch by design. A bare tuple annotation is tuple[Any, ...], any length, and a dict[str, tuple] holds a dict[str, tuple[A, B]].
  • A run whose Python lacks its headers says so: the ctypes boundary is correct and several times slower per call than the generated CPython ABI, and a node built from a system interpreter without python3-dev used to find that out only from ppy doctor. On one real kernel the difference was a native call at 14 us against the interpreted 5.6 us, becoming 1.3 us once the headers were there.
  • ppy doctor prints the C library it found, and docs/reference/compatibility.md says what the platform floor is: the wheel is pure Python, everything native is compiled where it runs and binds to that machine's libc, and the dependencies' wheels set the minimum -- glibc 2.17 for llvmlite, libcst 1.7, z3-solver 4.x and numpy up to 2.2; 2.27 for z3-solver 5.x; 2.28 for numpy 2.3 and libcst 1.8. ppy-lang pins libcst<1.8 on Python 3.13 and earlier: from 1.8 it ships manylinux_2_28 wheels only, and on Ubuntu 18.04 the install fell back to building its Rust sources and failed. Python 3.14 has no 1.7 wheel and takes 1.8.
  • import ppy is the whole integration. With the compiler installed, a .ppy module imported from a plain .py program is served from its native build: the first process builds it into the project's cache -- the artifact ppy run builds -- and binds its functions through the prebuilt binder, every later process finds the build, and a kernel that does not check clean, or needs the in-process JIT, loads as Python source with one line on stderr saying why. A program carved into .ppy kernels needs no bootstrap and no launcher. PPY_IMPORT=python turns it off for a process, native-import = false under [tool.ppy] for a project, PPY_QUIET=1 silences the notes, ppy.native_import(False) decides in code, and ppy.native_imports() says which modules came in native. The build itself is the compiler's (ppy_compiler.driver.native_import), asked for by name: the runtime still never requires the compiler, and a .ppy program without it loads as source.
  • scripts/dogfood.py counts the errors in a target's own files: a module reached through an import is another target's business.
  • ppy check pkg/a.py checks pkg.a. A file inside a package used to be named from its own directory -- a -- so every from . import b in it was unresolved, on the one command people run most against the file they are editing. And from pkg import mod now follows mod into the program when it is a module rather than a symbol: from . import types as T left T.Type with nothing behind it whenever types was not itself an entry, which was the compiler's own largest remaining self-reported error and every one of its 3,000 withheld consequences.
  • A name a package re-exports resolves to where it is defined: from ..diagnostics import Diagnostic follows diagnostics/__init__.py to .model. A bound method passed along as a value (notify=reporter.note) is a callable without its receiver, however it is later called. A check on an attribute narrows that attribute: after if self.end is not None:, self.end - 1 is an int, and so for isinstance(self.node, Call) and a truthiness test, until the attribute or its owner is assigned again. A walrus inside an and chain binds its name for the body. dir is a builtin. The compiler's self-reported errors went from 627 to 195 across these rounds, and its runtime's from 182 to 84.
  • super() in a class whose base is not the project's -- an Exception subclass -- is super(), not an undefined name. A list, set, or dict written out beside a declared type is held to that type's elements rather than typed from its first element and then rejected (stack: list[ast.AST] = [stmt]). Dictionary views (keys(), items()) are set-like, so left.keys() | right.keys() is defined.
  • An annotation may name a common standard-library class -- pathlib.Path, every ast node, re.Pattern, datetime, collections.deque, argparse.Namespace, and others -- without the analyzer modeling it. Each carries its real hierarchy, read from the class, so an ast.Call is accepted where an ast.AST is expected; Path / "name" is a Path; and Ellipsis and NotImplemented are names. p: Path used to be "not a type the project can analyze" in every file that took a path. Resolving these surfaced a few findings the unknown had been hiding, so the runtime's dogfood ceiling moved from 119 to 127 while the compiler's came down from 315 to 287.
  • The math module is modeled: all 57 functions and the five constants, with the exceptions the C implementation raises. Every numeric kernel imports it, and math.tanh in a reward function was an unknown signature that everything computed from it followed.
  • Every builtin exception is a known name. Thirteen were listed by hand and AssertionError, RuntimeError, OSError and fifty-three others were "not defined at this point"; the table now reads the interpreter's own hierarchy.
  • docs/internals/migrating.md says what to hand ppy migrate on a real project: profile, find the two or three files that do the numeric work, migrate those, and leave the orchestration as .py importing them through the loader. It also says how to read the report -- E1304 is the to-do list, W2006 is the count of what follows from it, and the rest is about the code.
  • The compiler migrates itself in CI. scripts/dogfood.py runs the converter over src/ppy_compiler, src/ppy_runtime, and src/ppy on every push and fails on a crash, on <unknown> in any message, or on more errors than the recorded ceiling, which only ratchets down. Thirty thousand lines of real Python found the crash below, the missing exceptions above, and the cascades; now they keep finding things.
  • ppy migrate no longer crashes on a module whose first statement is a relative import: placing the ppy import spelled the missing module name as an empty identifier, which libcst refuses.
  • The C backend, ppy emit c, and its C++ form, ppy emit cpp. Both read the canonical IR and write one translation unit per module: every function in the ABI the runtime binds, every export behind its public signature (extern "C" in C++), the overflow helpers and runtime shims the unit uses and no others, so it compiles on its own and answers what the LLVM road answers, fallbacks included. The C++ output is the emitter making C++ choices, never C text rewritten. --header-only makes every function static inline under a guard and refuses, with E1804, a feature that needs state the process owns; --standalone emits a whole program from main; ppy emit header prints the export declarations a built library ships. tests/test_c_backend.py compiles the C and the C++ and calls them on the LLVM road's inputs.
  • TargetInfo, and builds for another machine. One record holds what the compiler knows about a target -- triple, CPU and features, pointer width, endianness, ABI, OS, object format, data layout -- the host being one target among others, and the cache keys, the warm key, ppy doctor, and the linker ask it instead of sys.platform. ppy build --target TRIPLE (or [tool.ppy.llvm] target) retargets the objects and links them with a toolchain for the triple; the wrapper and the launcher, which only the running interpreter can build, are left out with a note, and the manifest names its target so a runtime elsewhere refuses it.
  • ppy build --python-extension: one importable CPython module of the native code, the generated boundary, and the module's own Python, bound as it is defined through the same hook the launcher uses; and ppy build --library: the exports laid out as lib/, include/, a pkg-config file, and the manifest.
  • ppy bind header foo.h: PPY bindings for a C header, read through libclang (ppy-lang[bind]) -- functions as typed @ffi.bind stubs, typedefs, enums, structs of scalars as dataclasses, numeric #defines as constants -- with what has no spelling yet listed by name rather than guessed at.
  • Four dialects and their namespaces: simd (vector<T, N> made, moved, shuffled, and reduced in lane order), cpu (prefetch and pause hints, and the cpu.features a function is compiled for), atomic (every operation with its C11 memory order, verified), and concurrency (spawn, join, and mutexes, conditions, and barriers that are memory the program owns, implemented over the atomics the same way on every backend). The LLVM and C backends lower all four; ppy.simd, ppy.cpu, ppy.atomic, and ppy.concurrent carry them into the language with reference implementations under CPython, the checker's rules (E1640-E1643), and the frontend's lowering, so a program using them runs the same on every path. @cpu.target("avx2") compiles a function with the features on, and the boundary binds it only where they are.
  • ppy.parallel v2. The parallel dialect -- parallel.for, reduce, map over an outlined body -- and lower-parallel, which decides once per build how a range is split: one chunk on the calling thread (serial, simd), chunks spawned through the concurrency dialect and joined (threads), or OpenMP regions the C backend spells (openmp); every choice gives the same answer, a floating-point reduction keeps its order unless @ppy.fastmath permits otherwise, and a chunk that fails a guard fails the loop. for i in parallel.range(n) in the language, with one +=/*= reduction, and @ppy.parallel asking the same of a function's outermost loops; the checker (E1650) and the frontend refuse what cannot run at once and say why, and optimization remarks say what became parallel.
  • The tensor family of the IR: shapes with symbols and expressions and the inference the verifiers hold operations to, the layout dialect, the tensor dialect and lower-tensor (views where memory exists, loops where it must be made, on the stack or the heap), the linalg dialect (loops for dot, matmul, the solves and Cholesky; LAPACK for QR, SVD, and eigendecomposition where a build has it), the fft dialect (the transform by its definition, complex as (re, im) pairs), the special dialect (erf, gamma, Bessel and friends through libm on both backends), and the sparse dialect (CSR, CSC, COO with explicit index types; matmul, add, transpose, convert, reduce). All of it is checked against NumPy on both backends.
  • The numeric plugins converge onto the tensor IR. A plugin names the shared operation a call is (tensor_operation): numpy.multiply, torch.mul, and jax.numpy.multiply are tensor.mul; numpy.sum and torch.sum are tensor.reduce {op = add}; scipy.special.erf over arrays is tensor.unary {op = erf}. The fused kernels are built from that vocabulary as tensor IR -- tensor.fill, tensor.unary, pow, min, max joined the dialect, and lower-tensor binds a symbolic dimension from the buffer a tensor is loaded from -- so nothing in the fusion path writes LLVM IR any more, and one kernel runs over a NumPy array or a CPU torch tensor alike behind each library's guards. The torch plugin reports its curated arithmetic as the shared operations while matmul and the other dispatcher-sensitive calls stay with the dispatcher; ppy explain shows the shared operation beside a call's lowering.
  • Tensor canonicalization and fusion on the IR. The tensor dialect's patterns remove views that change nothing, compose transposes and reshapes, fold arithmetic over fills into one scalar computation, and drop neutral elements where that is exact. tensor-fusion turns a chain of elementwise operations whose intermediates have a single reader -- with a reduce at the root where there is one -- into tensor.fused, a region computing one element from one element of each input, and lower-tensor makes a single loop of it; a result whose only reader is a tensor.store is written straight into the store's buffer. A fused NumPy or torch kernel is now one loop with no temporary and no copy, and the pass reports tensor ops fused for every group it makes.
  • Autodiff. ppy.grad(f) and ppy.value_and_grad(f) -- with argnums -- differentiate a function of floats whose body is assignments and a return over arithmetic, the math functions, abs, erf. Under CPython the derivative is made from the source; natively the autodiff transform differentiates the function's IR in reverse mode -- scalar arithmetic, select, casts, and over tensors the elementwise operations, fill, broadcast, reshape, transpose, reduce by sum, matmul, convert, with a buffer parameter's gradient written to a buffer -- after promote-slots has turned the frontend's stack slots back into values. One rule table and one order of accumulation on both paths, so they agree bit for bit; a branch, a loop, a write, or an effect is refused with the reason (E1660-E1662).
  • The columnar and arrow dialects. A columnar.column<T, nullable> is a column with a validity bitmap and a run-time length, a columnar.table a set of named columns; the operations pandas and PyArrow share -- arithmetic, comparison, and boolean logic with null propagation, cast, is_null, fill_null, select, filter, take, concat, sort_indices, aggregate, and over tables project, group_by, and an inner join -- are values the verifier checks, and lower-tensor makes loops of them in Arrow's layout: bit-packed validity, bit-packed bools, a merge sort for ordering, sort-based grouping and a sort-merge join. arrow.import reads an array from the Arrow C Data Interface struct without a copy and arrow.to_column makes it a column. Checked against NumPy on both backends and against an ArrowArray built by hand.
  • PyArrow converges onto the columnar IR. The plugin names the dialect's operations, and an expression tree of pyarrow.compute calls over float64 and bool arrays -- arithmetic, comparison, boolean logic, if_else, fill_null, is_null, is_valid -- is fused into one kernel built as columnar IR, run over the arrays' own buffers behind the same guards NumPy's kernels have, and answered as an Arrow array over the buffers the kernel filled. pandas spells the same operations by the same names.
  • pandas converges onto the same kernels. A tree of Series arithmetic, comparisons, fillna, isna/notna is one columnar kernel; an Arrow-backed Series is read as its Arrow array, a NumPy-backed float64 Series as its values behind a bitmap of ones, and the answer is a Series over the callers' index with the same backing. Alignment of different indexes, mixed backings, nullable extension dtypes, and bool answers over NumPy storage stay with pandas; a Series operator now resolves to the plugin's operation (it was composed into a name no plugin knew). ppy_runtime.arrow.exported lends a PyArrow array to native code as the C Data Interface's ArrowArray struct and releases it once the borrow ends.
  • ppy.xla, the StableHLO backend, and the PJRT bridge. @xla.jit marks a function of scalars whose body is arithmetic and math; the compiler lowers it to the IR, backend/stablehlo writes it as an MLIR module -- scalars as rank-0 tensors, the tensor dialect one to one onto StableHLO (broadcast_in_dim, reshape, transpose, reduce, dot_general, convert, a fused region as its body), erf as CHLO -- ppy emit stablehlo shows it, and the build stages it. At run time ppy_runtime.xla compiles the module through XLA's own bindings, caches the executable by digest, bindings, platform, and device (in memory and serialized on disk), and runs each call on the device; xla.devices() names them. JAX is not on the compile path; the bridge places buffers through JAX's device_put while that is the one public way to the client. What XLA cannot take is reported as W2007 and runs as written.
  • The gpu dialect: one execution model every GPU backend meets. A function is host, device, or kernel by its gpu.kind; device code reads gpu.thread_id, block_id, block_dim, grid_dim (each .x, .y, .z), waits at barrier and subgroup_barrier, trades values with subgroup_shuffle, takes shared_alloc and private_alloc memory, and is handed pointers into global and constant memory; the host launches a kernel with gpu.launch. The verifier holds the kinds -- a device operation in a host function; a guard, a buffer, a host call, or another dialect in device code; a kernel that returns or takes stack memory -- through the new Dialect.verify_function hook, and the LLVM backend leaves device code to the GPU backends. A program without a kernel is untouched.
  • The IR's text reader ends an operation at its line. An operation with nothing after its name -- gpu.barrier, a bare core.ret before the next block -- used to take the following line's value or label as its own operand or successor; the printed form always was one operation per line, and the reader now holds it to that.
  • ppy.cuda and ppy.hip, and the CUDA/HIP source backend. @cuda.kernel and @cuda.device (or hip.) mark device code; thread_id, block_id, block_dim, grid_dim, global_id, syncthreads, syncwarp, shared[T, N](), local[T, N](), the shfl family, and launch(kernel, grid, block, *args) are the vocabulary, lowered to the gpu dialect by the one frontend -- inside device code int arithmetic wraps and nothing guards -- and under CPython a launch runs the grid on threads that know their position, a reference with real barriers and shuffles. ppy emit cuda and ppy emit hip write a module as CUDA or HIP C++: __global__ kernels, __device__ functions, __shared__ memory, __shfl_sync or __shfl, and <<<grid, block>>> launches followed by a device synchronization whose status the host function reports. The CPU backends leave device code alone, and a launching function stays in Python until the launch runtime. E1644 names a misuse. A C++ unit now includes a header that is not C's standard library -- pthread.h, omp.h -- as it is spelled rather than as <cpthread>.
  • The NVVM backend and the CUDA launch runtime. backend/nvvm writes a module's kernels and device functions as LLVM IR for NVPTX -- the LLVM backend's own lowerings under the nvptx64-nvidia-cuda triple, a kernel a ptx_kernel, positions the llvm.nvvm.read.ptx.sreg.* registers, barrier0, addrspace(3) shared memory, shfl.sync shuffles (a 64-bit value as two halves), libdevice's __nv_* for the math library -- and as PTX through LLVM's NVPTX backend with libdevice linked and pruned; ppy emit nvvm-ir and ppy emit ptx show them. A build stages each kernel's PTX with the kinds of its parameters (cached like every artifact), and under ppy run cuda.launch runs it through the CUDA driver by ctypes -- no toolkit needed -- copying a native pointer's array to the device and back; cuda.compiled(kernel) says whether a launch runs there. Without the driver, a device, or the NVPTX backend the reference launch runs and W2008 names the reason; PPY_CUDA_ARCH picks the PTX architecture (sm_70 by default). A built artifact now carries its staged exports -- a kernel's PTX, an @xla.jit function's StableHLO -- as files beside the manifest, and the launcher binds them without the compiler, so the warm ppy run path and ppy run --prebuilt route them as the JIT path does.
  • ppy.aio, the async dialect, and the native async runtime. aio.sleep, accept, connect, read, write are awaitables, spawn starts a task, listen, port, close are immediate, and run drives a coroutine; sockets are ints and answer a negative errno rather than raising, so every path says the same. An async def of scalars and pointers whose awaits are these and other coroutines lowers to the async dialect -- create, start, await, the IO operations -- and lower-async makes it a starter and a resume function over a frame the runtime owns, spilling what lives across an await; the LLVM and C backends call the runtime, one C file compiled once into the cache (Linux, epoll; elsewhere asyncio runs everything). Calling a compiled coroutine hands back a NativeFuture that aio.run drives and asyncio can await; a built artifact links the runtime in. A guard failing inside a running coroutine fails its future and aio.run raises NativeGuardFailed. E1645 names a misuse.
  • The IR linker and the package-level build. A call from one module into another's native function now lowers as a declaration (ppy.external) the driver only allows for a callee it has already lowered, so the JIT resolves it across modules and ppy build links the modules' IR into one program: definitions answer declarations, shared generic instances are kept once, colliding private symbols are renamed with their references, dialects and libraries are merged. Whole-program optimization then internalizes what Python never binds, inlines small callees and @ppy.inline ones across module seams (@ppy.noinline holds), and drops dead private code, and one object comes out; ppy emit linked-ir prints the program. .ppyir is public from 0.2.0 at schema 1.
  • Sanitizers, the IR stage debugger, and the optimization report. ppy run and ppy build take --sanitize bounds,overflow,pointer,alignment (or [tool.ppy.llvm] sanitize): the sanitize pass instruments the IR before optimization -- every buffer index, every wrapping or proven integer operation, every load and store through a pointer -- and a check that fails returns a sanitizer status the boundary turns into SanitizerFailure rather than a fallback; lifetime and alias are refused with the reason. The IR gained the null pointer constant and the pointer-to-integer cast the checks need. ppy inspect --stage prints the program at any stage -- analysis, ir, canonical, tensor, columnar, optimized, gpu, stablehlo, llvm -- and ppy build --report-opt (or --report-opt-json FILE) reports what became native, what stayed in Python and why, the guards proofs removed, and every remark under a stable category.
  • Profile-guided optimization. ppy run --profile foo.ppy is a JIT run with the instrument-profile pass in the pipeline: every native function counts its blocks and the taken edge of every branch into a counter array the module carries next to its legend, the boundary records the kinds of value -- shapes, dtypes, column schemas -- each function is called with, and when the program ends the counters are read back and foo.ppyprof written, merged into one already there. ppy build --pgo foo.ppyprof (or [tool.ppy.llvm] pgo, or ppy run --pgo) applies it at the same point of the pipeline to every function whose graph still matches: hot and cold functions, branch weights, loop trip counts, all as IR attributes the inliner reads -- a hot callee at four times the budget, a cold one or an unreached call left alone -- and the LLVM backend writes as !prof metadata and hot/cold attributes. A changed function is named by W2009 and built without the profile; the report shows the profile first; every cache key and the warm run directory carry the profile's content. The prof dialect is public in .ppyir. The lowering cache (schema 6) now keeps each module's remarks and proved guards, so a warm build's report says what the cold build's did.
  • The direct AST-to-LLVM road is gone. The IR road -- the frontend in ppy_compiler.lowering, the passes, from_ir -- is the LLVM backend, and the default pipeline; "ast" in a project or PPY_LOWERING=ast is still read, answered with W2004, and builds on the IR road. The differential test that held the two roads to each other now holds the one road to CPython's own answers, and CI runs one suite. What stays of the old module is the native ABI and the eligibility rules both roads shared.
  • Artifact determinism, held by a test: a build's program object, library, boundary wrapper, manifest, generated Python, and header, and every text ppy emit prints, come out byte for byte the same from an empty cache, a full one, and an emptied one. The one artifact that did not -- the boundary wrapper, compiled from a draft named after the process that wrote it, a name the C compiler records in the object -- is compiled from its final name now.
  • The checker reads the ppy package's own modules as ordinary code: a helper ppy.aio defines and calls is not a use of the aio namespace, and the same for every namespace the compiler models, so ppy migrate over the package itself no longer reports the namespace rules against their own implementations.
  • import ppy is light again. ppy.aio had imported asyncio, socket, and the async runtime for every program, the native API ctypes.util, ppy.autodiff inspect, and the CPU probe platform and subprocess, together doubling the cost of importing the package and adding thirty milliseconds to every launched artifact; each loads when first used, and a test holds the async ones out of a plain import.
  • The artifact a warm ppy run launches is compiled for the machine's own CPU again, as JIT code always was and ppy build never is without --host-cpu; the run directory's name and the program object's key carry the CPU's features, so a cache carried to another machine never serves it. A test holds both.
  • Eleven examples for what 0.2.0 added, each hand-written and each held to the three paths: native memory and a libm binding, ppy.simd and ppy.cpu, atomics and threads, parallel.range, derivatives, ppy.aio coroutines, ppy.cuda kernels, ppy.xla, generics, columnar pandas expressions, and the toolbox of ppy emit, ppy inspect --stage, the report, the sanitizers, and PGO; the existing examples point at them. The runners skip an example that needs pandas, pyarrow, or scipy where those are missing. Found on the way: a type parameter bounded by a Protocol now lends its methods to attribute calls, as it already did to operators.
  • The documentation is a site, ppy.franknoh.dev, built with MkDocs from docs/ on every push to main and every release tag, versioned by mike (dev for the tip, X.Y and latest for a release). The language reference is split into one page per topic under docs/guide/, the configuration, diagnostics, and compatibility pages live under docs/reference/, the architecture, IR, conversion, migration, plugin, and solver pages under docs/internals/, and the example gallery, the performance tables, the API pages, the contributing page, and this changelog are generated at build time from the examples' READMEs, the recorded measurements, the docstrings, and the repository's own files, so none of them is a second copy. The gate builds the site with --strict, so a broken link fails it.

0.1.0a1

The first release, and an alpha in the ordinary sense: the language and the diagnostics are in use and tested, and neither is promised to stay put. Pin an exact version.

On PyPI as ppy-lang; the packages it installs are ppy, ppy_compiler, and ppy_runtime, so a program still writes import ppy.

The language

  • A .ppy file is valid Python 3.12+. Everything PPY adds is carried by decorators and annotations from the ppy package, all inert under plain CPython.
  • Strict analysis by default: an implicit Any is an error, dynamic features need a ppy.dynamic boundary, and a decorator must have vouched semantics.
  • ppy convert staticizes Python into strict PPY; ppy migrate is the permissive form. Both are deterministic, both refuse to write anything when the settled analysis holds an error.

Running it

Three paths, held to one answer — plain CPython, an optimized Python backend, and LLVM-lowered native code. Any observable difference between them is a compiler bug; the suite and examples/run_all.py compare all three on every example.

  • ppy build produces an artifact that runs through ppy_runtime with machine code from the library beside it, and keeps working with the compiler uninstalled.
  • ppy build --standalone links a native executable with no CPython inside, for a program whose reachable graph is entirely native.
  • ppy.input[T], ppy.buffer[T], and Buffer[T] (including one-byte ppy.i8/ppy.u8 elements) read and hold data without a Python object per value.

Plugins

NumPy, PyTorch, JAX/Flax, pydantic, and FastAPI/Uvicorn are modeled; each is an optional extra, and a missing runtime disables only its plugin.

Known limits

  • ppy.read_token has no standalone lowering yet, which is the one thing keeping the substring-search example off that path.
  • Floats do not print from a standalone binary, pending native formatting that reproduces CPython's shortest round-trip repr exactly.