Cómo acelerar tu código Python con numba

Introducción

En este artículo vamos a hacer un repaso exhaustivo de cómo acelerar sustancialmente tu código Python usando numba. Ya hablamos sobre la primera versión de numba en el blog, allá por 2012, pero ha habido importantes cambios desde entonces y la herramienta ha cambiado muchísimo. Recientemente Continuum publicó numba 0.17 con una nueva documentación mucho más fácil de seguir, pero aun así no siempre queda claro cuál es el camino para hacer que funcione, como quedó patente con el artículo sobre Cython de Kiko. Por ello, en este artículo voy a aclarar qué puede y qué no puede hacer numba, cómo sacarle partido y voy a detallar un par de ejemplos exitosos que he producido en los últimos meses.

Hablando de las nuevas versiones de numba, en su web podéis ver una evolución temporal del rendimiento de algunas tareas que utiliza asv para la visualización.

Entendiendo numba: el modo nopython

Como podemos leer en la documentación, numba tiene dos modos de funcionamiento básicos: el modo object y el modo nopython.

  • El modo object genera código que gestiona todas las variables como objetos de Python y utiliza la API C de Python para operar con ellas. En general en este modo no habrá ganancias de rendimiento (e incluso puede ir más lento), con lo cual mi recomendación personal es directamente no utilizarlo. Hay casos en los que numba puede detectar los bucles y optimizarlos individualmente (loop-jitting), pero no le voy a prestar mucha atención a esto.
  • El modo nopython genera código independiente de la API C de Python. Esto tiene la desventaja de que no podemos usar todas las características del lenguaje, pero tiene un efecto significativo en el rendimiento. Otra de las restricciones es que no se puede reservar memoria para objetos nuevos.

Por defecto numba usa el modo nopython siempre que puede, y si no pasa a modo object. Nosotros vamos a forzar el modo nopython (o «modo estricto» como me gusta llamarlo) porque es la única forma de aprovechar el potencial de numba.

Ámbito de aplicación

El problema del modo nopython es que los mensajes de error son totalmente inservibles en la mayoría de los casos, así que antes de lanzarnos a compilar funciones con numba conviene hacer un repaso de qué no podemos hacer para anticipar la mejor forma de programar nuestro código. Podéis consultar en la documentación el subconjunto de Python soportado por numba en modo nopython, y ya os aviso que, al menos de momento, no tenemos list comprehensions, generadores ni algunas cosas más. Permitidme que resalte una frase sacada de la página principal de numba:

With a few annotations, array-oriented and math-heavy Python code can be just-in-time compiled to native machine instructions, similar in performance to C, C++ and Fortran“. [Énfasis mío]

Siento decepcionar a la audiencia pero numba no acelerará todo el código Python que le echemos: está enfocado a operaciones matemáticas con arrays. Aclarado este punto, vamos a ponernos manos a la obra con un ejemplo aplicado 🙂

Antes de empezar: instalación

Puedes instalar numba en Windows, OS X y Linux con conda usando este comando:

conda se ocupará de instalar una versión correcta de LLVM, así que no tendrás que compilarla tú mismo. Y ya está.

Ahora viene una opinión personal pero que considero importante: si eres usuario de paquetes científicos y aún no estás utilizando conda (o Anaconda) para gestionarlos, estás en la edad de piedra. Me declaro fanboy absoluto de Continuum Analytics por crear una herramienta de código abierto (conda está en GitHub) que soluciona por fin y de una vez por todas los problemas y frustración que hemos tenido como comunidad desde hace 15 años y que Guido y otros se negaron a atajar. Yo llevo en esto solo desde 2011 pero aún recuerdo lo que es intentar compilar SciPy en Windows. Hazte un favor e instala Miniconda.

Acelerando una función con numba

Voy a tomar directamente el ejemplo que usó Kiko para su artículo sobre Cython y vamos a ver cómo podemos utilizar numba (y un poco de astucia) para acelerar esta función:

“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)

¡Vamos allá!

SoftwareVersion
Python3.4.3 64bit [GCC 4.4.7 20120313 (Red Hat 4.4.7-1)]
IPython3.0.0
OSLinux 3.18.6 1 ARCH x86_64 with arch
numpy1.9.2
numba0.17.0
cython0.22
Fri Mar 13 13:44:39 2015 CET

Vamos a empezar por importar los paquetes necesarios e inicializar la semilla del generador de números aleatorios:

Creamos nuestro array de datos:

Y voy a copiar descaradamente la función de Kiko:

Paso 1: analizar el código

Lo primero que pensé cuando vi esta función es que no me gustaba nada hacer append a esas dos listas tantas veces. Pero a continuación me pregunté si realmente tendrían tantos elementos… averigüémoslo:

Tenemos que más de un 10 % de los elementos de la matriz cumplen la condición de ser «mínimos locales», así que no es nada despreciable. Esto en nuestro ejemplo hace un total de más de 400 000 elementos:

Ahora la idea de crear dos listas y añadir los elementos uno a uno me gusta todavía menos, así que voy a cambiar de enfoque. Lo que voy a hacer va a ser crear otro array, de la misma forma que nuestros datos, y almacenar un valor True en aquellos elementos que cumplan la condición de mínimo local. De esta forma cumplo también una de las reglas de oro de Software Carpentry: “Always initialize from data“.

Encima puedo aprovechar la estupenda función nonzero de NumPy. Compruebo que las salidas son iguales:

Y evalúo los rendimientos:

Parece que los tiempos son más o menos parecidos, pero al menos ya no tengo dos objetos en memoria que van a crecer de manera aleatoria. Vamos a ver ahora cómo nos puede ayudar numba a acelerar este código.

Paso 2: aplicando numba.jit(nopython=True)

Como hemos dicho antes, vamos a forzar que numba funcione en modo nopython para garantizar que obtenemos una mejora en el rendimiento. Si intentamos compilar la función definida en primer lugar va a fallar, porque ya hemos dicho más arriba que una de las condiciones es que no se puede asignar memoria a objetos nuevos:

En este caso la traza es inservible y especificar los tipos de entrada no va a ayudar. Solo para verificar, vamos a ver qué pasa con el rendimiento si no forzamos el modo estricto:

Pocas ganancias respecto a la función sin compilar. ¿Qué pasa si intentamos lo mismo con la segunda función?

Me dice que no conoce la función zeros_like. Si acudimos a la documentación, podemos ver las características de NumPy soportadas por numba y las funciones de creación de arrays no figuran entre ellas. Esto es consistente con lo que hemos dicho más arriba: no vamos a poder asignar memoria a objetos nuevos.

Paso 3: Reestructurar el código

¿Estamos en un callejón sin salida entonces? ¡En absoluto! Lo que vamos a hacer va a ser separar la parte intensiva de la función para aplicar numba.jit sobre ella, e inicializar todos los valores desde fuera. Para los que hayan usado subrutinas en Fortran este enfoque les resultará familiar 🙂

Veamos qué ocurre ahora:

Habéis leído bien: 70x más rápido 🙂

¡Lo hemos conseguido! Ahora nuestro código funciona en numba sin problemas y encima es endemoniadamente rápido. Para completar la comparación en mi ordenador, voy a reproducir también la función hecha en Cython:

Por tanto, vemos que la versión con numba es el doble de rápida que la versión con Cython. Sobre gustos no hay nada escrito: yo por ejemplo valoro no «salirme» de Python usando numba mientras que a otro puede no importarle incluir especificaciones de tipos como en Cython. Los números, eso sí, son los números 🙂

Más casos de éxito

La atmósfera estándar

El cálculo de propiedades termodinámicas de la atmósfera estándar es un problema clásico que todo aeronáutico ha afrontado alguna vez muy al principio de su carrera formativa. La teoría es simple: imponemos una ley de variación de la temperatura con la altura \( T = T(h)\), la presión se obtiene por consideraciones hidrostáticas \( p = p(T)\) y la densidad por la ecuación de los gases ideales \( \rho = \rho(p, T)\). La particularidad de la atmósfera estándar es que imponemos que la variación de la temperatura con la altura es una función simplificada y definida a trozos, así que calcular temperatura, presión y densidad dada una altura se parece mucho a hacer esto:

El problema viene cuando se quiere vectorizar esta función y permitir que h pueda ser un array de alturas. Esto es muy conveniente cuando queremos pintar alguna propiedad con matplotlib, por ejemplo.
Se intuye que hay dos formas de hacer esto: utilizando funciones de NumPy o iterando por cada elemento del array. La primera solución se hace farragosa, y la segunda, gracias a la proverbial lentitud de Python, es extremadamente lenta. Mi amigo Álex y yo llevamos pensando sobre este problema años, y nunca hemos llegado a una solución satisfactoria (incluso encontramos algunos bugs en numpy.piecewise por el camino). Este año decidimos cerrar este asunto definitivamente así que con el equipo AeroPython exploramos varias implementaciones distintas. Hasta que por fin lo conseguimos: usamos numba para acelerar los bucles.

numba gana a C++

Como podéis leer en la discusión original, la función de la primera columna está escrita en C++. ¿Impresionado? 😉

Solución de Navier de una placa plana

Para mi proyecto fin de carrera me encontré con la necesidad de calcular la deflexión de una placa rectangular, simplemente apoyada en sus cuatro bordes (es decir, los bordes pueden girar: no están empotrados) sometida a una carga transversal. Este problema tiene solución analítica conocida desde hace tiempo, hallada por Navier:
\( \displaystyle w(x,y) = \sum_{m=1}^\infty \sum_{n=1}^\infty \frac{a_{mn}}{\pi^4 D}\,\left(\frac{m^2}{a^2}+\frac{n^2}{b^2}\right)^{-2}\,\sin\frac{m \pi x}{a}\sin\frac{n \pi y}{b}\)
siendo \( a_{mn}\) los coeficientes de Fourier de la carga aplicada. Como veis, para cada punto \( (x, y)\) hay que hacer una doble suma en serie; si encima queremos evaluar esto en un meshgrid, necesitamos un cuádruple bucle. Ya se anticipa que por muy hábiles que estemos, a Python le va a costar.

La clave estuvo, una vez más, en usar numba para optimizar los bucles. En GitHub tenéis el código completo, pero la parte importante es esta:

Solución de una placa plana

Podéis comprobar vosotros mismos que las diferencias de rendimiento en este caso son brutales. Y solo hemos añadido una línea a cada función.

Conclusiones

numba aún no es una herramienta estable, pero está rápidamente alcanzando un grado de madurez suficiente para optimizar código orientado a operar con arrays. Gracias a conda es trivial de instalar y los resultados respecto a soluciones más maduras como Cython son aplastantes, tanto en velocidad de ejecución como en la complejidad del código resultante.
De momento yo me quedo con numba, ¿y tú? 😉

15 pensamientos sobre “Cómo acelerar tu código Python con numba”

  1. Gran artículo!!! Pensar un poco ayuda a resolver los problemas…
    La intención del artículo sobre cython era mostrar sus posibilidades y no está exprimido del todo… (me apunto una actualización para ver si lo puedo optimizar más o aceptar definitivamente el ‘zas, en toda la boca’ con deportividad).
    En este enlace hacen un resumen, creo que acertado, sobre algunas de las posibilidades para ganar rendimiento: http://www.reddit.com/r/Python/comments/2ywf48/cython_numba_f2py_or_other_for_speeding_up/cpdp84q
    Vienen tiempos interesantes!!!

    1. ¡Gracias Kiko! De eso se trata, de ir exprimiendo cada vez más y de hacer *pair programming* 😛 Seguro que eres capaz de exprimir la versión de Cython todavía más. A numba todavía le queda una cosa que sí tiene Cython: las anotaciones con salida HTML. Estoy esperando impaciente a que lo implementen.

  2. Me sonó a regaño eso de “[…] estás en la edad de piedra. […] Hazte un favor e instala Miniconda. […]” pero nunca tan efectivo! 🙂 muchas gracias por eso! Hace algún tiempo quise probar Numba y me fue imposible instalarlo sin fallos. Doblemente útil este artículo, tanto por Numba como por Conda (acabo de instalarlo, y ya instalé además las bibliotecas que uso en mi tesina, a usarlo a partir de mañana, fue realmente instantáneo, y encima ahora tengo Python 3.4 (chau 3.2!)).

    1. Jajaja ¡era el efecto buscado! 😀 Muchas gracias por el comentario Cristian, lo mismo tienes oportunidad de estudiar este tema con Pybonacci en persona dentro de dos meses… 😉

  3. ¡Interesantísimo artículo! Muy bien descrita la cadena de problemas aparecidos por el camino.Y el aroma a “hay mil cosas por intentar con todas las herramientas de las que disponemos” me gustó porque es de lo que se trata: probar probar probar y probar.Y sobre todo también la sensación que transmites al resolver un problema(y mas si es arduo),que es algo realmente bonito .¡Enhorabuena!

  4. Saludos Juan Luis, esta muy interesante tu articulo, yo he estado intentando acelerar mi codigo Python y he estado probando varias alternativas (jit compilers, cython, numexpr), pero al parecer (en mis resultados), estas solo son significativamente mejores si se trata de cálculos vectoriales o matriciales muy extensos, cuando se programa algo que conlleva cálculos matriciales o vectoriales de forma repetitiva pero al fin y al cabo matrices y vectores pequeños el tiempo de ejecución es ligeramente mayor con respecto a Python original, creo que es algo que hay que acotar, ya que la mayoría de ejemplos que he visto por blogs y demás, es calcular un vector de 12489826481241234231 elementos (entre otros) y compararlo con lo que seria hacerlo con programación en Python pura. Pero nadie habla del limite inferior en el que el tiempo de resolución de ambos empiezan a ser iguales.
    Una desventaja si le pudiera llamar así (en mi punto de vista lo es), es que te tienes que casar con Anaconda o miniconda, ya que tratar de instalarlo por otro lado es un dolor de cabeza y en windows es peor el dolor, en cuanto a scipy, yo no he tenido problemas para “compilarlo”, no se exactamente que significa cuando lo mencionaste al principio, pero ya trae instaladores o paquetes para ambos sistemas operativos (windows y linux) y con py2exe puedes hacer una distribución de tus aplicaciones que corra de forma independiente usando scipy sin ningún problema también de forma independiente.
    Pero en general esta entrada es un buen aporte para aquellas personas que hacen calculos bastantes extensivos, Saludos

    1. Hola Antonio, ¡gracias por el comentario!
      Respecto a lo primero que dices: por un lado, el ejemplo de la atmósfera estándar muestra claramente cómo escalan los tiempos de ejecución (a lo mejor debería poner una gráfica), y puedes ver que numba es la implementación más rápida incluso para argumentos escalares.
      Por otro lado, el ejemplo de la placa de Navier es un cuádruple bucle y las mejoras de rendimiento se notan drásticamente incluso con mallados de una docena de puntos, así que de nuevo no estoy tomando un ejemplo «fabricado» con matrices enormes. Te recomiendo que hagas la prueba tú mismo.
      Respecto a lo que comentas de casarse con Anaconda, al final hay que casarse con alguien. Yo estuve un tiempo casado con Christoph Gohlke y sus paquetes binarios no oficiales, y la situación no me gustaba mucho. Mi matrimonio con pip fue un fiasco también, de Canopy me acabé divorciando para siempre… si no sabes lo que es compilar SciPy, te animo a que descargues el código fuente y hagas python setup.py install.
      ¡Un saludo!

      1. y que me dices de easy_install? yo a la vieja escuela me quede con el propio idle original y bueno uso una version un poco mejorada llama idlex, pero no llamaría a esto casarse como tal, e instalo todo con easy_install, cuando hay que hacer alguna compilación en c propiamente de la librería es recomendable instalar Visual C++ Compiler for Python (gratuito), y easy_install se encarga de la compilación (esto para windows claro), y para linux basta con instalar g++, e incluso he podido instalar el ipython 3 con el easy_install en ambos sistemas operativos, la verdad no me ha sido infiel hehehe, pero bueno el punto de la variedad y diversidad es lo que importa, saludos!

  5. Pingback: Cómo acelerar tu código Python co...

  6. Saludos! Quisiera agradecerles por este espacio y el gran aporte que hacen. Hace un mes no sabía nada de Python y ahora he podido trasladar casi todo mis trabajos desde Matlab.
    Mi pregunta es: Es posible combinar Plotly, Numba y los Widgets de iPython? Yo lo he intentado sin éxito.
    Si existe algún tutorial al respecto les agradecería si pudieran compartirlo.
    Muchas gracias!

    1. ¡Hola Javier! Muchas gracias por tus comentarios 🙂
      La verdad es que no hemos escrito un tutorial tan específico, pero si eres un poco más específico sobre qué problema estás teniendo podemos ayudarte. Te recomiendo que escribas a esta lista de correo:
      python-es@python.org
      Supongo que ya conoces nuestros artículos sobre numba y sobre plotly. Esperamos que tengas éxito, y si te sale bien, que nos mandes un tutorial para publicarlo en el blog 🙂
      ¡Un saludo!

Deja un comentario

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

− three = five