Deep Engineering
Expert·Published·3.11 · 3.12 · 3.13 · 3.14·20 MIN

The adaptive interpreter: how CPython rewrites itself as it goes

On the second execution of an instruction the interpreter swaps it for a narrow version fitted to the types it saw — and keeps a counter and a cache inside the bytecode itself to check the assumption still holds. That is where the 53% penalty for a line five types pass through comes from, and why switching the 3.14 JIT on lost on all four of the short loops it was tried on.

Full technical treatment

TL;DR

The bytecode you see in dis is not the bytecode that runs. Since 3.11 the interpreter rewrites instructions inside itself while working: having seen that a + b adds integers, it swaps the generic BINARY_OP for BINARY_OP_ADD_INT, which only knows integers and is therefore shorter.

There is no long wait: on 3.12 and later the substitution happens on the second execution — and what counts is the instruction, not the source line: the counter lives in its inline cache, so one line covering several instructions has several independent counters. On 3.11, the eighth.

The assumption is checked on every pass, and the check costs money. The same line of o.x with five different types going through it instead of one costs 53% more — while the instruction stays specialised rather than reverting.

Tier two (the JIT) is a separate thing: it replaces a sequence of instructions rather than one. In 3.14 it is built but off by default, and on four short loops switching it on made every single one slower, from +10.7% to +36.7% — not "faster" at all here.

What is going on

The mechanism is described in PEP 659 and inside the project itself — in CPython's internal documentation, and the second wording is the more precise:

Bytecode specialization … speeds up program execution by rewriting instructions based on runtime information. This is done by replacing a generic instruction with a faster version that works for the case that this program encounters.

InternalDocs/interpreter.md, tag v3.14.5

The key word is rewriting. This is not a compiler optimisation: the compiler emits a plain BINARY_OP knowing nothing about types. The rewriting happens later, at runtime, and the code does it to itself: Python/specialize.c has a _Py_Specialize_* function for each family of instructions.

You can check this without reading any C. dis has an adaptive flag that shows instructions in their present form:

PYTHON
import dis
 
def read(o):
    return o.x
 
class Point:
    def __init__(self):
        self.x = 1
 
def attrs(fn):
    return [
        i.opname
        for i in dis.get_instructions(fn, adaptive=True)
        if i.opname.startswith("LOAD_ATTR")
    ]
 
p = Point()
print("before warm-up:", attrs(read))
for _ in range(500):
    read(p)
print("after:         ", attrs(read))
before warm-up: ['LOAD_ATTR']
after:          ['LOAD_ATTR_INSTANCE_VALUE']

The same function, the same code object. It changed itself.

Where the counter lives

To decide when to specialise, an instruction needs somewhere to keep a counter, and the specialised version needs somewhere to keep what it believes: the type version, the attribute offset, a pointer to a descriptor. That space sits inside the bytecode array, right after the instruction:

The inline cache consists of one or more two-byte entries included in the bytecode array as additional words following the opcode/oparg pair.

InternalDocs/interpreter.md, tag v3.14.5

And this too is visible from outside, with the show_caches flag:

LOAD_ATTR_INSTANCE_VALUE 0 (x)
CACHE                    0 (counter: 832)
CACHE                    0 (version: 5833536)
CACHE                    0
CACHE                    0 (keys_version: 5833536)
CACHE                    0
CACHE                    0 (descr: 8595768128)
CACHE                    0
CACHE                    0
CACHE                    0

Nine two-byte cells for one LOAD_ATTR — and by the family's rule the first of them is always the counter. Which, incidentally, answers the question everyone asks on first seeing this output: CACHE is not an instruction. The interpreter does not execute it, it steps over it; it lives in the array because that puts the data next to the code that reads it.

The cache size is the same for every member of a family — a correctness requirement: LOAD_ATTR, LOAD_ATTR_SLOT and LOAD_ATTR_MODULE must occupy the same room in the array, or substituting one would shift all the code after it.

What turns into what

Eleven one-line operations, warmed with five hundred calls. On the left what the compiler emitted, on the right what it became (Python 3.13.7):

operationbeforeafter
a + b, integersBINARY_OPBINARY_OP_ADD_INT
a + b, floatsBINARY_OPBINARY_OP_ADD_FLOAT
a + b, stringsBINARY_OPBINARY_OP_ADD_UNICODE
seq[i], a listBINARY_SUBSCRBINARY_SUBSCR_LIST_INT
d[k], a dictBINARY_SUBSCRBINARY_SUBSCR_DICT
o.x, an ordinary attributeLOAD_ATTRLOAD_ATTR_INSTANCE_VALUE
o.x, a __slots__ slotLOAD_ATTRLOAD_ATTR_SLOT
o.method()LOAD_ATTRLOAD_ATTR_METHOD_WITH_VALUES
f(n), a Python functionCALLCALL_PY_EXACT_ARGS
a < b, integersCOMPARE_OPCOMPARE_OP_INT
for x in listFOR_ITERFOR_ITER_LIST

Look at two adjacent rows: o.x turns into different instructions depending on whether the attribute lives in the instance dictionary or in a slot. That is what "the case that this program encounters" means — what specialises is not an operation in general but a particular place in a particular function.

On 3.13 there are fifteen such families and seventy-four variants inside them. The largest family is CALL, with twenty.

When

The answer is unexpectedly soon:

versionbase familiesvariantssubstitution happens
3.11.151771on the 8th execution
3.12.31564on the 2nd
3.13.71574on the 2nd
3.14.71784on the 2nd

The measurement is direct: a fresh code object is executed one run at a time and asked for its instruction name after each. On 3.12 and later the name changes after the second pass.

The practical conclusion matters more than the number. "Warm-up" in Python is not thousands of iterations, as in the JVM. Any function called twice is already running on specialised instructions. A microbenchmark that timeit runs with number=1000 measures the steady state, not the warm-up — good news for measuring, bad news for anyone hoping to catch the interpreter cold.

What if the assumption does not hold

The internal documentation promises a revert:

The specialized instructions are responsible for checking that the special-case assumptions still apply, and de-optimizing back to the generic version if not.

InternalDocs/interpreter.md, tag v3.14.5

Let's measure it. Five classes declared identically do the same thing; one line of o.x in a loop; the only difference is how many types pass through it:

types at the call sitens per readinstruction at the endagainst one type
132.79LOAD_ATTR_INSTANCE_VALUE×1.00
241.00LOAD_ATTR_INSTANCE_VALUE×1.25
343.48LOAD_ATTR_INSTANCE_VALUE×1.33
550.07LOAD_ATTR_INSTANCE_VALUE×1.53

There are two observations here, and the second contradicts what I expected.

First: a polymorphic call site costs money. Half again in overhead for nothing, given that the work is identical and the classes indistinguishable. That is the price of the check: the specialised instruction compares the type version, and on a foreign type the comparison fails and the generic path has to run.

Second: the instruction does not revert. I expected to see a plain LOAD_ATTR in the last row — and never did. Across the whole run the instruction was rewritten once (generic to specialised) and did not change again, however many types went through it. So "de-optimizing back to the generic version" describes what happens inside the instruction's execution when the check fails, not the bytecode array getting its generic opcode back. Telling one from the other takes a measurement: a retelling of the PEP makes both pictures equally plausible.

One caveat, without which the conclusion would be wider than the measurement: this holds for the LOAD_ATTR family and this scenario. BINARY_OP behaved differently when the type changed — one specialised instruction was replaced by another specialised one fitted to the new type (BINARY_OP_ADD_INTBINARY_OP_ADD_FLOAT).

Tier two

Since 3.13 CPython has a second mechanism, and it is constantly confused with the first. The internal documentation names the difference in one sentence:

Runtime optimization in this interpreter can only be done for one instruction at a time. The JIT is based on a mechanism to replace an entire sequence of bytecode instructions, and this enables optimizations that span multiple instructions.

InternalDocs/jit.md, tag v3.14.5

The same page names what counts as hot: the JUMP_BACKWARD instruction — the end of a loop iteration — looks at the counter in its own inline cache and, once past a threshold, asks the optimiser to build a trace.

Whether it is running for you is not a question about a version number. sys._jit answers it:

PYTHON
import sys
print(sys._jit.is_available(), sys._jit.is_enabled(), sys._jit.is_active())

On the build this section was measured on (3.14.7) it is True False False: built, but off. It is switched on with the PYTHON_JIT=1 environment variable.

And since the build is the same and exactly one variable differs, comparing times here is legitimate — unlike comparing versions with each other. Four short loops, six independent pairs of runs, best of eleven repeats in each; the percentage is the ratio of the two modes' fastest runs:

loadwithout the JIT, µswith it, µsconclusion
arithmetic loop t += i * i981–10131144–1216+16.6%, ranges do not overlap
loop over a list reading an attribute520–541711–739+36.7%, do not overlap
a function call in a loop1018–10371169–1199+14.8%, do not overlap
a dictionary in a loop1520–15881683–1746+10.7%, do not overlap

The columns hold not one number but the range of six runs — and that is not pedantry: the machine drifts, and on a single pair of runs a four-percent difference would mean nothing. The runs were interleaved (no JIT, JIT, no JIT, …) so that the drift landed on both modes equally, and the conclusion reduces to one question: do the ranges overlap?

They do not overlap for any of the loads: the fastest run with the JIT stays slower than the slowest run without it.

Six pairs rather than three, and not out of caution for its own sake. The conclusion here rests not on the size of the difference but on the ranges never overlapping, and three pairs are too few to say that.

What these numbers do NOT mean: a verdict on the JIT. Four short loops on two cores in a container are neither pyperformance nor a real program. What they license is one statement: switching tier two on does not by itself guarantee a win, and it has to be checked on your code rather than by version number.

What follows for your code

Three conclusions, each a direct consequence of what was measured above.

Monomorphic call sites are cheaper than polymorphic ones. Not because "Python likes types" but because a specialised instruction checks exactly one assumption. A function that five different classes pass through in a hot loop pays half again — and the cure is not annotations but splitting it into two call sites. That one-and-a-half is the result of a LOAD_ATTR benchmark over five classes, not the price of polymorphism in general: another instruction family and another workload give their own number. And the order of work is the usual one — profile first, reshape the call site after, not the other way round.

__slots__ gives you a different instruction, not just memory savings. LOAD_ATTR_SLOT against LOAD_ATTR_INSTANCE_VALUE are different specialisations, and the choice between them is made once, on the first warm-up.

Microbenchmarks measure a warmed interpreter. Since substitution happens on the second execution, any timeit with more than two repeats is already running specialised code. Measuring the cold start with timeit is not possible — that needs a fresh code object per measurement.

Version history

VersionChangeWhat it means for your code
3.11PEP 659: specialisation arrives. Seventeen families, seventy-one variants, substitution on the eighth execution. PRECALL exists as a family of its own — seventeen variants for that one alone.
3.12PRECALL disappears and its work moves into CALL; the variant count drops (64) while coverage widens: COMPARE_OP and FOR_ITER, which did not specialise at all on 3.11, now give COMPARE_OP_INT and FOR_ITER_LIST. Method lookup gets its own specialisation (LOAD_ATTR_METHOD_WITH_VALUES) instead of the call being specialised. Warm-up shrinks from eight executions to two.
3.1374 variants. Tier two appears as a build option — an experimental JIT, off by default. You cannot infer its presence from a version number: it is a build flag.
3.1484 variants and seventeen families again; BINARY_SUBSCR merges into BINARY_OP, so subscripting now yields BINARY_OP_SUBSCR_LIST_INT. sys._jit appears — a way to ask the interpreter, rather than the documentation, whether tier two is built and whether it is on right now.

What measured this

The numbers in this article come from these scripts. Each one opens from here, together with the record of the run: what it was measured on, what came out, and with what spread.

Common misconceptions

Claim

dis shows the bytecode that runs

Actually

It shows what the compiler emitted. To see what runs you need a flag: dis.get_instructions(fn, adaptive=True). After five hundred calls the same function reports LOAD_ATTR_INSTANCE_VALUE instead of LOAD_ATTR — the same code object, changed in place.

Claim

Specialisation is a warm-up; it needs thousands of iterations

Actually

On 3.12, 3.13 and 3.14 the substitution happens on the SECOND execution of the instruction itself (the counter belongs to it, not to the source line); on 3.11, the eighth. Measured by stepping: a fresh code object is run once at a time and asked for its instruction name after each run. This is not the JVM — a function called twice already runs on specialised instructions.

Claim

The CACHE entries in dis output are execution overhead

Actually

They are not instructions at all. Inline-cache cells sit in the bytecode array right after the instruction, and the interpreter steps over them rather than executing them. LOAD_ATTR has nine of them, two bytes each; by the family's rule the first is always the counter — show_caches prints its value outright.

Claim

When the type changes, the instruction reverts to the generic one

Actually

Measured: over a run in which five different types passed through one line, the instruction was rewritten ONCE — generic to specialised — and stayed LOAD_ATTR_INSTANCE_VALUE. The de-optimisation the internal documentation describes happens inside the instruction's execution when the check fails; the generic opcode does not reappear in the bytecode array. What is not free is the failed check itself: five types at one site against one costs 53% more.

Claim

3.13 added a JIT, so Python got faster

Actually

In 3.14.7 tier two is built but switched off: sys._jit.is_available() is True, is_enabled() is False. Turning it on with PYTHON_JIT=1 on the same build made all four loops measured here slower: from +10.7% on the dictionary to +36.7% on the list walk. Measured over six interleaved pairs of runs, and no load has overlapping ranges, so this is not the machine drifting.

Sources & further reading

7 SOURCES

  1. PEP 659 — Specializing Adaptive InterpreterPEP. Mark Shannon, Informational, Python 3.11. The document that brought specialisation into CPython, and the source of the name and of the warm-up / specialise / check-the-assumption division.https://peps.python.org/pep-0659/
  2. InternalDocs/interpreter.md — the Specialization and Inline cache entries sectionsCPython source code. The mechanism described from inside the project rather than in an announcement. The wording about rewriting comes from here — «replacing a generic instruction with a faster version that works for the case that this program encounters» — as does the statement about reverting: «The specialized instructions are responsible for checking that the special-case assumptions still apply, and de-optimizing back to the generic version if not». Same page: the inline cache layout and the rule that the first cell is always the counter. CPython tag 3.14.5.https://github.com/python/cpython/blob/v3.14.5/InternalDocs/interpreter.md
  3. InternalDocs/jit.md — tier twoCPython source code. The boundary between the tiers stated outright: «Runtime optimization in this interpreter can only be done for one instruction at a time. The JIT is based on a mechanism to replace an entire sequence of bytecode instructions». Also from here: what declares itself hot is the JUMP_BACKWARD instruction, by the counter in its own inline cache. CPython tag 3.14.5.https://github.com/python/cpython/blob/v3.14.5/InternalDocs/jit.md
  4. Python/specialize.c — the _Py_Specialize_* functionsCPython source code. Where the decision to substitute is made: one function per family. Needed to show that specialisation is not a property of the compiler but work done at runtime. CPython tag 3.14.5.https://github.com/python/cpython/blob/v3.14.5/Python/specialize.c
  5. dis — adaptive and show_cachesOfficial documentation. The two flags the whole checkability of this article rests on: adaptive shows an instruction in its present form, show_caches shows the inline-cache cells along with the counter's value. Without them specialisation could only be retold.https://docs.python.org/3.14/library/dis.html
  6. What's New In Python 3.13 — the experimental JITOfficial documentation. The moment tier two appeared as a build option, and the plain caveat that it is off by default.https://docs.python.org/3.13/whatsnew/3.13.html
  7. sys._jit — the state of tier two in the current buildOfficial documentation. is_available / is_enabled / is_active. The only honest way to say whether the JIT is running right now, instead of guessing from a version number.https://docs.python.org/3.14/library/sys.html