Multiprocesamiento en Python: Benchmarking

Multiprocesamiento en Python: Benchmarking
Facebook Twitter Flipboard E-mail

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 python
from threading import Thread
from multiprocessing import Process
class 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 * b
Si ejecutamos el benchmark obtenemos los siguientes resultados:
Cargando test math1
Lanzando test...
normal (1 iteraciones)  1.289338 segundos
hilos (1 hilos)         1.385118 segundos
pocesos (1 procesos)    1.809659 segundos
normal (2 iteraciones)  1.655674 segundos
hilos (2 hilos)         2.807755 segundos
pocesos (2 procesos)    1.820391 segundos
normal (6 iteraciones)  6.239633 segundos
hilos (6 hilos)         8.628114 segundos
pocesos (6 procesos)    2.984100 segundos
normal (10 iteraciones) 13.274641 segundos
hilos (10 hilos)        14.349401 segundos
pocesos (10 procesos)   3.887890 segundos
Como 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 entradasalida1
Lanzando test...
normal (1 iteraciones)  1.744972 segundos
hilos (1 hilos)         1.774082 segundos
pocesos (1 procesos)    2.042126 segundos
normal (2 iteraciones)  2.481946 segundos
hilos (2 hilos)         2.203156 segundos
pocesos (2 procesos)    2.352040 segundos
normal (6 iteraciones)  7.412333 segundos
hilos (6 hilos)         3.197699 segundos
pocesos (6 procesos)    3.705665 segundos
normal (10 iteraciones) 12.318243 segundos
hilos (10 hilos)        5.431394 segundos
pocesos (10 procesos)   5.330251 segundos
En 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 Genbeta Dev | Multiprocesamiento en Python

Comentarios cerrados
Inicio