backend / python core / 31_dis_bytecode.md

dis — inspecting Python bytecode

2 min read source

dis — inspecting Python bytecode

CPython compiles source to bytecode (a sequence of opcodes for a stack-based VM). The dis module shows you what opcodes a function actually runs. This is mainly used for: optimization, understanding subtle behavior, and answering “what’s faster” questions definitively.

Basic use

import dis

def add(a, b):
    return a + b

dis.dis(add)
  2           0 RESUME                   0
  3           2 LOAD_FAST                0 (a)
              4 LOAD_FAST                1 (b)
              6 BINARY_OP                0 (+)
             10 RETURN_VALUE

Each line: bytecode offset, opcode, operand, mnemonic comment.

Why care?

Settling micro-optimization arguments

“Is a < b < c faster than a < b and b < c?”

def chained(a, b, c):
    return a < b < c

def expanded(a, b, c):
    return a < b and b < c

dis reveals chained uses one fewer load (the middle operand is duplicated on the stack with COPY/SWAP, not reloaded). Slightly faster, but in practice negligible.

Understanding closures

Closure cells show up as LOAD_DEREF / STORE_DEREF rather than LOAD_FAST:

def outer():
    x = 1
    def inner():
        return x
    return inner

dis.dis(outer)
# ...
# MAKE_CELL                0 (x)
# LOAD_CONST               1 (1)
# STORE_DEREF              0 (x)

The MAKE_CELL is what creates the closure storage.

Spotting expensive patterns

Calling .append in a tight loop generates LOAD_METHOD per iteration. Hoisting it (append = lst.append; append(x)) avoids the per-iteration method lookup. dis proves it.

Key opcodes worth knowing

Opcode What it does
LOAD_FAST Read a local variable (fastest)
LOAD_GLOBAL Read a global (slower; dict lookup)
LOAD_DEREF Read a closure cell
LOAD_CONST Push a literal constant
LOAD_ATTR Get an attribute (e.g. obj.x)
STORE_FAST Set local variable
BINARY_OP +, -, *, etc. (Python 3.11+ unified these)
CALL Function call (replaced CALL_FUNCTION in 3.11+)
RETURN_VALUE Pop top of stack and return
JUMP_FORWARD, POP_JUMP_IF_FALSE Branching
RESUME Marker for jit / debug — newer versions

Performance implication: globals are slow(er) than locals

import math

def slow():
    return math.sin(0.5)   # LOAD_GLOBAL math, LOAD_ATTR sin, CALL

def fast(sin=math.sin):
    return sin(0.5)        # LOAD_FAST sin, CALL

The “default arg as cache” trick is sometimes used in hot loops. It’s micro — usually only matters in inner loops with millions of iterations.

Modern bytecode changes (3.11+)

Python 3.11 introduced “specialized” opcodes via PEP 659 (adaptive interpreter). Common opcodes get specialized to common types — BINARY_OP becomes BINARY_OP_ADD_INT after a few iterations on integers. You’ll see these in dis.dis(func, adaptive=True).

Practical use: dis.show_code

dis.show_code(add)
# Name:              add
# Filename:          <stdin>
# Argument count:    2
# Variable names:    a, b
# Stack size:        2
# ...

Shows constants, names, free vars — useful for understanding closures and frame size.

Interview angle

Less common in interviews, but valuable when discussing performance: “How would you compare the speed of two equivalent Python expressions?” (Use timeit for empirical, dis for theoretical — count opcodes.) “Why is accessing a local faster than a global?” (LOAD_FAST is array index, LOAD_GLOBAL is dict lookup with hash.)