pycc-next 0.7.0: pydantic compiles, its tests pass, and it runs slower than CPython
0.7.0 is out. All five goals are met. Real libraries compile now: pydantic builds with two thirds of its functions native and passes its own test suite. And then I timed it. The compiled pydantic is slower than plain CPython. Both facts are in this post, with the numbers.
🏁 The scorecard
| goal | target | result |
|---|---|---|
| Windows | a release .zip, CI on windows-2025, win_amd64 wheels |
met: suite, corpus gate and real-package wheels run on Windows in CI |
| classes from libraries | bases from CPython modules, metaclasses, descriptors, with |
met: threading.Thread, Counter, argparse.Action, metaclasses, cached_property… |
| method-level fallback | only the smallest enclosing function goes to CPython | met |
| pydantic, measured once | builds, passes its suite, speed recorded either way | met: 809/1249 functions native, 5742 tests pass; ❗ slower than CPython |
| corpus | strict >= 190/200, 0 statements in CPython | met: 190/200 strict, 0/1191 in fallback, 200/200 correct |
The corpus goal is the one I missed in 0.6. It’s done.
🧬 pydantic: it compiles
pydantic 2.13.5 is ~1250 functions of very dynamic Python: metaclasses, descriptors,
generics, __get_pydantic_core_schema__ hooks everywhere. Built module by module with
--ext --non-strict (pydantic-core, the Rust part, unchanged and the same wheel for both
trees):
- 809 of 1249 functions compile natively, every module builds.
- 5742 of its tests pass against the compiled tree. Against the sources: 5762. The 20
that fail walk
sys._getframethrough compiled frames (which aren’t Python frames yet) or monkeypatch a compiled module’s globals, which compiled code doesn’t see.
Two pieces made that possible. Classes inheriting library classes: the class statement
runs in CPython, so metaclasses and __init_subclass__ behave exactly as they should,
while its method bodies are compiled. And method-level fallback: a construct pycc can’t
compile used to drag its whole class into CPython; now only the smallest function or method
around it goes.
🐢 …and it’s slower
Median of 15 interleaved fresh-process runs (bench/pydantic_check.py --time, shared
4-core Xeon, CPython 3.13):
| workload | CPython | compiled | speed vs CPython |
|---|---|---|---|
| import | 75.8 ms | 191.6 ms | ❌ 0.40x |
| model definition | 9.0 ms | 12.9 ms | ❌ 0.70x |
model_json_schema() |
18.0 ms | 25.8 ms | ❌ 0.70x |
| validation/serialization round-trips | 17.0 ms | 18.8 ms | ❌ 0.91x |
No row is a win. I said I’d record the result whether it was a speed-up or not, so here it is. I profiled it and found four causes:
- Import recompiles Python source every time. Classes whose statement runs in CPython,
their method forwarders and the methods that fall back are embedded as source, about
674 KB of it, and handed to
compile()on every import. CPython reads a cached.pycinstead. That’s most of the 0.40x. - The boundary costs. Every call into a compiled function pays ~25-40 ns: unpacking
arguments, copying a
strinto a native string, and for a method of a CPython-run class a Python forwarder frame on top. pydantic’s schema building is many short calls, so it pays that toll constantly. - Native object-protocol code doesn’t beat CPython’s own. Most of pydantic’s bodies
are attribute lookups, item access and calls on arbitrary objects. Compiled, that’s
C API calls, without CPython 3.13’s specializing interpreter caching each lookup.
Loop-heavy helpers do win (
_deduplicate_schemasruns ~6x faster), but there are few of them. - Exceptions were bridged slowly. A caught CPython exception was matched against up to
33 classes. Fixed in this release: builtin classes match by identity first, a caught
KeyErrorwent from 496 to 312 ns (CPython: 202). Still behind.
So “it compiles and its tests pass” is a correctness result, not a speed result. v0.8 is
about exactly this: no compile() at import, no forwarder frames, the common fallback
constructs (..., local import, nested def, lambda) compiled natively, and a target
of pydantic definition and schema building >= 1.3x faster than CPython.
🪟 Windows is first-class
The Windows zip appeared in 0.6 as a bonus. In 0.7 it’s a platform: CI runs the whole
test suite, the corpus gate and real-package wheel builds (diff-match-patch, tomli, pypng
into cp313-abi3-win_amd64 wheels, each tested with its own suite) on windows-2025 with
MSVC and LLVM’s own 22.1.8 release, pinned by sha256. The release workflow smoke-tests
pycc-v0.7.0-x86_64-windows.zip before publishing it. Known differences: native stdout is
\n-terminated UTF-8, no static libpython. I haven’t benchmarked Windows.
🎯 The corpus: 190/200, 0 fallbacks
200 real competitive-programming solutions. Strict mode now compiles 190 of them
unchanged (from 188), and --non-strict runs zero of the 1191 top-level statements in
CPython (from 1). The last two problems needed real compiler work:
isinstancefolding.if not isinstance(B[0], list): B = [[b] for b in B]— when the static type already decides the test, strict mode keeps only the branch that runs, and a parameter normalized like this gets one variable per path. A nested helper called with different container types is compiled once per call site.- A
rangekept in a variable.r = list(map(range, xs))thenfor i in r[0]compiles: arangethat’s only iterated becomes a(start, stop, step)triple. Andx is Noneof a value that can never beNonefolds away like anisinstancetest.
The 10 strict refusals left are genuinely dynamic: mixed-type containers, an int | None
used as an int without a check, a variable rebound to another type on some paths.
⚡ Benchmarks
Fresh run on the release commit, python3 bench/run.py, best of 7, shared 4-core Xeon
2.1 GHz, Linux x86_64:
| program | CPython | pycc-next | Rust | vs CPython | vs Rust |
|---|---|---|---|---|---|
| collections_bfs | 0.843s | 0.131s | 0.054s | 6.5x faster | 2.43x slower |
| fib | 0.243s | 0.013s | 0.008s | 18.5x faster | 1.73x slower |
| mandelbrot | 1.485s | 0.042s | 0.040s | 35.2x faster | 1.04x slower |
| nbody | 2.299s | 0.068s | 0.027s | 33.6x faster | 2.55x slower |
| prefix_sum_np (kernel) | 2.746s | 0.030s | 0.014s | 92.3x faster | 2.15x slower |
| sieve | 1.084s | 0.058s | 0.022s | 18.7x faster | 2.66x slower |
| spectral_norm | 0.815s | 0.017s | 0.009s | 47.0x faster | 1.87x slower |
| stencil2d (kernel) | 3.694s | 0.007s | 0.003s | 532.5x faster | 2.25x slower |
Typed numeric code is where it was: 18-47x faster than CPython on the plain programs. Sieve reads a bit slower than in 0.6 (0.058 s vs 0.048 s) on a busy machine; I’ll re-check it quietly before calling it a regression or noise.
The contrast with pydantic is the whole story of this release: typed code is fast, dynamic library code compiles but isn’t fast yet.
🔭 Next: v0.8
Library code faster than CPython. pydantic import no slower than CPython, definition and
schema building >= 1.3x faster, >= 1000/1249 functions native, compiled frames that
sys._getframe and tracebacks can see, and a fourth real package from another domain.
👉 Release v0.7.0: Linux x86_64, Linux aarch64, macOS arm64 and Windows x86_64 archives, each with its sha256.