Skip to main content

PPy

PyPI Python CI Docs License

PPy is a compiler for the statically analyzable part of Python. A .ppy file is valid Python: it runs under plain CPython with no compiler installed, and that run is the reference answer. The compiler reads the same file, proves what it can about it, lowers it through a canonical IR, and hands the result to a backend: native code through LLVM, C or C++ source, CUDA, PTX, StableHLO, or a backend from another package.

Code the compiler cannot prove stays Python. ppy check says where a program leaves the supported subset, and at runtime a failed guard falls back to the Python body. An overflowing multiply returns to CPython and finishes in arbitrary precision. A library call PPy has no equivalence argument for runs as the library's own call.

Documentation: ppy.franknoh.dev

Highlights

  • Plain CPython, the optimized Python backend, and the LLVM backend must print the same output. examples/run_all.py checks this for every example.
  • 7 code generation targets off one IR: LLVM (JIT and objects, host or cross), C11, C++17, CUDA, HIP, NVVM/PTX, and StableHLO, plus any backend a package installs.
  • 8 library plugins: NumPy, PyTorch, JAX/Flax, pydantic, FastAPI/Uvicorn, SciPy, pandas, and PyArrow.
  • Python's own str, list, dict, set, classes, exceptions, and generators compile without a rewrite, and so do random, math, heapq, bisect, itertools, collections, functools, and operator, with CPython's results (random draw for draw).
  • Collections that compile, with no pointers: ppy.Vec, Deque, Heap, LinkedList, HashMap, HashSet, TreeMap, and TreeSet.
  • A native call with two int arguments costs 35 ns from Python, against 33 ns for CPython's own call. It is 75 ns with a borrowed buffer of 100 ints, and 99 ns when a guard fails and the Python body runs. Lists, dicts, sets, and objects cross in the same generated C wrapper, with no Python frames on the native path; a call that only reads its lists, dicts, and sets reads them in place instead of copying them, and an object Python passes again and again stays resident in native memory between calls.
  • ppy convert and ppy migrate bring existing Python over one module at a time.
  • 2,580 tests on Python 3.12, 3.13, and 3.14, covering 67% of the compiler's 56k statements, plus differential fuzzing of generated programs. 81 diagnostic codes, each documented once.

Installation

uv add "ppy-lang[llvm]"        # or: pip install "ppy-lang[llvm]"
uv run ppy doctor              # reports what it found

Pin an exact version before 1.0 (ppy-lang[llvm]==0.7.2). A minor release may change the language, and the changelog says what changed.

Extras enable the rest: solver, numpy, torch, jax, pydantic, uvicorn, scipy, pandas, pyarrow, bind. A missing library only disables its plugin. Without the llvm extra, ppy run warns once (W2012) and runs the program on CPython. The details are in Installing.

Example

# collatz.ppy
import ppy


@ppy.pure
@ppy.opt(3)
def longest(limit: int) -> int:
    best: int = 0
    for start in range(1, limit):
        n: int = start
        steps: int = 0
        while n != 1:
            n = n // 2 if n % 2 == 0 else 3 * n + 1
            steps += 1
        best = max(best, steps)
    return best


print(longest(ppy.input[int]()))

Run it with echo 300000 | in front of each command:

uv run python    collatz.ppy           # 1  plain CPython, no compiler   1068.8 ±  5.5 ms
uv run ppy       collatz.ppy           # 2  optimized Python backend     1074.3 ± 10.3 ms
uv run ppy run   collatz.ppy           # 3  LLVM, built on the first run   41.7 ±  1.8 ms  (+ ~640 ms, once)
uv run ppy build --unsafe collatz.ppy -o dist   # 4  LLVM, built once (~640 ms)...
./dist/collatz                         #    ...then the native binary      30.8 ±  0.4 ms
                                       #    ...--host-cpu, not portable    28.8 ±  0.5 ms

gcc   -O3 collatz.c && ./a.out         # the same loop in C                41.9 ±  0.9 ms
clang -O3 collatz.c && ./a.out         #                                   32.8 ±  0.5 ms

The same source can go to other places:

uv run ppy check   collatz.ppy                  # the static checker alone
uv run ppy build   collatz.ppy --backend python # the optimized Python backend's modules
uv run ppy emit    ir  collatz.ppy              # the canonical IR (.ppyir)
uv run ppy emit    c   collatz.ppy              # one C11 translation unit
uv run ppy build   collatz.ppy --backend toy    # a backend some package installed

The first two commands in the timing block need no compiler toolchain. The rest share the frontend and the IR and differ only in the backend.

How it works

Python / .ppy source
      ↓   frontend: the CPython grammar, the module graph
static analysis: types, effects, refinements, contracts
      ↓   what cannot be proven stays Python, and is said so
canonical IR (typed SSA, 19 dialects, a public .ppyir on disk)
      ↓
shared optimization + the plugins' semantic lowerings
      ↓   the backend boundary
backend-specific passes, then backend validation
      ↓
LLVM · C11 · C++17 · CUDA · HIP · NVVM/PTX · StableHLO · your backend

Every backend consumes the same canonical IR after the same shared passes. That is why a backend can be a Python package with an entry point instead of a fork of this repository.

Performance

The Collatz kernel above, compiled by PPy and by other tools. One machine, ten fresh processes each, kernel wall time as mean ± standard deviation.

compiler kernel integer semantics
PPy ppy build --unsafe --host-cpu 28.8 ± 0.5 ms 64-bit, wraps on overflow (this machine's instruction set)
C (clang -O3 -march=native) 30.8 ± 0.7 ms 64-bit, wraps on overflow
PPy ppy build --unsafe 30.8 ± 0.4 ms 64-bit, wraps on overflow
Numba @njit 32.5 ± 0.9 ms 64-bit, wraps on overflow
C (clang -O3) 32.8 ± 0.5 ms 64-bit, wraps on overflow
PPy ppy run 41.7 ± 1.8 ms Python ints: guarded, falls back to arbitrary precision
C (gcc -O3) 41.9 ± 0.9 ms 64-bit, wraps on overflow
Codon -release 42.1 ± 12.1 ms 64-bit, wraps on overflow
PyPy 3.11 52.8 ± 0.5 ms Python ints
Cython (cdef long long) 61.6 ± 0.8 ms 64-bit, wraps on overflow
mypyc 71.6 ± 1.4 ms Python ints
Nuitka 714.7 ± 12.8 ms Python ints, no type specialization
CPython 3.14 1068.8 ± 5.5 ms Python ints

Notes on the table:

  • ppy run and ppy build are one compiler: the binary is ppy run ahead of time, and the first ppy run is the build into the cache. Both keep Python's integers (overflow is guarded and falls back to arbitrary precision) and land near gcc.
  • --unsafe drops the overflow guards for 64-bit wrap semantics like C's, which lands near clang. Bounds checks stay either way. The rows with wrap semantics were measured with it.
  • --host-cpu compiles for the machine doing the build instead of the portable baseline. It is off by default because an artifact is meant to be shipped. JIT code under ppy run always targets the host, which costs nothing because it never leaves the machine.
  • gcc -O3 -march=native changes nothing here (42.1 ± 1.3 ms). Codon's spread is its own: ten runs between 35 and 60 ms.
  • Each port is the straightforward one for its tool: @njit, a cdef long long .pyx, an annotated module for mypyc, codon build -release, nuitka --module. Versions: Numba 0.67 (warm), Cython 3.3, mypy 2.3.1, Nuitka 4.2 on CPython 3.13, Codon 0.19.6, PyPy 3.11.15 (warm), gcc 13.3, clang 22.1, CPython 3.14.5.

Eight more kernels and six whole programs against both C compilers are in examples/15_algorithms.

Why Python syntax

There is no new language to learn. .ppy uses Python's grammar, semantics, modules, and imports, so your editor, debugger, formatter, and test runner work on it unchanged. python program.ppy runs it with no compiler installed, and the other paths are held to that run. A disagreement is a compiler bug.

Types are ordinary annotations. The static subset is documented, and ppy check reports where a program leaves it, with a diagnostic code that has one meaning and one page of documentation.

Libraries

The eight plugins tell the compiler what NumPy, PyTorch, JAX/Flax, pydantic, FastAPI/Uvicorn, SciPy, pandas, and PyArrow mean: their types, their effects, and how an operation lowers. They describe this as a backend-neutral spec, not as backend code.

A plugin only adds a faster path where the compiler can argue the result is the same. Everywhere else the library's own call runs at its usual speed. A PyTorch training loop is still .backward() and the optimizer. No plugin compiles a library's whole surface, and none changes what a library returns.

Migrating existing code

uv run ppy convert src/ --in-place    # strict: the output must be valid strict PPy
uv run ppy migrate src/ --in-place    # permissive: rewrite toward it, report the rest

convert refuses to produce anything ppy check would reject, and says why. migrate is for ordinary existing Python. It first rewrites dynamic patterns that are static in practice (setattr with a constant name, globals()["X"] = ..., a constant importlib.import_module), then adds types, and classifies whatever remains (--report migration.json, --diff).

A module that converts cleanly gets the compiler. A module that does not keeps running as Python next to it.

Existing Python can also run as it is. With strict = false in [tool.ppy] (or --no-strict), ppy run types unannotated parameters from the calls the project makes (argparse options and operators on instances included), their default values, and their doctests, types unannotated fields from every store into them, compiles what that types, and runs the rest on CPython. Code the analysis cannot follow (eval, a star import, an import that fails) is a W2010 warning, not an error, and runs as CPython runs it. Of 400 TheAlgorithms/Python scripts run both ways, 387 print the same under ppy run --no-strict as under CPython and none differ; the other 13 are skipped as nondeterministic or slow.

Given this untyped Python:

import math

LIMIT = 3.0


def clamp(value):
    return min(value, LIMIT)


def spread(samples):
    total = 0.0
    for sample in samples:
        total += clamp(sample)
    return math.sqrt(total / len(samples))


print(spread([1.0, 2.0, 9.0]), spread((4.0, 5.0)))

ppy convert writes:

import math
from collections.abc import Sequence
from typing import Final

import ppy

LIMIT: Final[float] = 3.0


@ppy.pure
def clamp(value: float) -> float:
    return min(value, LIMIT)


@ppy.pure
def spread(samples: Sequence[float]) -> float:
    total: float = 0.0
    for sample in samples:
        total += clamp(sample)
    return math.sqrt(total / len(samples))


print(spread([1.0, 2.0, 9.0]), spread((4.0, 5.0)))

Types come from the whole call graph, not one file. samples is a Sequence because the body only reads it and one call site passes a tuple. LIMIT is Final because nothing rebinds it. @ppy.pure is attached only where the checker proved it. Nothing is renamed and no function is split.

Native features

ppy.native memory and FFI, ppy.simd, ppy.cpu, ppy.atomic, ppy.concurrent, ppy.parallel.range, ppy.grad, ppy.aio coroutines, ppy.cuda kernels, and ppy.xla.jit all have a Python reference implementation, so a program that uses them is still a Python program.

ppy.cuda launches PTX through the CUDA driver where there is one. ppy.hip is source emission only: ppy emit hip writes HIP C++ for hipcc, with no launch runtime of its own.

Extending the compiler

The canonical IR is typed SSA with 19 dialects: core, math, simd, cpu, atomic, concurrency, parallel, layout, tensor, linalg, fft, sparse, special, columnar, arrow, gpu, async, regex, prof. It has a text form and an on-disk form (.ppyir) that records its schema and dialect versions, so other tools can read it, and ppy build foo.ppyir builds one without the Python that produced it.

A library plugin registers types, effects, lowering specs, dialects, patterns, and passes for the libraries it claims.

A backend consumes the canonical IR after the shared passes. Backends are found through the ppy.backends entry-point group, so a backend can live in its own package with its own release schedule:

[project.entry-points."ppy.backends"]
toy = "ppy_toy:create_backend"
class ToyBackend(Backend):
    name = "toy"
    api_version = 1

    def emit_formats(self):
        return (EmitFormat("toy", ".toy"),)

    def register_passes(self, passes):
        passes.add(CountOps)

    def validate(self, module, context):
        """Refuse what this target cannot express, naming what and why."""

    def emit(self, module, format, context):
        """Canonical IR in; text, or bytes for a binary format, out."""
        return render(module)
ppy emit toy program.ppy             # the backend's own format
ppy build program.ppy --backend toy  # the backend's own artifacts
ppy doctor                           # every backend, its toolchain, its fingerprint

A backend declares the interface version it implements, adds passes at the backend stage, and refuses IR it cannot express instead of emitting it wrongly. It reads its own [tool.ppy.backends.<name>] configuration. Its distribution, version, and fingerprint are part of the cache key, so upgrading it invalidates what the old version built.

The backend interface is experimental. It is documented, tested, and versioned separately from the compiler, and it may still change. See Backends, the Backend API, Plugins, and The IR.

Documentation

ppy.franknoh.dev has the tutorial, the guide, every example with its output, the CLI, the API, and the internals. It is built from docs/ on every push: latest is the release and dev is the development branch.

Development

git clone https://github.com/franknoh/PPy.git
cd PPy
uv sync              # the compiler core, LLVM, NumPy, pydantic, and the linters
./scripts/check.sh   # the one gate; CI runs exactly this

Plugin runtimes are separate dependency groups (uv sync --group torch|jax|uvicorn|all). The rest of what a contributor needs, including the checks, the invariants a change has to keep, and how examples and measurements stay current, is in CONTRIBUTING.md.

Metadata

Release files for ppy-lang 0.7.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for ppy-lang 0.7.2
File Size Uploaded Provenance
ppy_lang-0.7.2.tar.gz 1.9 MB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for ppy-lang 0.7.2
File Interpreter ABI Platform
ppy_lang-0.7.2-py3-none-any.whl Python 3 none any Details

Total release size: 3.3 MB

Release files / ppy_lang-0.7.2.tar.gz

Download URL ppy_lang-0.7.2.tar.gz
Size 1.9 MB
Tags Source
SHA-256 checksum
How to use checksums
f5424e9ea30fd2fbd2d88df6bbb1d204cb5dac8533474c0c65ba13c3370a56be
BLAKE2b-256 checksum
How to use checksums
a4da0ce5a9956278ffe3bc7dcc8705470325ff6d2dce717ec6ba6932c2838bf1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

Release files / ppy_lang-0.7.2-py3-none-any.whl

Download URL ppy_lang-0.7.2-py3-none-any.whl
Size 1.4 MB
Tags Python 3
SHA-256 checksum
How to use checksums
93669f55f6d17cd2f549f5684ee47fb8baf5c5766c50b8bdfd32a22b996ac363
BLAKE2b-256 checksum
How to use checksums
47b1e8eddd9cd33ebdb19921a348d61e4165684e9e20d28c5837a63c655b6816
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

Security for ppy-lang 0.7.2

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for ppy_lang-0.7.2.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page