C elemental, querido Cython

Cython, que no CPython

No, no nos hemos equivocado en el título, hoy vamos a hablar de Cython.

¿Qué es Cython?

Cython son dos cosas:

  • Por una parte, Cython es un lenguaje de programación (un superconjunto de Python) que une Python con el sistema de tipado estático de C y C++.
  • Por otra parte, cython es un compilador que traduce codigo fuente escrito en Cython en eficiente código C o C++. El código resultante se podría usar como una extensión Python o como un ejecutable.

¡Guau! ¿Cómo os habéis quedado?

Lo que se pretende es, básicamente, aprovechar las fortalezas de Python y C, combinar una sintaxis sencilla con el poder y la velocidad.

Salvando algunas excepciones, el código Python (tanto Python 2 como Python 3) es código Cython válido. Además, Cython añade una serie de palabras clave para poder usar el sistema de tipado de C con Python y que el compilador cython pueda generar código C eficiente.

Pero, ¿quién usa Cython?

Pues mira, igual no lo sabes pero seguramente estés usando Cython todos los días. Sage tiene casi medio millón de líneas de Cython (que se dice pronto), Scipy y Pandas más de 20000, scikit-learn unas 15000,…

¿Nos empezamos a meter en harina?

La idea principal de este primer acercamiento a Cython será empezar con un código Python que sea nuestro cuello de botella e iremos creando versiones que sean cada vez más rápidas, o eso intentaremos.

Por ejemplo, imaginemos que tenemos que detectar valores mínimos locales dentro de una malla. Los valores mínimos deberán ser simplemente valores más bajos que los que haya en los 8 nodos de su entorno inmediato. En el siguiente gráfico, el nodo en verde será un nodo con un mínimo y en su entorno son todo valores superiores:

(2, 0)(2, 1)(2, 2)
(1, 0)(1. 1)(1, 2)
(0, 0)(0, 1)(0, 2)

[INCISO] Los números y porcentajes que veáis a continuación pueden variar levemente dependiendo de la máquina donde se ejecute. Tomad los valores como aproximativos.

Setup

Como siempre, importamos algunas librerías antes de empezar a picar código:

Creamos una matriz cuadrada relativamente grande (4 millones de elementos).

Ya tenemos los datos listos para empezar a trabajar.
Vamos a crear una función en Python que busque los mínimos tal como los hemos definido.

Veamos cuanto tarda esta función en mi máquina:

Buff, tres segundos y pico en un i7… Si tengo que buscar los mínimos en 500 de estos casos me va a tardar casi media hora.

Por casualidad, vamos a probar numba a ver si es capaz de resolver el problema sin mucho esfuerzo, es código Python muy sencillo en el cual no usamos cosas muy ‘extrañas’ del lenguaje.

Ooooops! Parece que la magia de numba no funciona aquí.

Vamos a especificar los tipos de entrada y de salida (y a modificar el output) a ver si mejora algo:

Pues parece que no, el resultado es del mismo pelo. Usando la opción nopython me casca un error un poco feo,…

Habrá que seguir esperando a que numba esté un poco más maduro. En mis pocas experiencias no he conseguido aun el efecto que buscaba y en la mayoría de los casos obtengo errores muy crípticos. No es que no tenga confianza en la gente que está detrás, solo estoy diciendo que aun no está listo para ‘producción’. Esto no pretende ser una guerra Cython/numba, solo he usado numba para ver si a pelo era capaz de mejorar algo el tema. Como no ha sido así, nos olvidamos de numba de momento.

Cythonizando, que es gerundio (toma 1).

Lo más sencillo y evidente es usar directamente el compilador cython y ver si usando el código python tal cual es un poco más rápido. Para ello, vamos a usar las funciones mágicas que Cython pone a nuestra disposición en el notebook. Solo vamos a hablar de la función mágica %%cython, de momento, aunque hay otras.

El comando %%cython nos permite escribir código Cython en una celda. Una vez que ejecutamos la celda, IPython se encarga de coger el código, crear un fichero de código Cython con extensión .pyx, compilarlo a C y, si todo está correcto, importar ese fichero para que todo esté disponible dentro del notebook.

[INCISO] a la función mágica %%cython le podemos pasar una serie de argumentos. Veremos alguno en este análisis pero ahora vamos a definir uno que sirve para que podamos nombrar a la funcíon que se crea y compila al vuelo, -n o --name.

El fichero se creará dentro de la carpeta cython disponible dentro del directorio resultado de la función get_ipython_cache_dir. Veamos la localización del fichero en mi equipo:

No lo muestro por aquí porque el resultado son más de ¡¡2400!! líneas de código C.

Veamos ahora lo que tarda.

Bueno, parece que sin hacer mucho esfuerzo hemos conseguido ganar en torno a un 5% – 25% de rendimiento (dependerá del caso). No es gran cosa pero Cython es capaz de mucho más…

Cythonizando, que es gerundio (toma 2).

En esta parte vamos a introducir una de las palabras clave que Cython introduce para extender Python, cdef. La palabra clave cdef sirve para ‘tipar’ estáticamente variables en Cython (luego veremos que se usa también para definir funciones). Por ejemplo:

En el bloque de código de más arriba he creado dos variables de tipo entero, var1 y var2, y una variable de tipo float, var3. Los tipos anteriores son la nomenclatura C.

Vamos a intentar usar cdef con algunos tipos de datos que tenemos dentro de nuestra función. Para empezar, veo evidente que tengo varias listas (minimosx y minimosy), tenemos los índices de los bucles (i y j) y voy a convertir los parámetros de los range en tipos estáticos (ii y jj):

Vaya decepción… No hemos conseguido gran cosa, tenemos un código un poco más largo y estamos peor que en la toma 1.

En realidad, estamos usando objetos Python como listas (no es un tipo C/C++ puro pero Cython lo declara como puntero a algún tipo struct de Python) o numpy arrays y no hemos definido las variables de entrada y de salida.

[INCISO] Cuando existe un tipo Python y C que tienen el mismo nombre (por ejemplo, int) predomina el de C (porque es lo deseable, ¿no?).

Cythonizando, que es gerundio (toma 3).

En Cython existen tres tipos de funciones, las definidas en el espacio Python con def, las definidas en el espacio C con cdef (sí, lo mismo que usamos para declarar los tipos) y las definidas en ambos espacios con cpdef.

  • def: ya lo hemos visto y funciona como se espera. Accesible desde Python
  • cdef: No es accesible desde Python y la tendremos que envolver con una función Python para poder acceder a la misma.
  • cpdef: Es accesible tanto desde Python como desde C y Cython se encargará de hacer el ‘envoltorio’ para nosotros. Esto meterá un poco más de código y empeorará levemente el rendimiento.

Si definimos una función con cdef debería ser una función que se usa internamente dentro del módulo Cython que vayamos a crear y que no sea necesario llamar desde Python.

Veamos un ejemplo de lo dicho anteriormente definiendo la salida de la función como tupla:

Vaya, seguimos sin estar muy a gusto con estos resultados.

Seguimos sin definir el tipo del valor de entrada.
La función mágica %%cython dispone de una serie de funcionalidades entre la que se encuentra -a o --annotate (además del -n o --name que ya hemos visto). Si le pasamos este parámetro podremos ver una representación del código con colores marcando las partes más lentas (amarillo más oscuro) y más optmizadas (más claro) o a la velocidad de C (blanco). Vamos a usarlo para saber donde tenemos cuellos de botella (aplicado a nuestra última versión del código):

[INCISO] En el código a continuación, si pulsáis sobre el símbolo ‘+’ que está delante de cada número de línea podréis ver el código C que se genera

El if parece la parte más lenta. Estamos usando el valor de entrada que no tiene un tipo Cython definido.
Los bucles parece que están optimizados (las variables envueltas en el bucle las hemos declarado como unsigned int).
Pero todas las partes por las que pasa el numpy array parece que no están muy optimizadas…

Cythonizando, que es gerundio (toma 4).

Ahora mismo, haciendo import numpy as np tenemos acceso a la funcionalidad Python de numpy. Para poder acceder a la funcionalidad C de numpy hemos de hacer un cimport de numpy.

El cimport se usa para importar información especial del módulo numpy en el momento de compilación. Esta información se encuentra en el fichero numpy.pxd que es parte de la distribución Cython. El cimport también se usa para poder importar desde la stdlib de C.

Vamos a usar esto para declarar el tipo del array de numpy.

Guauuuu!!! Acabamos de obtener un incremento de entre 25x a 30x veces más rápido.

Vamos a comprobar que el resultado sea el mismo que la función original:

Pues parece que sí 🙂

Vamos a ver si hemos dejado la mayoría del código anterior en blanco o más clarito usando --annotate.