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:
timeitfor micro-benchmarks,cProfile/pstats(python -m cProfile -s cumtime app.py) for where time goes, sampling profilers like py-spy for production,tracemallocfor 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
disshows 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.