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
- CPython objects everywhere. diff-match-patch’s text parameters arrived as CPython
str, so everytext1[x1] == text2[y1]in the hot loop wasPyObject_GetItemplus a rich compare. - Making them native was slower. My native
strindexing scanned the UTF-8 bytes on everys[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. - One rebinding boxed a name everywhere. Non-strict widening was keyed by name across
the whole module, so a
deltareassigned 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 | Nonelocals stay native strings, with null standing forNone;while s[i] in chars: i += 1runs 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.