Command Palette

Search for a command to run...

Hectal
PHASE 13Advanced ~28 min· topic 3 of 9

Topic 13.3

Bytecode and the dis Module

In one line

CPython compiles your source into bytecode, a list of small instructions for a stack-based virtual machine, and stores it in a code object. The dis module prints that bytecode, which shows what a line really costs and how the specialising interpreter speeds up hot code.

Think of it like this

A cooking show. The chef writes the recipe in everyday words (your source code). A producer turns it into a card with numbered, tiny steps: 'take egg, take bowl, crack egg into bowl'. A kitchen robot then follows the card one step at a time. The card is bytecode, and the robot is the Python virtual machine (the eval loop written in C). Topic 0.8 showed this pipeline briefly; here we read the card.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Bytecode
The low-level instructions CPython compiles your code into. It is not machine code.
Code object
The compiled form of a function, module or class body: instructions plus constants and names.
Virtual machine (VM)
The C program (the eval loop) that runs bytecode one instruction at a time.
Stack machine
A machine that works by pushing values on a stack and popping them to compute.
Opcode
The name of one bytecode instruction, such as LOAD_FAST.
Constant folding
Doing arithmetic on constants at compile time, so 6 * 7 becomes 42.
Specialising (adaptive) interpreter
A VM that rewrites hot instructions into faster versions for the types it has seen. Added in 3.11.
JIT
Just-in-time compiler: turns hot code into machine code while the program runs. Experimental in 3.13.

Step by step

01Read one function's bytecode

We disassemble a function of two lines. The first column is the source line, then the opcode, then its argument, then a readable hint in brackets.

Follow the stack. LOAD_FAST_LOAD_FAST pushes width and height. BINARY_OP 5 (*) pops them and pushes the product. STORE_FAST result pops it into the local result. On the next line, result and the constant 1 are pushed, BINARY_OP 0 (+) adds them, and RETURN_VALUE returns the top of the stack.

main.pywhole filepython
import dis

def area(width, height):
    result = width * height
    return result + 1

dis.dis(area)
terminal
$ python main.py # Python 3.13
── expected output ──
4 RESUME 0
 
5 LOAD_FAST_LOAD_FAST 1 (width, height)
BINARY_OP 5 (*)
STORE_FAST 2 (result)
 
6 LOAD_FAST 2 (result)
LOAD_CONST 1 (1)
BINARY_OP 0 (+)
RETURN_VALUE

02The stack, step by step

The stack grows and shrinks as the instructions run. The deepest it ever gets is stored in the code object (co_stacksize), so the VM can reserve exactly that much.

The stack, step by stepdiagram
Rendering diagram…

03Open the code object

Every function has a __code__ attribute. Looking inside shows the data the instructions refer to: the argument names, all the local names (arguments first), and the constants. The first slot of co_consts holds the function's docstring, or None when there is none (in this function it is None).

main.pywhole filepython
def scale(values, factor):
    total = 0
    for v in values:
        total += v * factor
    return total

code = scale.__code__
print(code.co_varnames)      # ('values', 'factor', 'total', 'v')
print(code.co_consts)        # (None, 0)

04compile, exec and constant folding

compile(source, filename, mode) is the compiler as a function. It returns a code object. exec runs it. Looking at co_consts shows that 6 * 7 has become the constant 42: the multiplication ran at compile time.

This is what import does behind the scenes, plus saving the result as a .pyc file in __pycache__ so next time it can skip the compile step.

05Why locals are fast and globals are slower

A local variable has a fixed slot number, so LOAD_FAST is an array index. A global name needs a dictionary lookup by name at run time, and an attribute (obj.attr) needs the lookup chain from Topic 13.4. This is the root of the old advice to bind append = result.append before a hot loop. Today the specialising interpreter caches many of those lookups, so measure before you rewrite code that way (Topic 13.7).

06Watch the interpreter specialise

After a function has run a thousand times with two ints, dis.dis(add, adaptive=True) shows BINARY_OP_ADD_INT instead of the generic BINARY_OP. The specialised instruction checks both operands are ints and adds them without the general dispatch. If you later call add with strings, the check on that instruction fails and the VM runs the generic path for that call. Only after the instruction keeps missing does it drop the specialised form and, if the new types are stable, specialise again, this time to something like BINARY_OP_ADD_UNICODE. One odd call does not undo the specialisation.

The names come from CPython 3.13. Other versions use different names and numbers of instructions.

07Where the JIT and other speed-ups fit

A JIT trades start-up time and memory for faster hot loops. PyPy has had a mature tracing JIT for years. CPython 3.13 added an experimental one that is off in normal builds, and it remains a work in progress; the supported speed-ups you get by default are the specialising interpreter, cheaper frames and faster calls.

Try it yourself

  1. 1

    Add a line and read the difference

    In the first example add total = result * 2 before the return. Run it and find the new LOAD_FAST, LOAD_CONST, BINARY_OP and STORE_FAST instructions. Which line number do they belong to?

  2. 2

    Break the specialisation

    In the third example, call add("a", "b") once after the warm-up loop and disassemble again: the name stays BINARY_OP_ADD_INT, because one miss is not enough. Now call it a thousand times with strings and disassemble again. The instruction becomes BINARY_OP_ADD_UNICODE. Try floats or lists and watch the name change.

  3. 3

    Spot the folded constant

    Change the source in the second example to "answer = 60 * 60 * 24". Look at co_consts and find the single constant 86400.

Code & diagrams

Disassemble a function (Python 3.13) New tab

Opcode names and numbers are version-specific. Run it on another version and compare.

Sign in to run this example in your browser.

Expected output

  4           RESUME                   0

  5           LOAD_FAST_LOAD_FAST      1 (width, height)
              BINARY_OP                5 (*)
              STORE_FAST               2 (result)

  6           LOAD_FAST                2 (result)
              LOAD_CONST               1 (1)
              BINARY_OP                0 (+)
              RETURN_VALUE
Code objects, compile and exec New tab
Sign in to run this example in your browser.

Expected output

name:         scale
arguments:    ('values', 'factor')
all locals:   ('values', 'factor', 'total', 'v')
constants:    (None, 0)
global names: ()
stack needed: True
bytecode is bytes: True
constants: (42, None)
answer: 42
has a loop opcode: True
first opcode: RESUME
The specialising interpreter after a warm-up New tab

Compare with the generic `BINARY_OP` in the first example. The adaptive form exists only after the function has run enough times.

Sign in to run this example in your browser.

Expected output

  4           RESUME_CHECK             0

  5           LOAD_FAST_LOAD_FAST      1 (a, b)
              BINARY_OP_ADD_INT        0 (+)
              RETURN_VALUE

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Disassemble something that is not code

Pass a number to dis.dis.

main.pywhole filepython
import dis

dis.dis(42)
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 3, in <module>
dis.dis(42)
~~~~~~~^^^^
File "/lib/python313.zip/dis.py", line 129, in dis
raise TypeError("don't know how to disassemble %s objects" %
type(x).__name__)
TypeError: don't know how to disassemble int objects

Myth vs fact

Myth

Python is interpreted, so it has no compile step.

Fact

Python always compiles source to bytecode first. The VM then interprets that bytecode. The compile step is fast and its result is cached in __pycache__.

Myth

Bytecode is the same on every Python version.

Fact

It changes in almost every release, which is why .pyc files are tied to a version and why dis output differs between versions.

Myth

The JIT in 3.13 makes Python as fast as C.

Fact

It is experimental, off in normal builds, and gives small gains today. The specialising interpreter of 3.11 gave larger improvements.

Myth

Micro-optimising bytecode is a good use of time.

Fact

The big wins are algorithms and data structures. Reading bytecode is for understanding, and for the rare hot spot after profiling.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Specialisation relies on inline caches: small slots stored inside the bytecode where the VM remembers, for example, the type version and the attribute slot it found last time. A stable type shape therefore makes code faster, and changing a class at run time (adding attributes after creation, or patching methods) invalidates caches.

  • ▸

    sys.settrace, sys.monitoring (3.12), debuggers, coverage tools and profilers all work at the level of bytecode events and line numbers from the co_lines() table. Understanding code objects explains how tools like coverage.py know which lines ran.

  • ▸

    A .pyc file stores a marshalled code object with a header including a magic number tied to the Python version and a source timestamp or hash. Python ignores a .pyc whose magic number does not match, which is why the cache folder is safe to delete.

  • ▸

    Free-threaded builds (Topic 12.2) and the JIT both rely on changes in the interpreter and in how objects are counted, which is one reason they are separate build options. Free-threading was experimental in 3.13 and officially supported (but not the default) from 3.14; the JIT was still experimental in 3.14.

  • ▸

    dis.dis(f, show_caches=True) prints the hidden CACHE entries that sit after instructions such as LOAD_ATTR and BINARY_OP; those are the inline caches. dis.Bytecode(f) and dis.get_instructions(f) give the same information as objects, with opname, argval, offset and positions, which is what tools build on. Exception handling is not done by bytecode: since 3.11 try costs nothing when no error happens, because the handlers live in a separate exception table (co_exceptiontable) that is read only when an exception is raised.

  • ▸

    Compile-time work you can observe with dis: constant folding, dropping code after return, and for if False: blocks removing the dead branch. The optimiser is conservative about anything that could change behaviour, for example it does not fold 1 / 0 (it raises at run time, not at compile time) or very large results. Code under if __debug__: and assert disappears with python -O, which is one more reason never to use assert for validation of input.

Remember this

  1. 1

    When a module is imported or a script runs, the compiler parses the source, builds a syntax tree (the program's structure as nested nodes), and emits a code object for the module and one for every function, class body and comprehension. A code object holds the instruction bytes (co_code), the constants used (co_consts), the names of global and attribute lookups (co_names), the local variable names (co_varnames), and the stack size the function needs. A function object is a code object plus its defaults and closure.

  2. 2

    The virtual machine is a stack machine. Instead of registers (named slots inside a CPU), it has a small stack: a pile where you only add or remove at the top, like a stack of plates. LOAD_FAST width pushes a local variable. BINARY_OP pops two values, multiplies them and pushes the result. STORE_FAST result pops the top and saves it into a local. RETURN_VALUE pops the top and returns it. Every expression becomes a short sequence of pushes and pops.

  3. 3

    dis.dis(function) prints the instructions with line numbers. The exact opcode names change between versions: Python 3.13 shows LOAD_FAST_LOAD_FAST where older versions show two LOAD_FAST lines. What stays the same is the idea. Never write code that parses dis output; it is a learning and debugging aid.

  4. 4

    Locals are stored in a fixed array, which is why LOAD_FAST (a local) is fast and LOAD_GLOBAL or LOAD_ATTR (a dict lookup, Topic 13.4) is slower. That is the reason behind the old tip of copying a global function into a local name inside a hot loop. Constants such as 6 * 7 are folded at compile time into 42, so no multiplication runs at all.

  5. 5

    Since Python 3.11 the interpreter is adaptive and specialising (PEP 659; a PEP is a Python Enhancement Proposal, the numbered design document for a change). After a piece of bytecode runs a few times, the VM rewrites it in place into a faster form for the types it saw, for example BINARY_OP_ADD_INT for adding two ints. If the types change, it falls back. dis.dis(f, adaptive=True) shows the specialised version after a warm-up. This is the main reason 3.11 was about 25 percent faster than 3.10 on average (the official figure was 10 to 60 percent, depending on the program), together with cheaper function calls.

  6. 6

    Python 3.13 added an experimental JIT (just-in-time compiler, PEP 744). It is not in the usual builds: it is compiled in with a build option and, even then, switched on with an environment variable (PYTHON_JIT=1). It uses a technique called copy-and-patch: hot sequences of bytecode are turned into small pre-built machine-code templates. It is a first step and the gains so far are modest and depend on the program; it was still experimental in 3.14, and you should not count on it. For real speed today, use the right algorithm, NumPy, or another tool (Topic 13.8).

Explain it without notes

01

What is bytecode, and who runs it?

02

What does a stack-based virtual machine do for the expression a * b + 1?

03

What is the specialising adaptive interpreter, and what changed in Python 3.11?

04

Why can x = 6 * 7 run no multiplication at run time?

05

Why does a try block cost almost nothing when no exception is raised, and what does that tell you about EAFP style?

Practice

01

Print the opcode names of lambda x: x + 1 with dis.get_instructions.

02

Print the co_varnames and co_consts of a function that has a parameter n and a local string text.

03

Compile the expression "2 * 21" in eval mode, print its constants, and evaluate it.

Trade-offs

  • ↔

    Bytecode gives portability but not speed. The same .pyc runs on any machine with the same Python version, but each instruction is still interpreted. Compiling to machine code (a JIT, Cython, Numba) is faster and less portable.

  • ↔

    Specialisation helps stable code. Hot functions that always see the same types gain; code whose types vary a lot (one function called with ints, strings and lists) benefits less.

Done when you can

  • Done when you can read a short dis listing and follow the stack.

  • Done when you can name the parts of a code object and what each holds.

  • Done when you can explain constant folding and the speed difference between locals and globals.

  • Done when you can explain what the specialising interpreter does and how to see it.

  • Done when you can say what the 3.13 JIT is and why you should not rely on it yet.