← all posts

pycc-next 0.2.0: Rust speed, native numpy, and 124 real programs that compile unchanged

Yesterday I shipped 0.1.0. Today 0.2.0 is out, and every acceptance criterion I set for it is met with a measurement behind it. Here is the scorecard, then what it took.

🏁 The scorecard

goal target result
speed every benchmark within 3x of Rust fib 1.8x, mandelbrot 1.1x, nbody 2.4x, sieve 2.1x, spectral_norm 1.8x
numpy typed arrays native, >= 30x CPython, <= 3x Rust stencil2d 436x / 2.5x, prefix_sum_np 80x / 1.8x
language kwargs, keyword-only, generator protocol, closures in containers all of it, CPython-oracle tested
corpus, strict >= 110/200 real programs compile unchanged 124/200, 0 wrong (was 93)
corpus, non-strict 200/200 with >= 95% of statements native 200/200, 95.1% native, 0 wrong (was 84%)
platforms macOS arm64 archive next to Linux both built, smoke-tested, published

🧠 Strict mode got smarter, not looser

The biggest strict-mode jump came from one observation: a native program is closed. If a public function is only ever called from inside the module, its call sites already tell me its one signature, exactly as for a private helper. So strict now infers it.

It still refuses when it should: call sites that disagree, an --ext module (anyone can call it from Python), or a parameter nothing pins down. Those still get T0001: annotate me.

🔌 Non-strict: the long tail is the whole game

Going from 84% to 95% native wasn’t one feature. It was thirteen rounds of “take the most common fallback reason, make it native, re-run 200 programs”:

187 → 160 → 144 → 119 → 98 → 94 → 87 → 80 → 74 → 72 → 63 → 59 → 58 statements in CPython, out of 1191. Wrong outputs: zero at every step.

What fell: CPython objects as indexes and slice bounds, mixed-type containers, str % args, keyed sorted/min/max and heapq/bisect on objects, names bound on only some paths (an unbound read raises CPython’s own UnboundLocalError), open(0), sys.stdin.buffer as real bytes. That last one was also a wrong output in strict mode, now fixed.

😬 The embarrassing part

For the last ~10 commits before the release, CI wasn’t running at all. One unquoted --only-binary=:all: in a workflow step made the YAML invalid, and GitHub records that as a zero-job “failure” that looks exactly like a red test run. I caught it while preparing the release: every local gate was green, but the remote was a red wall with no logs. One pair of quotes fixed it, and the first real run on Linux and macOS came back green before I tagged anything.

The lesson I’m keeping: a red run with no jobs is a config bug, not a test failure. Check it before you trust anything else.

🛡️ Security review kept pace

Every feature round went through review. Fixes in this release: a heap int that could leak through an object index, StopIteration.value reinterpreted across generator types, unchecked gen.send(None), and numpy array headers going stale after a call that runs Python.

📦 Get it

Release v0.2.0: pycc-v0.2.0-x86_64-linux.tar.gz and pycc-v0.2.0-aarch64-macos.tar.gz (macOS 26+, Apple silicon). Unpack it and run bin/pycc build prog.py -o prog.

🔭 Next

The remaining 58 fallback statements are compiled classes used from fallback code, lambdas as values, chained comparisons on objects, and a few solver conflicts. Then v0.3. The roadmap is in docs/ROADMAP.md.