Multiprocesamiento en Python: Benchmarking


Desarrollo tech.lat dev

Multiprocesamiento en Python: Benchmarking

Multiprocesamiento en Python: Benchmarking

Oscar Campos

En el artículo anterior indagábamos en las diferentes vías de las que disponemos a la hora de minimizar el impacto del GIL en nuestras aplicaciones en sistemas con más de un procesador.

Como ya se ha dicho anteriormente, el GIL impide que más de un hilo de ejecución en nuestras aplicaciones se ejecute a la vez en más de un núcleo de la CPU al necesitar cada hilo de ejecución en un mismo intérprete adquirir el GIL para poder acceder a la memoria de los objetos Python en la implementación de CPython.

La solución más sencilla (y la recomendada además por Guido van Rossum) para utilizar mas de un núcleo o procesador a la vez en nuestras aplicaciones es hacer uso del módulo multiproccessing en lugar del módulo threading con el que comparte casi toda su API.

La pregunta que realmente debemos hacernos es si el GIL realmente está afectando a nuestra aplicación. No todas las aplicaciones pueden beneficiarse de multiprocesamiento simétrico. También existe un poco de paranoia en referencia al multiprocesamiento utilizando procesos en lugar de hilos en algunos casos, injustificada.

Algunas consideraciones

Con el siguiente benchmark no pretendo reforzar ningún argumento ni afirmar de forma absoluta ninguna teoría. Tampoco pretendo herir ninguna sensibilidad ni enaltecer ningún ego. El benchmark es muy sencillo y desde aquí invito a los lectores a que lo ejecuten en sus propios sistemas y saquen sus propias conclusiones.

Todos los benchmarks están realizados en un Intel i930 de 8 núcleos a 2.8GHz con 12GB de RAM a 1666MHz y un LVM2 sobre un RAID 5 de 2.5TB con 6 discos Hitachi x500GB y 7200 r.p.m con una velocidad de lectura de 267.21 MB/sec bajo una Gentoo x86_64 con Kernel 3.0.0 ejecutando KDE 4.7.1 y CPyhton 2.7.1 (r271:86832, Jun 29 2011, 06:58:55) compilado con GCC 4.4.5

La mecánica del benchmark es muy sencilla, llamaremos a una función dada una vez en un bucle donde se llamará a la misma 100 veces usando llamadas, hilos y procesos. Usaremos 1, 2, 6 y 10 llamadas, hilos o procesos en cada benchmark y usaremos el módulo timeit de Python para controlar el tiempo de ejecución.

El código

El código del benchmark run_test.py es muy simple:

#!/usr/bin/env pythonfrom threading import Threadfrom multiprocessing import Processclass normal(object): def run(self): do_run()class hilos(Thread): def run(self): do_run()class procesos(Process): def run(self): do_run()def ejecuta(iteraciones, tipo):
funcs = list() if tipo == 'normal': t_object = normal elif tipo == 'hilos': t_object = hilos else: t_object = procesos for i in range(int(iteraciones)): funcs.append(t_object()) if tipo == 'normal': for i in funcs: i.run() else:
for i in funcs: i.start() for i in funcs: i.join()def print_results(func, results): print "%-23s %4.6f segundos" % (func, results)if __name__ == "__main__": import sys from timeit import Timer
if len(sys.argv) < 2: print "Uso: %s nombre_test/n" % sys.argv[0] sys.exit(1) test_name = sys.argv[1] if test_name.endswith('.py'): test_name = test_name[:-3] print "Cargando test %s" % test_name test = __import__(test_name) do_run = test.do_run print "Lanzando test..." for i in range(1, 11): if i not in [1, 2, 6, 10]: continue t = Timer("ejecuta(%s, 'normal')" % i, "from __main__ import ejecuta") #br = min(t.repeat(repeat=100, number=1))
br = sum(t.repeat(repeat=100, number=1))
print_results("normal (%s iteraciones)" % i, br) t = Timer("ejecuta(%s, 'hilos')" % i, "from __main__ import ejecuta") br = sum(t.repeat(repeat=100, number=1)) print_results("hilos (%s hilos)" % i, br) t = Timer("ejecuta(%s, 'procesos')" % i, "from __main__ import ejecuta") br = sum(t.repeat(repeat=100, number=1)) print_results("pocesos (%s procesos)" % i, br) print "/n", print "Test completado"Ahora podemos crear benchmarks creando nuevos módulos en python e implementando la función do_run en ellos. Vamos a empezar con una operación matemática sencilla math1.py:

def do_run(): a, b = 0, 1 for i in range(1000000): a, b = b, a * bSi ejecutamos el benchmark obtenemos los siguientes resultados:

Cargando test math1Lanzando test...normal (1 iteraciones) 1.289338 segundoshilos (1 hilos) 1.385118 segundospocesos (1 procesos) 1.809659 segundosnormal (2 iteraciones) 1.655674 segundoshilos (2 hilos) 2.807755 segundospocesos (2 procesos) 1.820391 segundosnormal (6 iteraciones) 6.239633 segundoshilos (6 hilos) 8.628114 segundospocesos (6 procesos) 2.984100 segundosnormal (10 iteraciones) 13.274641 segundoshilos (10 hilos) 14.349401 segundospocesos (10 procesos) 3.887890 segundosComo podemos comprobar el uso de hilos para la ejecución "en paralelo" de esta operación matemática no solo no produce los resultados esperados en sistemas SMP sino que es más lento que la ejecución sin hilos. Sin embargo, el uso de múltiples procesos mejora de forma dramática el rendimiento.

En el primer artículo de la serie dijimos que el GIL no sanciona las operaciones bloqueantes como la E/S en disco por que dichas operaciones liberan el GIL, vamos a comprobar que eso sea cierto a través de un nuevo benchmark llamado entradasalida1.py:

def do_run(): fd = open("/dev/urandom", "rb") for i in range(100): fd.read(1024)En esta ocasión leemos un kilobyte de datos aleatorios desde el dispositivo especial /dev/urandom cien veces, estos son los resultados:

Cargando test entradasalida1Lanzando test...normal (1 iteraciones) 1.744972 segundoshilos (1 hilos) 1.774082 segundospocesos (1 procesos) 2.042126 segundosnormal (2 iteraciones) 2.481946 segundoshilos (2 hilos) 2.203156 segundospocesos (2 procesos) 2.352040 segundosnormal (6 iteraciones) 7.412333 segundoshilos (6 hilos) 3.197699 segundospocesos (6 procesos) 3.705665 segundosnormal (10 iteraciones) 12.318243 segundoshilos (10 hilos) 5.431394 segundospocesos (10 procesos) 5.330251 segundosEn este caso se comprueba que el GIL se libera en las operaciones bloqueantes y la diferencia de rendimiento entre los hilos y los procesos solo empieza a ser visible a partir de los diez hilos/procesos.

Conclusión

Con estos sencillos benchmarks hemos comprobado como el uso de procesos en lugar de hilos puede mejorar de forma dramática la ejecución de código pure python que no libera el GIL.

También hemos comprobado como al usar operaciones que por el contrario si liberan el GIL como las operaciones de entrada salida, la diferencia es prácticamente imperceptible puesto que al ser el GIL liberado el sistema de hilos del sistema operativo ejecuta los diferentes hilos con su scheduler en varios procesadores.

Añadir nuevos y más complejos benchmarks a este sencillo sistema es muy simple, así que animo a los lectores a que ejecuten sus propios benchmarks en sus máquinas y envíen los resultados en los comentarios. Una vez aclarado el tema del GIL estamos preparados para seguir con el multiprocesamiento en Python a fondo.


En tech.lat Dev | Multiprocesamiento en Python