Exprime al máximo tu código Python con la velocidad del lenguaje C

Si alguna vez te has encontrado con que tu brillante algoritmo en Python funciona muy lentamente, este artículo es para ti. Descubrirás cómo acelerar tu código Python con la ayuda de Cython, la herramienta que une la simplicidad de Python con el rendimiento de C, y cuándo tiene más sentido usarlo.

¿Qué es Cython y para qué sirve?

Imaginemos que Python es un gerente inteligente y práctico. Conoce muchos idiomas, aprende rápido, pero cuando necesita hacer algún trabajo pesado y rutinario (por ejemplo, contar millones de números), lo hace sin prisa. Esto se debe a que Python es un lenguaje interpretado y tiene ciertos gastos generales en cada paso de la ejecución del código. Constantemente verifica los tipos de variables, gestiona la memoria y todo esto ralentiza el proceso.

Ilustración 3D del logo de Python y una letra C de Cython, conectados por un puente de luz que simboliza su integración.
Cython combina la facilidad de escritura de Python con el rendimiento de C, creando un puente para optimizar tu código.

En cambio, C o C++ son los “caballos de batalla”. No son tan cómodos para escribir código, pero realizan las tareas a una velocidad increíble porque se compilan directamente en código máquina, que el procesador entiende.

Cython es una herramienta única que actúa como “traductor” y “acelerador” entre estos dos mundos. En esencia, es un superconjunto del lenguaje Python que permite escribir código muy similar a Python, pero añadiendo elementos del lenguaje C. Lo más importante es que el código en Cython se compila luego en código C puro, que a su vez se compila en código máquina.

¿Para qué se necesita Cython?

En el aprendizaje automático (Machine Learning), nos enfrentamos constantemente a tareas que requieren una enorme capacidad de cálculo: entrenar modelos complejos, preprocesar gigabytes de datos, realizar simulaciones. Python, con sus ricas bibliotecas (NumPy, Pandas, Scikit-learn), es ideal para la creación de prototipos y la lógica de alto nivel. Pero cuando se llega a las partes “calientes” del código (aquellas que se ejecutan con más frecuencia y consumen más tiempo), su velocidad se convierte en un cuello de botella.

Cython nos permite tomar esas partes lentas del código Python, “envolverlas” en una capa similar a C, compilarlas, ¡y obtener un aumento de velocidad de decenas o incluso cientos de veces!

Es como si tu gerente inteligente (Python) de repente pudiera dar instrucciones directas y muy rápidas a los trabajadores (C), saltándose toda la burocracia.

Principales ventajas de Cython

Aceleración del código Python

Esta es, quizás, la razón principal por la que la gente recurre a Cython. ¿Cómo funciona? Cuando escribes código en Python puro, cada operación, cada acceso a una variable, requiere que el intérprete realice muchas comprobaciones. Por ejemplo, cuando sumas a + b, Python primero verifica si a y b son números, de qué tipo son, y solo después realiza la suma. Esto se llama tipado dinámico: los tipos de las variables se determinan durante la ejecución del programa.

Cython permite especificar explícitamente los tipos de las variables (por ejemplo, int, float), como en C. Cuando Cython compila tu código, ya sabe de qué tipo son los datos y puede generar un código C mucho más eficiente que no necesita estas comprobaciones constantes. Menos comprobaciones = ejecución más rápida.

Además, Cython ayuda a evitar el famoso GIL (Global Interpreter Lock). Este es un mecanismo en la implementación estándar de Python que solo permite que se ejecute un hilo de código Python a la vez. En algunos casos, cuando el código de Cython trabaja con datos que no requieren acceso a objetos de Python (por ejemplo, con arreglos de NumPy), puede “liberar” temporalmente el GIL, permitiendo que otros hilos de Python se ejecuten en paralelo. Esto abre la puerta al verdadero paralelismo en tareas computacionalmente intensivas.

Posibilidad de usar bibliotecas de C/C++

El mundo de C y C++ está lleno de bibliotecas increíblemente optimizadas para todo tipo de tareas: desde trabajar con gráficos y hardware de bajo nivel hasta cálculos de alto rendimiento. Muchas de estas bibliotecas se han desarrollado durante décadas y están perfeccionadas al máximo.

Cython permite llamar directamente a funciones y usar estructuras de datos de estas bibliotecas de C/C++. Esto significa que puedes tomar una parte de tu proyecto donde necesites el máximo rendimiento, encontrar una biblioteca de C adecuada e integrarla en tu proyecto de Python a través de Cython. No necesitas reescribir todo el proyecto en C++; simplemente usas lo mejor de ambos mundos. Es como si estuvieras construyendo una casa: los trabajos principales (Python) los haces según tu plan conveniente, pero para los cimientos o los sistemas de ingeniería complejos (C/C++) que requieren una resistencia especial, invitas a expertos.

Tipado estático

Como ya hemos mencionado, el tipado estático es la declaración del tipo de una variable (por ejemplo, int, float, str) de antemano, al escribir el código. En Python, normalmente esto no se hace; él mismo “adivina” el tipo.

En Cython, se pueden usar las palabras clave cdef (para variables y funciones de C, no visibles desde Python) y cpdef (para funciones de C que también están disponibles como funciones normales de Python) para declarar los tipos.

Ventajas del tipado estático en Cython:

  • Optimización por el compilador. El compilador sabe exactamente cuánta memoria necesita asignar para una variable y qué operaciones se le pueden aplicar, lo que permite generar un código máquina más rápido.
  • Menos sobrecarga. No hay necesidad de verificar los tipos durante la ejecución, lo que acelera significativamente las operaciones.
  • Detección temprana de errores. Los errores relacionados con la falta de coincidencia de tipos pueden detectarse en la etapa de compilación, en lugar de durante la ejecución del programa.

Para un principiante, esto puede parecer un poco complicado, pero en la práctica es simple: en lugar de x = 10, escribes cdef int x = 10. ¡Y este pequeño detalle proporciona un enorme aumento de velocidad!

Instalación de Cython y las herramientas necesarias

Instalar Cython es muy sencillo, pero requiere dos componentes:

  1. Instalación de Cython. Cython es una biblioteca de Python normal, por lo que se instala a través de pip:
pip install cython
  1. Un compilador de C: Cython genera código C, por lo que necesitas una herramienta que lo compile a código máquina.
    • Linux: Instala el paquete build-essential (sudo apt-get install build-essential).
    • macOS: Instala las Xcode Command Line Tools con xcode-select --install.
    • Windows: Instala las Build Tools for Visual Studio desde la web de Microsoft, asegurándote de seleccionar la carga de trabajo “Desarrollo de escritorio con C++”.

¡Después de instalar estas herramientas, tu sistema estará listo para trabajar con Cython!

Ejemplo: Acelerando una función simple con Cython

Vamos a escribir una función que calcula la suma de los cuadrados de los números hasta N. Es lo suficientemente simple como para entender el concepto, pero lo suficientemente “pesada” para la CPU como para ver la diferencia de rendimiento.

Paso 1: La función en Python puro

Crea un archivo my_functions.py:

# my_functions.py
def calculate_sum_of_squares_python(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

Paso 2: La versión optimizada en Cython

Ahora, crea un archivo my_functions_cython.pyx. La extensión .pyx es fundamental, le dice a Cython que este es su código.

# my_functions_cython.pyx
# cpdef crea una función rápida de C accesible desde Python
cpdef long long calculate_sum_of_squares_cython(long long n):
    # cdef declara variables de tipo C
    cdef long long total = 0
    cdef long long i

    for i in range(n):
        total += i * i
    return total

Presta atención a cdef y cpdef, así como a la especificación explícita de los tipos long long. ¡Esta es la clave para la aceleración! Se usa long long para que la suma no se desborde para valores grandes de n.

Paso 3: El script de compilación setup.py

Para compilar el archivo .pyx en un módulo de Python ejecutable, necesitamos un archivo especial setup.py. Utiliza la biblioteca setuptools.

# setup.py
from setuptools import setup
from Cython.Build import cythonize

setup(
    ext_modules = cythonize("my_functions_cython.pyx")
)

Este archivo le dice a setuptools: “Encuentra todos los archivos .pyx y compílalos en módulos de extensión de Python”.

Paso 4: Compilar el código

Abre una terminal en la carpeta donde se encuentran estos tres archivos y ejecuta el comando:

python setup.py build_ext --inplace
  • build_ext es el comando para construir los módulos de extensión.
  • --inplace es una opción muy conveniente que coloca el módulo compilado (por ejemplo, my_functions_cython.cpython-3x-yoursystem.so o .pyd) directamente en el directorio actual, junto a tus archivos .py y .pyx. Esto facilita su importación.

Después de ejecutar el comando, verás un nuevo archivo con la extensión .so (Linux/macOS) o .pyd (Windows). ¡Ese es tu módulo de Cython compilado!

Paso 5: Comparar el rendimiento

Ahora puedes importar y usar la función de Cython como si fuera una función normal de Python:

# test_performance.py
import time
from my_functions import calculate_sum_of_squares_python
# Importamos el módulo compilado
from my_functions_cython import calculate_sum_of_squares_cython

N = 1_000_000

# Probando la versión de Python
start_time = time.perf_counter()
calculate_sum_of_squares_python(N)
python_time = time.perf_counter() - start_time
print(f"Tiempo en Python: {python_time:.6f} segundos")

# Probando la versión de Cython
start_time = time.perf_counter()
calculate_sum_of_squares_cython(N)
cython_time = time.perf_counter() - start_time
print(f"Tiempo en Cython: {cython_time:.6f} segundos")

print(f"\n¡Cython es {python_time / cython_time:.2f} veces más rápido!")

Ejecuta test_performance.py del paso anterior.

Resultado esperado: Verás que la versión de Cython es drásticamente más rápida, demostrando cómo el tipado estático elimina la sobrecarga del intérprete de Python en bucles intensivos.

En mi máquina (Macbook Pro con procesador Apple M1) con N = 1_000_000 obtuve aproximadamente los siguientes resultados:

Probando la suma de cuadrados hasta 1000000:
Versión Python: Resultado = 333332833333500000, Tiempo = 0.0409372090 segundos
Versión Cython: Resultado = 333332833333500000, Tiempo = 0.0000008750 segundos
Los resultados coinciden: True
¡Cython es 46785.38 veces más rápido que Python!

Como puedes ver, ¡la diferencia es colosal! Para una operación tan simple, Cython aceleró el código decenas de miles de veces. Esto sucede porque la versión de Cython, gracias al tipado estático, se compila en un bucle de C muy eficiente que el procesador ejecuta directamente, sin la sobrecarga del intérprete de Python.

Esta es la magia de Cython en acción. Imagina cómo podría afectar tu código de aprendizaje automático, donde a menudo realizas cálculos repetitivos similares sobre enormes arreglos de datos.

Uso de Cython con NumPy

NumPy es la piedra angular del aprendizaje automático y la computación científica en Python. Ya está escrito en C/Fortran y es muy rápido para operaciones sobre arreglos completos.

Pero a veces necesitas escribir tu propia operación personalizada que no se implementa de manera eficiente con las herramientas de NumPy (por ejemplo, un bucle complejo con saltos condicionales que NumPy no puede vectorizar). Es aquí donde Cython y NumPy forman un dúo poderoso.

Para que Cython pueda trabajar eficientemente con arreglos de NumPy, debe conocer su estructura y tipos de datos. Para ello, se utiliza un mecanismo especial llamado Memory Views (vistas de memoria) y cimport numpy.

  1. cimport numpy. Esto no es lo mismo que un import numpy normal. cimport se usa para importar “definiciones” (estructuras y tipos) de la parte C del módulo. Esto permite a Cython entender cómo trabajar con las estructuras internas de NumPy a nivel de C.
cython cimport numpy as np # ¡Esto es cimport, no import! 
import numpy as np # Este es el import normal para usar NumPy en la parte de Python
  1. Especificación de tipos para arreglos. Usamos la sintaxis np.ndarray[tipo_elemento, ndim=cantidad_dimensiones] para declarar los tipos de los arreglos de NumPy.

tipo_elemento: Se usan tipos especiales de NumPy como np.float64_t, np.int32_t, etc. (presta atención al _t al final, son tipos específicos de Cython).

ndim: El número de dimensiones del arreglo (por ejemplo, ndim=1 para un vector, ndim=2 para una matriz).

Ejemplo:

# Declaración de un arreglo unidimensional de números de punto flotante de doble precisión
cdef np.ndarray[np.float64_t, ndim=1] my_array

# Declaración de una matriz bidimensional de números enteros
cdef np.ndarray[np.intc_t, ndim=2] my_matrix

Cuando se usan Memory Views, Cython puede obtener acceso directo a los datos brutos del arreglo, evitando los objetos de Python. Esto proporciona un enorme aumento de velocidad. Además, al trabajar con Memory Views, Cython puede liberar el GIL, permitiendo ejecutar operaciones multiproceso si es aplicable.

Ejemplo: aceleración del trabajo con arreglos (suma, filtrado)

Realicemos una operación compleja elemento por elemento sobre un arreglo que no es tan fácil de vectorizar con NumPy puro o en la que queremos obtener la máxima velocidad.

Por ejemplo, creemos una función que filtre un arreglo, dejando solo los elementos que superan un umbral, y al mismo tiempo les aplique alguna transformación.

Paso 1: Baseline de Python/NumPy

Crea un archivo array_ops.py:

# array_ops.py
import numpy as np

def custom_filter_and_transform_python(arr, threshold):
    """
    Filtra un arreglo, dejando los elementos > threshold, y los eleva al cuadrado.
    Devuelve una nueva lista.
    """
    result = []
    for x in arr:
        if x > threshold:
            result.append(x * x)
    return np.array(result) # Devolvemos un arreglo de NumPy para una comparación justa

Esta implementación en Python puro será bastante lenta debido al bucle y la creación de una lista de Python.

Paso 2: Versión de Cython con NumPy Memory Views

Crea un archivo array_ops_cython.pyx:

# array_ops_cython.pyx
cimport numpy as np # Para cimportar los tipos de NumPy
import numpy as np   # Para el import normal de NumPy en la parte de Python

# cpdef hace que la función esté disponible desde Python
# np.float64_t - tipo C para float64
# ndim=1 - arreglo unidimensional
cpdef np.ndarray[np.float64_t, ndim=1] custom_filter_and_transform_cython(
    np.ndarray[np.float64_t, ndim=1] arr,
    double threshold): # double - tipo C para float
    """
    Filtra un arreglo, dejando los elementos > threshold, y los eleva al cuadrado,
    usando Cython y Memory Views.
    """
    cdef int i, count = 0
    cdef double val

    # Primero, determinamos el tamaño del arreglo de salida
    for i in range(arr.shape[0]):
        if arr[i] > threshold:
            count += 1

    # Creamos un nuevo arreglo de NumPy para el resultado
    cdef np.ndarray[np.float64_t, ndim=1] result = np.empty(count, dtype=np.float64)

    cdef int current_idx = 0
    for i in range(arr.shape[0]):
        val = arr[i] # Acceso directo al elemento del arreglo a través de Memory View
        if val > threshold:
            result[current_idx] = val * val
            current_idx += 1

    return result

Aquí:

  • Usamos cimport numpy as np para los tipos.
  • Especificamos explícitamente los tipos para el arreglo de entrada (np.ndarray[np.float64_t, ndim=1]) y el umbral (double).
  • Iteramos sobre el arreglo usando acceso directo arr[i], que Cython convierte en operaciones de C rápidas.
  • Creamos un nuevo arreglo de NumPy para el resultado para evitar la sobrecarga de las listas de Python.

Paso 3: Compilamos el código de Cython

Crea o actualiza el archivo setup.py (si ya lo tenías, simplemente añade array_ops_cython.pyx a la lista de cythonize):

# setup.py
from setuptools import setup
from Cython.Build import cythonize
import numpy # Es importante importar numpy aquí para que setup.py encuentre sus rutas de inclusión

setup(
    ext_modules = cythonize(
        ["my_functions_cython.pyx", "array_ops_cython.pyx"], # Ahora dos archivos
        compiler_directives={'language_level': "3"} # Nos aseguramos de que Cython use la sintaxis de Python 3
    ),
    include_dirs=[numpy.get_include()] # Añadimos las rutas a los archivos de cabecera de NumPy
)

Ejecuta en la terminal: python setup.py build_ext --inplace

Paso 4: Probamos el rendimiento

Crea un archivo test_array_performance.py:

# test_array_performance.py
import time
import numpy as np
from array_ops import custom_filter_and_transform_python
from array_ops_cython import custom_filter_and_transform_cython # Importamos el módulo compilado

N = 50_000_000 # Un arreglo grande
data = np.random.rand(N) * 100
threshold_val = 50.0

print(f"Probando filtrado y transformación de un arreglo de tamaño {N}:")

# Probando la versión de Python
start_time = time.perf_counter()
python_result = custom_filter_and_transform_python(data, threshold_val)
end_time = time.perf_counter()
python_time = end_time - start_time
print(f"Versión Python: Resultado (primeros 5) = {python_result[:5]}, Tiempo = {python_time:.10f} segundos")

# Probando la versión de Cython
start_time = time.perf_counter()
cython_result = custom_filter_and_transform_cython(data, threshold_val)
end_time = time.perf_counter()
cython_time = end_time - start_time
print(f"Versión Cython: Resultado (primeros 5) = {cython_result[:5]}, Tiempo = {cython_time:.10f} segundos")

print(f"Los tamaños de los resultados coinciden: {python_result.shape == cython_result.shape}")
# Para comparar el contenido de los arreglos, usamos np.allclose, ya que son floats
print(f"El contenido de los arreglos coincide aproximadamente: {np.allclose(python_result, cython_result)}")

if cython_time > 0:
    print(f"¡Cython es {python_time / cython_time:.2f} veces más rápido que Python!")

Resultados aproximados (pueden variar mucho dependiendo de la máquina):

Probando filtrado y transformación de un arreglo de tamaño 50000000:
Versión Python: Resultado (primeros 5) = [7245.24851271 8082.53540512 8975.35963276 8303.80282365 9729.36847544], Tiempo = 3.4725322080 segundos
Versión Cython: Resultado (primeros 5) = [7245.24851271 8082.53540512 8975.35963276 8303.80282365 9729.36847544], Tiempo = 0.2644369170 segundos
Los tamaños de los resultados coinciden: True
El contenido de los arreglos coincide aproximadamente: True
¡Cython es 13.13 veces más rápido que Python!

¡Y de nuevo un aumento impresionante! Esto demuestra lo útil que es Cython para trabajar con arreglos de NumPy cuando necesitas una lógica personalizada que es imposible o difícil de vectorizar.

Cuándo usar Cython y cuándo otras herramientas (Numba, PyPy, C++)

Cython es una herramienta poderosa, pero no siempre es la única o la mejor solución. Veamos cuándo conviene usarlo y cuándo es mejor considerar alternativas.

Cuándo usar Cython:

  • Ajuste fino del rendimiento. Cuando necesitas el máximo control sobre cómo el código Python interactúa con el nivel de C, con tipado explícito de variables.
  • Integración con bibliotecas existentes de C/C++. Si ya tienes algoritmos escritos en C/C++ o necesitas usar bibliotecas de C de terceros en un proyecto de Python.
  • Creación de módulos de extensión de Python. Si estás desarrollando una biblioteca, una parte de la cual debe ser muy rápida y compilada, para que otros usuarios puedan simplemente importarla como un módulo de Python normal.
  • Trabajo con arreglos de NumPy. Para escribir operaciones personalizadas y de alto rendimiento sobre arreglos de NumPy que no pueden ser vectorizadas eficientemente o que requieren una lógica de bucles compleja (como en el ejemplo de filtrado).

Alternativas y su aplicación

Numba

  • ¿Qué es? Un compilador JIT (Just-In-Time) que compila “al vuelo” funciones de Python en código máquina.
  • ¿Cuándo usarlo? Es tu primera opción para acelerar código numérico en Python, especialmente si usa mucho NumPy. Simplemente añade el decorador @jit o @njit a tu función, y Numba intentará optimizarla.
  • Ventajas: Increíblemente fácil de usar, a menudo proporciona un excelente aumento de rendimiento sin cambiar el código. Soporta la compilación para GPU.
  • Desventajas: Menos control que Cython. No es adecuado para la integración con bibliotecas C/C++ arbitrarias. Puede no funcionar para funciones que usan muchos objetos específicos de Python.
  • Resumen: Empieza con Numba para tareas numéricas. Si sus capacidades no son suficientes, pasa a Cython.

PyPy:

  • ¿Qué es? Un intérprete alternativo de Python que utiliza la compilación JIT para todo tu código.
  • ¿Cuándo usarlo? Si necesitas acelerar todo un proyecto de Python, y no solo funciones individuales.
  • Ventajas: A menudo proporciona una aceleración significativa para el código Python puro “de fábrica”, sin modificar el código.
  • Desventajas: Compatibilidad incompleta con todas las extensiones de C (por ejemplo, algunas versiones de NumPy o SciPy pueden funcionar más lento o no funcionar en absoluto), lo que puede ser crítico para el ML. Requiere ejecutar toda la aplicación bajo un intérprete diferente.
  • Resumen: Pruébalo si tu proyecto consiste principalmente en código Python “puro” y no depende mucho de extensiones de C específicas. Para proyectos de ML con un uso intensivo de NumPy / SciPy, generalmente no es la mejor opción.

C++ (con wrappers como pybind11 o CFFI):

  • ¿Qué es? Escribir las partes críticas del código completamente en C++ y crear “wrappers” para llamarlas desde Python.
  • ¿Cuándo usarlo? Cuando necesitas el máximo rendimiento absoluto o cuando ya tienes una gran base de código en C++ que necesitas integrar con Python.
  • Ventajas: Máxima velocidad, control total.
  • Desventajas: La mayor complejidad de desarrollo, requiere un buen conocimiento de C++. Más tiempo de desarrollo y depuración.
  • Resumen: Esta es la solución para los casos más extremos, cuando Cython o Numba ya no son suficientes y estás dispuesto a realizar un esfuerzo de ingeniería significativo.

Cómo elegir: Cython o una alternativa

  1. ¿Tareas numéricas, código pesado en NumPy? Empieza con Numba.
  2. ¿Necesitas un control fino, integración con bibliotecas de C/C++ o Numba no ayudó? Prueba Cython.
  3. ¿Quieres acelerar todo el código Python sin cambios y no hay problemas con las extensiones de C? Considera PyPy.
  4. ¿Necesitas el máximo rendimiento y estás listo para la complejidad de C++? Escribe en C++ y usa wrappers.

Conclusión: El poder de Cython

Cython no es solo un “acelerador”, sino un poderoso puente entre el Python de alto nivel y conveniente y el C de bajo nivel y rápido.

Aquí están las ideas clave que vale la pena llevarse:

  • Cython compila código similar a Python en C, y luego en código máquina, lo que proporciona un enorme aumento de rendimiento.
  • Sus principales ventajas son la aceleración del código mediante el tipado estático, la capacidad de integrarse sin problemas con bibliotecas de C/C++ y el trabajo eficiente con arreglos de NumPy.
  • Usarlo no es tan difícil: basta con escribir el código en un archivo .pyx, crear un setup.py simple y compilar.
  • Para los especialistas en aprendizaje automático, Cython es especialmente valioso para optimizar los “cuellos de botella” en el código, como preprocesamientos de datos complejos, funciones de activación personalizadas, funciones de pérdida no triviales o simulaciones.

No es necesario reescribir todo tu proyecto en Cython. Su fuerza radica en aplicarlo de manera selectiva a aquellas partes del código que realmente son un “cuello de botella”. Como un buen ingeniero, primero perfilas tu código, encuentras las funciones más lentas y luego les aplicas Cython (o Numba, si es adecuado).

Categorizado en:

Python,