Architecture¶
This page is the map of the compiler: the stages a .ppy source passes
through, the package that owns each stage, and the rules that hold across
them. The diagram below is the pipeline from parsing to the backends.
.ppy sources
│ frontend/ parse (CPython grammar), module graph, .py/.ppy shadowing
▼
symbol tables analysis/symbols declarations, imports, fields, directives
▼
type & effect analysis analysis/checker flow typing, refinements, purity fixpoint
▼
├── driver/convert + analysis/inference call-site fixpoint → ConversionPlan
├── driver/rewrite the plan applied to source, through a CST
├── migration/ rewrite passes + report behind `ppy migrate`
├── opt/ AST passes for the Python backend
├── lowering/ typed AST → canonical IR (ir/)
│ driver/ir_pipeline the shared passes (ir/transforms); ir/linker joins modules
│ ├── backend/llvm/ IR → LLVM IR → wrapper → link/JIT
│ ├── backend/c/ IR → C11, C++17, CUDA, HIP source
│ ├── backend/nvvm/ IR → NVVM IR → PTX, launched by ppy_runtime.cuda
│ ├── backend/stablehlo/ IR → StableHLO, run by ppy_runtime.xla
│ └── an installed backend IR → whatever it makes (ppy.backends entry point)
└── lsp/, driver/explain the same analysis, served interactively
Analysis produces data. Only driver/rewrite.py touches source text, and only
through the ConversionPlan that driver/convert.py filled in. Nothing in
analysis/ knows the output is text, and nothing in the backends re-derives
what the checker already proved.
The backends, the compiler's own and an installed one alike, read the canonical IR after the shared passes and nothing above it (Backends).
Modules¶
Each package and what it holds:
| package | contents |
|---|---|
ppy (runtime) |
the import hook and the inert directives/markers, and the modules a program writes against (native, ffi, simd, cpu, atomic, concurrent, autodiff, cuda, hip, xla, aio), each a Python implementation that answers the same as the compiled one. This is all a plain CPython run ever loads. |
ppy_runtime |
everything a built artifact needs at launch: the native ABI as data (abi), the guarded binding trampolines (binding), generated-module identity and execution (generated, execute), the binder protocol (dispatch), and the manifest-driven launch path (manifest, launch), and the runtimes native code calls into: aio (the epoll loop, one C file compiled once), cuda (the driver API through ctypes, launching PTX), xla (the PJRT bridge), exported and regions (staged artifacts and compiled torch regions). The hard rule: this package never imports ppy_compiler. Uninstalling the compiler must not break a built application, and a test keeps that true by poisoning the compiler and running a launcher. |
frontend/ |
source loading, the module graph, ambiguity detection (E1003). |
migration/ |
the ppy migrate layer over the shared conversion engine: deterministic rewrite passes (pipeline, dynamic, globals) that prove each rewrite equivalent before making it, and the classified report (report) that says what remains. |
analysis/ |
results (what analysis produced: the types every other package reads), symbols (declarations), checker (types, refinements, effects), binding (one shared call-argument binder), lexical (point-sensitive name resolution: what a name means at each statement, shared by decorator identity, reflection, and the write index), aliasing (flow-sensitive local alias analysis: mutation and escape resolve through what a name may refer to rather than its spelling), inference (staged evidence/generalization fixpoint with a convergence guard), decorators (what each known decorator does, that unknown means opaque, and the shared class_construction facts behind both strict class checking and safe hoisting), global_writes (scope-aware project-wide write index behind Final), reflection (who reads annotations at runtime, blocking their materialization), codec (exact-inverse serialization of analysis facts for the cache), render (types back to annotation source). |
lowering/ |
the frontend of the native road: ast_to_ir turns a typed, effect-checked function into canonical IR (guards spelled, overflow and rounding on the operation, cross-module calls as declarations), abi the native signatures. |
ir/ |
the typed canonical IR every backend lowers: model (modules, functions, blocks, SSA values with use lists), types, dialect (the registry and OpSpec), dialects/ (core, math, simd, cpu, atomic, concurrency, parallel, layout, tensor, linalg, fft, special, sparse, columnar, arrow, gpu, async, prof), verify, printer/parser/codec (.ppyir), pattern (rewrites to a fixed point), passes (the pass manager with analyses and stages), transforms/ (canonicalize, simplify-cfg, dce, promote-slots, tensor fusion and lowering, columnar lowering, parallel lowering, async lowering, autodiff, sanitize, profile, whole-program), linker (modules into one program). See The IR. |
opt/ |
AST-level passes: constant folding, inlining, LICM, loop transforms; used by the Python backend and as pre-lowering cleanup. |
backend/python/ |
runs optimized AST under CPython with the loader installed. |
target |
TargetInfo: the triple, CPU and features, pointer width, endianness, ABI, OS, object format, and data layout of the machine a build is for; the host is one target among others, and nothing else consults sys.platform. |
bind/ |
ppy bind header: a C header read through libclang, written as ppy.ffi bindings. |
backend/c/ |
the source backends: emit reads the canonical IR and writes one C11 or C++17 translation unit (or a header-only form), and with gpu the CUDA or HIP spelling of the same IR, kernels and launches included; runtime holds the C shims a standalone program links (the LLVM standalone build compiles the same table). |
backend/nvvm/ |
the device backend: IR → NVVM IR → PTX through LLVM's NVPTX target with libdevice linked in; ppy_runtime.cuda launches it. |
backend/stablehlo/ |
IR → StableHLO for @ppy.xla.jit functions; ppy_runtime.xla compiles and runs it through PJRT. |
backend/ |
base (the interface a backend implements: formats, passes, validation, emit, build, toolchain, fingerprint), registry (the builtin backends and the installed ones, found through the ppy.backends entry-point group and loaded when asked for), builtin (the compiler's own backends described through the same interface). See Backends. |
backend/llvm/ |
ir_pipeline (the LLVM road over the shared passes, which live in driver/ir_pipeline) and from_ir (canonical IR → LLVM IR, dialect by dialect; lowering keeps the native ABI and eligibility rules, lowering_cache what a build reuses), wrapper (generated CPython-ABI entry points, METH_FASTCALL, GIL release), fusion (NumPy elementwise loops), specialize/jit (guarded runtime specialization), parallel (the worker pool), link (objects → shared library, for the host or a --target), extension/packaging (--python-extension, --library). |
plugins/ |
numpy, torch, jax, pydantic, uvicorn. See Plugins. |
cache/ |
the content-addressed store (SQLite) and key construction. |
driver/ |
CLI, pipeline orchestration, ir_pipeline (the canonical IR of a project after the shared passes, which is the one road every backend takes, and the backend boundary after it), convert (what to write) and rewrite (writing it) either side of plan, fmt, lint, test, explain. |
lsp/ |
the language server, on the same analysis. |
The three-path invariant¶
Plain CPython, the Python backend, and the LLVM backend must produce the same answer. A guard that fails at runtime falls back to the Python body instead of answering differently.
The invariant is checked by tests. examples/run_all.py runs all
57 example programs on all three paths and diffs the output,
and the test suite does the same per feature.
Cache and incremental builds¶
The store is content-addressed: a key is a blake2b digest over the source, the compiler version and schema, the opt level, the active directives, the dependency digests, and the fingerprints of every plugin whose library the module imports. Nothing is invalidated by time: a key either describes the artifact or misses.
The LLVM path caches per stage, so a rebuild does only what changed:
| stage | keyed by | on a no-change rebuild |
|---|---|---|
| lowering (canonical IR, LLVM IR, ABI decisions, remarks) | module source + deps + opt level + sanitizers + profile | reused; LLVM never loads |
| the program's object | every module's lowering key | reused; nothing links |
| linked library | the set of object keys | reused |
Editing one file re-lowers that file, links the program again, and touches nothing else; a change in a module's interface invalidates its dependents through the dependency digests.
Damage and recovery¶
The store holds optimization state and nothing else: every artifact in it can be recomputed from the source it came from.
- A damaged SQLite index is moved aside as
index.sqlite.corrupt-<timestamp>, rebuilt empty, and reported once asW2101. Compilation continues with cache misses. - Where even a fresh index cannot be written, the store works in memory and every lookup is a miss.
- Recording an artifact spans two tables and runs in one transaction, so a reader never sees a row whose dependencies have not landed.
Compatibility states the contract.
Runtime specialization¶
@ppy.jit compiles a version specialized to the argument classes actually
seen, guarded on exact class identity. A guard miss runs the Python body and
may compile another specialization. All-scalar @dataclass value classes are
flattened to scalar SSA values at the ABI, with reads guarded on exact class.
Code generation¶
JIT-compiled code targets the host CPU. Its name and feature set are handed to LLVM, so AVX2/FMA and friends are on where the machine has them. This measured a third faster on a 384×384 matmul kernel; memory-bound kernels are unchanged. It costs nothing, because JIT code never leaves the machine that made it.
Emitted objects, built artifacts, and standalone binaries stay on the
portable baseline instead. They may run on another machine, like a C
compiler's output without -march=native.
ppy build --host-cpu ([tool.ppy.llvm] host-cpu) trades that away on
purpose: the artifact gets this machine's instruction set and faults on an
older one. The choice is part of the cache key, along with the host CPU's
name and features, so a baseline object is never handed to a host build or
carried between machines.
Reading input¶
ppy/_io.py is a runtime-only reader: a small C scanner over file
descriptor 0, compiled on first use into the user cache and bound through
ctypes. It is exposed as:
ppy.input[T]()for linesppy.scan[T]()for tokens- the lower-level
ppy.read_ints/ppy.read_token
ppy.buffer[T](n) beside it is the allocation both a CPython run and a
standalone binary understand.
The reader lives in the ppy package, outside the compiler, because it is
useful on every path, plain CPython included. It degrades to a pure-Python
implementation where no C compiler exists. The checker types ppy.input[T]()
and ppy.scan[T]() from their subscript, the way it types ppy.check[T] and
ppy.assume[T].
The boundary¶
Python callers cross into native code through a generated METH_FASTCALL
wrapper that parses, guards, calls, and boxes in C. The wrapper also holds the
Python implementation, so a refused guard is a C-to-Python call instead of a
NotImplemented bounced through a Python frame. No Python code stands on the
call path (@ppy.jit keeps a thin Python watcher only while it is still
learning which argument shapes repeat).
Measured with examples/bench_boundary.py:
| call | time |
|---|---|
| plain Python call (baseline) | 28 ns |
| forced-native two-int call | 47 ns |
| borrowed buffer | 65 ns |
| guard failure into the fallback | 86 ns |
Built artifacts ship the compiled wrapper and bind through it at launch. The
ctypes trampoline remains only as the fallback where no C toolchain exists
(W2004 says so once).
Threads¶
Generated wrappers release the GIL around native calls
(Py_BEGIN_ALLOW_THREADS), so @ppy.native functions scale on threads:
measured 1.95× on two threads against 0.98× for the same code on plain
CPython (examples/28_threads).
@ppy.parallel loops run on a process-wide worker pool sized by
[tool.ppy.parallel] threads.