← all posts

pycc-next 0.4.0: untyped Python, 11.5x faster, and exceptions that finally get freed

0.3.0 had an honest weak spot: untyped code got correctness but barely any speed. diff-match-patch, compiled unchanged, ran 1.7x faster than CPython. 0.4.0 goes after exactly that.

🏁 The scorecard

goal target result
untyped code native diff-match-patch >= 5x CPython 11.5x (was 1.7x), its suite passes
exceptions freed constant memory in raise/catch loops ~2 B/iter (was up to 1.9 KB/iter)
corpus, strict >= 170/200 171/200, 0 wrong
corpus, non-strict <= 8 statements in CPython 8/1191, 200/200 correct
second real package another domain, own tests, faster tomli: suite passes, 1.67x faster
v0.3 gaps circular imports, IntEnum, sorting, dataclasses.fields done

🐢➡️🚀 Why untyped code was slow, in three profiles

  1. CPython objects everywhere. diff-match-patch’s text parameters arrived as CPython str, so every text1[x1] == text2[y1] in the hot loop was PyObject_GetItem plus a rich compare.
  2. Making them native was slower. My native str indexing scanned the UTF-8 bytes on every s[i]. Embarrassing, and only visible once the objects were gone. Fix: strings cache their code-point count, ASCII strings index in O(1), and non-ASCII strings get a small offset table.
  3. One rebinding boxed a name everywhere. Non-strict widening was keyed by name across the whole module, so a delta reassigned in one method became an object in all of them. Now it’s per function.

Then the piece that makes it work for code nobody annotated: guarded native copies. A public untyped --ext function that loops gets a twin with guessed types (str for what you index and slice, int for what you count with). At the call, type(p) is str picks the twin; anything else (a subclass, bytes, None) takes the original object path. Same results either way, verified by calling it from CPython with a zoo of argument types.

1.7x → 11.5x on the package’s own speed test.

🧾 tomli: the honest one

tomli is the TOML parser behind the stdlib’s tomllib, and it’s written as typed code. It built and passed its tests on the first serious try. Speed: 0.87–1.10x. A wash.

About half the time was inside libpython itself (regex, dict lookups), work any implementation does. The rest was overhead: returning (pos, value) tuples, str | None locals boxed as objects, refcount calls. Fixes, all general:

  • functions that always return a fixed-length tuple hand values back without a tuple;
  • str | None locals stay native strings, with null standing for None;
  • while s[i] in chars: i += 1 runs as a single scan.

Result: 1.67x (best of 9), 2.60M vs 4.51M instructions per call. Its documents are still dict[str, Any] of CPython objects, which is the next ceiling.

🗑️ Exceptions are freed now

Until now every raised exception lived until exit. A loop catching a failing json.loads leaked 1.9 KB per iteration. Exceptions are now reference counted: except ... as e, bare raise, finally, return/break out of handlers, generators, exceptions crossing to and from CPython. 18 constant-memory probes, valgrind-clean.

🧪 A test bug that hid for a while

While chasing a red CI I found that our peak-memory probes were partly measuring the test harness, not the program. On Linux a child process inherits its parent’s memory high-water mark across exec. One test also had its slack in bytes where Linux reports kilobytes, so it would have allowed a 1 GB leak. Probes now read the child’s own VmHWM, and I injected a deliberate 32-byte-per-iteration leak to prove they still catch one.

⚡ Numbers

python3 bench/run.py, best of 7, shared 4-core box: fib 18.9x faster than CPython, mandelbrot 35x, nbody 33x, sieve 21x, spectral_norm 48x, numpy stencil 459x. Every program is within 2.8x of hand-written Rust.

🔭 Next

Native containers that hold objects (dict[str, Any]), so typed packages like tomli stop round-tripping through the C API. And the last 8 fallback statements.

Release v0.4.0