Cython, Numba y mypyc: compilar Python para casos extremos

Hay un momento en todo proyecto de cómputo intensivo donde el profiler te mira fijamente y los números no mienten: el intérprete de CPython tiene un coste fijo por operación que ningún refactor va a eliminar. El bytecode dispatch loop, el reference counting en cada asignación, el boxing de cada entero en un objeto heap: todo eso suma. Cuando ese coste domina tu perfil, compilar Python a código nativo deja de ser optimización prematura y se convierte en ingeniería necesaria.

Tienes tres herramientas principales para ese momento, y cada una ataca el problema desde un ángulo distinto.

Cython es un superconjunto de Python que añade anotaciones de tipo propias (cdef, cpdef) y se compila a C. Lo que escribes sigue pareciendo Python, pero con tipos estáticos opcionales; el compilador genera código C que el compilador del sistema convierte a una extensión .so. Las operaciones sobre variables tipadas evitan completamente el intérprete: un int de Cython es un int de C, no un PyObject * en el heap.

Numba toma el camino opuesto: no cambias el código, lo anotas con @numba.jit y el compilador JIT basado en LLVM compila la función en la primera llamada. El coste es ese primer call (o el tiempo de nopython=True en warmup), la ganancia es velocidad nativa en loops sobre arrays NumPy sin escribir una línea de C.

mypyc es el más conservador: usa las anotaciones de tipo de mypy que probablemente ya tienes, y las convierte en operaciones C. Sin cambiar el código. El resultado no es tan agresivo como Cython con tipos explícitos, pero si tu codebase ya está anotada, el ROI es notable con coste de adopción mínimo.

El precio de los tres es el mismo: pierdes la dinamicidad de Python puro, el ciclo de desarrollo se complica con pasos de compilación, el debugging se vuelve más opaco, y algunos patrones que CPython maneja trivialmente (monkey-patching, introspección profunda en runtime) pueden romperse o desaparecer.

Úsalos cuando el profiler te lo ordene, no antes.

# benchmark_suite.py
"""
Ejemplo ejecutable que compara las tres estrategias contra Python puro.
Requiere: pip install cython numba mypyc numpy
El módulo Cython se asume compilado previamente (ver setup.py al final).
"""

import time
import numpy as np
import numba

# ── 1. Python puro ────────────────────────────────────────────────────────────

def sum_of_squares_pure(n: int) -> float:
    total = 0.0
    for i in range(n):
        total += i * i
    return total


# ── 2. Numba JIT ──────────────────────────────────────────────────────────────

@numba.jit(nopython=True, cache=True)
def sum_of_squares_numba(n: int) -> float:
    # nopython=True garantiza que nunca caiga al intérprete;
    # si Numba no puede compilar algo, falla ruidosamente en vez de
    # silenciosamente degradar a modo "object mode".
    total = 0.0
    for i in range(n):
        total += i * i
    return total


# ── 3. NumPy vectorizado (baseline realista para comparar con Numba) ──────────

def sum_of_squares_numpy(n: int) -> float:
    # Crea el array en C y reduce en un solo call; no hay loop en Python.
    arr = np.arange(n, dtype=np.float64)
    return float(np.dot(arr, arr))


# ── 4. mypyc — el módulo se compila con: mypyc mypyc_module.py ───────────────
# El archivo mypyc_module.py tiene el contenido de abajo;
# después de compilar se importa como extensión C normal.
#
#   mypyc_module.py:
#   ----------------
#   def sum_of_squares_mypyc(n: int) -> float:
#       total: float = 0.0
#       for i in range(n):
#           total += i * i
#       return total
#
# mypyc convierte el loop en aritmética C directamente porque
# 'n' e 'i' están anotados como int y 'total' como float.
try:
    from mypyc_module import sum_of_squares_mypyc  # type: ignore[import]
    HAS_MYPYC = True
except ImportError:
    HAS_MYPYC = False


# ── 5. Cython — el .pyx se compila con setup.py (ver abajo) ──────────────────
# cython_module.pyx:
# ------------------
# # cython: language_level=3
# cpdef double sum_of_squares_cython(int n):
#     cdef int i
#     cdef double total = 0.0
#     for i in range(n):
#         total += i * i
#     return total
#
# 'cpdef' genera tanto una versión C (callable desde C) como
# una Python wrapper; 'cdef int i' elimina el boxing del loop variable.
try:
    from cython_module import sum_of_squares_cython  # type: ignore[import]
    HAS_CYTHON = True
except ImportError:
    HAS_CYTHON = False


# ── Benchmarking ──────────────────────────────────────────────────────────────

def bench(label: str, fn, n: int, warmup: bool = False) -> None:
    if warmup:
        fn(n)  # primer call para JIT o cualquier inicialización

    iterations = 200
    start = time.perf_counter()
    for _ in range(iterations):
        fn(n)
    elapsed = (time.perf_counter() - start) / iterations * 1_000  # ms por call

    print(f"  {label:<30} {elapsed:.4f} ms/call")


def main() -> None:
    N = 10_000_000
    print(f"\nBenchmark: sum_of_squares(n={N:,}), 200 iteraciones\n")

    bench("Python puro",           sum_of_squares_pure,  N)
    bench("NumPy vectorizado",     sum_of_squares_numpy, N)

    # Numba necesita un warmup call para compilar en la primera ejecución.
    bench("Numba JIT (con warmup)", sum_of_squares_numba, N, warmup=True)

    if HAS_CYTHON:
        bench("Cython (cpdef + cdef)", sum_of_squares_cython, N)
    else:
        print("  Cython no compilado — ejecuta: python setup.py build_ext --inplace")

    if HAS_MYPYC:
        bench("mypyc",               sum_of_squares_mypyc, N)
    else:
        print("  mypyc no compilado  — ejecuta: mypyc mypyc_module.py")


if __name__ == "__main__":
    main()


# ── setup.py para el módulo Cython ────────────────────────────────────────────
# Guarda como setup.py en el mismo directorio y ejecuta:
#   python setup.py build_ext --inplace
#
# from setuptools import setup
# from Cython.Build import cythonize
# import numpy as np
#
# setup(
#     ext_modules=cythonize(
#         "cython_module.pyx",
#         compiler_directives={
#             "boundscheck": False,   # elimina comprobación de índices
#             "wraparound": False,    # elimina índices negativos
#             "cdivision": True,      # división C nativa (sin check de cero)
#         },
#     ),
#     include_dirs=[np.get_include()],
# )

Qué está pasando en cada capa

Python puro ejecuta el loop enteramente en el dispatch loop de CPython. Cada i * i crea un objeto int, lo suma a total (otro PyObject *), y el resultado vive en el heap hasta que el GC lo recoge. Para n = 10_000_000, eso son decenas de millones de allocations.

Numba con nopython=True es la directiva más importante que puedes darle al decorador. Sin ella, si Numba encuentra algo que no sabe compilar cae silenciosamente a “object mode”, que es básicamente CPython con overhead extra. Con nopython=True, si falla, explota limpiamente y te dice exactamente qué no pudo compilar. El flag cache=True persiste el resultado de compilación en disco para que la segunda ejecución del proceso no pague el coste de LLVM.

Cython con cpdef y cdef es donde la diferencia entre tipos es más visible. Una función def normal en Cython sigue siendo callable desde Python con la interfaz habitual, pero los argumentos se convierten desde PyObject * al entrar. Una función cdef solo es callable desde C: cero overhead de conversión, pero invisible desde Python. cpdef genera ambas versiones automáticamente: la versión C para llamadas internas, la Python wrapper para el exterior. La directiva boundscheck=False es arriesgada —elimina la validación de índices en arrays— úsala solo cuando hayas verificado que el código es correcto.

mypyc funciona de forma distinta a los otros dos: no optimiza loops matemáticos agresivamente, sino que elimina el overhead del intérprete en despacho de métodos, atributos tipados de clases, y llamadas a funciones anotadas. Su punto fuerte no es el cómputo numérico (ahí Numba y Cython ganan claramente) sino acelerar código de lógica de negocio tipado que hace muchas llamadas entre funciones propias. El proyecto mypy compila su propio typechecker con mypyc y reporta aceleraciones de 3x–4x en código real.

Errores que debes conocer

Error: usar Numba sin nopython=True y asumir que estás ejecutando código nativo cuando en realidad el JIT está fallando silenciosamente y corriendo en object mode.

# ❌ Wrong
@numba.jit
def compute(arr):
    return [x ** 2 for x in arr]  # list comprehension → object mode sin avisar

# ✅ Right
@numba.jit(nopython=True)
def compute(arr: np.ndarray) -> np.ndarray:
    # fuerza error explícito si no puede compilar
    result = np.empty_like(arr)
    for i in range(len(arr)):
        result[i] = arr[i] ** 2
    return result

El segundo falla ruidosamente si encuentras construcciones no soportadas, lo que es exactamente lo que quieres: saber cuándo no estás obteniendo lo que crees.


Error: compilar con Cython añadiendo tipos pero olvidar las directivas del compilador, y quedarse con un 30% de la ganancia posible.

# ❌ Wrong — cython_module.pyx sin directivas
# cpdef double sum_array(double[:] arr):
#     cdef int i
#     cdef double total = 0.0
#     for i in range(len(arr)):      # boundscheck activo: valida cada acceso
#         total += arr[i]
#     return total

# ✅ Right — directivas al inicio del archivo .pyx
# # cython: boundscheck=False, wraparound=False, cdivision=True
# cpdef double sum_array(double[:] arr):
#     cdef Py_ssize_t i              # tipo correcto para índices de arrays
#     cdef double total = 0.0
#     for i in range(arr.shape[0]):  # shape[0] es más idiomático que len()
#         total += arr[i]
#     return total

Py_ssize_t es el tipo correcto para índices en lugar de int porque en sistemas de 64 bits puede indexar arrays que superan INT_MAX.


Error: usar mypyc en módulos con patrones dinámicos (monkey-patching, __getattr__ custom, atributos añadidos en runtime) esperando que el comportamiento se preserve.

# ❌ Wrong — mypyc compila la clase y "congela" el layout de atributos
class Config:
    def __init__(self) -> None:
        self.debug: bool = False
    # mypyc genera un struct C; añadir atributos fuera de __init__ falla

cfg = Config()
cfg.extra_field = "surprise"  # AttributeError en el módulo compilado

# ✅ Right — declara todos los atributos en __init__ con anotaciones de tipo
class Config:
    debug: bool
    extra_field: str

    def __init__(self) -> None:
        self.debug = False
        self.extra_field = ""

mypyc convierte instancias de clases en structs C con layout fijo; todos los atributos deben ser conocidos en tiempo de compilación.


La decisión entre los tres no es técnica sino de contexto: si ya tienes anotaciones de tipo y el bottleneck está en lógica general, mypyc con cero refactor; si el cuello de botella es un loop numérico sobre arrays, Numba en diez minutos; si necesitas control total o integración con librerías C existentes, Cython vale la inversión.

169

Dejar un comentario

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

Scroll al inicio