Changelog¶
0.3.7 — 2026-09-25¶
- A standalone binary reads more than
int.ppy.inputandppy.scanoffloat, of the fixed widths (ppy.i8throughppy.u64,ppy.f32,ppy.f64), and of tuples of them lower to the C runtime, and so doppy.input[Buffer[int]](),ppy.input[Buffer[float]](),ppy.input[list[int]](),ppy.input[list[float]](), andppy.scan[Buffer[float]](https://github.com/franknoh/PPy/blob/main/n). The float grammar isfloat()'s, and where CPython raises the binary names the same exception and stops. - A standalone
printwrites a float the wayreprdoes: 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 aTypeError: the value is read asintand must fit, or the read raisesOverflowError.ppy.input[Model]()reads one line of JSON into a dataclass, a pydantic model, or aTypedDict, andppy.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 orTypedDictis checked field by field and a mismatch is aValueErrornaming 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, andppy.TreeSet(key order, withfloor,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 underppy 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_collectionsruns five problems with them: 4.3 s under CPython, 0.39 s underppy run. - Collections hold anything with a native form: numbers, tuples of numbers,
dataclasses (ordered with
order=True), and other collections, soVec[Vec[int]],HashMap[int, Vec[int]],Heap[tuple[int, int]], andVec[Edge]compile. Keys may be tuples ofint. 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 withselfas a handle,x is Nonecompares with the null handle, andlen(obj)andif obj:call__len__and__bool__. Reference cycles are not freed. - Generic classes:
class Stack[T]with fields and methods overT. The checker substitutes a receiver's type arguments into its fields and methods, soStack[int]().push(2.5)isE1301, and native code instantiates the class and its methods per type argument. - Standalone programs may define classes (no bases,
@dataclassor none, methods and annotated fields) and uselenand truth tests on them. - Emitted C reserves the C library's names, so a function called
removeno longer collides withstdio.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_allocreturnsint8_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.inputandppy.scanas plainscanf(...);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 falseflushdoes not emitfflush. 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, plainintdeclarations, and%dinput/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, onemain, source names, C++ module namespaces, inline literals, fused stdio output, and checkedscanfreads. 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.
narrowandcopy_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 bycatcopies the whole cache per token. The write is not silent:copy_carriesWriteMemory, which@ppy.pureforbids, 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 oftuple[torch.Tensor, ...]now becomes astd::tuple, which pybind11 hands Python as an ordinary tuple, and withcausalaboolparameter andlengthanint64_trather 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_gpt2measures 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 wheretorch.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 forrelu(add(matmul(x, w), b))and not enough for anything with a dimension in it:softmax,transpose,reshape,layer_norm, andscaled_dot_product_attentionhad no entry, keyword arguments were refused outright, and anintparameter of the region was declareddouble. Each operation is now described by the C++ signature it is called through -- a dimension is anint64_t, a shape anat::IntArrayRefwritten as a tuple,keepdimandis_causalabool,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 (meanover everything, or over named dimensions) takes the one the call fills. Thirteen operations were added to the curated set for it, andexamples/46_gpt2is GPT-2 XL with each of its forty-eight blocks compiled to one region, measured against PyTorch eager andtorch.compileon an RTX 4090.
0.3.3 — 2026-09-15¶
ppy.Tensor[dtype, shape]carries common tensor contracts through type checking and IR, withppy.bf16distinct 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=Falseto 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
printwith string literalendandsep, boolean literalflush, 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 pythonis held to the same policy as every other backend that is not LLVM. It shared the LLVM branch of the dispatch, soppy build foo.ppyir --backend pythonbuilt 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--targetwere 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.ppyirtarget is refused for the Python backend, which reads PPY source, and-ois refused rather than ignored, since its modules go to the project cache.--warmis 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.isinstanceagainst a@runtime_checkableprotocol answers whether the attributes are there and nothing of what they take or answer, so a class whosef(self)returns a string satisfies a protocol declaringf(self, x: int) -> int; handing that value back as the protocol was the false typed valueppy.checkexists to refuse. Plain and runtime-checkable protocols are refused alike, wherever they appear in the target -- nested in alist,tuple,set, ordict, behindAnnotated, or as a union arm in either order -- andppy.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
backendstage and no other. Whatregister_passesreceives is aBackendPassRegistrar(passes.add(MyPass)), not thePassManager, 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
EmitFormatsays whether it is written per module (the default, throughemit) or per program (throughemit_program, every module at once). A per-module format whose target resolves to several modules needs-o DIRand 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 NAMEchooses the backend before anything runs. A.ppyirtarget 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--backendsaid, and--warmis 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-formatsentry-point group, soppy 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.backendis the interface (Backend,BackendContext,EmitFormat,BuildResult,ToolchainStatus,BackendValidationError;BACKEND_API_VERSION1, 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 newbackendstage, refuses invalidatewhat it cannot take, and emits text or bytes or builds. A distribution registers one through theppy.backendsentry-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 NAMEbuilds through it;ppy doctorlists 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 thellvmliteunder it too). Refusals areE1903(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), andE1904(a backend pass that broke the IR, named). The shared IR pipeline moved out of the LLVM package intodriver/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 builtininput()means:ppy.input[str]()is the line with its newline removed, spaces kept,""for an empty line,EOFErrorat the end;ppy.input[int]()and[float]parse the whole line asint(input())andfloat(input())do, so1 2isValueError;ppy.input[tuple[int, int]]()splits one line and requires exactly its fields, never reaching into the next. The scanner the oldppy.inputwas isppy.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 convertno longer turns a loop ofint(input())into a bulk scan that would read across lines. A line of fields is a line read too:ppy.input[list[int]]()islist(map(int, input().split())), andppy.input[Buffer[int]]()reads a line of integers straight into a buffer, asarray.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 ofintis 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 formsint()reads within 64 bits and hands any other field -- a wider integer, non-ASCII digits, no integer at all -- toint()itself, so a typed line read means exactly what the idiom it stands for means:a, b = ppy.input[tuple[int, int]]()reads9223372036854775808asmap(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_intsandppy.read_tokenstay the buffer-oriented forms;read_tokencuts 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 isValueErrornaming it -- the compiled reader no longer skips overabcto find the3after it while the fallback raised. A high-level string read is never cut:ppy.input[str]()andppy.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 exactlynintegers or raises: the input ending first isEOFErrorwith the tokens before it read, where it used to hand back a buffer padded with zeros that were never in the input; a negativenisValueError. 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 anAnnotated, not only the type under it:ppy.check[ppy.i8](https://github.com/franknoh/PPy/blob/main/300)is refused, as areppy.check[ppy.Array[int, 3]](https://github.com/franknoh/PPy/blob/main/(1, 2)), aVector[T]with a wrong element, aBuffer[T]whose buffer holds another format or is not one contiguous dimension, a value outside itsRange, aLength, aShape(symbolic dimensions bound consistently), aDType, or aContiguousthe value's own metadata contradicts; anf32wider 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, andppy.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.xlacompiles for the platform JAX would pick -- the GPU where a CUDA or ROCm plugin is installed -- where it used to compile forcpuunlessPPY_XLA_PLATFORMsaid otherwise, so an@xla.jitfunction on a GPU machine ran on acpu:0it never asked for. It compiles for one device of that platform (the first;PPY_XLA_DEVICEnames 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: alist[int]element by element, adict[str, float]key and value, a tuple field by field, a dataclass field by field, a union member by member. ATit 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 onlychecktheTypeErrorit may raise.ppy runandppy buildmean the same program: both keep Python's integer semantics by default, and--unsafeon either is the one spelling of 64-bit wrap semantics.build --safeis 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--unsafeand say so.examples/compare.pyholds 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, andppy.assumeare 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 anative.ptr[T]likestack_alloc's -- the same loops fill and read it,native.offsetkeeps 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_allocis the same surface; the checker types both (E1644for a misuse) and a function that allocates stays in Python underppy run, as one that launches does, whileppy emit cudaandppy emit hipwrite it into the host function as agpu.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 explainreports a body that lowered asllvm backend: native, whatever the effects suggested: a parallel loop'sThreadeffect 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.kernelruns once per program of a launch;tile.arange(BLOCK)is the lane index,tile.loadandtile.storegather and scatter through anative.ptrwith a mask, arithmetic and comparisons are lane by lane with a scalar broadcast,tile.wherechooses per lane, andtile.sum,tile.max,tile.minreduce 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 new44_tileexample 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.pyfrom a manifest of every counterpart program, between markers in each README, with the run recorded beside the programs;bench.ymlruns it andscripts/refresh.pyon a self-hosted runner for every push todevthat 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'sreand Rust'sregex, the buffer kernels against Numba, Cython, NumPy, and C, the fused NumPy kernel against NumPy, numexpr, Numba, and JAX, and Newton's method on appy.gradderivative 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.pyprints 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, andexamples/compare.pyholds 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 * iunderparallel.range, lowers: the store writes throughoutand binds nothing outside the loop, which is what the guide promised and what the check mistook for an assignment ofout. - Regular expressions run natively. A pattern compiled from a bytes literal
at module level --
WORD = re.compile(rb"[A-Za-z]+")-- or written intore.search(rb"...", buf)becomes aregex.search,regex.match, orregex.fullmatchoperation over aBuffer[ppy.u8], and the newlower-regexpass 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 whatreanswers -- ordered alternation, greedy and lazy repeats, the group's last iteration,$before a trailing newline,\bat ASCII word edges,posandendpos-- on random inputs across both backends. A match is a local whosestart,end, andspanare native;m is Noneandif 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 tore. The checker typesre.compile,re.Pattern,re.Match, and the flags. Awhile 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.venvandvenv; a second environment kept beside the first no longer costs a scan of every package in it. ppy.input[T]()takes no argument, andppy.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 aprintbefore the read rather than an argument that meant a prompt for one type and a count for another. The checker says so (E1305for an argument to a scalar read or a missing count,E1301for a count that is not anint), and the converter writesinput("p")'s prompt asprint("p", end="", flush=True)before the statement that reads -- or, inside a loop's test or a comprehension, as the one-expressionprint(...) or ppy.input[T]()so it still prints each time.- The C backend writes structured code. Loops are
while, branches areif/elsewithbreak,continue, andreturn, 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 isif (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-gotowriter, 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++'sint64_t(x)casts.ppy emit --formatrunsc,cpp,cuda,hip, andheaderoutput through clang-format, with the project's.clang-formatwhere it has one. - A pandas or PyArrow expression fuses into one loop in fact, not only in
name: the kernel is a
columnar.mapthat 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 nullfillnafills andisnafinds -- before, the kernel read a NaN as a value behind a bitmap of ones, sos.fillna(0.0)underppy runhanded 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 newand_kleeneandor_kleene, the three-valued logic pandas computes over an Arrow-backed Series:(s > t) & t.notna()is false, not null, wheretis null. PyArrow'sand_/or_keep the two-valuedand/or, andpc.and_kleene/pc.or_kleenename 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 holdsppy runto whatpythonprints 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 wheres.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.pyvalidates 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 -- withaccelerator_check.pyfailing a GPU run whose JAX sees only a CPU, the CUDA, tile, and XLA examples underppy runwithppy explainbeside 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.mdrecords the last run.- The docs' example counts come from the tree:
18joins the two existing markers, the architecture page's program count is a marker, and a test holdsexamples/README.md's own count to the same functions, so no page spells a number the tree has moved past. ppy convertkeeps Python's unpacking where the target is starred:a, *rest = map(int, input().split())becomesa, *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;floatandstrfields 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]: aLiteralis its value and its type, soLiteral[1]refusesTrueandLiteral[False]refuses0though the pairs compare equal; a fixed-width integer (i8,u8, ...) refuses abool; and the whole ofTis 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_PLATFORMSdid not ask for the CPU) is raised too, naming the plugin. Without JAX the platform is the CPUavailable()already qualifies;PPY_XLA_PLATFORMstill wins. - The hardware harness: a Pod that is not gone after a run fails the run
(
cleanup_ok, and the exit status with it),--keepexcepted; 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 ansshdfor the injected key since the image runs none, with PPy installed beside its JAX under version constraints and everyjax*distribution checked to be the one that was there; andppy emit hipis a step of a ROCm run, the source compiled by the image'shipcc. An MI300X run passed on every step. Unit tests with a fakerunpodctlhold 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_jaxtimes its steps without a host synchronization between them. ppy.native.compiled(f)says whether callingfruns its native form here. Underppy runthe 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.compiledanswers through the same probe, the training examples print# native prep: Trueunderppy runas they should, and the entry point bears the function's qualified name rather thancall_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,compiledfalse,ppy emit hipforhipcc. - The ROCm harness installs every group when the image's JAX allows it
and every group but
jaxotherwise, and says which. - The lowering cache dropped a coroutine's future kind from its signature,
so the second
ppy runof a program whose entry coroutine was served from the cache bound it through the plain boundary and handedaio.runa bare handle instead of a future (TypeErrorfrom 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'soutputs/that the documentation site embeds; each exists once. The six judge problems gained a smallinput.txtso 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, andppy.__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.ppyiris that text with a schema and dialect-version header a reader refuses rather than guesses at.docs/internals/ir.mdis 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|iroverrides 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 // -1takes the fallback instead of trapping in the division. - Plugin interface 2.
Pluginis a base class with a no-op default for every hook, so the compiler callsoperator,subscript,instance_attribute,call_alias,decorator_semantics, andadjust_calldirectly 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 theppy.pluginsentry-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 ascolumnaroperations, everything the model does not capture exactly left to pandas), andpyarrow(Arrow typed as Arrow, the curated compute named as the samecolumnaroperations). - 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 addsnetwork,atomic,python_dynamic,gpu_launch, anddevice_memory; the socket and urllib surface carriesnetwork. 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 unlessMut(E1613) -- and aBuffer[T]is borrowed unless the program says otherwise. The IR carriesownershipandnoaliason 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 codea + bon a value class dispatches statically to the class's own__add__; native code never falls back to dynamic dispatch. - The
mathdialect: elementary functions named once for every backend, pure, folding on constants,floor(floor(x))once. The frontend writesmath.sqrtwhere the source says so; the LLVM backend lowers it to the intrinsic of that name. ppy.nativeis 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 overarraymemory under CPython so the three paths agree, and pointer operations in native code.@native.externbinds a stub to a C symbol -- ctypes under CPython, a direct call in native code, the library linked and loaded -- and@native.exportgives 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.ffiis 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;.ppyiris the IR's on-disk form, self-describing down to each function's ABI, andppy build foo.ppyirbuilds an object, a library, and a manifest from it alone.
Speed of the compiler itself, measured before being changed.
- A warm
ppy runno 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 toppy_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 aneeds-jitnote. - A built artifact carries its torch ATen regions. The extension compiled
against the installed PyTorch is copied beside the manifest and recorded
under
regions, andppy_runtimeloads 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.ppykernel that imports torch is served natively byimport ppy. ppy build --warm TARGETbuilds ahead of time whatppy runandimport ppybuild on first use, for one module or every.ppyunder 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.ppykernels -- native preprocessing loops and an ATen-region model -- that runs the same underpython,torchrun, andaccelerate launch, withimport ppyas 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 convertandppy migratestop 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 twoLoad/Storesingletons 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 theLoad/Storesingletons 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; oneW2006line 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 = Nonethere and a tensor insetup()isTensor | None, where it used to beNoneand 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 ^ bon sets and frozensets) was "not defined",frozenset(...)was typed as aset, 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 PaththenPath(p)was an unknown signature whilepathlib.Path(p)was not.pathlib.Pathis modeled -- construction, the filesystem calls with their IO effect, the spellings of other paths, the string parts -- and so are theastfunctions (parse,walk,unparse, and the rest).x: typeis 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 whatrestholds.type[Base]is the class object ofBaseor a subclass, a class is callable, anddict[str, dict[str, int]]is adict[str, Any]:Anyis anything at any depth of an invariant argument.str.partitionand the rest of thestrmethods with a known result are modeled; an external class's bases are spelled the way the annotation spells them, so alibcst.Callis alibcst.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 anIterable. A function, a class, a module: everything is anobject.type(x)of something unresolved is some class and fitstype[Base];list | dictin anisinstanceis a type;table.get(type(node.op))may ask with a wider key than the table holds. A library exception is aBaseException. program or {}is neverNone: an operand oforthat 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].factsis notNone, untilargs[0]is written. A union of tuples unpacks position by position, socount, first = seen.get(key, (0, node))gives anintcount. Being one of the constants inx in {"a", "b"}is being of their type. ppy check,run, andbuildknow the fields__init__assigns.self.width = widthsays as much about the field as an annotation would, and onlyconvertandmigrateused to hear it: the single-pass path reportedhas no attributeon 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 checkcosts 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 callself.append, andsuper().__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:
getattris 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 ofpkg.utilused 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
Finaland 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.Pathreached 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, andpath / "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, withbimporting anawhose 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 z3onppy runandppy 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 carryingstart <= i <= stop - 1from itsrange()-- settled by intervals when they suffice and by Z3 otherwise; a proven chain lowers to the plainnswinstruction, 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. Needsppy-lang[solver];ppy doctorreports the solver; the artifact and the warm run directory are keyed by the prover and its version.docs/internals/solver.mdsays 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 TermwithTerm = 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 / 2withx: int | floatis afloat, not an undefined operator. - A dataclass is built the way
dataclassesbuilds it: the fields of each dataclass base first, a field with a default or afield(default=...)optional,kw_only=True,field(kw_only=True)and everything after aKW_ONLYmarker 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 = 8in 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, ysets two fields.mask | otheron arrays isbitwise_or, with&,^and the shifts. Being one of the constants inx in {"small", "medium", "large"}is being those literals, which aLiteral[...]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: annn.Modulesubclass is called throughforward.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 atuplethat aforcan walk.sys.path,sys.modules,sys.version_infoare known.--no-strictis accepted after the subcommand as well as before it, except onconvert, which has no strictness escape hatch by design. A baretupleannotation istuple[Any, ...], any length, and adict[str, tuple]holds adict[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-devused to find that out only fromppy 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 doctorprints the C library it found, anddocs/reference/compatibility.mdsays 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 forllvmlite,libcst1.7,z3-solver4.x andnumpyup to 2.2; 2.27 forz3-solver5.x; 2.28 fornumpy2.3 andlibcst1.8.ppy-langpinslibcst<1.8on Python 3.13 and earlier: from 1.8 it shipsmanylinux_2_28wheels 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 ppyis the whole integration. With the compiler installed, a.ppymodule imported from a plain.pyprogram is served from its native build: the first process builds it into the project's cache -- the artifactppy runbuilds -- 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.ppykernels needs no bootstrap and no launcher.PPY_IMPORT=pythonturns it off for a process,native-import = falseunder[tool.ppy]for a project,PPY_QUIET=1silences the notes,ppy.native_import(False)decides in code, andppy.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.ppyprogram without it loads as source.scripts/dogfood.pycounts the errors in a target's own files: a module reached through an import is another target's business.ppy check pkg/a.pycheckspkg.a. A file inside a package used to be named from its own directory --a-- so everyfrom . import bin it was unresolved, on the one command people run most against the file they are editing. Andfrom pkg import modnow followsmodinto the program when it is a module rather than a symbol:from . import types as TleftT.Typewith nothing behind it whenevertypeswas 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 Diagnosticfollowsdiagnostics/__init__.pyto.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: afterif self.end is not None:,self.end - 1is anint, and so forisinstance(self.node, Call)and a truthiness test, until the attribute or its owner is assigned again. A walrus inside anandchain binds its name for the body.diris 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 -- anExceptionsubclass -- issuper(), 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, soleft.keys() | right.keys()is defined.- An annotation may name a common standard-library class --
pathlib.Path, everyastnode,re.Pattern,datetime,collections.deque,argparse.Namespace, and others -- without the analyzer modeling it. Each carries its real hierarchy, read from the class, so anast.Callis accepted where anast.ASTis expected;Path / "name"is aPath; andEllipsisandNotImplementedare names.p: Pathused 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
mathmodule is modeled: all 57 functions and the five constants, with the exceptions the C implementation raises. Every numeric kernel imports it, andmath.tanhin 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,OSErrorand 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 migrateon a real project: profile, find the two or three files that do the numeric work, migrate those, and leave the orchestration as.pyimporting them through the loader. It also says how to read the report --E1304is the to-do list,W2006is the count of what follows from it, and the rest is about the code. - The compiler migrates itself in CI.
scripts/dogfood.pyruns the converter oversrc/ppy_compiler,src/ppy_runtime, andsrc/ppyon 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 migrateno longer crashes on a module whose first statement is a relative import: placing theppyimport 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-onlymakes every functionstatic inlineunder a guard and refuses, withE1804, a feature that needs state the process owns;--standaloneemits a whole program frommain;ppy emit headerprints the export declarations a built library ships.tests/test_c_backend.pycompiles 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 ofsys.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; andppy build --library: the exports laid out aslib/,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.bindstubs, 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 thecpu.featuresa function is compiled for),atomic(every operation with its C11 memory order, verified), andconcurrency(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, andppy.concurrentcarry 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.parallelv2. The parallel dialect --parallel.for,reduce,mapover an outlined body -- andlower-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.fastmathpermits 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.parallelasking 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, andjax.numpy.multiplyaretensor.mul;numpy.sumandtorch.sumaretensor.reduce {op = add};scipy.special.erfover arrays istensor.unary {op = erf}. The fused kernels are built from that vocabulary as tensor IR --tensor.fill,tensor.unary,pow,min,maxjoined the dialect, andlower-tensorbinds 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 whilematmuland the other dispatcher-sensitive calls stay with the dispatcher;ppy explainshows 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-fusionturns a chain of elementwise operations whose intermediates have a single reader -- with areduceat the root where there is one -- intotensor.fused, a region computing one element from one element of each input, andlower-tensormakes a single loop of it; a result whose only reader is atensor.storeis 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 reportstensor ops fusedfor every group it makes. - Autodiff.
ppy.grad(f)andppy.value_and_grad(f)-- withargnums-- 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 theautodifftransform differentiates the function's IR in reverse mode -- scalar arithmetic,select, casts, and over tensors the elementwise operations,fill,broadcast,reshape,transpose,reduceby sum,matmul,convert, with a buffer parameter's gradient written to a buffer -- afterpromote-slotshas 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, acolumnar.tablea 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 tablesproject,group_by, and an innerjoin-- are values the verifier checks, andlower-tensormakes 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.importreads an array from the Arrow C Data Interface struct without a copy andarrow.to_columnmakes it a column. Checked against NumPy on both backends and against anArrowArraybuilt by hand. - PyArrow converges onto the columnar IR. The plugin names the dialect's
operations, and an expression tree of
pyarrow.computecalls overfloat64andboolarrays -- 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/notnais one columnar kernel; an Arrow-backed Series is read as its Arrow array, a NumPy-backedfloat64Series 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.exportedlends a PyArrow array to native code as the C Data Interface'sArrowArraystruct and releases it once the borrow ends. ppy.xla, the StableHLO backend, and the PJRT bridge.@xla.jitmarks a function of scalars whose body is arithmetic and math; the compiler lowers it to the IR,backend/stablehlowrites 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),erfas CHLO --ppy emit stablehloshows it, and the build stages it. At run timeppy_runtime.xlacompiles 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'sdevice_putwhile that is the one public way to the client. What XLA cannot take is reported asW2007and runs as written.- The gpu dialect: one execution model every GPU backend meets. A function
is
host,device, orkernelby itsgpu.kind; device code readsgpu.thread_id,block_id,block_dim,grid_dim(each.x,.y,.z), waits atbarrierandsubgroup_barrier, trades values withsubgroup_shuffle, takesshared_allocandprivate_allocmemory, and is handed pointers intoglobalandconstantmemory; the host launches a kernel withgpu.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 newDialect.verify_functionhook, 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 barecore.retbefore 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.cudaandppy.hip, and the CUDA/HIP source backend.@cuda.kerneland@cuda.device(orhip.) mark device code;thread_id,block_id,block_dim,grid_dim,global_id,syncthreads,syncwarp,shared[T, N](),local[T, N](), theshflfamily, andlaunch(kernel, grid, block, *args)are the vocabulary, lowered to the gpu dialect by the one frontend -- inside device codeintarithmetic 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 cudaandppy emit hipwrite a module as CUDA or HIP C++:__global__kernels,__device__functions,__shared__memory,__shfl_syncor__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.E1644names 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/nvvmwrites a module's kernels and device functions as LLVM IR for NVPTX -- the LLVM backend's own lowerings under thenvptx64-nvidia-cudatriple, a kernel aptx_kernel, positions thellvm.nvvm.read.ptx.sreg.*registers,barrier0,addrspace(3)shared memory,shfl.syncshuffles (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-irandppy emit ptxshow them. A build stages each kernel's PTX with the kinds of its parameters (cached like every artifact), and underppy runcuda.launchruns it through the CUDA driver by ctypes -- no toolkit needed -- copying anativepointer'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 andW2008names the reason;PPY_CUDA_ARCHpicks the PTX architecture (sm_70by default). A built artifact now carries its staged exports -- a kernel's PTX, an@xla.jitfunction's StableHLO -- as files beside the manifest, and the launcher binds them without the compiler, so the warmppy runpath andppy run --prebuiltroute them as the JIT path does. ppy.aio, the async dialect, and the native async runtime.aio.sleep,accept,connect,read,writeare awaitables,spawnstarts a task,listen,port,closeare immediate, andrundrives a coroutine; sockets are ints and answer a negative errno rather than raising, so every path says the same. Anasync defof scalars and pointers whose awaits are these and other coroutines lowers to the async dialect --create,start,await, the IO operations -- andlower-asyncmakes 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 aNativeFuturethataio.rundrives and asyncio can await; a built artifact links the runtime in. A guard failing inside a running coroutine fails its future andaio.runraisesNativeGuardFailed.E1645names 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 andppy buildlinks 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.inlineones across module seams (@ppy.noinlineholds), and drops dead private code, and one object comes out;ppy emit linked-irprints the program..ppyiris public from 0.2.0 at schema 1. - Sanitizers, the IR stage debugger, and the optimization report.
ppy runandppy buildtake--sanitize bounds,overflow,pointer,alignment(or[tool.ppy.llvm] sanitize): thesanitizepass 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 intoSanitizerFailurerather than a fallback;lifetimeandaliasare refused with the reason. The IR gained the null pointer constant and the pointer-to-integer cast the checks need.ppy inspect --stageprints the program at any stage -- analysis, ir, canonical, tensor, columnar, optimized, gpu, stablehlo, llvm -- andppy 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.ppyis a JIT run with theinstrument-profilepass 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 andfoo.ppyprofwritten, merged into one already there.ppy build --pgo foo.ppyprof(or[tool.ppy.llvm] pgo, orppy 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!profmetadata andhot/coldattributes. A changed function is named byW2009and 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 defaultpipeline;"ast"in a project orPPY_LOWERING=astis still read, answered withW2004, 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 emitprints, 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
ppypackage's own modules as ordinary code: a helperppy.aiodefines and calls is not a use of the aio namespace, and the same for every namespace the compiler models, soppy migrateover the package itself no longer reports the namespace rules against their own implementations. import ppyis light again.ppy.aiohad imported asyncio, socket, and the async runtime for every program, the native APIctypes.util,ppy.autodiffinspect, and the CPU probeplatformandsubprocess, 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 runlaunches is compiled for the machine's own CPU again, as JIT code always was andppy buildnever 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
libmbinding,ppy.simdandppy.cpu, atomics and threads,parallel.range, derivatives,ppy.aiocoroutines,ppy.cudakernels,ppy.xla, generics, columnar pandas expressions, and the toolbox ofppy 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 tomainand every release tag, versioned by mike (devfor the tip,X.Yandlatestfor a release). The language reference is split into one page per topic underdocs/guide/, the configuration, diagnostics, and compatibility pages live underdocs/reference/, the architecture, IR, conversion, migration, plugin, and solver pages underdocs/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
.ppyfile is valid Python 3.12+. Everything PPY adds is carried by decorators and annotations from theppypackage, all inert under plain CPython. - Strict analysis by default: an implicit
Anyis an error, dynamic features need appy.dynamicboundary, and a decorator must have vouched semantics. ppy convertstaticizes Python into strict PPY;ppy migrateis 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 buildproduces an artifact that runs throughppy_runtimewith machine code from the library beside it, and keeps working with the compiler uninstalled.ppy build --standalonelinks a native executable with no CPython inside, for a program whose reachable graph is entirely native.ppy.input[T],ppy.buffer[T], andBuffer[T](including one-byteppy.i8/ppy.u8elements) 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_tokenhas 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.