Skip to Content
Course content

365: Understanding Bytecode with dis

Click on the "Edit" button in the top corner of the screen to edit your slide content.

You've probably spent a lot of time now thinking about Python as a high-level language. We talk about objects, decorators, and generators. But at some point, you'll hit a wall where the code looks correct, the logic is sound, but the performance is just... off. Or maybe you're wondering why two different ways of writing the same logic result in different speeds. To answer those questions, you have to stop looking at the Python source code and start looking at the bytecode.

Let's look at a classic scenario. Imagine you're processing a massive stream of data and you need to filter it into a list. You might write something like this:

def build_list_loop(data):
    result = []
    for item in data:
        if item > 10:
            result.append(item)
    return result

def build_list_comp(data):
    return [item for item in data if item > 10]

Now, you know from experience that list comprehensions are generally faster. But why? If you're just guessing, you're not engineering; you're gambling. When I want to know the "why," I reach for the dis module.

The Overhead of Method Lookups

If we disassemble the build_list_loop function using dis.dis(), we can see exactly what the Python Virtual Machine (PVM) is doing. I'll focus on the inner loop part of the output:

import dis

def build_list_loop(data):
    result = []
    for item in data:
        if item > 10:
            result.append(item)
    return result

dis.dis(build_list_loop)

You'll see a sequence like this in the output:

12 LOAD_FAST                1 (result)
    14 LOAD_METHOD              0 (append)
    16 LOAD_FAST                2 (item)
    18 CALL_METHOD              1

Look closely at those instructions. Every single time the loop runs, Python has to LOAD_FAST the result list, then LOAD_METHOD to find where the append function lives on that list object, and then CALL_METHOD to actually execute it. That lookup—searching the object's dictionary for the "append" attribute—happens on every single iteration. It's a tiny tax, but over a million items, that tax becomes a massive bill.

The Efficiency of LIST_APPEND

Now, let's see what happens when we disassemble the list comprehension. This is where things get interesting.

dis.dis(build_list_comp)

In the bytecode for the comprehension, you won't see LOAD_METHOD or CALL_METHOD. Instead, you'll see something like this:

22 LOAD_FAST                1 (item)
    24 LIST_APPEND              2

The LIST_APPEND instruction is a specialized opcode. It doesn't look up a method on an object; it tells the PVM to directly push the value onto the list currently being built. It bypasses the entire attribute lookup process. This is the "secret sauce" that makes comprehensions faster. It's not magic; it's just a more direct instruction to the virtual machine.

Using Bytecode to Debug Logic

I don't use dis every day, but it's an invaluable tool when I'm debugging subtle bugs related to closures or default arguments. For example, if you've ever wondered why def add_item(item=[]): causes the list to persist across function calls, looking at the bytecode reveals that the list is created once at definition time (as a constant attached to the function object) rather than at execution time.

When you're staring at a piece of code and the behavior doesn't match your mental model, stop trusting your intuition. Disassemble the function. The bytecode doesn't lie; it tells you exactly what the PVM is doing, regardless of how the sugar-coated Python syntax makes it look.




📋 Practical Task

Bytecode Comparison: Mapping vs. Loops

In this exercise, you will analyze the performance difference between a standard for loop and the map() function by examining their bytecode. Your goal is to identify the specific opcode that makes map() different from a manual loop.

Instructions:

  1. Create two functions: square_loop(numbers) which uses a for loop to square numbers and append them to a list, and square_map(numbers) which uses map(lambda x: x*x, numbers).
  2. Use the dis module to disassemble both functions.
  3. Print the disassembly of both to the console.
  4. Identify and write down (in a comment) which instruction is present in the square_loop version that is completely absent in the square_map version's primary execution path.
  5. Observe how map handles the function call compared to how the loop handles the list append method.
import dis

def square_loop(numbers):
    res = []
    for n in numbers:
        res.append(n * n)
    return res

def square_map(numbers):
    return list(map(lambda x: x * x, numbers))

# Your task: Disassemble both and analyze the difference in how 
# the 'append' operation is handled vs how map() handles the iteration.
dis.dis(square_loop)
print("-" * 30)
dis.dis(square_map)
Rating
0 0

There are no comments for now.

to be the first to leave a comment.