Site icon Ampersand Tutorials

Python Bytecode and the Specializing Interpreter (PEP 659)

Quick answer: CPython compiles your source to bytecode once and executes it on a stack machine. Since 3.11 that machine is adaptive: after a few hundred executions, hot bytecode is rewritten into type-specialised forms such as BINARY_OP_ADD_INT (PEP 659), which is why several classic micro-optimisations no longer move the needle. Hoisting globals into locals measured within 2% of no change; sum() has used compensated summation since 3.12 and now matches math.fsum exactly; and s += "x" is only quadratic when another reference to the string is alive.

Part 11 of our Python series — Module 3, the deep dive. The previous article covered the object model; Module 1 starts at environment setup.

From source to bytecode

python script.py does three things: tokenise and parse to an AST, compile to bytecode, then run that bytecode in the CPython virtual machine. Only the last step repeats on later runs — the compiled form is cached in __pycache__/script.cpython-314.pyc, keyed by source mtime and interpreter version.

Bytecode is a stack machine instruction stream. Every value is pushed onto an evaluation stack and consumed by the next operation. You can read it directly:

import dis

def add(a, b):
    return a + b

dis.dis(add)
#   3   RESUME_CHECK        0
#       LOAD_FAST_BORROW    1 (a)
#       LOAD_FAST_BORROW    2 (b)
#       BINARY_OP           0 (+)
#       RETURN_VALUE

Two things to notice. LOAD_FAST is an array index, not a dict lookup — local variables live in a fixed-size slot array on the frame, which is why they are the cheapest name lookup in the language. And in 3.14 the opcode is LOAD_FAST_BORROW: the interpreter borrows a reference to the object without touching its reference count, trimming a pair of increment/decrement operations per load. That is what a mature interpreter looks like — saving work at the level of individual machine instructions.

The four namespaces

NamespaceStorageLookup
Localframe slot arrayindex, fastest
Enclosing (closure)cell objectspointer dereference
Globalmodule __dict__dict lookup with a per-version inline cache
Builtinbuiltins.__dict__dict lookup, last resort

Reading a name walks that order: local, enclosing, global, builtin — the LEGB rule. A name that is assigned anywhere in a function is local for the whole function, which is the real mechanism behind UnboundLocalError, and why global/nonlocal exist: they change which namespace the compiler binds the name in.

One consequence worth internalising: because locals are slots computed at compile time, locals() is a snapshot. Mutating the dict it returns does not write back to the frame, and exec() inside a function cannot create new locals. Use a dict through exec(..., ns) when you actually need dynamic names.

PEP 659: the adaptive interpreter

Before Python 3.11 every + paid for a generic dispatch — check left type, check right type, pick an implementation. PEP 659 turned the interpreter into a quickening, specializing runtime: each instruction gets a small inline cache, and after enough executions CPython rewrites the opcode into a type-specialised variant.

The evidence is visible with dis once the code is warm:

import dis

def add(a, b):
    return a + b

for _ in range(200):        # warm it up
    add(1, 2)

dis.dis(add, adaptive=True)
#   LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (a, b)
#   BINARY_OP_ADD_INT        0 (+)

BINARY_OP_ADD_INT is the specialised form for two small ints. LOAD_FAST_BORROW_LOAD_FAST_BORROW is a superinstruction: two loads fused into one dispatch. Specialisation is per code object, per call site, and it is pessimistic-safe — if you later pass a float or a string, CPython de-optimises back to the generic opcode and re-specialises for the new type. That is why “write the loop the way you will run it” matters: a function always called with ints runs one specialised path.

Autopsy: which optimisation folklore is dead

Every one of these numbers was measured with timeit on CPython 3.14, Linux/Windows x86-64. Treat them as a shape, not gospel — always re-measure on your own workload, which the last article in this module shows how to do properly.

Advice from the old daysMeasured resultVerdict
“Hoist global constants into locals”0.0983 s global vs 0.0968 s local per 1M reads; 0.0993 vs 0.0916 on a re-runreal but single-digit % — not the 2x people claim
“Copy self.value into a local inside loops”0.1046 s vs 0.1067 s per 1M readsno gain
“sum() loses precision — use math.fsum“naive loop off by 50078 on a 200k list; sum() matched fsum exactlyobsolete since 3.12
“Never put try/except inside a hot loop”0.0199 s try/except vs 0.0354 s if-check per 1Mtry/except faster
“String += is always O(n²)”single-reference +=: linear (503 µs → 4066 µs for 10k → 80k)conditional — see below

The global-versus-local row is worth reading carefully, because both extremes are wrong. In CPython 3.10 and earlier, a global load meant a runtime dict lookup on every iteration and hoisting could win 30–50%. Since 3.11, LOAD_GLOBAL carries a per-code-object inline cache that is valid across an entire loop, so the remaining difference is the single-digit percentage above — and it costs nothing to not restructure your code for it.

The sum() row is the one that surprises people. CPython 3.12 changed sum() to use Neumaier compensated summation, so it is no longer a naive accumulator:

import math
values = [1e100, 1.0, -1e100]
naive = 0.0
for v in values:
    naive += v
print(naive)            # 0.0    -- the 1.0 vanished
print(sum(values))      # 1.0    -- compensated
print(math.fsum(values))# 1.0

The try/except row needs the same kind of precision: entering a try block is nearly free in 3.11+ (zero-cost exceptions — there is no setup opcode at all), while raising an exception still costs a full unwind. Use exceptions for genuinely exceptional paths, not as your branching primitive in a tight loop, but do not contort a design to avoid a try that never fires.

The string row is the most interesting. CPython’s unicode_concatenate resizes the string in place when its reference count is exactly one — so the classic O(n²) is downgraded to O(n):

Scenario20k appends80k appendsScaling
s += "x", one reference1.14 ms9.53 mslinear
s += "x", a copy kept in a list114.86 ms3424 msquadratic (O(n²))

Add one live reference and the in-place optimisation is disabled, so each += copies the whole string. "".join() remains the right default (measured at roughly half the time of even the fast path), but if you have ever profiled a “mysteriously quadratic” loop, this is the mechanism to look for.

What still costs real time

Interpreter switches worth knowing

python -O script.py        # strips assert statements, sets __debug__ False
python -X importtime -c "import json"   # shows per-module import cost
python -m py_compile f.py  # write the .pyc without running

-O removes assert (verified: python -O skips a bare assert False), so never rely on asserts for validation that must survive production. -X importtime is the fastest way to find a startup that is 1.2 seconds of pandas import.

Complete executable example

# interpreter_tour.py -- what the interpreter optimises for you (and what it does not)
import dis
import functools
import math
import timeit


def section(title):
    print("\n" + title)
    print("=" * len(title))


def show_bytecode(fn, warm=200):
    for _ in range(warm):
        fn(1, 2)
    print(f"{fn.__name__}: {fn.__code__.co_code.hex()[:40]}... specialized forms below")
    dis.dis(fn, adaptive=True)


def add(a, b):
    return a + b


def bench(label, stmt, number):
    best = min(timeit.repeat(stmt, number=number, repeat=3))
    print(f"{label:34s} {best:9.4f} s")
    return best


section("1. Warm bytecode shows specialised opcodes")
show_bytecode(add)

section("2. Local versus global name lookup (1M reads x 5)")
GLOBAL_X = 1


def use_global():
    total = 0
    for _ in range(1_000_000):
        total += GLOBAL_X
    return total


def use_local():
    local_x = GLOBAL_X
    total = 0
    for _ in range(1_000_000):
        total += local_x
    return total


g = bench("global lookup", lambda: use_global(), 5)
l = bench("local lookup", lambda: use_local(), 5)
print(f"local is {100 * (1 - l / g):.1f}% faster -- folklore says optimize this")

section("3. Function call versus inline work (100k elements x 10)")


def inc(x):
    return x + 1


called = bench("with a function call", lambda: [inc(i) for i in range(100_000)], 10)
inlined = bench("inline", lambda: [i + 1 for i in range(100_000)], 10)
print(f"call overhead: {100 * (called / inlined - 1):.0f}%")

section("4. sum() is compensated since CPython 3.12")
tricky = [1e100, 1.0, -1e100]
naive = 0.0
for value in tricky:
    naive += value
print("naive loop :", naive)
print("builtin sum:", sum(tricky))
print("math.fsum  :", math.fsum(tricky))
assert sum(tricky) == math.fsum(tricky) == 1.0

section("5. String += is linear -- until a second reference exists")


def plus_solo(n):
    text = ""
    for _ in range(n):
        text += "x"
    return text


def plus_shared(n):
    text = ""
    keep = []
    for _ in range(n):
        keep.append(text)      # second live reference: in-place resize is disabled
        text += "x"
    return text


def joined(n):
    return "".join("x" for _ in range(n))


for n in (10_000, 20_000, 40_000):
    solo = timeit.timeit(lambda: plus_solo(n), number=5) / 5
    shared = timeit.timeit(lambda: plus_shared(n), number=5) / 5
    fast = timeit.timeit(lambda: joined(n), number=5) / 5
    print(f"n={n:6d}  += solo {solo * 1e3:7.2f} ms  "
          f"+= shared {shared * 1e3:8.2f} ms  join {fast * 1e3:7.2f} ms")
    if n == 20_000:
        assert shared > 10 * solo, "expected the shared-reference penalty"
    del shared, solo, fast

section("6. Memoisation beats micro-tuning")


def fib_plain(n):
    return n if n < 2 else fib_plain(n - 1) + fib_plain(n - 2)


@functools.cache
def fib_cached(n):
    return n if n < 2 else fib_cached(n - 1) + fib_cached(n - 2)


plain = bench("fib(28) plain", lambda: fib_plain(28), 1)
cached_many = bench("fib(28) cached x1000", lambda: fib_cached(28), 1000)
per_call = cached_many / 1000
print(f"speedup: {plain / per_call:.0f}x (per-call {per_call * 1e6:.2f} us)")
print("\nAll interpreter-tour assertions passed.")

Line by line: show_bytecode warms the function past the specialisation threshold before disassembling, which is what makes the specialised opcodes visible; bench takes the minimum of three repeats because the minimum is the least noisy estimator; section 3 shows an optimisation that is still real; section 4 asserts the sum() claim instead of trusting the article; section 5 keeps a list of intermediate strings to deliberately trigger the quadratic path and asserts it is at least 10× slower; the cache is timed over 1,000 calls and divided, because a single cached call is smaller than the timer’s own resolution; functools.cache is the modern spelling of lru_cache(maxsize=None).

Common mistakes

Key takeaways and challenge

Challenge: take a slow function of your own and dis it before and after warming. Then convert one inner loop into a generator and one repeated call into an lru_cached helper, and measure both with timeit. Report which change won — the answer is usually the cache.

Want a mentor to walk you through profiling a real project? Ampersand Academy offers one-to-one Python training.

What is Python bytecode?

Bytecode is the instruction stream CPython compiles your source into, cached in __pycache__ and executed on a stack machine. Locals live in fast array slots while globals are dictionary lookups.

What is the specializing interpreter in Python 3.11 and later?

PEP 659 adds inline caches that rewrite hot instructions into type-specific forms such as BINARY_OP_ADD_INT. Specialisation is per call site and reverts safely if the types change.

Is hoisting globals into local variables still worth it?

Barely. Measured on CPython 3.14 the gain was under ten percent, because LOAD_GLOBAL is now inline-cached. Spend the effort on algorithms and caching instead.

Does sum lose precision compared with math.fsum?

Not since CPython 3.12, which changed sum to use Neumaier compensated summation. A naive Python loop was off by 50078 on a 200000-value list where sum and math.fsum agreed exactly.

Is string concatenation with += always quadratic?

No. CPython resizes the string in place while its reference count is one, which stayed linear in measurement. Keep a second reference to intermediate strings and the same code becomes quadratic.

Exit mobile version