Skip to content

Extension module work - #200

Merged
KotlinIsland merged 5 commits into
mainfrom
extension-module-work
Sep 4, 2026
Merged

KotlinIsland merged 5 commits into
mainfrom
extension-module-work

Conversation

@KotlinIsland

Copy link
Copy Markdown
Owner

No description provided.

KotlinIsland and others added 3 commits September 5, 2026 03:06
three independent type-inference defects the native backend work ran into while
checking real projects.

a parameter-shape base spelled as a parenthesised tuple was inferred as a runtime
tuple value and reported `invalid-base`, so ty rejected code the transpiler
accepts and emits as `class C(tuple[A, B])`.

a `src/` holding files named after stdlib modules made `by check` panic and take
every other file in the run with it. a panic that cancels the whole run is worse
than any diagnostic being wrong, and both of these reproduced on the baseline.
the first is `known_class_to_instance`. a first-party module that shadows a
stdlib one makes a known class resolve into code whose own inference asks for
that same known class again; specializing what was found reopens the recursion a
level lower, through `generic_context` and `explicit_bases`. the sibling query
that performs the lookup half already recovers, so this one gets the same
treatment rather than a new policy. the recovery answers `Unknown` for one
fixpoint iteration inside the cycle and nothing outside it. the second is
`inferred_return_type`'s recovery running a salsa query of its own, which salsa
forbids. the query was reached through the union builder asking a literal for its
fallback *instance* — which means resolving the class's module and reading the
symbol out of it. it now asks the element it already has which class it is
instead, keeping "which class is this literal's fallback" apart from "what type
does that class make". where the lookup used to fail, which is exactly the
shadowed case, the old comparison matched nothing and kept a redundant literal
beside its own instance, so the new form is strictly more precise.

`object & ~int`, which is what `if not isinstance(x, int)` narrows to, carries no
positive element of its own. both the subscript store and the delete path iterated
those elements alone, so they visited nothing at all there. for the store that was
a crash: `infer_loud` was never reached, so a `MultiInferenceGuard` was dropped
unfinalised and its `debug_assert` aborted the run. it was also losing real work —
the key and the assigned value were never inferred with diagnostics, so
`x[missing_key] = missing_value` silently dropped two `unresolved-reference`
errors as well. for the delete it was quieter: nothing was reported at all where
`object` has no `__delitem__`. an intersection with only negatives still has a
positive bound, because everything is an `object`. the read side already fell back
to it through `positive_elements_or_object`, which exists for this — so this is a
known pattern applied in the two places that had missed it, not a new rule.

Co-Authored-By: Claude Opus 5 <[email protected]>
seven defects, each one a case where an emitted module gave a different answer
from the interpreted twin and said nothing about it. a silent wrong answer is the
worst thing this compiler can do — a decline costs speed, a crash is at least
visible, and these were neither.

five of them shared one shape: a hand-maintained list that had to agree with
another list, and did not. `__dict__` on an emitted instance is now a real `dict`
over the whole state that every write updates, rather than a mapping that named
only half of it. a decline protects the function it is raised in, not the class
that function is handed — `render` declined correctly, the interpreted twin ran,
and it still failed because the emitted class it was given had no `__dict__` to
reach. `walk` now descends into `match` case bodies, and `written_names` and
`local_representations` learned pattern captures alongside it; widening `walk`
alone traded an honest decline for a wrong answer, so all three moved together.
`dead-registers` no longer carries its own copy of every operand's shape: it goes
through `Op::dest_mut`, `Op::loop_cursor_mut` and `Op::operands_mut`, so there is
one list rather than two that had to agree, and `ArraySet` was the one that had
drifted. the `warnings.warn` class-body gate is lifted — the two defects its first
re-costing turned up are both fixed — and the weakref gate now asks whether an
instance of ours could stand where the referent is read, so `ref(self.attr)` is
left alone where `ref(self)` still refuses.

the sixth is binding. a `PyCFunction` is not a descriptor, so `Cls.method =
mod.fn` — which is what `functools.total_ordering` does — installed something that
never received the receiver. with a default on that receiver it answered quietly
rather than raising: `Thing().label()` gave `'bound'` interpreted and `'UNBOUND'`
compiled, from ordinary code with nothing reported. cpython offers no way to make
a `PyCFunction` bind, and a custom binding type loses `inspect.isroutine` and
`copy`'s atomic handling, so neither of those was the answer. each exported
module-level definition now gets a real `function` written into the interpreted
twin, which forwards to the native and takes its name, docstring, defaults,
annotations and `__wrapped__` from the definition standing under the same name. it
takes `*args, **kwargs` deliberately: written with the exact parameter list it
fails, because the transpiler rewrites a mutable default into a sentinel plus a
body test, so the twin's `__defaults__` holds a sentinel the native has never
heard of. a transparent forwarder makes no arity or default decision at all, so
none of them can be got wrong. the cost is one forwarder frame on the single entry
through a module's own name: measured at ~40ns, constant in the work done, because
an intra-module call is `Op::CallNative` by symbol and never touches the module
namespace. two more were found on the way — a module defining a function called
`globals` made the installer fail on its own first line, and the forwarder's frame
broke `warnings.warn(stacklevel=…)`, which now steps over it.

the seventh is comparison. one `tp_richcompare` backs all six, so a class writing
`__lt__` took the slot over from `object` for the other five as well, and two
things followed. `object`'s richcompare is not empty — it is where `!=` gets its
meaning, negate `__eq__` — and answering `NotImplemented` in its place threw that
away: `Money(5) != Money(5)` was `True` compiled against `False` interpreted, from
ordinary code with no decorator anywhere. an unwritten comparison now goes to the
base's slot, where python would have answered it. and `PyType_Ready` publishes a
wrapper under every name a filled slot backs, so the type advertised a `__le__`,
`__gt__` and `__ge__` this body never wrote — which is why
`functools.total_ordering` saw all four roots present and set none. those names
come back off after the type is built; the slot is untouched, so what a comparison
*does* is unchanged and only what the class *says* moves. the same goes for the
pairs a binary number slot backs and for `__setitem__` alongside `__delitem__`.
with that gone, the class-statement decline for a decorated class is no longer
needed and `@total_ordering` on a class compiles where it used to fall back
entirely — a decline recovered rather than replaced. removing it exposed a cascade
that was always there: a class whose decline is settled at the end of its lowering
has a layout by then, and every class holding one declines too. `tracemalloc` went
50 compiled to 26 that way. the question is asked in `class_fields` now, where the
layout is decided, so a declining class is an ordinary object to the rest of the
module.

the semantic-delta table is updated throughout for what moves. `type(f)` and the
binding row go, since both now match python; `__code__`, `__wrapped__` and the
forwarder's traceback frame arrive; and applying `@total_ordering` from another
module raises at the `setattr`, since an emitted class is sealed — loud where it
used to be quiet.

Co-Authored-By: Claude Opus 5 <[email protected]>
…oup of one

reading `cell.v` on an emitted class cost the whole `PyObject_GenericGetAttr`
trip per iteration — an interned string, a type lookup, the real `property`
object, `property_descr_get`, a `METH_FASTCALL` PyCFunction, the wrapper with
argument binding and instance unboxing — then the body, then a box and an
immediate unbox, and the same again for the write. the body at the end of it is
one struct field load already marked infallible.

both halves are now called directly. soundness is the method path's argument plus
one it does not get: a `property` is a *data* descriptor, so the class binding
wins over the instance dict and no shadowing test is needed. halves stay on the
protocol where the protocol is the answer — no setter, or a body taking more than
python hands it.

a group of *one* is now lowered too, which recovers a decline rather than only
speed: the name answered with a real `property` carried over from the twin, but
the object it carried held the interpreted function, so the getter's body never
ran compiled at all. two attempts were needed. the first moved the census —
classes that had compiled now declined and cascaded — so a group of one is
optional, since a lone getter is a single ordinary `def` and can be left where it
stands. the second was census-clean and still wrong: `ABCMeta` computes
`__abstractmethods__` from the namespace it is handed, and a published property
is not in it, so compiled `Integral` reported its abstract methods unimplemented.
it is taken only for a class `type` itself builds, which computes nothing from
what the body bound.

and the arity gate comes off a defaulted half. it was written for `tp_getset`,
whose function pointers really do pass exactly one and two arguments, but a half
is published over the function's wrapper and binds its arguments as a call
through the name would. a half stacked under a second decorator is still declined
— it now says so as the property it is, rather than falling through to the
generic message about a name written twice.

Co-Authored-By: Claude Opus 5 <[email protected]>
KotlinIsland and others added 2 commits September 5, 2026 04:18
three speedups, none of which changes an answer. the census is byte-identical
across all three — 550 modules, 7381 compiled and 1111 interpreted, with no
decline reason moving anywhere in the corpus.

two error checks asked a second question when the value in hand already carried
the answer. a tagged register's sentinel is the tag bit alone, so `By_LongOf`
already turns it into NULL and the X-forms drop it; testing `x != BY_INT_ERROR` as
well put a second compare and branch on the straight line of every release, which
a loop pays every trip. a static assert now pins the sentinel's shape, because
this is the first thing that depends on it rather than on its value. and a call
returning a fixed-length tuple was checked by asking the thread whether an
exception was set. a struct reserves no bit pattern of its own, but a member
usually does, so a tuple now borrows a sentinel from its first member that has one
and the check is a compare against that member; each member also takes its own
undefined value rather than a zero, so the pattern is really there to read. only a
tuple holding nothing but doubles and fixed-width integers still falls back to the
thread. the compare is not merely the cheaper of the two: `PyErr_Occurred` is an
opaque call, so the C compiler has to assume it clobbers everything and stops
inlining the callee into the loop around it.

`split`, `startswith`, `join` and `upper` are the `str` methods with a C-API entry
point of their own. a method site already skips the attribute lookup, but it still
ends in an indirect call into the method's python-facing wrapper, which unpacks
the argument array again before doing the work; for these four the work itself is
reachable directly, so the wrapper and the array both go. each keeps the site it
had, and takes the direct path only where the receiver is an *exact* `str` and the
arguments are the shape the entry point serves — a `str` subclass with a method of
its own, a separator that is not a string, a `startswith` given a tuple of
prefixes or a start and end range all fall through to `str`'s own method. so the
answer stays python's own rather than an approximation of it, and a receiver the
fast path refuses is no worse off than before.

and `tp_free` now parks a dead instance's block on a small per-class array instead
of handing it to the allocator, with `tp_alloc` taking it from there. it sits
entirely below `tp_dealloc`, so the object is really destroyed, a written `__del__`
really runs and the next instance really is a new one — only the block is reused.
cpython does the same for floats, tuples and frames. this was hand-written first
and timed before any of it was designed: on alloc, an object built and dropped
every iteration, 1.99x against a 1.003x floor; on objects, 1.12x against 1.012x,
both in one process with the legs interleaved ABBA.

the gate is `mutable_type`, false only for a sealed leaf — no base, nothing
derived, no decorator, no written `__new__` — which is exactly the shape whose
`tp_alloc` and `tp_free` nothing can inherit, so every block that reaches the
array is this class's own size. a free-threaded build gets none of it, because the
array is shared mutable state and the module says it keeps none.

a class whose instances have a finalizer is turned away as well. the collector
keeps 'this object has been finalized' in its own header, in front of the block
rather than in it, and untracking masks `_gc_prev` down to exactly that bit. a
recycled block would therefore arrive already-finalized, and python has no call
that clears it, so the second object to live there is one `__del__` never runs for.
the bit is only ever set after a non-null `tp_finalize` has been called, and the
two things that fill that slot are a written `__del__` and a generator's own, so
turning both away leaves a parked block's header all zeroes, which is what the
allocator hands back. the rest of the enumeration is on `recycles_instances`: the
object header's trace links, which a tracing build refuses over; a managed dict or
inline values, which an emitted class never asks for; a weakref list, likewise;
and the block's size, which the sealed-leaf gate settles. the module drains its
arrays on the way out, so it is memory held rather than memory leaked.

differential tests sit either side of that gate and over the three other routes a
recycled block could show the last instance's state. an optional field written on
only every other construction is the one part of an instance a constructor does
not overwrite, so it is where the reset is load-bearing: with the reset cut back
to the object header the compiled leg reads TrueFalseTrueTrueTrueTrue against
python's TrueFalseTrueFalseTrueFalse. the other two are the allocator pairing for
a class with no dict, which is allocated through the plain allocator rather than
the collector's, and a run of cycles collected before the blocks are handed out
again — the collector's other header flag, which the deallocator's own untrack is
what clears.

the suite rows moved 1.01x to 2.42x on alloc, 1.43x to 3.91x on alloc_slots,
0.77x to 1.23x on tuples — from behind to ahead — and 1.12x to 1.51x on calls.
the tagged-register half on its own is worth 1.25-1.27x on methods and 1.08-1.11x
on inherit, measured in one process with the legs interleaved ABBA and confirmed
by swapping which build each name got; the byte-identical null leg read 1.00-1.03x
either way.

Co-Authored-By: Claude Opus 5 <[email protected]>
four gaps, each of which had cost real time.

`bg.sh` had no way to end a job, so it was done by hand with `pkill`, which kills
the script the wrapper is running and leaves the wrapper to spawn the next one. a
sweep chain killed that way ran orphaned for 98 minutes while benchmarks were
being timed against the load it made. `start` now runs the job under job control
so it leads a process group, and `stop` signals the group, escalates, and
re-checks rather than trusting the kill. signalling the group is guarded on the
job actually leading one: `kill -TERM -$pid` names the group *numbered* `$pid`,
which for a job started before this change is somebody else's.

`cdiff` compares emitted C, and the runtime header is included rather than
inlined — so a change to a helper left all 550 modules byte-identical and the
rung named nothing to walk. each leg's `by.h` is captured now, and a module whose
C is unchanged but which calls a changed helper reads as `header`. the
attribution is conservative on purpose: every changed line must name a `By_`
symbol of its own or nothing is scoped out, because `diff -p` blames a change
after the last function in a file on that function, which would drop real modules
from the walk.

`fntwin` asks what a module *body* captured, which no rung did — every other one
reads through the module's own names, where the compiled function answers. it is
one import per module with no second leg and no alarm, so it stays trustworthy on
a busy machine: contention can drop a module, but it cannot invent a twin.

and the benchmark harness ran a fixed repeat count, which spends milliseconds on a
slow row and seconds on a fast one, so the fast rows carried all the noise. each
build now runs to a duration instead, and the row reports the floor it measured
rather than borrowing a global one. the new rows are the three property shapes — a
plain pair, an extension property and a lone getter — and pairs, a tuple whose slot
holds a reference rather than an int. pairs lands at 0.40x, the worst row in the
tuple group, and the error-check work did not move it: a tuple parameter is still
passed as an object and indexed through the object protocol, where mypyc passes
the struct by value.

Co-Authored-By: Claude Opus 5 <[email protected]>
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

ecosystem check

Linter (stable)

✅ ecosystem check detected no linter changes.

Linter (preview)

✅ ecosystem check detected no linter changes.

Formatter (stable)

✅ ecosystem check detected no format changes.

Formatter (preview)

✅ ecosystem check detected no format changes.

@KotlinIsland
KotlinIsland merged commit bc69ba2 into main Sep 4, 2026
49 of 50 checks passed
@KotlinIsland
KotlinIsland deleted the extension-module-work branch September 4, 2026 18:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant