Frontend Labs
⋯

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.


Generated from real-life-python-guide.md · 8 Oct 2026 16:36