type-directed optimizations¶
every optimization here is unavailable to a compiler reading PEP 484 types. each one exists because basedpython lets a program state something that mypy can only try to infer, and usually cannot
ranked¶
| # | optimization | source feature | payoff | cost |
|---|---|---|---|---|
| 1 | precise types everywhere | analysis.sound-types |
huge | none |
| 2 | error-path elision | raises |
huge | low |
| 3 | closed-world binding | api.lock, final |
huge | medium |
| 4 | escape analysis | local, once |
huge | medium |
| 5 | tagged unions | sealed, enum class |
large | medium |
| 6 | monomorphization | reified generics | large | high |
| 7 | range analysis | literal types, Array[Dim + 1] |
large | medium |
| 8 | exact floats | no number promotions | medium | none |
| 9 | inline blocks | trailing lambdas, once |
medium | medium |
| 10 | null-check elision | narrowing, not T, & |
medium | low |
| 11 | intrinsics | extensions, Character, regex |
medium | low |
| 12 | fixed layout | data class, init mods |
medium | low |
| 13 | free threading | frozen, local |
large* | high |
* payoff is unbounded but only for code that is actually parallel
three more are planned rather than available, and two of them are large enough to change the design rather than extend it. they are worked out separately in planned features:
| optimization | source feature | payoff | cost |
|---|---|---|---|
| tagged unions for generics | single T |
large | high |
| use-site devirtualization and layout pinning | final T |
large | low |
| value-keyed specialization | literal T |
medium | medium |
sound types, the enabler¶
python's gradual guarantee forces a checker to
infer Any for anything unannotated. for a compiler, Any means: box it, look
it up at runtime, check it on the way back in. mypyc spends most of its
performance ceiling on this, and the only fix available to a mypy user is to
annotate every local by hand
analysis.sound-types deletes the problem:
def f(a=1): # `a` is `int`, not `Any`
total = 0 # `total` is `int`, not `Any`
for x in a: # element type flows through
total += x
return total
under gradual rules this function is entirely object-typed: boxed accumulator,
generic +, boxed loop. under sound types every register is int and the loop
body is native integer arithmetic
this costs nothing to implement in the compiler — it is a property of the types it is handed. it is listed first because it multiplies the value of everything below it, and because it means basedpython code gets compiled well without an annotation campaign
error-path elision¶
mypyc emits an error check after every native call. that is not a heuristic
gap — its Call op derives error_kind from the callee's return type alone
(ERR_MAGIC, or ERR_MAGIC_OVERLAPPING when the sentinel overlaps a valid
value), and FuncDecl carries no "cannot fail" bit for it to consult. mypyc
tracks failure finely per operation and not at all per function:
CPyTagged r1 = f(x);
if (r1 == CPY_INT_TAG) goto error; // after every single call
CPyTagged r2 = g(r1);
if (r2 == CPY_INT_TAG) goto error;
exception tracking makes that check typed. a
raises Never function cannot fail, so it gets the
native-infallible convention and its callers emit
nothing:
def f(x: int) -> int raises Never: ...
def g(x: int) -> int raises Never: ...
def h(x: int) -> int raises Never:
return g(f(x))
three effects compound:
- fewer branches. in tight numeric code the error checks can be a third of the emitted branches
- no error-value reservation. an infallible
i64return does not need a sentinel, so the wholeerror_overlapcomplication — where a valid value doubles as the error signal and forces aPyErr_Occurred()call to disambiguate — disappears for that function - straight-line blocks. an error edge is a CFG edge; removing it lets the C compiler keep basic blocks large, which is what its own optimizer wants
partial sets pay too. a function declared raises ValueError and called inside
try: … except ValueError: needs no propagation past the handler, and the
handler's dispatch is a known single type instead of an exception-matching
sequence. a function whose set is A | B and whose caller catches A | B
generates a two-way switch on the tag, not a chain of PyErr_GivenExceptionMatches
raises ... opts out and gets today's behaviour. that is the correct default
for anything crossing into interpreted code
the lockfile as a closed-world boundary¶
the hard question for any python compiler is "can this function be replaced at runtime". mypyc answers it with a compilation-unit heuristic and a pile of documented caveats
basedpython already has an artifact that answers it exactly. the api lockfile is a committed, reviewed, line-oriented statement of the public type-level surface. at tier 3 it stops being only a review artifact and becomes the ABI contract:
- in the lockfile → the symbol keeps a stable python-visible surface: a
pythonentry point, its name, its argument names, its docstring, its monkey-patchability - not in the lockfile → the symbol is internal to the unit. it may be inlined, renamed, given an unboxed signature, monomorphized into several copies, or deleted entirely if nothing calls it
this is a better boundary than any compiler-internal heuristic for a reason that has nothing to do with compilation: it is reviewable. the diff that changes what the optimizer may assume is the same diff a human already approves. a regression in the ABI is visible in code review before it is visible in a crash report
by compile --tier=3 therefore requires a current lockfile and refuses if
by generate-api-file would produce a different one — the same check CI already
runs
final gives the same thing at class granularity, in any tier: a final class
cannot be subclassed, so every method call on it is a direct call, and a
sealed class gives the closed subclass set, so a call on the base is a
switch over a known-small set rather than a vtable indirection
and the planned use-site final T
gives it at place granularity, which is finer than either: a hot function can
demand an exact Widget at one parameter while the class stays open for
everyone else
escape analysis that crosses calls¶
local lifetimes already implement a static escape analysis, checked by ty today. its purpose is expressing intent, but a compiler reads it as something else entirely: a declared, interprocedural, type-checked non-escape proof
no python compiler has this. escape analysis is normally intraprocedural and
dies at the first call into unknown code — the compiler must assume a callee
keeps everything it is given. local says it does not, and that promise is
checked, not trusted
three optimizations follow
borrowed arguments¶
a local parameter is passed without a reference count. no IncRef on
entry, no DecRef on exit, on either side of the call:
the refcount pass (ir, pass 18) already elides pairs
within a function; local extends that across the boundary. for a hot loop
calling a helper per iteration, this removes two atomic operations per
iteration under free-threading, and two dependent memory writes under the GIL
stack allocation¶
if every use of a freshly-constructed object is local, and it is not stored,
returned, or captured, it never needs a heap allocation:
def area(local r: Rect) -> float: ...
def total(rects: list[(float, float)]) -> float:
sum = 0.0
for w, h in rects:
sum += area(Rect(w, h)) # Rect never escapes
return sum
Rect(w, h) becomes a StackAlloc — a frame slot, no PyObject header, no
allocator call, no refcounting. with a final or data class Rect the C
compiler then applies scalar replacement and the struct disappears too, leaving
two doubles in registers
non-escape is only half the precondition, though. a frame slot needs a size,
and an open class gives only "unknown" — a value typed Rect may be a subclass
with more fields. so the complete rule is local proves it does not escape and
exactness proves how big it is, where exactness comes from a @final class
today and, once it lands, from a use-site
final T on any class
at all
linear callbacks¶
a once callback is called exactly once, and that is checked. so it can be
inlined unconditionally with no code-size analysis, and its closure — normally a
heap-allocated environment object — becomes frame slots
sealed hierarchies become tagged unions¶
an enum class is an algebraic sum type whose variants subclass the enum, and a sealed class publishes the complete subclass set. compiled, that is a discriminated union:
enum class Expr:
case Lit(value: int)
case Add(left: Expr, right: Expr)
case Neg(operand: Expr)
def eval(self) -> int raises Never:
match self:
case Expr.Lit(v): return v
case Expr.Add(l, r): return l.eval() + r.eval()
case Expr.Neg(o): return -o.eval()
- the value is
{ uint8_t tag; union { … } }, not three heap objects matchis aSealedSwitch— a Cswitchthe compiler turns into a jump table — not a chain ofisinstance- exhaustiveness is already proven by ty, so there is no default arm and no "unreachable" fallback to keep the C compiler happy
- combined with
raises Never,evalcompiles to a recursive function over a tagged struct with no allocation, no refcounting, and no error path — which is to say, to what you would have written in C
nested payloads that are themselves recursive still need indirection, so Add
holds pointers. the flattening applies to the tag dispatch and to leaf
variants unconditionally, and to whole values where they do not escape
this is the optimization with the largest gap between compiled and interpreted basedpython, because the interpreted lowering — a dataclass hierarchy — is the most expensive possible encoding of the same idea
the planned single T
extends this same representation to generic classes, where the tag is the
instance's one witness value rather than a stored discriminant. it reuses
RUnion unchanged, which is why it is the largest of the planned wins and why it
is worth designing the sealed representation to be reusable now
monomorphization¶
standard python erases type parameters, so mypyc compiles one boxed body per generic function. reified generics make the type argument a real runtime value, and — more usefully for a compiler — make the instantiation syntactically explicit at the call site:
the monomorphize pass emits sum_all$int with an int element representation
and sum_all$float with an unboxed f64 one. inside each, the element type is
concrete, so the loop body is native arithmetic instead of PyNumber_Add
policy, because monomorphization is how compilers get slow:
- specialize per distinct
RTypetuple, not per static type —list[str]andlist[bytes]share a body - only for instantiations reachable within the unit, which tier 3 knows exactly
- capped at
compile.monomorphize-limit(default 8) per function; beyond that, fall back - the erased body is always emitted, so cross-unit and interpreted callers have something to call, and so the cap is never a correctness cliff
type mappings are the ideal case: T in (int, float)
bounds the instantiation set to two, statically, with no call-site analysis needed at
all
two planned modifiers act on this pass from opposite ends.
literal T adds a value
key alongside the type key, so pad(s, 8) can specialize on the constant 8 and
unroll. overlapping T
adds no optimization at all, but rejects the union-valued typevar solutions that
defeat this pass — so an overlapping generic is statically guaranteed to
specialize, which is a promise about predictability rather than about speed
ranges from the type system¶
mypyc's future work asks for integer range analysis, to pick untagged representations, skip overflow checks, and remove index checks. it is an inference problem there. basedpython already carries part of the answer in the annotations, through literal types, symbolic type operations, and integer type parameters:
def clamp(x: Literal[0] | Literal[1] | Literal[2]) -> int: ...
def extend[Dim: int](a: Array[Dim]) -> Array[Dim + 1]: ...
- a union of integer literals is a known finite set, so it picks a
u8with no tag, no overflow check, and no boxing 2 ** 8folds toLiteral[256]in a type position, so a size written as an expression is still a constant to the range passArray[Dim + 1]keeps shape arithmetic symbolic until specialization, so a relationship written once at the signature is available at every use — the machinery exists today for type checking, and the range pass is a second consumer of it
a conventional interval analysis (pass 05) covers everything unannotated — loop
induction variables, len() results, range() bounds. the declared facts seed
it, which is what makes it effective across call boundaries, where interval
analysis normally gives up
the notation that is missing¶
the payoff would be much larger with a bounded integer type — a way to say
"an int in 0..<n" directly, so that an index parameter can be declared
in-bounds against a shape parameter rather than inferred:
that turns a bounds check into a static proof, which is the single most valuable thing range analysis can do for numeric loops — a per-element bounds check is what blocks vectorization. the type system already has the pieces (bound ranges for two-ended bounds, symbolic operations for the arithmetic); what is missing is the surface syntax and the comparison-as-bound semantics. it is worth designing as a language feature on its own merits, and the compiler is the reason to prioritize it
exact floats¶
python's typing spec special-cases float to mean int | float. so mypy sees
x: float and a compiler must accept a PyLongObject there, which means a
branch and a conversion at every use, and a boxed representation for anything
stored
basedpython does not do this. in a
.by file float is exactly float:
x: float under |
representation |
|---|---|
| the typing spec (mypyc) | PyObject *, or double + an int fast path |
| basedpython | double |
list[float] becomes a native double buffer, an arithmetic expression over
floats becomes straight SSE, and none of it needs a guard. the same applies to
complex. this is a free optimization: it is a consequence of a decision the
language already made for clarity, and the compiler just gets to keep it
trailing lambdas are inline functions¶
a trailing lambda block binds exactly one
argument, and its parameter can be declared local or once in the callee's
signature — which constrains what the block may do with the receiver:
extension list:
def each(self, local body: (local Element) -> None) -> None:
for x in self:
body(x)
xs.each():
print(it)
body is local (does not escape) and called in a loop. the inline pass
substitutes the block into each's body and then each into the call site,
giving a plain loop over xs with print(it) in it — no closure allocation,
no per-element call, no boxing of it
this is exactly kotlin's inline fun optimization, and basedpython arrives at it
by the same route: a syntactic form for blocks, plus lifetime annotations on the
parameter that receives them. the difference is that kotlin needs an inline
keyword and we can derive it from local / once
narrowing is a proof¶
ty's narrowing already produces intersections and negations. a compiler reads them as removed checks:
| after narrowing | what the compiler drops |
|---|---|
A & not None |
the null test on every subsequent use |
x is Shape.Circle |
the tag test inside the branch |
A & B on a protocol |
the structural lookup — both members are known |
a sealed match arm |
the discriminant is a constant in the arm |
parametric type tests go further: an
is list[int] check that ty accepts as reifying T gives the branch a concrete
element representation, so the container read inside it is unboxed
intrinsics¶
several basedpython surfaces are defined today as lowerings to python expressions. compiled, they lower to native calls instead:
| surface | interpreted lowering | compiled |
|---|---|---|
s.character_count |
len(_by_graphemes(s)) |
a rust segmenter over the UTF-8 buffer, no list |
s.first / s.last |
list index into a materialized list | a single cluster scan from either end |
an extension method |
a module-level function call | a direct native call, inlinable |
?. chains |
nested conditionals | a single null test over the whole chain |
?? |
a conditional expression | a cmov |
a str that is known to be a str¶
str is the one builtin the abstract object protocol costs the most on, because
almost every operation on it is cheap once the type is settled and the protocol's
whole job is settling the type. so a str-typed operand buys three things, none
of which needs a source annotation beyond the type already being str:
| operation | through the protocol | knowing it is a str |
|---|---|---|
len(s) |
PyObject_Length, a slot dispatch |
a field read |
s[i] |
mp_subscript, with the index boxed to get in |
a character read at the known kind |
a == b |
PyObject_RichCompareBool, a reflected-operand search |
length, kind, memcmp |
each is guarded on the exact type, because a subclass may have overridden the operation, and a fast path that ignored that would be a different answer rather than a faster one
growing rather than copying¶
a + b on two strings has a worse problem than dispatch. PyUnicode_Concat
always allocates a new string and copies both operands into it — it has to,
because it cannot know whether anyone else can see a. so a string built a piece
at a time is copied once per piece, and building it is quadratic in its own
length.
cpython has the same problem and half a fix: BINARY_OP_INPLACE_ADD_UNICODE
grows the left operand in place, but only when the operand is the local the
result is stored back into. out = out + a + b does not match that shape — the
first + is followed by another + rather than by the store — so the whole of a
chained concatenation deoptimizes back to copying.
what makes the in-place form legal is that nothing else can see the string, and
what a register machine can prove is exactly that: a concatenation whose left
operand register is not read again can take the reference with it, leaving the
count at one. by_opt::str_append marks those, and codegen empties the register
into the call.
the same liveness settles the one behavioural question it raises. a failed append cannot put the reference back, so a register a handler could still read must keep its own — and a register a handler could read is live across the error edge, which is an edge the analysis already follows. the condition that makes the append fast is the condition that makes its failure unobservable
regex shapes¶
regex group types mean the pattern is parsed
at check time and its group structure is statically known. when the pattern is a
literal, we can go further and compile the pattern itself into the extension:
m.group(1) becomes a struct field read on a match object with a fixed layout,
and the matcher is a native DFA rather than a call into _sre
this is a large piece of work and it is scoped out of the initial milestones, but the type system has already done the hard half — it knows the shape
fixed layouts and always-defined attributes¶
mypyc keeps a bitfield per class recording which attributes are currently
defined, and checks it on every read, because python lets __init__ skip an
assignment. it lists "always defined attributes" as future work
basedpython gets it from declarations:
- a
data classalready emitsslots=Trueand a generated__init__that assigns every field — every attribute is always defined, so the bitfield and its check disappear initmethod modifiers declare and assign in one place, with the same consequence- a
privatefield is name-mangled and cannot be assigned from outside, so its definedness is a whole-class property rather than a per-instance one
what is left is the bitfield only for classes that genuinely assign conditionally, which is the case it was designed for
a frozen field is read once¶
done. a frozen data class cannot have its fields changed after the
constructor wrote them, so two reads of one field are a single read — and the
part a type system is needed for is that this holds across an arbitrary call.
an optimizer that must assume any call may mutate any object has to reload.
two things are worth recording, because both were found rather than designed:
frozenin the ir meant "emit no setters", which a generator's state class and a closure environment both set while their fields change on every step. that is a different question from immutability, and conflating them gave a wrong answer within seconds of the differential harness seeing it.ClassIr::immutableis now the question the fold asks, and the setter rule is derived from it- the fold still invalidates on a
SetFieldto the same field. the licence comes from the declaration, but the fold rests on what the ops do
an exact place keeps its direct call¶
done. a class that is decorated, extends another, or is extended by another is
emitted as a mutable heap type and gives up the direct method call — python can
rebind a method on one, or override it in a subclass. that is a fact about the
class; @final is a fact about the place, and it re-licenses the direct call
because no subclass can exist.
⚠️ sealed is not exactness, and the difference is easy to miss: it closes the
world outside the declaring module and says nothing about a subclass inside it.
treating it as exactness produced a direct call to a base's method where a subclass
declared a few lines down overrode it. sealing licenses a switch over the known
subclasses (§5), which is a different and weaker thing than a direct call.
free threading¶
this is the speculative section, and the one with the highest ceiling
cpython 3.14 supports free-threaded builds. the obstacle to using them is not the GIL, it is that nothing in the language says which values may be shared. so runtimes fall back to atomic refcounting everywhere and to defensive locking
the floor is not an optimization at all but a requirement: an extension must
declare Py_mod_gil = Py_MOD_GIL_NOT_USED or importing it re-enables the GIL
process-wide (technology).
everything below is what can be built on top of that floor
basedpython can say it:
frozen data classis deeply immutable — shareable across threads with no synchronization at all, and (with immortalization) with no refcount trafficlocalproves a value does not escape the call, so it cannot be reached by another thread, so its refcounting can stay non-atomic on the owning threadonceproves a callback runs exactly once, which is the linearity property a work-stealing scheduler wantsraisesmakes the failure modes of a parallel region a declared, finite set instead of an open one
the concrete deliverable is a parallel form over a local-parameterized block
whose safety is checked rather than documented:
accepted only when the block captures nothing mutable, its parameter is local,
and its return type is frozen or a primitive — all of which ty can already
decide. this is the thing basedpython could have that no other python dialect
can, and it is worth designing toward even though it lands last
what we deliberately do not optimize¶
- dict and set iteration order, which is observable and must be preserved
ison small ints and interned strings, which is observable. note that unboxed fixed-length tuples already breakisin mypyc; we take the same trade and document it in plan- the instance dict on a native class, which every class keeps unless it
declares
__slots__— the layout is faster and the dict costs an allocation, but a class that quietly refused a name its interpreted twin stored would be buying that with a wrong answer - anything where the win is smaller than the divergence risk. the differential harness is the arbiter, and a passing benchmark that fails it is not an optimization