Skip to content

pycc-next v0.5.0

Choose a tag to compare

@github-actions github-actions released this 16 Oct 19:12
963640b

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 by bench/corpus.py --show-fallbacks and 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/!s and a constant spec
      compiles natively (str()/repr()/format() concatenated);
    • float('inf') held by a variable an int later replaces (best = float('inf')
      ... best = min(best, cost)) is an int infinity: ordering, equality, min/max,
      printing, hash, float(), +/-/* by an int and / answer as CPython's
      float infinity does;
    • min(s) / max(s) of a str;
    • non-strict: CPython's own builtins over CPython objects -- range(...) as a value,
      tuple(xs), iter of 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 holding None is
      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 of Nones (or a dict looked up with an object key)
      makes only that container CPython's, not the classes of the values flowing into it.
  • bench/corpus.py --only 017,077 runs a subset; --show-fallbacks lists the
    problems with statements in CPython.

  • bench/package_check.py --instructions counts, 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's diff_main of 2000 characters 133.3M (126.9M), tomli's loads()
    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 every bench/py program
    builds in under 1 s; CI fails a package over 10 s or a benchmark build over 2 s
    (bench/package_check.py prints each module's build time, bench/run.py --build-only each 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 --ext shim and an embedded build's launcher and sidecar
    are compiled and assembled while LLVM runs. PYCC_TIME=1 prints per-phase timings.

  • A third real package (v0.5 accept 2): bench/package_check.py adds
    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). png builds 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, its Reader/Writer classes
    still run in CPython.

  • Untyped byte loops run native: a guarded specialization also guesses bytearray for a
    parameter whose items are used as numbers ((x + a) & 0xff with x = s[i], an index
    local computed from an int parameter counts as its use too) and takes the caller's
    bytearray as 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 own IndexError/ValueErrors (the stored byte is checked before the
    index, as CPython does). pypng's undo_filter_* loops run 25x-50x faster per byte.

  • abs() of a small int is computed inline instead of calling the runtime.

  • Native variadic tuples (v0.5 accept 1, partly): tuple[T, ...] of str, 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, so f(t) is t holds for a tuple that
    came from CPython; a strict --ext tuple[str, ...] parameter takes a tuple of
    str (anything else raises TypeError), and a variadic tuple of mutable elements is
    refused at the boundary (C0003) rather than copied. tuple[int, ...] and the other
    scalar ones are T0053 in strict mode (not implemented yet).

  • Native tuples of objects: under --non-strict and at the --ext boundary,
    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, True from -> 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 raised TypeError).
  • The C0001 for an unsupported .get() call no longer says it needs exactly two
    arguments: it names both dict.get(key) and dict.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 a def also binds (f = g, a loop or with
    target, ...) is refused with C0001 (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
    def or lambda that runs in CPython fallback and rebinds a compiled function's name
    (in a block, through global) makes its defs run there too; a value of a
    function defined more than once (g = f, then another def f) keeps the
    definition of that moment. Before, compiled callers kept calling the old function or
    raised NameError.
  • 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/tuple headers, the data blocks of tuples of 1 to 8 elements (one list per
    length) and str objects are taken from and returned to bounded pools
    (crates/pycc_rt/src/pool.rs) instead of malloc/free, and slicing or extending a
    tuple writes its words and kinds straight into its data block (no temporary
    vectors). tomli's keys are native tuple[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 (see docs/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's diff_bisect lost 7-13% between
    two commits that generated identical code for it; with the padding its diff_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 frozenset constant of scalars into a
    CPython object (a frozenset crosses into CPython as an immutable copy, which aliasing
    cannot tell apart): since set(xs)/frozenset(xs) count as fresh containers,
    tomli's TOML_WS = frozenset(" \t") and its siblings had become objects, its
    skip_chars(src, pos, TOML_WS) calls lost their native frozenset[str]
    specialization and loads() 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.md for every known difference from CPython 3.13.