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
disshows you is not the one that runs. Since 3.11 the interpreter rewrites instructions inside itself while working: it saw thata + badds integers, so it swapped the genericBINARY_OPfor the narrowBINARY_OP_ADD_INT. - There is no waiting: on 3.12 and later the swap happens on the second execution of the instruction itself (the counter belongs to it, not to the source line), on 3.11 the eighth. This is not a JVM-style warm-up.
- What specialises is a particular place in a particular function, not an operation in general:
o.xgives different instructions depending on whether the attribute lives in the instance dictionary or in a__slots__slot. - The assumption is checked on every pass, and the check is not free: one line with five types going through it instead of one costs 53% more.
- The JIT (tier two) is a separate thing and in 3.14 is off by default. Switching it on, on the same build, made all four loops measured here slower, not faster.
The interpreter edits itself as it goes
The usual picture is this: the compiler turned source into bytecode, and then the interpreter loop runs that bytecode. It is accurate right up to Python 3.11.
From 3.11 there is a third participant. While the code runs, the interpreter
watches what exactly passes through it and swaps instructions for narrower
ones. It was BINARY_OP — "add anything to anything, work it out as you go". It
became BINARY_OP_ADD_INT — "add two integers", no working out.
You can check this in one line, without reading CPython's C: dis has an
adaptive flag that shows instructions in their present form.
def attrs(fn):
return [
i.opname
for i in dis.get_instructions(fn, adaptive=True)
if i.opname.startswith("LOAD_ATTR")
]
print(attrs(read)) # ['LOAD_ATTR']
for _ in range(500):
read(p)
print(attrs(read)) # ['LOAD_ATTR_INSTANCE_VALUE']The same function, the same code object. It changed itself.
It happens almost immediately
The word "specialisation" drags in an expectation of a long warm-up — that is how the JVM works, compiling hot code after thousands of iterations. Python is not like that.
The measurement is simple: a fresh piece of code is executed one run at a time, and after each run it is asked what its instruction is called now.
| version | substitution happens |
|---|---|
| 3.11.15 | on the 8th execution |
| 3.12.3 | on the 2nd |
| 3.13.7 | on the 2nd |
| 3.14.7 | on the 2nd |
The practical conclusion: a function called twice is already running on
specialised instructions. In particular, any timeit measures the steady
state rather than the warm-up.
A place specialises, not an operation
This matters more than it sounds. The specialisation is recorded not "for
addition in general" but for one particular line in one particular function.
Which is why the same o.x gives different instructions:
| what is read | what it becomes |
|---|---|
| an attribute in the instance dictionary | LOAD_ATTR_INSTANCE_VALUE |
an attribute in a __slots__ slot | LOAD_ATTR_SLOT |
| a method | LOAD_ATTR_METHOD_WITH_VALUES |
Hence a non-obvious consequence: __slots__ is not only about saving memory. It
also changes the instruction the attribute is read with.
The check costs money
A specialised instruction has to make sure its assumption still holds — otherwise it would return a wrong answer. That comparison runs on every pass, and if different types go through one place, it fails regularly.
Five classes declared identically do the same thing. The only difference is
how many types pass through one line of o.x:
| types | ns per read | against one type |
|---|---|---|
| 1 | 32.79 | ×1.00 |
| 2 | 41.00 | ×1.25 |
| 5 | 50.07 | ×1.53 |
Half again in overhead for nothing — on this benchmark: five classes through
one o.x. The cure is not type annotations but splitting hot code so that one
place sees one type. Profile first, though: without that you do not know this is
the hot place.
What about the JIT?
Since 3.13 CPython has a second mechanism too — a JIT. It does something different: it replaces a whole sequence of instructions rather than one.
The main thing worth knowing about it: you cannot judge it by a version number. In 3.14 it is built but off; you can ask the interpreter directly:
import sys
print(sys._jit.is_available(), sys._jit.is_enabled()) # True FalseIt is switched on with the PYTHON_JIT=1 environment variable. On four short
loops run six times each per mode on one and the same build, switching it on
made all four slower: from +10.7% on the dictionary to +36.7% on the
list walk. On this code and this machine, "turn the JIT on and it gets faster"
did not hold.
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.
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:
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.
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):
| operation | before | after |
|---|---|---|
a + b, integers | BINARY_OP | BINARY_OP_ADD_INT |
a + b, floats | BINARY_OP | BINARY_OP_ADD_FLOAT |
a + b, strings | BINARY_OP | BINARY_OP_ADD_UNICODE |
seq[i], a list | BINARY_SUBSCR | BINARY_SUBSCR_LIST_INT |
d[k], a dict | BINARY_SUBSCR | BINARY_SUBSCR_DICT |
o.x, an ordinary attribute | LOAD_ATTR | LOAD_ATTR_INSTANCE_VALUE |
o.x, a __slots__ slot | LOAD_ATTR | LOAD_ATTR_SLOT |
o.method() | LOAD_ATTR | LOAD_ATTR_METHOD_WITH_VALUES |
f(n), a Python function | CALL | CALL_PY_EXACT_ARGS |
a < b, integers | COMPARE_OP | COMPARE_OP_INT |
for x in list | FOR_ITER | FOR_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:
| version | base families | variants | substitution happens |
|---|---|---|---|
| 3.11.15 | 17 | 71 | on the 8th execution |
| 3.12.3 | 15 | 64 | on the 2nd |
| 3.13.7 | 15 | 74 | on the 2nd |
| 3.14.7 | 17 | 84 | on 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.
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 site | ns per read | instruction at the end | against one type |
|---|---|---|---|
| 1 | 32.79 | LOAD_ATTR_INSTANCE_VALUE | ×1.00 |
| 2 | 41.00 | LOAD_ATTR_INSTANCE_VALUE | ×1.25 |
| 3 | 43.48 | LOAD_ATTR_INSTANCE_VALUE | ×1.33 |
| 5 | 50.07 | LOAD_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_INT →
BINARY_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.
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:
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:
| load | without the JIT, µs | with it, µs | conclusion |
|---|---|---|---|
arithmetic loop t += i * i | 981–1013 | 1144–1216 | +16.6%, ranges do not overlap |
| loop over a list reading an attribute | 520–541 | 711–739 | +36.7%, do not overlap |
| a function call in a loop | 1018–1037 | 1169–1199 | +14.8%, do not overlap |
| a dictionary in a loop | 1520–1588 | 1683–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
| Version | Change | What it means for your code |
|---|---|---|
| 3.11 | PEP 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.12 | PRECALL 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.13 | 74 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.14 | 84 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.
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- The bytecode you see in
disis not the bytecode that runs. Since 3.11 the interpreter rewrites instructions inside itself while working: having seen thata + badds integers, it swaps the genericBINARY_OPforBINARY_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.xwith 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.
In fact
- 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 reportsLOAD_ATTR_INSTANCE_VALUEinstead ofLOAD_ATTR— the same code object, changed in place. - 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.
- 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_ATTRhas nine of them, two bytes each; by the family's rule the first is always the counter —show_cachesprints its value outright. - 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. - In 3.14.7 tier two is built but switched off:
sys._jit.is_available()is True,is_enabled()is False. Turning it on withPYTHON_JIT=1on 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.
By version
- 3.11
- PEP 659: specialisation arrives. Seventeen families, seventy-one variants, substitution on the eighth execution.
PRECALLexists as a family of its own — seventeen variants for that one alone.< - 3.12
PRECALLdisappears and its work moves intoCALL; the variant count drops (64) while coverage widens:COMPARE_OPandFOR_ITER, which did not specialise at all on 3.11, now giveCOMPARE_OP_INTandFOR_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.13
- 74 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.14
- 84 variants and seventeen families again;
BINARY_SUBSCRmerges intoBINARY_OP, so subscripting now yieldsBINARY_OP_SUBSCR_LIST_INT.sys._jitappears — a way to ask the interpreter, rather than the documentation, whether tier two is built and whether it is on right now.<
What is covered
- What is going on
- Where the counter lives
- What turns into what
- When
- What if the assumption does not hold
- Tier two
- What follows for your code
- Version history
- What measured this
Common misconceptions
dis shows the bytecode that runs
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.
Specialisation is a warm-up; it needs thousands of iterations
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.
The CACHE entries in dis output are execution overhead
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.
When the type changes, the instruction reverts to the generic one
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.
3.13 added a JIT, so Python got faster
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
- 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/
- 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
- 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
- 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
- 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
- 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
- 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