← all posts

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._getframe through 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:

  1. 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 .pyc instead. That’s most of the 0.40x.
  2. The boundary costs. Every call into a compiled function pays ~25-40 ns: unpacking arguments, copying a str into 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.
  3. 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_schemas runs ~6x faster), but there are few of them.
  4. 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 KeyError went 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:

  • isinstance folding. 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 range kept in a variable. r = list(map(range, xs)) then for i in r[0] compiles: a range that’s only iterated becomes a (start, stop, step) triple. And x is None of a value that can never be None folds away like an isinstance test.

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.