Bytecode, the interpreter and performance

Python · Expert · 18 min

Real speedups come from measuring and knowing what the interpreter actually does with your code.

CPython compiles source to bytecode stored in code objects (func.__code__), then a loop in the interpreter executes those instructions on a value stack. dis.dis(func) shows them: LOAD_FAST for locals, LOAD_GLOBAL for globals and built-ins, LOAD_ATTR, BINARY_OP, CALL and so on. Exact opcodes change between versions, so treat bytecode as a diagnostic tool, never a stable API.

Since 3.11 CPython has a specialising adaptive interpreter (PEP 659). After a few executions, generic instructions are replaced by specialised variants based on observed types — for example BINARY_OP adding two ints becomes BINARY_OP_ADD_INT, and LOAD_ATTR on an instance becomes a cached slot load. dis.dis(func, adaptive=True) shows the quickened form. Code with stable types runs fastest; highly polymorphic code keeps falling back to slow paths. Python 3.13 also includes an experimental JIT (PEP 744), only present if CPython was built with --enable-experimental-jit and not enabled in standard releases.

Practical performance work, in order:

  • measure: timeit for micro-benchmarks, cProfile/pstats (python -m cProfile -s cumtime app.py) for where time goes, sampling profilers like py-spy for production, tracemalloc for memory
  • fix the algorithm and data structures first: a set instead of list membership beats any micro-trick
  • lean on built-ins and C-implemented libraries (sum, sorted, str.join, NumPy) that do work outside the bytecode loop
  • hoist repeated attribute and global lookups out of hot loops, and avoid needless function-call layers in them
  • only then consider caching (functools.cache), multiprocessing, or compiled extensions (Cython, Rust via PyO3, mypyc)

Example

import dis
import timeit

def total(prices, tax):
    result = 0
    for p in prices:
        result += p * tax
    return result

dis.dis(total)        # LOAD_FAST..., FOR_ITER, BINARY_OP (*), BINARY_OP (+=) ...

data = list(range(10_000))
for _ in range(10):   # warm up so the adaptive interpreter specialises
    total(data, 2)
ops = {i.opname for i in dis.get_instructions(total, adaptive=True)}
print(sorted(o for o in ops if "BINARY_OP" in o))   # e.g. ['BINARY_OP_ADD_INT', 'BINARY_OP_MULTIPLY_INT']

lookup_list, lookup_set = list(range(10_000)), set(range(10_000))
t_list = timeit.timeit(lambda: 9_999 in lookup_list, number=2_000)
t_set = timeit.timeit(lambda: 9_999 in lookup_set, number=2_000)
print(f"list {t_list:.4f}s vs set {t_set:.6f}s")   # algorithm beats micro-tuning

t_loop = timeit.timeit(lambda: total(data, 2), number=200)
t_builtin = timeit.timeit(lambda: sum(data) * 2, number=200)
print(f"loop {t_loop:.3f}s, builtin {t_builtin:.3f}s")

Remember

  • dis shows the bytecode CPython executes; opcodes vary by version
  • Since 3.11 the adaptive interpreter specialises hot, type-stable code
  • Profile first, then fix algorithms, then use built-ins and C-backed libraries

Watch out for

  • Micro-optimising code before profiling shows where the time actually goes.

Check yourself

  1. Since Python 3.11, why does `BINARY_OP` sometimes appear as `BINARY_OP_ADD_INT` in adaptive disassembly?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/dis.html