Ejecute su código Python hasta 80x más rápido usando la biblioteca Cython

Excelente lenguaje para la prototipos rápidos y el desarrollo del código, pero una cosa que a menudo escucho a la gente decir sobre usarlo es que es lento ejecutar. Este es un punto de dolor particular para los científicos de datos e ingenieros de ML, ya que a menudo realizan operaciones computacionalmente intensivas, como la multiplicación matricial, los cálculos de descenso de gradiente o el procesamiento de imágenes.

Con el tiempo, Python ha evolucionado internamente para abordar algunos de estos problemas mediante la introducción de nuevas características al lenguaje, como el subproceso múltiple o la reescritura de la funcionalidad existente para mejorar el rendimiento. Sin embargo, el uso de Python del intérprete global (GIL) a menudo a los esfuerzos pescados como este.

También se han escrito muchas bibliotecas externas para cerrar esta brecha de rendimiento percibida entre Python y los idiomas compilados como Java. Quizás el más utilizado y conocido de estos es el Numpy biblioteca. Implementado en el lenguaje C, Numpy fue diseñado desde cero para admitir múltiples núcleos de CPU y procesamiento numérico y de matriz súper rápido.

Hay alternativas a Numpy, y en un artículo reciente de TDS, presenté el numexpr La biblioteca, que, en muchos casos de uso, puede incluso superar a Numpy. Si está interesado en aprender más, incluiré un enlace a esa historia al final de este artículo.

Otra biblioteca externa que es muy efectiva es Numba. Numba utiliza un compilador justo en el tiempo (JIT) para Python, que traduce un subconjunto de código Python y Numpy en código de máquina rápida en tiempo de ejecución. Está diseñado para acelerar las tareas de computación numérica y científica aprovechando la infraestructura del compilador LLVM (máquina virtual de bajo nivel).

En este artículo, me gustaría discutir otra biblioteca externa que mejora el tiempo de ejecución, Cython. Es una de las bibliotecas de Python más actuantes, pero también una de las menos entendidas y utilizadas. Creo que esto es al menos parcialmente porque tienes que ensuciarte un poco las manos y hacer algunos cambios en tu código original. Pero si sigue el simple plan de cuatro pasos que describiré a continuación, los beneficios de rendimiento que puede lograr lo harán más que valioso.

¿Qué es Cython?

Si no ha oído hablar de Cython, es un superconjunto de Python diseñado para proporcionar un rendimiento similar a C con código escrito principalmente en Python. Permite convertir el código de Python en el código C, que luego se puede compilar en bibliotecas compartidas que se pueden importar a Python al igual que los módulos Python regulares. Este proceso da como resultado los beneficios de rendimiento de C al tiempo que mantiene la legibilidad de Python.

Mostraré los beneficios exactos que puede lograr convirtiendo su código para usar Cython, examinando tres casos de uso y proporcionando los cuatro pasos necesarios para convertir su código Python existente, junto con tiempos comparativos para cada ejecución.

Configurar un entorno de desarrollo

Antes de continuar, debemos establecer un entorno de desarrollo separado para la codificación para mantener las dependencias de nuestros proyectos separadas. Usaré WSL2 Ubuntu para Windows y un cuaderno Jupyter para el desarrollo de código. Utilizo el Administrador de paquetes UV para configurar mi entorno de desarrollo, pero no dude en usar las herramientas y métodos que se adapten a usted.

$ uv init cython-test
$ cd cython-test
$ uv venv
$ source .venv/bin/activate
(cython-test) $ uv pip install cython jupyter numpy pillow matplotlib

Ahoratipo ‘Notebook Jupyter’ en su símbolo del sistema. Debería ver un cuaderno abierto en su navegador. Si eso no sucede automáticamente, lo que probablemente verá es una pantalla de información después de ejecutar el comando Jupyter Notebook. Cerca de la parte inferior de eso, habrá una URL que debe copiar y pegar en su navegador para iniciar el cuaderno Jupyter.
Su URL será diferente a la mía, pero debería verse algo así:-

http://127.0.0.1:8888/tree?token=3b9f7bd07b6966b41b68e2350721b2d0b6f388d248cc69d

Ejemplo 1 – acelerando para bucles

Antes de comenzar a usar Cython, comencemos con una función de Python regular y tiempo cuánto tiempo lleva funcionar. Este será nuestro punto de referencia base.

Codificaremos una función simple de doble bucle que tarda unos segundos en ejecutarse, luego usaremos Cython para acelerarlo y medir las diferencias en el tiempo de ejecución entre los dos métodos.

Aquí está nuestro código Python estándar de línea de base.

# sum_of_squares.py
import timeit

# Define the standard Python function
def slow_sum_of_squares(n):
    total = 0
    for i in range(n):
        for j in range(n):
            total += i * i + j * j
    return total

# Benchmark the Python function
print("Python function execution time:")
print("timeit:", timeit.timeit(
        lambda: slow_sum_of_squares(20000),
        number=1))

En mi sistema, el código anterior produce la siguiente salida.

Python function execution time:
13.135973724005453

Veamos cuánto de mejora hace Cython.

El plan de cuatro pasos para el uso efectivo de Cython.

El uso de Cython para aumentar su código de ejecución en un cuaderno Jupyter es un proceso simple de 4 pasos.

No se preocupe si no es un usuario de cuaderno, ya que mostraré cómo convertir archivos regulares de Python .py para usar Cython más adelante.

1/ En la primera celda de su cuaderno, cargue la extensión de Cython escribiendo este comando.

%load_ext Cython

2/ Para cualquier celda posterior que contenga el código de Python que desea ejecutar usando Cython, agregar el %% Cython Comando mágico antes del código. Por ejemplo,

%%cython
def myfunction():
    etc ...
        ...

3/ Las definiciones de funciones que contienen parámetros deben escribirse correctamente.

4/ Por último, todas las variables deben escribirse adecuadamente utilizando el CDEF directiva. Además, donde tiene sentido, use funciones de la biblioteca C Standard C (disponible en Cython utilizando el de libc.stdlib directiva).

Tomando nuestro código Python original como ejemplo, así es como debe parecer estar listo para ejecutarse en un cuaderno usando Cython después de aplicar los cuatro pasos anteriores.

%%cython
def fast_sum_of_squares(int n):
    cdef int total = 0
    cdef int i, j
    for i in range(n):
        for j in range(n):
            total += i * i + j * j
    return total

import timeit
print("Cython function execution time:")
print("timeit:", timeit.timeit(
        lambda: fast_sum_of_squares(20000),
        number=1))

Como espero que pueda ver, la realidad de convertir su código es mucho más fácil de lo que los cuatro pasos de procedimiento requeridos pueden sugerir.

El tiempo de ejecución del código anterior fue impresionante. En mi sistema, este nuevo código Cython produce la siguiente salida.

Cython function execution time:
0.15829777799808653

Esa es una aceleración de más de 80x.

Ejemplo 2 – Calcule Pi usando Monte Carlo

Para nuestro segundo ejemplo, examinaremos un caso de uso más complejo, cuya base tiene numerosas aplicaciones del mundo real.

Un área donde Cython puede mostrar una mejora significativa del rendimiento está en simulaciones numéricas, particularmente aquellas que involucran un cálculo pesado, como las simulaciones de Monte Carlo (MC). Las simulaciones de Monte Carlo implican ejecutar muchas iteraciones de un proceso aleatorio para estimar las propiedades de un sistema. MC se aplica a una amplia variedad de campos de estudio, que incluyen ciencias climáticas y atmosféricas, gráficos por computadora, búsqueda de IA y finanzas cuantitativas. Casi siempre es un proceso muy intensivo computacionalmente.

Para ilustrar, usaremos Monte Carlo de manera simplificada para calcular el valor de PI. Este es un ejemplo bien conocido en el que tomamos un cuadrado con una longitud lateral de una unidad e inscribimos un cuarto de círculo dentro de él con un radio de una unidad, como se muestra aquí.

Imagen de AI (GPT-4O)

La relación del área del cuarto de círculo al área de la plaza es, obviamente, (PI/4).

Entonces, si consideramos muchos puntos aleatorios (x, y) que todos se encuentran dentro o en los límites del cuadrado, ya que el número total de estos puntos tiende al infinito, la relación de puntos que se encuentran en o dentro del cuarto de círculo al número total de puntos tienden hacia Pi /4. Luego multiplicamos este valor por 4 para obtener el valor de PI en sí.

Aquí hay un código típico de Python que podría usar para modelar esto.

import random
import time

def monte_carlo_pi(num_samples):
    inside_circle = 0
    for _ in range(num_samples):
        x = random.uniform(0, 1)
        y = random.uniform(0, 1)
        if (x**2) + (y**2) <= 1:  
            inside_circle += 1
    return (inside_circle / num_samples) * 4

# Benchmark the standard Python function
num_samples = 100000000

start_time = time.time()
pi_estimate = monte_carlo_pi(num_samples)
end_time = time.time()

print(f"Estimated Pi (Python): {pi_estimate}")
print(f"Execution Time (Python): {end_time - start_time} seconds")

Ejecutar esto produjo el siguiente resultado de tiempo.

Estimated Pi (Python): 3.14197216
Execution Time (Python): 20.67279839515686 seconds

Ahora, aquí está la implementación de Cython que obtenemos siguiendo nuestro proceso de cuatro pasos.

%%cython
import cython
import random
from libc.stdlib cimport rand, RAND_MAX

@cython.boundscheck(False)
@cython.wraparound(False)
def monte_carlo_pi(int num_samples):
    cdef int inside_circle = 0
    cdef int i
    cdef double x, y
    
    for i in range(num_samples):
        x = rand() / <double>RAND_MAX
        y = rand() / <double>RAND_MAX
        if (x**2) + (y**2) <= 1:
            inside_circle += 1
            
    return (inside_circle / num_samples) * 4

import time

num_samples = 100000000

# Benchmark the Cython function
start_time = time.time()
pi_estimate = monte_carlo_pi(num_samples)
end_time = time.time()

print(f"Estimated Pi (Cython): {pi_estimate}")
print(f"Execution Time (Cython): {end_time - start_time} seconds")

Y aquí está el nuevo resultado.

Estimated Pi (Cython): 3.1415012
Execution Time (Cython): 1.9987852573394775 seconds

Una vez más, esa es una aceleración 10x bastante impresionante para la versión de Cython.

Una cosa que hicimos en este ejemplo de código que no tuvimos en el otro es importar algunas bibliotecas externas de la biblioteca estándar C. Esa era la línea

from libc.stdlib cimport rand, RAND_MAX

El CIMPORT El comando es una palabra clave Cython utilizada para importar funciones, variables, constantes y tipos. Lo usamos para importar versiones de lenguaje C optimizadas de las funciones equivalentes de pitón roci.uniform ().

Ejemplo 3— Manipulación de imágenes

Para nuestro último ejemplo, haremos alguna manipulación de imágenes. Específicamente, alguna convolución de imagen, que es una operación común en el procesamiento de imágenes. Hay muchos casos de uso para la convolución de la imagen. Lo usaremos para tratar de afilar la imagen ligeramente borrosa que se muestra a continuación.

Imagen original de Yury Taranik (con licencia de Shutterstock)

Primero, aquí está el código Python regular.

from PIL import Image
import numpy as np
from scipy.signal import convolve2d
import time
import os
import matplotlib.pyplot as plt

def sharpen_image_color(image):

    # Start timing
    start_time = time.time()
    
    # Convert image to RGB in case it's not already
    image = image.convert('RGB')
    
    # Define a sharpening kernel
    kernel = np.array([[0, -1, 0],
                       [-1, 5, -1],
                       [0, -1, 0]])
    
    # Convert image to numpy array
    image_array = np.array(image)
    
    # Debugging: Check input values
    print("Input array values: Min =", image_array.min(), "Max =", image_array.max())
    
    # Prepare an empty array for the sharpened image
    sharpened_array = np.zeros_like(image_array)
    
    # Apply the convolution kernel to each channel (assuming RGB image)
    for i in range(3):
        channel = image_array[:, :, i]
        # Perform convolution
        convolved_channel = convolve2d(channel, kernel, mode='same', boundary='wrap')
        
        # Clip values to be in the range [0, 255]
        convolved_channel = np.clip(convolved_channel, 0, 255)
        
        # Store back in the sharpened array
        sharpened_array[:, :, i] = convolved_channel.astype(np.uint8)
    
    # Debugging: Check output values
    print("Sharpened array values: Min =", sharpened_array.min(), "Max =", sharpened_array.max())
    
    # Convert array back to image
    sharpened_image = Image.fromarray(sharpened_array)
    
    # End timing
    duration = time.time() - start_time
    print(f"Processing time: {duration:.4f} seconds")
    
    return sharpened_image

# Correct path for WSL2 accessing Windows filesystem
image_path = '/mnt/d/images/taj_mahal.png'

image = Image.open(image_path)

# Sharpen the image
sharpened_image = sharpen_image_color(image)

if sharpened_image:
    # Show using PIL's built-in show method (for debugging)
    #sharpened_image.show(title="Sharpened Image (PIL Show)")

    # Display the original and sharpened images using Matplotlib
    fig, axs = plt.subplots(1, 2, figsize=(15, 7))

    # Original image
    axs[0].imshow(image)
    axs[0].set_title("Original Image")
    axs[0].axis('off')

    # Sharpened image
    axs[1].imshow(sharpened_image)
    axs[1].set_title("Sharpened Image")
    axs[1].axis('off')

    # Show both images side by side
    plt.show()
else:
    print("Failed to generate sharpened image.")

La salida es esta.

Input array values: Min = 0 Max = 255
Sharpened array values: Min = 0 Max = 255
Processing time: 0.1034 seconds
Imagen del autor

Veamos si Cython puede superar ese tiempo de ejecución de 0.1034 segundos.

%%cython
# cython: language_level=3
# distutils: define_macros=NPY_NO_DEPRECATED_API=NPY_1_7_API_VERSION

import numpy as np
cimport numpy as np
import cython

@cython.boundscheck(False)
@cython.wraparound(False)
def sharpen_image_cython(np.ndarray[np.uint8_t, ndim=3] image_array):
    # Define sharpening kernel
    cdef int kernel[3][3]
    kernel[0][0] = 0
    kernel[0][1] = -1
    kernel[0][2] = 0
    kernel[1][0] = -1
    kernel[1][1] = 5
    kernel[1][2] = -1
    kernel[2][0] = 0
    kernel[2][1] = -1
    kernel[2][2] = 0
    
    # Declare variables outside of loops
    cdef int height = image_array.shape[0]
    cdef int width = image_array.shape[1]
    cdef int channel, i, j, ki, kj
    cdef int value
    
    # Prepare an empty array for the sharpened image
    cdef np.ndarray[np.uint8_t, ndim=3] sharpened_array = np.zeros_like(image_array)

    # Convolve each channel separately
    for channel in range(3):  # Iterate over RGB channels
        for i in range(1, height - 1):
            for j in range(1, width - 1):
                value = 0  # Reset value at each pixel
                # Apply the kernel
                for ki in range(-1, 2):
                    for kj in range(-1, 2):
                        value += kernel[ki + 1][kj + 1] * image_array[i + ki, j + kj, channel]
                # Clip values to be between 0 and 255
                sharpened_array[i, j, channel] = min(max(value, 0), 255)

    return sharpened_array

# Python part of the code
from PIL import Image
import numpy as np
import time as py_time  # Renaming the Python time module to avoid conflict
import matplotlib.pyplot as plt

# Load the input image
image_path = '/mnt/d/images/taj_mahal.png'
image = Image.open(image_path).convert('RGB')

# Convert the image to a NumPy array
image_array = np.array(image)

# Time the sharpening with Cython
start_time = py_time.time()
sharpened_array = sharpen_image_cython(image_array)
cython_time = py_time.time() - start_time

# Convert back to an image for displaying
sharpened_image = Image.fromarray(sharpened_array)

# Display the original and sharpened image
plt.figure(figsize=(12, 6))
plt.subplot(1, 2, 1)
plt.imshow(image)
plt.title("Original Image")

plt.subplot(1, 2, 2)
plt.imshow(sharpened_image)
plt.title("Sharpened Image")

plt.show()

# Print the time taken for Cython processing
print(f"Processing time with Cython: {cython_time:.4f} seconds")

La salida es,

Imagen del autor

Ambos programas se desempeñaron bien, pero Cython fue casi 25 veces más rápido.

¿Qué hay de ejecutar Cython fuera de un entorno de cuaderno?

Hasta ahora, todo lo que te he mostrado asume que estás ejecutando tu código dentro de un cuaderno Jupyter. La razón por la que hice esto es que es la forma más fácil de presentar a Cython y hacer que se ejecute algo de código rápidamente. Si bien el entorno del cuaderno es extremadamente popular entre los desarrolladores de Python, todavía se contiene una gran cantidad de código de Python en archivos .py regulares y se ejecuta desde una terminal utilizando el comando Python.

Si ese es su modo principal de codificación y ejecución de scripts de Python, el %Load_ext y %% Cython Los comandos de Magic Ipython no funcionarán ya que Jupyter/Ipython entienden esos solo.

Entonces, aquí le mostramos cómo adaptar mi proceso de conversión de Cython de cuatro pasos si está ejecutando su código como un script de Python regular.

Tomemos mi primera sum_of_squares Ejemplo para mostrar esto.

1/ Crear un archivo .pyx en lugar de usar %% Cython

Mueva su código mejorado por Cython a un archivo llamado, por ejemplo:-

sum_of_squares.pyx

# sun_of_squares.pyx
def fast_sum_of_squares(int n):
    cdef int total = 0
    cdef int i, j
    for i in range(n):
        for j in range(n):
            total += i * i + j * j
    return total

Todo lo que hicimos fue eliminar la directiva %% Cython y el código de tiempo (que ahora estará en la función de llamadas)

2/ Crear un archivo setup.py para compilar su archivo .pyx

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

setup(
    name="cython-test",
    ext_modules=cythonize("sum_of_squares.pyx", language_level=3),
    py_modules=["sum_of_squares"],  # Explicitly state the module
    zip_safe=False,
)

3/ Ejecute el archivo setup.py usando este comando,

$ python setup.py build_ext --inplace
running build_ext
copying build/lib.linux-x86_64-cpython-311/sum_of_squares.cpython-311-x86_64-linux-g

4/ Cree un módulo Python normal para llamar a nuestro código Cython, como se muestra a continuación, y luego ejecutarlo.

# main.py
import time, timeit
from sum_of_squares import fast_sum_of_squares

start = time.time()
result = fast_sum_of_squares(20000)

print("timeit:", timeit.timeit(
        lambda: fast_sum_of_squares(20000),
        number=1))
$ python main.py

timeit: 0.14675087109208107

Resumen

Con suerte, te he convencido de la eficacia de usar la Biblioteca Cython en tu código. Aunque puede parecer un poco complicado a primera vista, con un poco de esfuerzo, puede obtener increíbles mejoras de rendimiento para sus tiempos de ejecución sobre el uso de Python regular, incluso cuando se usa bibliotecas numéricas rápidas como Numpy.

Proporcioné un proceso de cuatro pasos para convertir su código Python regular para usar Cython para ejecutarse dentro de los entornos de Notebook Jupyter. Además, expliqué los pasos necesarios para ejecutar el código Cython desde la línea de comandos fuera de un entorno de cuaderno.

Finalmente, reforzé lo anterior mostrando ejemplos de convertir el código de pitón regular para usar Cython.

En los tres ejemplos que mostré, logramos ganancias de aceleraciones de 80x, 10x y 25x, lo que no está demasiado mal.


Como se prometió, aquí hay un enlace A mi artículo anterior de TDS sobre la utilización de la biblioteca NUMEXPR para acelerar el código Python.