Part III — Advanced: internals, concurrency, performance
This Part answers the questions experienced engineers ask about Python:
why it is slow, what the GIL really prevents, when threads help, why
asyncio exists, how frameworks like Django, pydantic, and SQLAlchemy do
their "magic", and how to make Python code fast when it matters. Each
answer depends on knowing what CPython does underneath, so the Part starts
there.
25. How CPython runs your code: bytecode, frames, and the specializing interpreter
25.1 Look at the bytecode
import dis
def add_tax(price, rate):
return price * (1 + rate)
dis.dis(add_tax)
RESUME 0
LOAD_FAST_LOAD_FAST 1 (price, rate) # 3.13 fuses two loads
...
LOAD_CONST 1 (1)
...
BINARY_OP 0 (+)
BINARY_OP 5 (*)
RETURN_VALUE
(The exact instructions vary by version; 3.14 changed several.) CPython is
a stack machine: instructions push and pop values on a per-call value
stack. The evaluation loop in Python/ceval.c is essentially:
for (;;) {
opcode = next_instruction();
switch (opcode) {
case BINARY_OP: right = POP(); left = POP();
result = left->type->tp_as_number->nb_add(left, right); /* dynamic dispatch */
PUSH(result); break;
...
}
}
Every BINARY_OP has to find out the operand types and call the right C
function. A C compiler does that work once, at compile time; CPython does
it every time the line runs. That, plus allocating an object for every
intermediate integer, is why a Python loop over numbers is slow.
25.2 Frames
Each function call creates a frame: the code object, the local variables, the value stack, and a pointer to the caller's frame. Tracebacks are a walk up this chain, and debuggers and profilers read it:
import sys
def inner():
f = sys._getframe()
while f:
print(f.f_code.co_name, f.f_lineno)
f = f.f_back
def outer():
inner()
outer() # inner, outer, <module>
Python has a recursion limit (sys.getrecursionlimit(), 1000 by default)
because deep recursion exhausts the C stack. Recursive algorithms over deep
data (a 10 000-node linked structure, deeply nested JSON) should be written
with an explicit stack instead.
25.3 The specializing adaptive interpreter (3.11+)
PEP 659 made CPython watch what types each instruction actually sees.
After a few executions, a generic instruction is replaced by a specialized
one: BINARY_OP seen only with two ints becomes BINARY_OP_ADD_INT,
LOAD_ATTR on the same class becomes a direct slot load. If the guess
turns out wrong, it de-specializes.
import dis
def total(xs):
s = 0
for x in xs:
s = s + x
return s
for _ in range(10):
total(list(range(100)))
dis.dis(total, adaptive=True) # shows e.g. BINARY_OP_ADD_INT, FOR_ITER_LIST
The practical lesson: type-stable code is faster. A loop that always
sees ints stays specialized; one that mixes ints, floats, and None keeps
falling back to the generic path.
3.13 added an experimental copy-and-patch JIT (PYTHON_JIT=1 on builds
configured with it). As of 3.14 it gives modest gains and is off by
default. Do not count on it; count on vectorization (Ch 31).
25.4 Why this matters for AI code
import time
def py_dot(a, b):
return sum(x * y for x, y in zip(a, b))
n = 1_000_000
a = [1.0] * n; b = [2.0] * n
t = time.perf_counter(); py_dot(a, b); t_py = time.perf_counter() - t
import numpy as np
A = np.ones(n); B = np.full(n, 2.0)
t = time.perf_counter(); A @ B; t_np = time.perf_counter() - t
print(f"python {t_py * 1000:.1f} ms, numpy {t_np * 1000:.2f} ms, {t_py / t_np:.0f}x")
On the laptop this guide was tested on: 19 ms versus 0.08 ms, about 250× faster. NumPy runs one C loop over contiguous doubles with SIMD instructions; Python runs a million iterations of the eval loop and allocates two million float objects. The bytecode loop is the cost you avoid by pushing work into libraries.