Repository navigation
pycc-next v0.5.0
Typed packages at native speed. Tuples of any element type, including CPython objects,
are native (tuple[T, ...], tuple[int, Any]); runtime free lists cut allocations; a
third real package, pypng, decodes PNGs 12-15x faster than CPython with its own suite
passing; builds are 5x faster (diff-match-patch 25 s -> 5 s on CI); strict mode compiles
181/200 real-world programs unchanged and --non-strict runs all but 2 of their 1191
top-level statements natively. CI now gates package build time and hot-path instruction
counts. tomli stays at ~1.5x CPython: half of its loads() runs inside CPython's regex
engine and dict operations, which a compiler cannot take over without breaking aliasing
(see ROADMAP v0.5 accept 1).
Benchmarks (python3 bench/run.py, best of 7, shared 4-core box): fib 26.5x faster than
CPython / 1.7x of Rust, mandelbrot 34.6x / 1.0x, nbody 32.8x / 2.5x, sieve 37.5x / 2.0x,
spectral_norm ~66x / 2.0x, collections_bfs 6.9x / 3.0x, numpy kernels stencil2d 590x /
2.3x and prefix_sum_np 135x / 1.6x. Real packages (bench/package_check.py --time):
diff-match-patch 12.4x, pypng 15.2x, tomli 1.56x.
Added
-
Real-world corpus (v0.5 accept 3):
python3 bench/corpus.py --flags=--non-strict
passes 200/200 with 0 wrong outputs and 2 of 1191 top-level statements in CPython
fallback (v0.4: 8), both listed bybench/corpus.py --show-fallbacksand explained in
the README's corpus note; strict mode passes 181/200 (171 before). What it took:- strict inference: a tuple assignment of values that can observe none of its targets
(a, b = [], set(),m, seen = 0, {0: None}) binds each name on its own, so its
empty containers are typed by their own uses;f(d[k])types an unannotated parameter
from the dict's value type; an unannotated__init__parameter is typed from an
argument read from an unannotated function's return (N, K = MAP()) or from a
comprehension's loop variable ([Node(i) for i in range(n)]); a call-site
parameter some call cannot type yet waits until it can (a later call may disagree);
a global the module binds only inside a loop types the functions reading it; "literal".format(...)with{}/{0}fields,!r/!sand a constant spec
compiles natively (str()/repr()/format()concatenated);float('inf')held by a variable anintlater replaces (best = float('inf')
...best = min(best, cost)) is anintinfinity: ordering, equality,min/max,
printing,hash,float(),+/-/*by anintand/answer as CPython's
float infinity does;min(s)/max(s)of astr;- non-strict: CPython's own builtins over CPython objects --
range(...)as a value,
tuple(xs),iterof a list display,next,list,sorted,sum,min/max
of one object or of two values,enumerate,itertools.accumulate/chain; a
generator that yields native values beside objects yields objects;return None
beside a value of another type returns an object; a tuple display holdingNoneis
an object to the solver as to the checker; a fresh set on the right of|,&,
-,^on an object crosses as a copy;set(xs)is a fresh container; a value of
another type stored into a list ofNones (or a dict looked up with an object key)
makes only that container CPython's, not the classes of the values flowing into it.
- strict inference: a tuple assignment of values that can observe none of its targets
-
bench/corpus.py --only 017,077runs a subset;--show-fallbackslists the
problems with statements in CPython. -
bench/package_check.py --instructionscounts, under callgrind, the instructions one
call of each real package's hot path executes against the compiled modules and fails
a package over its documented bound, 5% above its count on CI's Linux x86_64 runner:
diff-match-patch'sdiff_mainof 2000 characters 133.3M (126.9M), tomli'sloads()
2.93M (2.79M; 2.53M with Ubuntu's python3.13, v0.5 accept 1's method), pypng's
read()22.2M (21.2M). CI runs it there: unlike wall time, the count does not vary
between runs of a shared runner. -
Compile speed (v0.5 accept 4): building diff-match-patch takes 5.2 s on the CI Linux
runner (v0.4: ~25 s), tomli 2.8 s and pypng 1.0 s, and everybench/pyprogram
builds in under 1 s; CI fails a package over 10 s or a benchmark build over 2 s
(bench/package_check.pyprints each module's build time,bench/run.py --build-onlyeach program's). The non-strict specialization loop runs independent
attempts on threads (the program without specializations beside the first attempt,
every suspect of a span-less failure alone at once) and a failing check runs once,
not twice; the type checker hashes with FxHash and the compiler allocates with
mimalloc; a large optimized module is emitted as several objects by LLVM's backend
on several threads; the--extshim and an embedded build's launcher and sidecar
are compiled and assembled while LLVM runs.PYCC_TIME=1prints per-phase timings. -
A third real package (v0.5 accept 2):
bench/package_check.pyadds
pypng 0.20220715.0 (MIT, a pure-Python PNG codec,
sdist pinned by sha256), a single module (the table now takes one as well as a
package).pngbuilds unchanged with--ext --non-strict, its own 103 tests pass
against it (CI) and reading a 256x256 RGB image that uses all five PNG filters runs
10x faster than in CPython (python3 bench/package_check.py pypng --time --runs 5:
0.015 s vs 0.157 s); 15 of its 85 functions compile, itsReader/Writerclasses
still run in CPython. -
Untyped byte loops run native: a guarded specialization also guesses
bytearrayfor a
parameter whose items are used as numbers ((x + a) & 0xffwithx = s[i], an index
local computed from anintparameter counts as its use too) and takes the caller's
bytearrayas it is: items are read and stored in its own buffer, its size and buffer
pointer held in registers until something may run CPython code (then re-read), with
bytearray's ownIndexError/ValueErrors (the stored byte is checked before the
index, as CPython does). pypng'sundo_filter_*loops run 25x-50x faster per byte. -
abs()of a smallintis computed inline instead of calling the runtime. -
Native variadic tuples (v0.5 accept 1, partly):
tuple[T, ...]ofstr, class
instances or containers is a native tuple -- indexing, slicing,+,*,in,
comparison, hashing, iteration, unpacking, dict keys and set elements without the C
API -- and a fixed tuple of matching elements is one with no conversion. It crosses
into CPython as a copy made once and remembered, sof(t) is tholds for a tuple that
came from CPython; a strict--exttuple[str, ...]parameter takes atupleof
str(anything else raisesTypeError), and a variadic tuple of mutable elements is
refused at the boundary (C0003) rather than copied.tuple[int, ...]and the other
scalar ones areT0053in strict mode (not implemented yet). -
Native tuples of objects: under
--non-strictand at the--extboundary,
tuple[int, Any]keeps its position native and its value the CPython object itself,
and a native tuple returned where a tuple with object slots is declared is converted
element by element (return pos, Truefrom-> tuple[int, Any]).
Fixed
- The non-strict object stdlib shim passed a one-argument call's tuple argument as the
argument tuple (heapq.heapify(t)of a CPython tuple raisedTypeError). - The
C0001for an unsupported.get()call no longer says it needs exactly two
arguments: it names bothdict.get(key)anddict.get(key, default), and what the
one-argument form takes. - Compiled callers see every rebinding of a module-level function (#1): a
module-scope binding of a name adefalso binds (f = g, a loop orwith
target, ...) is refused withC0001(in non-strict mode it runs in CPython, with the
defs of the name, so compiled code reads the name from the module's namespace); a
defor lambda that runs in CPython fallback and rebinds a compiled function's name
(in a block, throughglobal) makes itsdefs run there too; a value of a
function defined more than once (g = f, then anotherdef f) keeps the
definition of that moment. Before, compiled callers kept calling the old function or
raisedNameError. - A guarded specialization calls a builtin its original reaches through CPython (
abs
of an object) natively when its typed arguments allow (and as the original does when
they do not), instead of always through CPython. - A function was not specialized when its original failed to compile in a way reported
without a source span in the specialized attempt (a module with no imports and an
abs()of an object): such a failure is the original's, not the copy's. - A string index or slice that raised (
s[0]of"",s[::0]) leaked its 72-byte
placeholder result; a list or variadic tuple sliced with a zero step leaked an empty
list.
Performance
- Small runtime objects are recycled through free lists, as CPython does for tuples:
list/tupleheaders, the data blocks of tuples of 1 to 8 elements (one list per
length) andstrobjects are taken from and returned to bounded pools
(crates/pycc_rt/src/pool.rs) instead ofmalloc/free, and slicing or extending a
tuple writes its words and kinds straight into its data block (no temporary
vectors). tomli's keys are nativetuple[str, ...]values;loads()of its
benchmark document takes 2.54M instructions per call under callgrind (v0.4: 2.60M;
2.70M with native tuples before the pools; CPython: 4.56M), meeting v0.5 accept 1 as
amended (seedocs/ROADMAP.md). - Objects keep every jump within a 32-byte boundary on x86 (LLVM's
-x86-branches-within-32B-boundaries, padding with instruction prefixes where it
can): Skylake-derived Intel cores (through Cascade Lake, common among cloud and CI
machines) run a microcode update for erratum SKX102 that slows a loop whose jump
crosses or ends on such a boundary, so a hot loop's speed depended on the size of the
unrelated code placed before it. diff-match-patch'sdiff_bisectlost 7-13% between
two commits that generated identical code for it; with the padding itsdiff_main
speed test runs ~15% faster than at either commit on such a core, for ~2% more
executed instructions (nops) where nothing stalls. - Non-strict mode no longer widens a module's
frozensetconstant of scalars into a
CPython object (a frozenset crosses into CPython as an immutable copy, which aliasing
cannot tell apart): sinceset(xs)/frozenset(xs)count as fresh containers,
tomli'sTOML_WS = frozenset(" \t")and its siblings had become objects, its
skip_chars(src, pos, TOML_WS)calls lost their nativefrozenset[str]
specialization andloads()rose from 2.53M to 2.95M instructions per call; it is
2.53M again (tests/frozenset_constants_native.rs).
Known gaps
- See
docs/DEVIATIONS.mdfor every known difference from CPython 3.13.