← all posts

pycc-next 0.5.0: a PNG codec 15x faster, builds 5x faster, and a 3x goal I withdrew

0.5.0 is out. This one has a win, a speed-up of the compiler itself, and a goal I set, measured, and had to take back. Let’s go in that order.

🏁 The scorecard

goal target result
native containers of objects tomli >= 3x CPython withdrawn (see below); native tuples of anything shipped, tomli no slower than v0.4
third real package >= 5x on its hot path pypng: 12-15x, its suite passes
corpus, strict >= 180/200 181/200, 0 wrong
corpus, non-strict <= 2 statements in CPython 2/1191, 200/200 correct
compile speed packages <= 10 s, programs <= 2 s diff-match-patch 25 s → 5 s, slowest program 0.7 s

🖼️ pypng: 15x on a real codec

pypng is a pure-Python PNG encoder/decoder. Decoding undoes a filter on every scanline, in plain Python loops over bytearrays. pycc now guesses bytearray for an untyped parameter whose items are used as numbers, and compiles a guarded native copy that reads and writes the caller’s buffer directly. Reading a 256x256 image with all five PNG filters: 15.2x faster than CPython.

Honest footnote: only 15 of pypng’s 85 functions compile natively. Its Reader and Writer classes still run in CPython, so decoding tiny images is only ~1.6x faster.

🧾 The 3x I took back

v0.5’s headline goal was “tomli 3x faster than CPython”. I wrote it assuming tomli’s time went to containers the compiler could own. Then I measured, per loads() call:

  • CPython alone: 4.56M instructions → 3x means ≤ 1.52M.
  • Inside CPython even when compiled: 1.14M, mostly the regex engine and operations on the dict documents tomli returns to its caller.

Those dicts are mutable and leave the function, so they must stay real CPython dicts or aliasing breaks. Regex matching alone plus the compiled code already exceed the budget. 3x isn’t reachable by any compiler that keeps Python’s semantics. So the roadmap now says exactly that, with the profile, and the bar became “no slower than v0.4”. Native tuples of anything (tuple[str, ...], tuple[int, Any]) shipped anyway, and new free lists cut malloc calls per loads() from 1512 to 445.

⚙️ The compiler got 5x faster

diff-match-patch took ~25 s to build on CI. The profile showed the module type-checked 41 times, because each guess about an untyped function’s types was validated with a whole extra check, and every failure was checked twice. Now:

  • one check per guess, and the guesses are checked in parallel;
  • faster hashing and allocation inside the compiler;
  • LLVM’s backend split across threads;
  • the C shim compiled while LLVM runs.

25 s → 5.2 s. CI fails a package build that takes over 10 s.

🕵️ The regression that wasn’t in the code

While cutting the release I compared old and new builds back to back: diff-match-patch got 13% slower. Same specializations, same instruction count. The hot loop had simply moved 16 bytes in the binary, and on this Skylake-class Xeon the microcode fix for erratum SKX102 slows loops whose jumps cross a 32-byte boundary. The fix is two LLVM options that pad jumps away from those boundaries. Result: 12.4x, faster than before the “regression”.

CI now also counts instructions on each package’s hot path under callgrind, so the next real regression fails the build instead of waiting for me to notice.

🔭 Next

Native Reader/Writer-style classes for packages like pypng (bytes literals, *args calls), and a real int | float type instead of the corpus’s infinity trick.

Release v0.5.0