Del `.py` al bytecode: el pipeline de compilación de CPython

Cuando Python “ejecuta” tu script, no está interpretando el texto fuente línea a línea. Lo que realmente ocurre es un pipeline de cuatro etapas que transforma caracteres Unicode en instrucciones para una máquina virtual. Entender cada etapa te permite razonar sobre el rendimiento, depurar comportamientos inesperados y usar herramientas como dis con criterio quirúrgico.

El pipeline completo

Tokenización es el primer paso: el módulo tokenize convierte el texto plano en una secuencia de tokens tipados (NAME, OP, NUMBER, STRING, NEWLINE…). En esta fase se detectan errores de indentación y caracteres ilegales. El resultado no tiene estructura jerárquica todavía —es solo una lista plana.

El AST (Abstract Syntax Tree) llega después: el parser toma esa lista de tokens y construye un árbol que captura la estructura semántica del programa. ast.parse() te expone este árbol directamente. Cada nodo es un objeto Python con atributos como lineno y col_offset, lo que explica por qué los mensajes de error de Python incluyen número de línea con tanta precisión.

La compilación transforma el AST en un objeto code. Este objeto contiene el bytecode —una secuencia de instrucciones compactas para la CPython VM— más metadatos: nombres de variables locales, constantes literales, número de argumentos. En CPython ≥ 3.6, cada instrucción ocupa exactamente dos bytes: un opcode (el identificador de operación) y su argumento. El opcode es lo que determina qué hace la instrucción; el argumento indexa en tablas como co_consts o co_varnames.

Ese objeto code se serializa en un archivo .pyc dentro de __pycache__/. El nombre incluye la versión del intérprete y un tag de plataforma: modulo.cpython-312.pyc. En la cabecera del .pyc hay un número mágico (específico a cada versión de CPython), y según el modo de invalidación, o bien un timestamp + tamaño del .py original, o un hash del contenido. Si al importar el módulo el .py ha cambiado, CPython descarta el .pyc y recompila. Por eso nunca deberías editar .pyc a mano: la próxima importación lo sobreescribirá sin avisar.

La ejecución la realiza el eval loop en ceval.c: un switch gigante sobre opcodes que consume la secuencia de instrucciones del objeto code, manteniendo una pila de valores (value stack) por frame.

Inspección con dis

import dis
import ast
import sys

# ------------------------------------------------------------------
# Función deliberadamente variada para generar opcodes interesantes
# ------------------------------------------------------------------
def process(items: list[int], threshold: int = 10) -> list[int]:
    result = []
    for x in items:
        if x > threshold:
            result.append(x * 2)
    return result


def main() -> None:
    # Muestra la versión del intérprete y el pipeline completo

    print(f"CPython {sys.version}\n")

    # --- Etapa 1: tokens -------------------------------------------------
    import tokenize, io
    src = "x = a + b * 2\n"
    tokens = list(tokenize.generate_tokens(io.StringIO(src).readline))
    print("=== TOKENS ===")
    for tok in tokens:
        print(f"  {tokenize.tok_name[tok.type]:10s} {tok.string!r}")

    # --- Etapa 2: AST ----------------------------------------------------
    tree = ast.parse(src)
    print("\n=== AST (dump compacto) ===")
    print(ast.dump(tree, indent=2)[:400], "...")   # truncado para legibilidad

    # --- Etapa 3: bytecode del objeto code -------------------------------
    code_obj = process.__code__
    print(f"\n=== CODE OBJECT de 'process' ===")
    print(f"  co_varnames : {code_obj.co_varnames}")
    print(f"  co_consts   : {code_obj.co_consts}")
    print(f"  co_stacksize: {code_obj.co_stacksize}")

    # --- Etapa 4: listado de instrucciones --------------------------------
    print("\n=== BYTECODE (dis.dis) ===")
    dis.dis(process)

    # dis.Bytecode te da acceso programático a cada instrucción
    print("\n=== INSTRUCCIONES INDIVIDUALES ===")
    for instr in dis.Bytecode(process):
        # instr.offset es la posición en bytes dentro del bytecode
        # instr.argval es el valor ya resuelto (e.g., el nombre real, no el índice)
        print(
            f"  offset={instr.offset:3d}  "
            f"opname={instr.opname:<20s} "
            f"arg={str(instr.arg):<6} "
            f"argval={instr.argval!r}"
        )


if __name__ == "__main__":
    main()

Lo que está pasando y por qué importa

process.__code__ es el objeto code que CPython produjo al compilar la función. co_varnames lista las variables locales en el orden en que el compilador las numeró —ese orden es el argumento que usarán opcodes como LOAD_FAST y STORE_FAST. LOAD_FAST es más rápido que LOAD_NAME precisamente porque accede a una ranura de array por índice en lugar de hacer una búsqueda en diccionario. Eso explica por qué las funciones son más rápidas que el scope global en CPython.

co_consts agrupa todos los literales. Fíjate que threshold=10 aparece ahí: los valores por defecto de argumentos se almacenan en el objeto función (en __defaults__), pero el literal 10 en sí vive en co_consts. El compilador de CPython hace constant folding básico —2 * 3 en código fuente se colapsa a 6 en co_consts— pero no nada más sofisticado; eso lo dejarían para PyPy o para optimizadores externos como Numba.

dis.dis() imprime cuatro columnas: número de línea fuente, offset en bytes, nombre del opcode, argumento numérico y su valor resuelto. Cuando veas FOR_ITER y GET_ITER juntos, es el par que implementa todo for loop en Python —sin importar si iteras una lista, un generador o un archivo. Esto también te explica por qué añadir __iter__ a una clase es suficiente para que funcione con for: el opcode no sabe ni le importa el tipo concreto.

La entrada dis.Bytecode te da acceso programático a cada Instruction. Esto es útil cuando estás escribiendo herramientas de análisis estático, profilers propios, o simplemente intentas entender por qué una función tarda más de lo esperado. Ver diez LOAD_GLOBAL donde esperabas LOAD_FAST es una señal de que tu variable “local” en realidad no lo es.

Errores que debes conocer

Error: Creer que modificar el .pyc manualmente o borrar __pycache__ afecta el comportamiento del módulo ya importado en la sesión actual. Una vez que un módulo está en sys.modules, CPython no vuelve a leer el disco hasta que lo elimines explícitamente de sys.modules o reinicias el proceso.

# ❌ Wrong: esto no recarga el módulo aunque borres el .pyc
import importlib
import os
import shutil

import mymodule
shutil.rmtree("__pycache__")
import mymodule          # sigue usando la versión en sys.modules

# ✅ Right
import importlib
import mymodule
importlib.reload(mymodule)   # fuerza re-compilación y re-ejecución del módulo

importlib.reload() vuelve a compilar el fuente (regenerando el .pyc) y re-ejecuta el código del módulo en el namespace existente del objeto módulo.


Error: Usar dis.dis() sobre un string de código fuente esperando ver el bytecode de una función anidada completa. dis.dis("def f(): return 1+1") solo desensambla el nivel superior —el MAKE_FUNCTION— no el cuerpo de f.

# ❌ Wrong: el cuerpo de f queda oculto
dis.dis("def f(): return 1 + 1")

# ✅ Right: compila primero y desensambla el objeto code de la función
import dis, types
code = compile("def f(): return 1 + 1", "<string>", "exec")
# El objeto code de f está en co_consts del módulo
for const in code.co_consts:
    if isinstance(const, types.CodeType):
        dis.dis(const)

El objeto code de cada función anidada vive en co_consts del objeto code padre, no en el nivel principal —la recursión es exactamente como CPython navega el árbol de objetos code en tiempo de ejecución.

170

Dejar un comentario

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

Scroll al inicio