Skip to content

pycc-next v0.4.0

Choose a tag to compare

@github-actions github-actions released this 16 Oct 00:42
b91b0ef

Fast untyped code, no leaks. --non-strict keeps untyped code native: strings index in
O(1), names widen per function, and public untyped --ext functions get guarded native
copies, so diff-match-patch's own speed test runs 11.5x faster than CPython (v0.3:
1.7x). A second real package, tomli (the parser behind tomllib), builds unchanged and
passes its own suite, 1.67x faster than CPython. Exceptions are reference counted and
freed. Strict mode compiles 171/200 real-world programs unchanged (v0.3: 161), and
--non-strict runs all but 8 of their 1191 top-level statements natively (v0.3: 23).
Circular imports, IntEnum as int, sorting class instances and dataclasses.fields
/ asdict work as in CPython.

Benchmarks (python3 bench/run.py, best of 7, shared 4-core box): fib 18.9x faster than
CPython / 2.2x of Rust, mandelbrot 35.2x / 0.98x, nbody 32.8x / 2.6x, sieve 21.3x /
2.5x, spectral_norm 47.5x / 1.8x, collections_bfs 6.5x / 2.7x, numpy kernels stencil2d
459x / 2.8x and prefix_sum_np 95x / 2.1x.

Added

  • Untyped code runs native (v0.4 accept 1): an --ext --non-strict module's unannotated
    function or method that loops gets a guarded native specialization -- a copy whose
    parameters are typed str or int from how its body uses them, called when the
    arguments are exactly those types (any other call runs on the CPython objects as
    before; a copy the checker refuses is dropped, never fallen back). diff-match-patch's
    own speed test now runs 12x faster than CPython (1.03 s vs 12.3 s) (v0.3: 1.7x), its suite passing
    (python3 bench/package_check.py --time). --report-fallbacks lists specialized:
    and not specialized: functions.
  • A second real package (v0.4 accept 4): bench/package_check.py is a table of pinned
    packages and adds tomli 2.5.0 (MIT, the TOML parser
    tomllib comes from; GitHub tag archive pinned by its sha256 and that of the files used). Its
    four modules build unchanged with --ext --non-strict --foreign-relative-imports, its
    own test suite passes against them (CI) and 36 of 40 functions compile; loads() runs
    1.6x faster than in CPython (python3 bench/package_check.py tomli --time --runs 15,
    best of 15 runs of 100 calls on one machine: 0.036 s vs 0.059 s, 1.52x-1.75x over seven
    runs; was 0.40x). What typed packages written for mypyc needed:
    • from __future__ import annotations: an annotation pycc cannot lower is erased
      instead of making its statement fall back, and fallback code is compiled with the
      flag; names imported under if TYPE_CHECKING: from a sibling module that are
      aliases (Pos = int, Key = tuple[str, ...]) are read through to their definition.
    • A function whose signature the --ext boundary cannot carry (tuple[int, str]
      result, frozenset parameter) is compiled for the module's callers and runs as its
      CPython twin when Python calls it (twin: in --report-fallbacks).
    • Non-strict signature wrappers: defaults of unannotated or object parameters, and a
      Python caller passing a value of another type than a scalar parameter's runs the
      function's CPython twin, so its own error handling answers (loads(b"") raises
      tomli's TypeError, not the boundary's).
    • A module function with an Iterable[T] parameter gets a frozenset[T]
      specialization that the module's own calls with a native frozenset run.
    • Native frozenset(str), frozenset(chr(i) for i in range(n)) (a chr whose
      argument is provably not a surrogate is native in non-strict mode too), set
      displays in membership tests, isdisjoint/issuperset/issubset with a str,
      str.startswith(p, start), find/index(sub, start).
    • Non-strict: isinstance(o, (A, B)) of an object, repr(o), raise o from cause,
      type(native), an object rebinding an int/float/bool parameter (converted),
      an object passed for a frozenset[str]/frozenset[int] parameter (copied), a
      tuple display returned as tuple[...], an object in a native frozenset, a try
      and its handler binding a name with two types, Callable[...], Iterable[...]
      and tuple[X, ...] annotations as objects, self.x = {} as an object attribute.
  • Faster code around CPython objects and tuples (v0.4 accept 4):
    • Multi-value returns: a function whose every return is a tuple display of one
      arity, or forwards another such function, gets a compiled twin that returns the
      values without a tuple, and a caller that unpacks the call (pos, value = f(...))
      calls it -- no CPython tuple (nor pycc tuple, for a native tuple[int, str]) is
      built, unpacked and freed. Result types are what the returns agree on, an object
      where they differ; the function itself serves every other use, and a call whose
      name may be rebound runs it (tests/multi_return.rs).
    • a, b = x, y with object values binds the targets without building two tuples
      (tests/object_swap_unpack.rs).
    • Non-strict: a str | None local bound only to native str values and None
      (char: str | None = src[pos] under postponed annotations) stays a native str,
      null standing for None; its literal comparisons, frozenset[str] membership and
      is None tests are native and any other use sees the object it stands for
      (tests/optional_str_local.rs).
    • while s[i] in chars: i += 1 (or not in) over a set of str is one scan of the
      text, the IndexError of running off either end raised as the loop raises it
      (tests/str_scan_loop.rs).
    • In a condition, an object compared with a str literal, or tested against a native
      frozenset of str, is compared by its UTF-8 bytes when it is an exact str, with
      no str made for the literal and no CPython copy of the set; anything else takes
      CPython's own operation (tests/object_text_compare.rs).
    • Non-strict object operations take shorter paths: o[i] and o[a:b] with native
      int keys read an exact tuple or list directly, a tuple display of objects is
      built as a tuple, o.m(args) with literal or parameter arguments is one
      PyObject_VectorcallMethod, a rich comparison in an if/while answers a native
      bool (two exact str operands are compared without a result object), and
      isinstance of a plain built-in value skips the __class__ lookup
      (tests/object_fast_paths.rs).
    • A string literal reads its site's cached, immortal str with no call; freeing a
      list or an instance no longer allocates; error paths are laid out of line; the
      runtime builds as one codegen unit.
  • Crossing to CPython is cheaper: a native str or frozenset converted to a CPython
    object keeps that object and shares it on its next crossing (a str a Python caller
    passed in shares the caller's); str literals are built once per site; attribute
    names are interned once; the C shim is compiled with -O2; the pending-exception test
    after each runtime call no longer reads thread-local storage while no thread has an
    exception pending; c in frozenset_of_str for one ASCII character is a bit test.
  • Circular imports between project modules run as in CPython (v0.4 accept 5): a module
    imported back sees the partially initialized one -- the names it bound before the
    import -- and from m import x of a name m has not bound yet raises CPython's
    ImportError with its message. Only the entry module imported back is still refused
    (E0108, now "import cycle through the entry module").
  • Language gaps from v0.3 (v0.4 accept 5): IntEnum members are usable where
    an int is needed (arithmetic, comparisons, range(), indexing, int parameters);
    sorted(), min(), max() and list.sort() of class instances order by __lt__
    (or __gt__, or @dataclass(order=True)), also with key= returning instances and
    reverse=; a list changed while it is sorted raises ValueError: list modified during sort as in CPython; x in s for a set of instances uses __hash__/__eq__;
    dataclasses.fields(), asdict(), astuple() and replace() compile natively
    (asdict() of mixed field types needs --non-strict); printing a list or tuple of
    instances uses their __repr__; a strict --ext module publishes its native
    module-level variables (numbers, strings, tuples of them) as attributes, kept current
    when a function rebinds them with global.
  • Format specs and printf-style formatting compile natively: f"{x:>10,.2f}",
    format(x, spec) and "%5d|%-8.3f|%s" % (...) over int, bool, float and str
    follow CPython's format-spec mini-language and % conversions exactly, errors
    included (pycc_rt::format; tests/format_spec.rs). A conversion CPython refuses
    for its argument's type ('%x' % 1.5) is a compile-time T0021 in strict mode; a
    spec with a nested field ({x:{w}}) is still C0001.
  • Strict mode admits a read of a name bound on some paths only: it is checked at run
    time and raises CPython's UnboundLocalError/NameError where CPython does
    (tests/maybe_unbound_runtime.rs). T0041 stays for a name CPython would read
    something else for when unbound (a builtin, __name__), a del/except ... as
    name, and a buffer.
  • Non-strict: a comprehension over a CPython object whose elements are native lists,
    sets, dicts or tuples collects a native list; set(o) of an object and an empty
    set()/dict() the empty-container pass cannot type are CPython's own; a name
    rebound from one container kind to another (t = [..], then t = {..} on some
    path) becomes an object; t |= {..} on such a name and a for over a maybe-bound
    object run through CPython (tests/non_strict_object_containers.rs).
  • d.get(k) with no default compiles (as d[k] if k in d else None, for a key that is a
    name, a literal or a subscript of those): an Optional of the value type. An
    Optional[int|float|bool] compares with == None/!= None (narrowing like
    is None) and renders with print, str() and f-strings
    (tests/dict_get_optional.rs).
  • An unannotated self.d = {} is typed from the first self.d[k] = v in the class's
    own methods (it was C0001; with no such store it is now T0003), and an empty
    attribute container whose producer reads an unannotated method parameter is typed
    once the solver has typed the parameter (tests/attribute_dict_producer.rs).
  • An empty set()/dict()/list() or attribute container whose producer reads an
    unannotated function's result (s.add(dsu.find(i))) is typed once the solver has
    typed the function; s.update(it)/xs.extend(it) type an empty container from any
    iterable; sorted((a, b)) (and min, max, sum) of a tuple display compiles
    (tests/solver_typed_containers.rs).
  • Non-strict: a name holding a native container that meets a CPython object (a call
    argument, an item store, a comparison) becomes an object when every binding of it is a
    fresh container -- a display, a comprehension, an empty constructor, or the result of a
    function that only returns lists nothing else holds -- so aliasing is kept; a list
    display nested in an object list, a list holding a big int literal, a
    collections.defaultdict the native dict cannot hold (built by CPython from its
    factory) and sys.stdin.buffer read as bytes are CPython's own; a chained comparison
    over objects whose middle operands are names or literals is the conjunction of its
    links (tests/non_strict_widened_containers.rs). The corpus meets v0.4 accept 3:
    strict 170/200, non-strict 200/200 with 8/1191 statements in CPython fallback.
  • A module-level pass statement compiles; a bare zip() statement is dropped.
  • The empty-container pass reads d.get(k, 0) when the call is also readable as a
    user or CPython method (non-strict), so d = {} filled that way stays native.

Changed

  • Native str indexing, slicing and len() are O(1) in the string's length: a string
    caches its code-point count and, when not ASCII, the byte offset of every 32nd code
    point; one-character ASCII strings are shared. in on str uses a substring search.
  • Non-strict widening is per function: a name one function rebinds with another type
    becomes a CPython object in that function only, not in every function of the module.
  • Non-strict: an object tested for membership in a native str (c in "aeiou") is
    CPython's own test (was a fallback).

Fixed

  • An empty frozenset() was always frozenset[int], so frozenset("") == frozenset()
    answered False: it is now typed by its uses, like set() (an equality, a set
    operator or a membership test with a typed set, or frozenset(x)'s argument)
    (tests/empty_frozenset.rs).
  • Implicit concatenation of plain string literals and f-strings
    ("allowed" f" {n} levels") dropped the plain parts.
  • A method call on an imported module (m.set(...), builtins.set()) was bound to the
    signature of a method of the same name a class of the module defines, filling in its
    defaults.
  • A call of a module-level function before its def statement ran (from a function
    called earlier) raises CPython's catchable NameError: name 'f' is not defined, before
    any argument is evaluated, in native strict, --non-strict and --ext builds and
    across a circular import; it aborted the process (exit 134).
  • The peak-RSS leak tests no longer fail at random when the suite runs in parallel. On
    Linux a child's ru_maxrss also counts the resident set of the test harness that
    spawned it (exec folds the replaced address space's high-water mark into it), so
    two probes compared how much the harness grew in between. Every probe now reads its
    own peak (VmHWM, at its exit) through one helper, tests/memory_support. The growth
    limit is 2 MB (was 1 MB): a probe embedding CPython grows by up to one 1 MiB obmalloc
    arena or not depending on the loop count; one leaked object per iteration still adds
    3-6 MB.
  • A NameError for a global of an imported project module read before it is bound
    names it as written (name 'LATE' is not defined), not by the compiler's qualified
    spelling.
  • Exception objects are freed (v0.4 accept 2): a raised or caught exception, its
    message, __cause__, group members and a StopIteration's value are reference counted
    and released with the last reference -- the end of an except handler (its as
    binding is unbound then, as in CPython), a return/break/continue out of it, a
    finally clause that replaces it, or whatever list, attribute or local kept it. A
    loop catching ZeroDivisionError no longer grows by ~220 bytes per iteration, nor one
    catching a failing json.loads by ~1.9 kB, in strict, --non-strict and --ext
    builds (tests/memory_leaks_exceptions.rs, tests/memory_leaks_ext.rs). A CPython
    exception bridged into compiled code now carries its original object, which is
    released with it, instead of a per-call table of originals.

Known gaps

  • See docs/DEVIATIONS.md for every known difference from CPython 3.13.