¿Por qué este script de Python se ejecuta 4 veces más lento en varios núcleos que en un solo núcleo?

Estoy tratando de entender cómo funciona GIL de CPython y cuáles son las diferencias entre GIL en CPython 2.7.xy CPython 3.4.x. Estoy usando este código para la evaluación comparativa:

from __future__ import print_function import argparse import resource import sys import threading import time def countdown(n): while n > 0: n -= 1 def get_time(): stats = resource.getrusage(resource.RUSAGE_SELF) total_cpu_time = stats.ru_utime + stats.ru_stime return time.time(), total_cpu_time, stats.ru_utime, stats.ru_stime def get_time_diff(start_time, end_time): return tuple((end-start) for start, end in zip(start_time, end_time)) def main(total_cycles, max_threads, no_headers=False): header = ("%4s %8s %8s %8s %8s %8s %8s %8s %8s" % ("#t", "seq_r", "seq_c", "seq_u", "seq_s", "par_r", "par_c", "par_u", "par_s")) row_format = ("%(threads)4d " "%(seq_r)8.2f %(seq_c)8.2f %(seq_u)8.2f %(seq_s)8.2f " "%(par_r)8.2f %(par_c)8.2f %(par_u)8.2f %(par_s)8.2f") if not no_headers: print(header) for thread_count in range(1, max_threads+1): # We don't care about a few lost cycles cycles = total_cycles // thread_count threads = [threading.Thread(target=countdown, args=(cycles,)) for i in range(thread_count)] start_time = get_time() for thread in threads: thread.start() thread.join() end_time = get_time() sequential = get_time_diff(start_time, end_time) threads = [threading.Thread(target=countdown, args=(cycles,)) for i in range(thread_count)] start_time = get_time() for thread in threads: thread.start() for thread in threads: thread.join() end_time = get_time() parallel = get_time_diff(start_time, end_time) print(row_format % {"threads": thread_count, "seq_r": sequential[0], "seq_c": sequential[1], "seq_u": sequential[2], "seq_s": sequential[3], "par_r": parallel[0], "par_c": parallel[1], "par_u": parallel[2], "par_s": parallel[3]}) if __name__ == "__main__": arg_parser = argparse.ArgumentParser() arg_parser.add_argument("max_threads", nargs="?", type=int, default=5) arg_parser.add_argument("total_cycles", nargs="?", type=int, default=50000000) arg_parser.add_argument("--no-headers", action="store_true") args = arg_parser.parse_args() sys.exit(main(args.total_cycles, args.max_threads, args.no_headers)) 

Cuando ejecuto este script en mi máquina i5-2500 de cuatro núcleos en Ubuntu 14.04 con Python 2.7.6, obtengo los siguientes resultados (_r significa tiempo real, _c para el tiempo de CPU, _u para el modo de usuario, _s para el modo de kernel):

  #t seq_r seq_c seq_u seq_s par_r par_c par_u par_s 1 1.47 1.47 1.47 0.00 1.46 1.46 1.46 0.00 2 1.74 1.74 1.74 0.00 3.33 5.45 3.52 1.93 3 1.87 1.90 1.90 0.00 3.08 6.42 3.77 2.65 4 1.78 1.83 1.83 0.00 3.73 6.18 3.88 2.30 5 1.73 1.79 1.79 0.00 3.74 6.26 3.87 2.39 

Ahora, si uno todos los hilos a un núcleo, los resultados son muy diferentes:

 taskset -c 0 python countdown.py #t seq_r seq_c seq_u seq_s par_r par_c par_u par_s 1 1.46 1.46 1.46 0.00 1.46 1.46 1.46 0.00 2 1.74 1.74 1.73 0.00 1.69 1.68 1.68 0.00 3 1.47 1.47 1.47 0.00 1.58 1.58 1.54 0.04 4 1.74 1.74 1.74 0.00 2.02 2.02 1.87 0.15 5 1.46 1.46 1.46 0.00 1.91 1.90 1.75 0.15 

Entonces, la pregunta es: ¿por qué ejecutar este código Python en múltiples núcleos es 1.5x-2x más lento por el reloj de pared y 4x-5x más lento por el reloj de la CPU que ejecutarlo en un solo núcleo?

Preguntar y buscar en google produjo dos hipótesis:

  1. Cuando se ejecuta en múltiples núcleos, un subproceso puede volver a progtwigrse para ejecutarse en un núcleo diferente, lo que significa que la memoria caché local se invalida, por lo tanto, la desaceleración.
  2. La sobrecarga de suspender un hilo en un núcleo y activarlo en otro núcleo es mayor que suspender y activar el hilo en el mismo núcleo.

¿Hay alguna otra razón? Me gustaría entender lo que está pasando y poder respaldar mi entendimiento con números (lo que significa que si la ralentización se debe a fallas en la memoria caché, quiero ver y comparar los números para ambos casos).

Se debe a que GIL golpea cuando varios subprocesos nativos compiten por GIL. Los materiales de David Beazley sobre este tema te dirán todo lo que quieras saber.

Vea la información aquí para una buena representación gráfica de lo que está sucediendo.

Python3.2 introdujo cambios en la GIL que ayudan a resolver este problema, por lo que debería ver un mejor rendimiento con 3.2 y versiones posteriores.

También se debe tener en cuenta que el GIL es un detalle de implementación de la implementación de referencia del lenguaje de cpython. Otras implementaciones como Jython no tienen GIL y no sufren este problema en particular.

El rest de la información de D. Beazley sobre la GIL también será útil para usted.

Para responder específicamente a su pregunta acerca de por qué el rendimiento es mucho peor cuando hay varios núcleos involucrados, consulte la diapositiva 29-41 de la presentación Inside the GIL . Entra en una discusión detallada sobre la contención de GIL multinúcleo en lugar de múltiples hilos en un solo núcleo. La diapositiva 32 muestra específicamente que el número de llamadas al sistema debido a la sobrecarga de señalización de hilos pasa por el techo a medida que agrega núcleos. Esto se debe a que los hilos ahora se ejecutan simultáneamente en diferentes núcleos y que les permite participar en una verdadera batalla de GIL. A diferencia de múltiples hilos que comparten una sola CPU. Una buena viñeta de resumen de la presentación anterior es:

Con múltiples núcleos, los subprocesos enlazados a la CPU se progtwign simultáneamente (en diferentes núcleos) y luego tienen una batalla GIL.

El GIL evita que varios hilos de python se ejecuten simultáneamente. Eso significa que cada vez que un subproceso necesita ejecutar el bytecode de Python (la representación interna del código), adquirirá el locking (deteniendo efectivamente los otros subprocesos en los otros núcleos). Para que esto funcione, la CPU necesita vaciar todas las líneas de caché. De lo contrario, el hilo activo operaría en datos obsoletos.

Cuando ejecuta los subprocesos en una sola CPU, no es necesario vaciar la memoria caché.

Esto debería explicar la mayor parte de la desaceleración. Si desea ejecutar el código de Python en paralelo, debe usar procesos e IPC (sockets, semáforos, IO de memoria asignada). Pero eso puede ser lento por diferentes razones (la memoria debe ser copiada entre procesos).

Otro enfoque es mover más código en una biblioteca de C que no contiene la GIL mientras se ejecuta. Eso permitiría ejecutar más código en paralelo.