← all posts

pycc-next 0.6.0: pip install compiles your project, pydantic goes 63% native, and a corpus goal I missed

0.6.0 is out. The theme was drop-in for real projects: you shouldn’t have to rewrite anything, or even run a new command, to get compiled code. Four of the five goals are met. The fifth isn’t, and it’s in the table below exactly as measured.

🏁 The scorecard

goal target result
pip install builds with pycc a PEP 517 backend, wheels of 3 real packages pass their suites met: diff-match-patch, tomli, pypng built by pip wheel, suites pass on Linux x86_64 + aarch64
untyped classes pypng >= 60/85 functions, PngSuite decode >= 3x CPython met, thinly: 64/85 (78 with method-level fallback), 3.18x
real int \| float type CPython semantics for every operation met: one machine word, pure int/float code unchanged
no hidden differences a differential fuzzer, nightly >= 30 min met: 3314 programs in 30 min, 0 findings; 18 earlier mismatches fixed
corpus, strict >= 190/200 ❌ 188/200 (from 183)
corpus, non-strict 0 statements in CPython ❌ 1/1191 (from 2), 200/200 correct

Two misses, both in the corpus goal. They move to v0.7 with three tracked issues.

📦 pip install . now runs the compiler

The release archive ships a wheel of pycc.build, a PEP 517 (and PEP 660) build backend. Point a project at it:

[build-system]
requires = ["pycc-build"]
build-backend = "pycc.build"

[tool.pycc]
strict = false          # build every module --non-strict
pure-python = ["app.cli"]  # keep this one as .py

and pip install . compiles every module to an abi3 extension (--ext), producing a real cp313-abi3-manylinux_<glibc>_x86_64 wheel with a proper RECORD and METADATA. Modules can be kept in Python, or allowed to fall back, one by one. A module pycc refuses fails the build with pycc’s own diagnostics: no silently slower wheel.

The proof is in CI: diff-match-patch, tomli and pypng are built by plain pip wheel, installed into fresh venvs, and their own test suites run against the installed wheels. The backend needs no network and runs no project code.

🔢 int | float, for real

Python code mixes int and float all the time: best = float('inf') later replaced by an int, an accumulator starting at 0 that receives floats. 0.5 handled the infinity case with a trick and a list of documented differences. 0.6 replaces the trick with a real type.

An int | float is one machine word extending the int encoding: a small int, a heap big int, or a float stored inline when its magnitude is roughly between 1e-77 and 1e77 (boxed otherwise). Every operation is CPython’s: //, %, **, exact mixed comparisons with big ints, round, hash, f-string formatting. Pure int and pure float code compiles exactly as before. The old DEVIATIONS rows are gone.

🎲 A fuzzer that argues with CPython

New in fuzz/: a generator of typed Python programs. Each runs under CPython 3.13 and in four pycc builds (native strict, --non-strict, --ext, --ext --non-strict). Stdout, the uncaught exception and the exit status must all agree; a mismatch is reduced to a minimal program automatically.

About five hours of local runs found 18 real mismatches: deque.extend ignoring maxlen, repr() of floats with two shortest representations, a set display iterating in the wrong order, for ... else running else after break, and more. Each is fixed with a regression fixture. The last 30-minute nightly run: 3314 programs, 0 findings.

🖼️ pypng: classes compile, 3.18x on PngSuite

0.5 made pypng’s scanline filters fast, but its Reader class stayed in CPython, so tiny images decoded only ~1.6x faster. Now untyped classes compile under --non-strict: chunk handlers looked up by getattr(self, "_process_" + kind), generator methods, bytes literals, *args calls, except struct.error:.

Decoding every PngSuite image its tests use: 3.18x faster than CPython (18.8 ms vs 59.8 ms, best of 15 interleaved runs), 4.57x fewer instructions under callgrind. Honest footnote: the target was 3x and the median is 3.16x. The margin is thin. What’s left is mostly CPython itself: zlib, struct, array.

🧬 Method-level fallback, and pydantic at 63%

This is v0.7 work that landed before the release, so it ships in 0.6.0.

Before, one construct pycc couldn’t compile sent its whole class to CPython. Now only the smallest enclosing function or method goes there. The class keeps every other method compiled: its defs forward to compiled bodies, with self, super() and name mangling behaving as in CPython. Classes inheriting from library classes compile too: threading.Thread, collections.Counter, argparse.Action, metaclasses, descriptors, @property, cached_property, the with statement.

So I pointed it at pydantic 2.13.5 (its Python part, pinned by sha256), built module by module with --ext --non-strict:

  • 789 of its 1249 functions compile natively, and every module builds (dozens failed to build before this release’s work).
  • 5716 of its 5740 tests pass against the compiled tree. The 24 failures: tests that monkeypatch module attributes compiled code doesn’t see, sys._getframe from compiled frames, and four subprocess tests that fail under plain CPython too.
  • Speed: not measured yet. I’m not quoting a number I don’t have.

pypng went from 64 to 78 of 85 functions native with the same change.

⚡ Benchmarks

Fresh run on this commit, python3 bench/run.py, best of 7, on a shared 4-core Xeon 2.1 GHz that was busy with other builds:

program CPython pycc-next Rust vs CPython vs Rust
collections_bfs 0.900s 0.144s 0.049s 6.3x faster 2.92x slower
fib 0.282s 0.013s 0.007s 21.7x faster 1.91x slower
mandelbrot 1.545s 0.043s 0.042s 35.5x faster 1.03x slower
nbody 2.530s 0.067s 0.025s 37.7x faster 2.68x slower
prefix_sum_np (kernel) 2.798s 0.029s 0.013s 95.4x faster 2.19x slower
sieve 1.000s 0.048s 0.021s 20.7x faster 2.35x slower
spectral_norm 0.836s 0.018s 0.011s 45.5x faster 1.69x slower
stencil2d (kernel) 3.751s 0.007s 0.003s 530.5x faster 2.17x slower

Read the ratios carefully: sieve shows 20.7x here against 37.5x in 0.5.0’s notes, and fib 21.7x against 26.5x. pycc’s own times are about the same or better (sieve 0.048 s vs 0.057 s). CPython just ran faster on this run, so the ratio shrank. I’m keeping the number as measured anyway.

❌ The goal I missed

The corpus is 200 real competitive-programming solutions. The target was >= 190 compiling unchanged in strict mode, and zero top-level statements falling back in non-strict mode. I got 188 and 1.

The remaining strict refusals are mostly legitimately dynamic Python: mixed-type containers, an int | None used as an int without a None check, a variable rebound along different paths. The last fallback is one function, problem 188’s mat_pow. It rebinds its list parameter to a list of lists under an isinstance test, and its nested helper is called with both. Fixing it needs three separate compiler changes (issues #5, #6, #7). I’d rather ship 0.6 with an honest ❌ than hold four finished goals hostage to one function.

🪟 Bonus: Windows

Also early from v0.7: a Windows x86_64 archive. pycc builds native .exe programs and abi3 .pyd extension modules with the MSVC toolchain, and pycc.build tags wheels cp313-abi3-win_amd64. CI runs the full test suite, the corpus gate and the real-package wheel checks on windows-2025, and the release workflow smoke-tests the zip before publishing. It’s new, so it has its own documented differences (stdout is \n-terminated UTF-8, no static libpython). The benchmarks above are Linux only; I haven’t timed Windows.

🔭 Next: v0.7

The carried-over corpus goal, more framework-shaped code, and measuring pydantic’s speed now that most of it compiles.

👉 Release v0.6.0: Linux x86_64, Linux aarch64, macOS arm64 and Windows x86_64 archives, each with its sha256.