
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_VALUETwo 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
| Namespace | Storage | Lookup |
|---|---|---|
| Local | frame slot array | index, fastest |
| Enclosing (closure) | cell objects | pointer dereference |
| Global | module __dict__ | dict lookup with a per-version inline cache |
| Builtin | builtins.__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 days | Measured result | Verdict |
|---|---|---|
| “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-run | real 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 reads | no gain |
“sum() loses precision — use math.fsum“ | naive loop off by 50078 on a 200k list; sum() matched fsum exactly | obsolete since 3.12 |
“Never put try/except inside a hot loop” | 0.0199 s try/except vs 0.0354 s if-check per 1M | try/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.0The 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):
| Scenario | 20k appends | 80k appends | Scaling |
|---|---|---|---|
s += "x", one reference | 1.14 ms | 9.53 ms | linear |
s += "x", a copy kept in a list | 114.86 ms | 3424 ms | quadratic (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
- Function calls — 100k calls to a trivial
inctook 0.0428 s versus 0.0298 s for the same work inline: about 44% overhead. Keep hot inner loops call-free, but only after profiling says so. - Generator allocation is effectively free — 208 bytes for a generator object no matter how long it is, versus 8.45 MB for a materialised list of 1,000,000 ints. Wrap long pipelines in generators on principle.
- Repeated computation —
functools.cacheturnedfib(28)from 0.0306 s of brute-force recursion into a cache-hit cost of about 50 nanoseconds per later call (measured over 1,000 calls). Memoisation beats every micro-tweak in this article combined.
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
- Micro-optimising before profiling. The table above shows two “optimisations” worth roughly nothing. Measure, then change the algorithm.
- Trusting
dison cold code. You will see generic opcodes; specialisation only appears after the code is hot. - Relying on
locals()writes. It is a snapshot dict; assignments to it vanish. Use an explicit namespace dict. - Assuming all interpreters behave this way. PyPy, GraalPy, and CPython 3.13+ free-threaded builds specialise differently. Benchmark on the runtime you deploy.
assertin production validation. Stripped by-O; use explicitif ... raisefor anything that must always run.
Key takeaways and challenge
- Bytecode is a stack machine; locals are array slots, globals are dict lookups.
- CPython 3.11+ rewrites hot instructions into specialised opcodes — check with
dis.dis(fn, adaptive=True). - Half the classic micro-optimisation advice is now noise; measure instead.
sum()is compensated since 3.12 and matchesmath.fsum.s += xis quadratic only while a second reference is alive.- The two things that always win: fewer function calls in hot loops, and not computing the same thing twice.
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.
Last updated on · Written by Dinesh Kumar R
