Wednesday, February 5, 2020

Aceleración de código Python aplicando Numba

Aplicación de Numba para agilizar el cálculo de irradiancias en la caracterización de lentes ópticas difractivas

Introducción

Los lenguajes de programación que se utilizan para el análisis de fenómenos físicos que requieren una cantidad notable de recursos, son optimizados para aprovechar al máximo la gestión entre el CPU, el GPU y la memoria y realizar las tareas de cálculo en el menor tiempo posible. Python, un lenguaje que ha tomado auge en los últimos años, tiene la notable desventaja respecto de C que requiere, relativamente, mucho tiempo para ejecutar sus instrucciones, ya que es un lenguaje interpretado; es decir, a medida se van leyendo las instrucciones, éstas se van ejecutando.

Por tanto, se han invertido múltiples esfuerzos por diferentes programadores para agilizar la ejecución de programas: Desarrollando un archivo compilado, utilizando la GPU del sistema y aprovechando el manejo de estructuras vectoriales; sin embargo, algunos programas ya se encuentran elaborados por científicos y desarrolladores, lo cual hace más difícil implementar las medidas de ejecución antes mencionadas.

Una opción viable para aumentar notablemente la rapidez de los programas escritos en Python, en la aplicación del paquete numpy, es el uso de una libraría conocida como numba. Esta librería, mediante el uso de unos “decoradores” hace posible que la función o método de una clase, sea analizada y procesada de la manera más optima posible, incluso paralelizando procesos que se encuentran contenidos en una estructura de la forma for‑loop.

El contexto en el cual se desea agilizar el proceso de cálculo en en la caracterización de lentes difractivas, donde cada lente está representada por matrices de elementos, donde puede tener valores entre 200 y 1000. Para el análisis, todos esos elementos se calculan en ecuaciones como la siguiente




Donde las variables refieren a las coordenadas de cada elemento matricial de la lente, la cual debe integrarse y calcularse para cada coordenada del espacio además, se incluye la variable que especifica a la longitud de onda de la luz aplicada a la lente. La fórmula anterior, a su vez, se aplica un número de veces igual que el de puntos en el espacio en que se requiere conocer la irradiancia, generándose de esta manera planos de irradiancia y árboles de irradiancia.

Se hace notar que la computadora utilizada para esta ilustración, no posee tarjetas de vídeo que contengan “cuda cores” y ha sido necesario buscar otras alternativas para acelerar el código.

De manera general, para realizar el cálculo de irradiancias en el espacio 3D utilizando Python, se sigue la estructura:

import librerías_utilizadas

def function1(**args) # Esta es una función paralelizable

def function0(**args) # Esta función organiza la información y asigna valores a variables, llama function1.

Se muestran los resultados obtenidos para tres casos. Se hace notar que las funciones representan métodos de una clase llamada lente.

a) Realizando ejecuciones paralelas de function1 mediante la librería de multiprocessing

La función del cálculo de irradiancia se declara mediante function0, una variable tipo String, posteriormente se utiliza la función “pool.apply” para ejecutar el método parallel_xyz de forma paralela. En esta última función, se utiliza eval y un dictionario con las variables que requerirá function0.

import multiprocessing as mp

...

def parallel_xyz(self, axis, function0, dict0):

(dict0['x'], dict0['y'], dict0['z']) = axis

result = eval(function0, None, dict0)

return result

function0 ='np.abs(np.exp(1j*k*z)/(1j*lambdaa*z)*np.sum(lente * np.exp(1j*k*((x-x0)**2+((y-y0)**2))/(2*z))))'

pool = mp.Pool()

results = [pool.apply(self.parallel_xyz, args=(u, function0, dict0)) for u in axis]

pool.close()

b) Utilizando el paquete numba sin paralelización.

La función de la irraciancia no se declara como un String como en el caso anterior, ya que numba no reconoce su contenido y reporta un error. Nótese el uso de los decoradores @staticmethod que debe utilizarse porque se aplica al método de una clase y @jit que buscará agilizar el proceso; sin embargo lo realiza con un solo procesador.

Nótese que cada valor asignado a result[idx] se calcula utilizando operaciones entre matrices (lente) y escalares (k)

from numba import jit, jitclass, float64

from numba.errors import NumbaDeprecationWarning, NumbaPendingDeprecationWarning

import warnings

@staticmethod

@jit(nopython=True)

def parallel_xyz2(lente, x0, y0, axis, k, lambdaa):

result = np.empty((len(axis)))

for idx,(x, y, z) in enumerate(axis):

result[idx] = np.abs(np.exp(1j*k*z)/(1j*lambdaa*z)*np.sum(lente * np.exp(1j*k*((x-x0)**2+((y-y0)**2))/(2*z))))

return result

results = self.parallel_xyz2(self.data, self.dim_x, self.dim_y, axis, self.k, self.lambdaa)

c) Utilizando numba con paralelización.

De manera similar al anterior, se utilizan decoradores, ahora con la particularidad de proporcionar el argumento parallel = True en el decorador @jit. De manera especial, para que las operaciones se desarrollaran paralelamente, ha sido necesario declarar las operaciones de cada i-ésimo elemento

from numba import njit

@staticmethod

@njit(parallel=True)

def parallel_xyz2(lente, x0, y0, x, y, z, k, lambdaa):

var = x.size

result = [0.0] * var

for i in prange(var):

result[i] = np.abs(np.exp(1j * k * z[i]) / (1j * lambdaa * z[i]) * np.sum(lente * np.exp(1j * k * ((x[i] - x0) ** 2\ + ((y[i] - y0) ** 2)) / (2 * z[i]))))

return result

results = self.parallel_xyz2(self.data, self.dim_x, self.dim_y, x1, y1, uz1, self.k, self.lambdaa)

Para realizar las pruebas, se utilizaron dos lentes iguales y se calculó la irradiancia en un plano arbitrario, el primero de puntos y el segundo puntos mediante las líneas de código siguientes:

(código 1) irr0 = lente0.irradiancia_xyuz(np.linspace(-0.1, 0.1, 50), np.linspace(-0.1, 0.1, 50), 0.355)

(código 2) irr0 = lente0.irradiancia_xyuz(np.linspace(-0.1, 0.1, 100), np.linspace(-0.1, 0.1, 100), 0.355)

Resultados

Los resultados obtenidos son:


a) Multiprocessing [s]

b) Numba sin paralelización [s]

c) Numba con paralelización [s]

Código 1

36.506

8.324

2.988

Código 2

144.578

30.276

7.097


Si se define la relación de tiempo puede observarse una relación de para el código 1 y para el código 2.

Conclusiones

  • No siempre se dispone de procesadores gráficos que contengan “cuda cores” de Nvidia para acelerar el código de programas para cálculo de matrices y deben aprovecharse los recursos de Intel o AMD disponibles en un momento determinado.

  • El uso del paquete numba ha demostrado que acelera notablemente, entre 12.5 y 20 veces, los tiempos requeridos para el cálculo de irradiancias en un plano.

  • Se recomienda el uso de numba en programas escritos en Python para cálculo de matrices, por su notable nivel de aceleración del código y muy poca necesidad de cambiar los programas previamente escritos.