Thursday, May 5, 2022

La importancia de la buena administración de ambientes virtuales en Python

 Introducción

Cuando alguien se inicia en la programación con Python, posiblemente no conozca el concepto de ambientes virtuales y sus implicaciones en la dependencia de paquetes, como en el uso de memoria del recurso de hardware que utilice. Este blog permitirá al lector conocer un poco sobre estos ambientes virtuales y crear un criterio para saber cuándo usarlos en sus diferentes proyectos.
Un ambiente virtual es una herramienta que ayuda a preservar las dependencias requeridas por diferentes proyectos separadas, creando ambientes virtuales aislados entre ellos. Representa una herramienta muy importante para los programadores en Python. 

Los ambientes virtuales son necesarios porque:[1]

  • Eliminan problemas de dependencia de paquetes, al dar la posibilidad de instalar versiones funcionales de éstos a versiones de Python; por ejemplo, Pytorch funciona muy bien para Python3.8 pero no para otras, específicamente con arquitecturas de hardware aarch64[2] .
  • Hace que el proyecto esté auto contenido y sea reproducible, capturando el listado de todos los paquetes en un archivo conocido como requirements.txt. Generalmente, almacena en una carpeta todos esos paquetes.
  • Permite instalar los paquetes en un anfitrión sin tener los privilegios para ello.
  • Mantiene la carpeta global “site-packages/” limpia, removiendo la necesidad de instalar paquetes con alcance a todo el sistema y que posiblemente, solo sean necesarios para un proyecto.
  • Pueden ser importados y exportados para ser reutilizados en varias computadoras.

Existen dos herramientas para crear ambientes virtuales, tanto en windows como en linux: Venv y Virtualenv.

Tabla 1. Código a ejecutar para lograr diferentes acciones entre venv y virtualenv.

Aunque son muy parecidos en la aplicación, Vritualenv se caracteriza de Venv por:[6]

  • Es más lento (al no tener el método de inicialización de datos de la aplicación).
  • No es tan extensible.
  • No puede crear entornos virtuales para versiones de python instaladas arbitrariamente (y descubrirlas automáticamente).
  • No se puede actualizar a través de pip.
  • No tiene una API programática tan rica (describe entornos virtuales sin crearlos).

De forma adicional, venv no puede descubrir automáticamente versiones de python instaladas arbitrariamente, mientras que virtualenv sí lo hace. Esto significa que con venv debe especificar la ruta completa del ejecutable de python, si desea usar alguna otra versión de python que no sea la primera en la ruta[7]. Con virtualenv, solo puede dar el número de versión. Consulte el descubrimiento de python en la documentación de virtualenv.

En tema de espacio sí debe considerarse crear la menor cantidad de ambientes virtuales. Generalmente, el programador utiliza un IDE[8] para redactar, depurar y ejecutar sus programas, uno de ellos bastante utilizado es PyCharm[9] que resulta gratuito para programar en Python y es multiplataforma.

Cada vez que se redacta el código para un proyecto, se abre una ventana como la mostrada en la figura 1, donde se aprecian los siguientes campos:

  • Location (El primer campo). La ubicación de los archivos de código y auxiliares que componen el proyecto.
  • Location (Abajo de new environment). La ubicación de los paquetes para el ambiente virtual a crear.
  • Base interpreter.  La ubicación del ejecutable Python con su versión específica a utilizar
Fig.1 Ventana de configuración para un proyecto nuevo en PyCharm.

En tal caso se recomienda tener una versión preferida de Python, la cual puede instalarse de manera global para todo el sistema y, en tal caso, se podrán seleccionar las opciones “Inherit global site-packages” y “make avaiblable for all projects”, lo cual ocasionará que cada proyecto tenga un conjunto de paquetes comunes, ahorrando memoria del anfitrión.
Si no se tiene una versión preferida, entonces no deberán seleccionarse las opciones mencionadas en el párrafo anterior, pero deberá tomarse en cuenta que cada versión instalará sus propios paquetes y utilizará un espacio de memoria aislado de otras versiones.

La figura 2 Muestra el caso en que se crean ambientes virtuales para almacenar paquetes propios de la aplicación. El uso de memoria en disco duro es considerable, siendo el mayor de 4.3GB y el menor de 154MB. Si se almacenaran en los ambientes virtuales los códigos de tipo py, la cantidad de memoria utilizada por todos los proyectos en la carpeta “PyCharmProjects” podría reducirse hasta llegar cercanamente a un 33%; es decir, al proyecto que más memoria requiere.

Fig.2 Carpetas que contienen ambientes virtuales para proyectos en Python.

Conclusiones

Los ambientes virtuales en Python se utilizan para aislar los recursos que requieren diferentes versiones de Python, lo cual facilita al programador la redacción de diversor programas asegurando la compatibilidad con los paquetes utilizados.
Se recomienda utilizar una versión de Python global para el sistema para disponer de todos los paquetes a utilizar en la mayoría de proyectos y crear la menor cantidad de ambientes virtuales que utilice paquetes distintos a la versión global, porque de esta manera se utilizará de manera óptima, los recursos de memoria del anfitrión.

Preguntas motivadoras

  • ¿Cuál piensa que es el porcentaje de personas que, sabiendo programar aplicaciones sencillas de Python, desconocen la importancia de la buena administración de ambientes virtuales?
  • ¿Será necesario tener varios ambientes virtuales en un IoT?
  • ¿Cuál será la diferencia de instalar Python en un entorno Conda, respecto de los mencionados anteriormente?

Lecturas recomendadas

  • Creación y administración de ambientes en Python. [enlace]
  • Comparación de venv y virtualenv. [enlace]
  • Virtualenv and venv: Python virtual environments explained. [enlace]
  • Pipenv & Virtual Environments. [enlace]
  • Installing packages using pip and virtual environments. [enlace]

Vídeos sugeridos

  • Python Tutorial: VENV (Windows) - How to Use Virtual Environments with the Built-In venv Module. [enlace]
  • Learn Python 1: First install and Virtual Environments - Windows 10. [enlace]
  • The Complete Guide to Python Virtual Environments!. [enlace]
  • Python Tutorial: VENV (Mac & Linux) - How to Use Virtual Environments with the Built-In venv Module. [enlace]
  • How to Create Python Virtual Environment on Ubuntu – virtualenv. [enlace]
  • How to use a Python Virtual Environment the RIGHT way with Jupyter Notebook. [enlace]

Referencias

    [1] https://towardsdatascience.com/virtual-environments-104c62d48c54
    [2] https://elandroidefeliz.com/identificar-tipo-de-procesador-en-android/
    [3] https://www.collinsdictionary.com/dictionary/english/built-in-feature
    [4] https://towardsdatascience.com/virtual-environments-104c62d48c54
    [5] https://www.geeksforgeeks.org/python-virtual-environment/
    [6] https://virtualenv.pypa.io/en/latest/
    [7] [enlace]
    [8] https://www.codecademy.com/article/what-is-an-ide
    [9] https://www.jetbrains.com/pycharm/download/#section=windows





Tuesday, May 3, 2022

Aceleración de código python mediante clases

Introducción

Es de interés para todo programador, que sus códigos se ejecuten con la mayor rapidez posible. Al ejecutarse un programa rápidamente, se puede ahorrar dinero porque ya no es necesario utilizar un hardware con altas prestaciones y, además, el usuario se sentirá más cómodo al apreciar los resultados de su interacción con la computadora más rápidamente.
Python es un lenguaje interpretado, lo cual le hace lento comparado con C o C++, ya que éste último es compilado[1] y, aunque muchas de sus librerías son compiladas en C++ para que un programa se ejecute más rápidamente[2], mucho del código aún debe ser interpretado, lo cual ralentiza el programa global.

 Consejos para agilizar la ejecución de los programas

 Existen muchas publicaciones que brindan consejos para ejecutar los programas más rápidamente, entre otras, pueden mencionarse[3]:

  • Especifique tipos de datos en Python. Python utiliza un tiempo importante en detectar el tipo de variable a utilizar según el contexto en muchas ocasiones, lo cual no sucede si se declara el tipo desde el la creación de la misma.
  • Reemplace listas con expresiones generadoras. Especialmente cuando se utilizan operaciones que aglutinan un conjunto de datos; es mejor aplicar la expresión aritmética directamente a una función que a una lista de números. Este código sum([num**2 for num in range(1000000)]) tarda más que éste sum((num**2 for num in range(1000000)))
  • Reemplace variables globales con variables locales. Las variables locales se ejecutan más rápidamente, dado su contexto.
  • Evite importar librerías completas. Es mejor utilizar from A import B, ya que de esta manera se reduce el tiempo de ejecución de las funciones o métodos importados. 

En los lenguajes de programación orientados a objetos se disponen de clases y funciones. Las primeras tienen métodos que se parecen mucho a las funciones, ya que entregan un resultado después de procesar información correspondiente a sus argumentos de entrada.

La tabla 1 muestra las diferencias entre los métodos y las funciones[4].

Tabla 1. Diferencias entre métodos y funciones.

Un experto opina[5]:

“ La función proporciona su propio entorno para las variables, por lo que la metatabla cambia para adaptarse al nuevo entorno, y cuando la función finaliza, el entorno vuelve al estado de la capa exterior. En un estado ideal, una función no debería tener efectos secundarios.
La clase, por otro lado, sirve para la generación de objetos. Tras la inicialización, los objetos se crean y almacenan como tablas hash con sus respectivas direcciones. Con el tiempo, dichos objetos se eliminan de la memoria, ya sea manualmente o mediante la recolección de elementos no utilizados.”

Exposición del caso

Sin embargo, en el IIIE (Instituto de Investigación e Innovación en Electrónica) de la Universidad Don Bosco, se ha llevado un proyecto que permite comparar la ejecución entre funciones y métodos, desarrollado en una raspberry-pi utilizada para realizar el reconocimiento de rostros. Dado que un IoT tiene prestaciones limitadas, se pensó en una forma para optimizar el programa, proponiéndose utilizar clases con sus respectivos métodos para procesar la información en lugar de funciones aisladas.

La figura 1 muestra los diagramas de flujo utilizados para métodos y funciones.

Fig. 1 Diagramas de flujo: a) Usando funciones, en cada bloque indicado con (1), b) Usando métodos de clase creados al inicio del programa y usados en bloques (2)

Los resultados se muestran en la tabla siguiente, donde se tienen dos procesos principales “Espera” e “Identificación total”. Para cada uno de ellos se midió el tiempo de ejecución, siendo T1 con la aplicación de funciones y T2 con el uso de clases. En la columna “T2/T1” puede apreciarse en una notación de porcentaje, la relación de tiempos para la ejecución del código, evidenciando que el uso de clases ha reducido a 70% y 33% el tiempo de espera e identificación total, respectivamente.

Tabla 2. Comparativa en los tiempos de ejecución utilizando funciones (T1) y utilizado clases con sus respectivos métodos (T2)
 Conclusión

Utilizar clases con sus respectivos métodos para procesar la información que administra un programa, reduce el tiempo de ejecución al optimizar los recursos de hardware. Por tanto, se recomienda utilizar esta práctica en lugar de múltiples funciones independientes.

Lecturas recomendadas

  • Python Classes and Objects [Enlace]
  • Classes [Enlace]
  • Python Objects and Classes [Enlace]
  • Speed Up Python Code [Enlace]
  • Optimization Tips for Python Code [Enlace]
  • Performance tips [Enlace]
  • Optimizing Your Python Code [enlace]
  • Python Performance Tuning: 20 Simple Tips [Enlace]

Vídeos recomendados

  •  Python OOP Tutorial 1: Classes and Instances [Enlace]
  • Python Classes and Objects - OOP for Beginners [Enlace]
  • Python Classes and Objects || Python Tutorial || Learn Python Programming [Enlace]
  • Python Object Oriented Programming (OOP) - For Beginners [Enlace]

Referencias

    [1] https://www.geeksforgeeks.org/difference-between-compiled-and-interpreted-language/
    [2] https://www.pythonpool.com/precompiled-standard-library-in-python/
    [3] https://towardsdatascience.com/10-techniques-to-speed-up-python-runtime-95e213e925dc
    [4] https://techvidvan.com/tutorials/python-methods-vs-functions/
    [5] https://www.quora.com/Are-classes-slower-than-functions-in-Python



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.

Friday, April 26, 2019

Multiprocesamiento en paralelo con Python

Introducción

Dado que Python se encuentra ampliamente difundido en la comunidad científica, por su simplicidad en la redacción de código, fácil depuración mediante IDEs amigables y gran cantidad de librerías, es conveniente utilizarlo en muchas aplicaciones que requieren una alta complejidad computacional.

Afortunadamente, en el transcurso de los años, se ha ido incrementando la cantidad de núcleos que cada CPU posee para realizar la ejecución de programas en paralelo, tal como se muestra en la figura 1.

Fig.1 Número mayor de núcleos por procesador de Intel y AMD por año.

Sin embargo, mientras algunas aplicaciones permiten ejecutar bucles de tipo for en paralelo con solo cambiar un comando, la flexibilidad de Python hace que deba configurarse manualmente la ejecución en paralelo de los programas. A cambio, se logra reducir el tiempo de ejecución en una fracción de menos del 10% que si se ejecutara secuencialmente; por tanto, la estructuración del código para la ejecución paralela provee un alto beneficio para el programador.

Si bien es cierto, es posible aprovechar los núcleos que posee un procesador físico, python también puede hacer que varios computadores trabajen en paralelo mediante el paso de mensajes, haciendo uso de su librería Mpipy. En este texto únicamente se abordará el caso de procesadores multinúcleo.

La figura 2 muestra el aprovechamiento de todos los núcleos del procesador en una computadora personal.

Fig.2 Aprovechamiento de núcleos en un procesador, a) Solo un núcleo al 100%, b) Todos los núcleos al 100%.

Si bien no resulta difícil concluir que la ejecución paralela de varias tareas trae como consecuencia una reducción en el tiempo global de ejecución respecto de una ejecución secuencial, al final de este texto se ilustra con un ejemplo que en ciertas ocasiones la ejecución secuencial es más rápida, gracias a la buena administración que hace Python en la memoria RAM del sistema.

Es posible realizar procesamiento en paralelo, haciendo uso de los múltiples núcleos al interior de los procesadores actuales, con objeto de reducir notablemente los tiempos de ejecución de los programas.

Una lista de valores y una o varias constantes.

En el siguiente programa, se tiene una lista de valores contenidos en la variable u_list, en que cada uno de ellos se procesará con tres matrices constantes en la operación.

En este caso, se ejecutarán paralelamente procesos de la forma mostrada en la figura 3.

Fig. 3: Ejecución paralela de una lista de valores con una o varias constantes

El código que permite realizar la ejeución paralela como se ilustra en la figura 3, se muestra en el programa 1.

Programa 1 - código

import multiprocessing (1)
from functools import partial
import numpy as np

def sub_funcion(matrix1, matrix2, matrix3, ith_u): (2)
sum_matrix = matrix1.sum() + matrix2.sum() + matrix3.sum()
result = sum_matrix + ith_u
return result

inicial, final = 1, 1000 (3)
matrix1 = np.arange(1, 10)
matrix2 = np.arange(10, 20)
matrix3 = np.arange(20, 30+1)
sum_mat = matrix1.sum() + matrix2.sum() + matrix3.sum()

u_list = np.arange(inicial, final + 1)
sum_ulist = u_list.sum()

num_cores = multiprocessing.cpu_count() - 1 (4)
pool = multiprocessing.Pool(processes=num_cores) (5)
func = partial(sub_funcion, matrix1, matrix2, matrix3) (6)
result = np.array(pool.map(func, u_list)) (7)
pool.close() (8)
pool.join() (9)

print ('Las matrices son:\nmatrix1 = {}\nmatrix2 = {}\nmatrix3 = {}\nEl resultado final es {}\n'.format(matrix1,
matrix2, matrix3,result.sum()))

print('Realizando los calculos de matrices y de la variable u_list de forma independiente...\n'
'Sumatoria de variables en matrices * cantidad de elementos en u_list = {}\nSumatoria de numeros en variables u_list = {}\nFinalmente, la suma de ambos terminos = {}'.format(sum_mat * u_list.size, sum_ulist, sum_mat * u_list.size +sum_ulist))



Antes de hacer los cálculos se presentan los contenidos de las 3 matrices. Nótese que el tamaño de todas ellas es diferente, lo cual no afecta la ejecución porque se toma la variable completa para cada vez que se ejecuta sub_funcion.

Programa 1 - resultado

Las matrices son:
matrix1 = [1 2 3 4 5 6 7 8 9]
matrix2 = [10 11 12 13 14 15 16 17 18 19]
matrix3 = [20 21 22 23 24 25 26 27 28 29 30]
El resultado final es 965500

Realizando los calculos de matrices y de la variable u_list de forma independiente:
Sumatoria de variables en matrices * cantidad de elementos en u_list = 465000
Sumatoria de numeros en variables u_list = 500500
Finalmente, la suma de ambos terminos = 965500

Process finished with exit code 0


Del programa, pueden destacarse los siguientes elementos:

  1. La función partial es clave, permite reunir múltiples argumentos para aplicarlos a una sola función.

  2. Esta sub_funcion es la que se ejecutará en paralelo a otras copias de la misma.

  3. Los valores initial y final definen una lista de valores que serán procesados dentro de cada sub_funcion.

  4. Es posible establecer cuántos núcleos del procesador se utilizarán durante el proceso. Mediante la función multiprocessing.cpu_count() se conocen los que hay disponibles.

  5. Se inicia el procesamiento paralelo.

  6. La función partial tiene como primer agumento el nombre de la función sub_funcion y, a continuación, todas las variables que se mantendrán constantes para cada proceso paralelo.

  7. La función pool.map devuelve todos los resultados en un puntero de dirección. Por ello, se convierten en un arreglo mediante la función np.array.

  8. La función pool.close() hace que los procesos que están trabajando en paralelo ya no acepten más argumentos.

  9. La función pool.join() espera a que todos los procesos terminen para continuar la ejecución del programa.


Notas especiales:

  • Nótese que la u_list es una lista de valores como argumento de pool.map, mientras que ith_u como argumento de sub_funcion es una variable que contiene un iésimo de u_list automáticamente.

  • Aunque el principio es simple, el código es un poco largo porque se quiere mostrar que se obtiene el mismo resultado mediante la aplicación de una fórmula y mediante la ejecución de 1000 procesos en paralelo.

Esta estructura es conveniente utilizarla cuando existen varios argumentos iguales, que permanecerán constantes e iguales para todos los procesos simultáneos y solamente una lista de valores se tiene que procesar con estos argumentos.

Varias variables de igual dimensión

En este caso, se asume que todos los argumentos tienen la misma cantidad de elementos. La figura 4 ilustra la ejecución de los procesos. Nótese que en el caso de los arreglos, que pueden ser de una, dos o más dimensiones, será necesario convertirlos a unidimensionales y luego restablecer sus dimensiones originales para poder aplicar el algoritmo descrito en el programa 2.

Fig.4 Ejecución de procesos en paralelo cuyas variables tienen la misma dimensión

El siguiente programa ilustra la ejecución paralela para el caso de variables de igual tamaño.

Programa 2 - código

import multiprocessing
from pathos.pools import ProcessPool as Pool
import numpy as np

def sub_funcion(matrix1, matrix2, matrix3):
sum_matrix = matrix1 + matrix2 + matrix3
print('sub_funcion, {} + {} + {} = {}'.format(matrix1, matrix2, matrix3, sum_matrix))
return sum_matrix

matrix1 = np.arange(0, 10)
matrix2 = np.arange(10, 20)
matrix3 = np.arange(20, 30)

num_cores = multiprocessing.cpu_count() - 1 (1)
pool = Pool(nodes=num_cores)
result = list(pool.map(sub_funcion, matrix1, matrix2, matrix3)) (2)

print('\nLos valores devueltos de cada proceso se muestran en la lista {}'.format(result))



En la presentación del resultado, se muestra el valor iésimo de las variables matrix1, matrix2 y matrix3, donde es requisito indispensable que todas tengan el mismo tamaño. Finalmente, se muestran en una lista los valores obtenidos por cada sub_funcion.

Programa 2 - resultado

sub_funcion, 0 + 10 + 20 = 30
sub_funcion, 1 + 11 + 21 = 33
sub_funcion, 2 + 12 + 22 = 36
sub_funcion, 3 + 13 + 23 = 39
sub_funcion, 4 + 14 + 24 = 42
sub_funcion, 5 + 15 + 25 = 45
sub_funcion, 6 + 16 + 26 = 48
sub_funcion, 7 + 17 + 27 = 51
sub_funcion, 8 + 18 + 28 = 54
sub_funcion, 9 + 19 + 29 = 57

Los valores devueltos de cada proceso se muestran en la lista [30, 33, 36, 39, 42, 45, 48, 51, 54, 57]

Process finished with exit code 0



Del programa, pueden destacarse los siguientes elementos:

  1. De manera similar al programa anterior, puede establecerse el número de núcleos participarán simultáneamente.

  2. Propio de la función map en Python, el primer argumento es el nombre de la función y los siguientes argumentos son las variables.

Este algoritmo es apropiado utilizarlo cuando todos los argumentos tienen la misma dimensión.

Uso de clases, tareas y resultados

Además de los dos algoritmos anteriores que se centran en el uso de los núcleos del procesador, puede utilizarse el presentado como programa 3, donde se crea la clase paralell de la cual pueden crearse muchas y perfectamente pueden ejecutarse de forma paralela. La entrega de información a procesar se realiza mediante la asignación de tareas y, luego de procesarla, se almacena en una especie de memoria para resultados.



Programa 3 - código

import multiprocessing

a =
100 (1)

class paralell(multiprocessing.Process): (2)
def __init__(self, task_queue, result_queue):
multiprocessing.Process.
__init__(self)
self.task_queue = task_queue
self.result_queue = result_queue

def run(self): (3)
proc_name =
self.name
while True:
next_task =
self.task_queue.get() (4)
if next_task is None:
print('{}: Exiting'.format(proc_name))
self.task_queue.task_done()
break
global a (5)
result = a + next_task +
10 (6)
self.result_queue.put(result)
self.task_queue.task_done() (7)


if __name__ == '__main__':
tasks = multiprocessing.JoinableQueue()
(8)
results = multiprocessing.Queue()
(9)
num_workers = multiprocessing.cpu_count()
consumers = [paralell(tasks, results)
(10)
for i in range(num_workers)
]
for w in consumers: w.start() (11)

num_jobs =
10000
for k0 in range (num_jobs): (12)
tasks.put(k0)

for i in range(num_workers): (13)
tasks.put(
None)

tasks.join()
(14)
acc =
0

while num_jobs:
result = results.get()
(15)
acc += result
num_jobs -=
1

print ('resultado final = {}'.format(acc)) (16)



En este programa, worker representa a cada núcleo del procesador ya que realiza el trabajo de ejecutar una rutina. Se utiliza la lógica que un trabajo (job) puede ser ejecutado por varios trabajadores (workers).

Programa 3 - resultado

paralell-4: Exiting
paralell-2: Exiting
paralell-8: Exiting
paralell-5: Exiting
paralell-1: Exiting
paralell-6: Exiting
paralell-7: Exiting
paralell-3: Exiting
num_jobs = 4
num_jobs = 3
num_jobs = 2
num_jobs = 1
num_jobs = 0
resultado final = 51095000

Process finished with exit code 0



De los comentarios en el programa:

  1. Para efectos de probar si una variable puede utilizarse en los procesos en paralelo, hacer cálculos y generar un consolidado final, se inicializa "a" con 100.

  2. La clase paralell recibe como argumento la clase Multiprocessing.Process. Esto permite inicializar sus propias variables self.task_queue y self.result_queue, las cuales almacenarán las tareas y los resultados respectivamente.

  3. El método run se ejecutará cuando se ejecute paralell.start()

  4. Cada nueva tarea (o bien, valor a procesar) será recibida con la ejecución del método self.task_queue.get()

  5. En Python, al definir "a" como global, será buscada en el directorio raíz de variables.

  6. El resultado tendrá valores comprendidos entre 100 + 0 + 10 y 100 + 9999 + 10.

  7. Es necesario ejecutar el método self.task_queue.task_done() al finalizar cada tarea, no importa que la tarea recibida sea None.

  8. La variable tasks representa a las tareas que se ejecutarán en paralelo por cada worker. Es una estructura tipo FIFO.

  9. La variable results guarda los resultados que serán guardados por las tareas ejecutadas por cada worker.

  10. La variable consumers representa una lista de "workers" que recibe cada uno como argumento un espacio para tareas (tasks) y un espacio para resultados (results)

  11. Cada uno de los "workers" es inicializado y se pone a trabajar. Internamente, cada uno ejecuta el método run.

  12. A partir de la variable num_jobs, se tiene una lista de 10,000 elementos, desde 0 hasta 9,999. Cada uno de ellos se le brinda a un "worker" para que sea procesado

  13. Con este bucle, a un total de workers igual al número de núcleos en el procesador, se le encarga de ejecutar la tarea "None". Esto es totalmente indispensable y de no hacerlo, los workers seguirán activos impidiendo la finalización del programa.

  14. Se espera a que todas las tareas sean ejecutadas para continuar el programa.

  15. De cada uno de los trabajos llevados a cabo (10,000 en este caso), se recibe cada valor y se acumula en la variable "acc".

  16. Se presenta el resultado de acumular los valores entregados en la variable results. Pueden sumarse los valores constantes de cada resultado, sum1 = 10000 * 110 = 1100000 y luego los numeros naturales del 1 al 9999 mediante la fórmula sum2 = (n + n ** 2)/2, con n=9999. sum2 = 49995000. Finalmente, sum1 + sum2 = 51095000.

Este programa representa una alternativa de tantas para la ejecución paralela de funciones, con una perspectiva en el uso de tareas y resultados.

Casos excepcionales

No siempre la ejecución paralela se ejecuta más rápidamente, ya que para realizarla, debe organizarse la memoria de la computadora y luego administrar las tareas. Existen casos en que las tareas pueden ser muchas pero son simples y fáciles de acomodar en la RAM del sistema.

A continuación, el programa 4 muestra la ejecución secuencial de la suma de tres matrices, elemento por elemento, obteniendo un tiempo de 0.00722 segundos.

Programa 4 - código

import numpy as np
import time

num_elementos =
10000
matrix1 = np.arange(
0, num_elementos)
matrix2 = np.arange(
2 * num_elementos, 3 * num_elementos)
matrix3 = np.arange(
4 * num_elementos, 5 * num_elementos)

t1 = time.time()
result0 = []
for k in range(num_elementos):
result0.append(matrix1[k] + matrix2[k] + matrix3[k])
result = np.array(result0).sum()

tejecutado = time.time() - t1
print('\nLa suma de todos los valores generados por {} procesos es {} y se ejecuto en {} segundos'.format(num_elementos, result, tejecutado))



Programa 4 - resultado

La suma de todos los valores generados por 10000 procesos es 749985000 y se ejecuto en 0.00722002983093262 segundos

Process finished with exit code 0



Este otro código utiliza una ejecución paralela y tarda 0.88594 segundos; es decir, 122.7 veces más que la secuencial.

Programa 5 - código

import multiprocessing
from pathos.pools import ProcessPool as Pool
import numpy as np
import time

def sub_funcion(matrix1, matrix2, matrix3):
sum_matrix = matrix1 + matrix2 + matrix3
return sum_matrix

num_elementos =
10000
matrix1 = np.arange(
0, num_elementos)
matrix2 = np.arange(
2 * num_elementos, 3 * num_elementos)
matrix3 = np.arange(
4 * num_elementos, 5 * num_elementos)

t1 = time.time()
num_cores = multiprocessing.cpu_count() -
1
pool = Pool(
nodes=num_cores)
result0 =
list(pool.map(sub_funcion, matrix1, matrix2, matrix3))
result = np.array(result0).sum()

tejecutado = time.time() - t1
print('\nLa suma de todos los valores generados por {} procesos es {} y se ejecuto en {} segundos'.format(num_elementos, result, tejecutado))



Programa 5 - resultado

La suma de todos los valores generados por 10000 procesos es 749985000 y se ejecuto en 0.8859429359436035 segundos

Process finished with exit code 0

La ejecución de código Python en paralelo ahorrará un tiempo de ejecución notable respecto del tiempo requerido en una ejecución secuencial, siempre y cuando cada proceso sea complejo.

Referencias

  • https://www.reddit.com/r/Amd/comments/6cu5ss/highest_amount_of_cores_per_cpu_amd_vs_intel_year/

  • https://docs.python.org/3/library/multiprocessing.html

  • https://www.machinelearningplus.com/python/parallel-processing-python/