El módulo dis: leer e interpretar bytecode Python

Cuando escribes Python, el intérprete no ejecuta tu código fuente directamente. Primero lo compila a bytecode: una secuencia de instrucciones para la CPython Virtual Machine (la VM de pila que vive en ceval.c). El módulo dis es la ventana a ese nivel. Sin él, optimizar Python es pura especulación.

dis.dis() desensambla cualquier función, método, clase o string de código y te devuelve el listado completo: número de línea fuente, offset en bytes dentro del code object, opcode (el nombre de la instrucción), y su argumento. Cada opcode corresponde a un case en el bucle de evaluación de CPython; cuando sabes qué opcodes genera tu código, sabes exactamente qué trabajo hace la VM.

El diseño es deliberado: Python mantiene un code object por función (accesible en func.__code__), que contiene co_code (los bytes), co_consts, co_varnames, co_names, etc. Los opcodes no trabajan con valores directamente, sino con índices en esas tuplas. Eso es lo que dis traduce a texto legible.

¿Cuándo lo usas? Cuando tienes una hipótesis de optimización y necesitas confirmar que el compilador la materializó, cuando algo es inexplicablemente lento y los profilers no dan el detalle suficiente, o cuando quieres entender por qué dos expresiones equivalentes en apariencia tienen rendimiento diferente. Lo que no hace dis es medir tiempo: es análisis estático del bytecode, no un profiler.

El riesgo de ignorarlo es escribir “optimizaciones” que no existen en bytecode. He visto código de producción reescrito para “evitar lookups globales” donde el desarrollador nunca verificó que el compilador ya lo manejaba, o donde el refactor introducía más instrucciones, no menos.

import dis
import timeit
from typing import Iterator


# ── 1. LOAD_FAST vs LOAD_GLOBAL ──────────────────────────────────────────────

def sum_global_range() -> int:
    # `range` se resuelve en co_names → LOAD_GLOBAL en cada iteración
    total = 0
    for i in range(1000):
        total += i
    return total


def sum_local_range() -> int:
    _range = range          # mueve `range` a co_varnames → LOAD_FAST
    total = 0
    for i in _range(1000):
        total += i
    return total


# ── 2. List comprehension vs for + append ────────────────────────────────────

def squares_for_loop(n: int) -> list[int]:
    result = []
    for i in range(n):
        result.append(i * i)    # append se resuelve con LOAD_METHOD + CALL
    return result


def squares_comprehension(n: int) -> list[int]:
    # Se compila a un code object separado con LIST_APPEND, sin lookup de método
    return [i * i for i in range(n)]


# ── 3. Función auxiliar para imprimir bytecode con separadores ───────────────

def show_bytecode(fn) -> None:
    print(f"\n{'─' * 60}")
    print(f"  {fn.__name__}")
    print(f"{'─' * 60}")
    dis.dis(fn)


if __name__ == "__main__":
    show_bytecode(sum_global_range)
    show_bytecode(sum_local_range)
    show_bytecode(squares_for_loop)
    show_bytecode(squares_comprehension)

    # Verificamos que la diferencia de bytecode se traduce en tiempo real
    N = 10_000
    t_global = timeit.timeit(sum_global_range, number=N)
    t_local  = timeit.timeit(sum_local_range,  number=N)
    t_for    = timeit.timeit(lambda: squares_for_loop(100),      number=N)
    t_comp   = timeit.timeit(lambda: squares_comprehension(100), number=N)

    print(f"\nLOAD_GLOBAL: {t_global:.3f}s  |  LOAD_FAST: {t_local:.3f}s")
    print(f"for+append:  {t_for:.3f}s   |  comprehension: {t_comp:.3f}s")

Cómo leer lo que dis produce

LOAD_FAST vs LOAD_GLOBAL: la diferencia que el bytecode hace visible

El output de sum_global_range incluye una línea como esta en el cuerpo del bucle:

LOAD_GLOBAL   0 (range)

En sum_local_range, esa misma posición dice:

LOAD_FAST     0 (_range)

La diferencia no es cosmética. LOAD_GLOBAL ejecuta un lookup en frame->f_globals (un dict), con su hashing y posible colisión. LOAD_FAST indexa directamente fastlocals[i], que es un array de punteros en C —acceso O(1) sin hashing. En un bucle de un millón de iteraciones, esa diferencia se acumula. El bytecode te lo confirma sin ambigüedad: si ves LOAD_GLOBAL dentro de un for tight, tienes un candidato de optimización.

Por qué la comprehension genera bytecode estructuralmente diferente

squares_for_loop produce algo como:

LOAD_FAST     0 (result)
LOAD_METHOD   0 (append)       ← lookup del método en cada iteración
LOAD_FAST     1 (i)
LOAD_FAST     1 (i)
BINARY_OP     5 (*)
CALL          1
POP_TOP

squares_comprehension compila la expresión interna a un code object anidado y usa LIST_APPEND, una instrucción especializada que opera directamente sobre el objeto lista en la pila sin resolver ningún atributo. No hay LOAD_METHOD, no hay CALL al método append; la VM sabe que es una lista y usa el camino corto en C. Puedes verlo así:

GET_ITER
FOR_ITER      ...
LOAD_FAST     0 (i)
LOAD_FAST     0 (i)
BINARY_OP     5 (*)
LIST_APPEND   1               ← sin lookup, instrucción especializada
JUMP_BACKWARD ...

La comprehension también crea su propio scope (el code object anidado tiene sus propios co_varnames), lo que significa que la variable de iteración i es local a ese scope y se accede via LOAD_FAST desde el inicio.

Verificar que tus optimizaciones existen de verdad

El patrón más valioso de dis es el de verificación. Supón que refactorizas una función “para evitar recalcular algo”. Antes de asumir que funciona, desemsambla ambas versiones y cuenta instrucciones. Si la versión “optimizada” genera más opcodes que la original, acabas de empeorar las cosas. CPython aplica algunas optimizaciones en el compilador (constant folding para literales, por ejemplo: 2 * 3 se convierte en LOAD_CONST 6 directamente), pero es conservador. No asumas que lo hace por ti; compruébalo.

También puedes inspeccionar el code object directamente para obtener los metadatos sin el output formateado:

fn = squares_comprehension
print(fn.__code__.co_consts)    # constantes embebidas, incluyendo el code object anidado
print(fn.__code__.co_varnames)  # variables locales
print(fn.__code__.co_names)     # nombres globales/atributos referenciados

Si co_names contiene algo que esperabas como local, tienes una fuga a global lookup.

Errores que debes conocer

Error: asumir que una optimización existe porque la escribiste, sin verificar el bytecode.

# ❌ "Optimización" que no reduce instrucciones
import math

def compute(values):
    _sqrt = math.sqrt          # intención: evitar LOAD_GLOBAL de math
    return [_sqrt(x) for x in values]

# Verifica con dis.dis(compute) — aún ves LOAD_DEREF o LOAD_FAST,
# pero el code object de la comprehension accede a _sqrt via LOAD_DEREF
# (closure), que tiene un coste diferente al que anticipabas.
# ✅ Correcto: el binding local debe estar en el mismo scope que el bucle
def compute(values):
    _sqrt = math.sqrt
    result = []
    for x in values:           # aquí _sqrt es LOAD_FAST, sin closure overhead
        result.append(_sqrt(x))
    return result

El code object anidado de una comprehension captura variables del scope exterior via LOAD_DEREF (celda de clausura), no LOAD_FAST. Para el caso de un binding de función, la diferencia es pequeña pero no nula, y el punto más importante es que no es lo que creías.

Error: comparar funciones sin considerar que CPython 3.11+ cambió el encoding del bytecode (words de 2 bytes → CACHE entries, SPECIALIZATION). Los offsets y algunos opcode names difieren entre versiones.

# ❌ Script de análisis que asume Python 3.9 offsets
offset_of_call = 14   # hardcodeado, rompe en 3.11 con adaptive interpreter

# ✅ Usa dis.get_instructions() para iterar programáticamente
import dis

def find_global_loads(fn):
    return [
        instr for instr in dis.get_instructions(fn)
        if instr.opname in ("LOAD_GLOBAL", "LOAD_ATTR")
    ]

dis.get_instructions() devuelve objetos Instruction con atributos nombrados, inmune a cambios de formato en el output de texto y portable entre versiones del intérprete.

171

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio