Comparé estas dos funciones (descomprimen los pares nuevamente en las listas de fuentes, provienen de aquí ):
n = 10**7 a = list(range(n)) b = list(range(n)) pairs = list(zip(a, b)) def f1(a, b, pairs): a[:], b[:] = zip(*pairs) def f2(a, b, pairs): for i, (a[i], b[i]) in enumerate(pairs): pass Resultados con timeit.timeit (cinco rondas, los números son segundos):
f1 1.06 f2 1.57 f1 0.96 f2 1.69 f1 1.00 f2 1.85 f1 1.11 f2 1.64 f1 0.95 f2 1.63 Claramente, f1 es mucho más rápido que f2 , ¿verdad?
Pero luego también medí con timeit.default_timer y obtuve una imagen completamente diferente:
f1 7.28 f2 1.92 f1 5.34 f2 1.66 f1 6.46 f2 1.70 f1 6.82 f2 1.59 f1 5.88 f2 1.63 Así que claramente f2 es mucho más rápido, ¿verdad?
Suspiro. ¿Por qué los tiempos difieren totalmente de esa manera y qué método de tiempo debo creer?
Código de referencia completo:
from timeit import timeit, default_timer n = 10**7 a = list(range(n)) b = list(range(n)) pairs = list(zip(a, b)) def f1(a, b, pairs): a[:], b[:] = zip(*pairs) def f2(a, b, pairs): for i, (a[i], b[i]) in enumerate(pairs): pass print('timeit') for _ in range(5): for f in f1, f2: t = timeit(lambda: f(a, b, pairs), number=1) print(f.__name__, '%.2f' % t, end=' ') print() print('default_timer') for _ in range(5): for f in f1, f2: t0 = default_timer() f(a, b, pairs) t = default_timer() - t0 print(f.__name__, '%.2f' % t, end=' ') print()Como comentó Martijn, la diferencia es la recolección de basura de Python, que timeit.timeit desactiva durante su ejecución. Y zip crea 10 millones de objetos iteradores , uno para cada uno de los 10 millones de iterables que se le otorgan.
Entonces, recolectar 10 millones de objetos simplemente lleva mucho tiempo, ¿verdad? ¡Misterio resuelto!
Bueno no. Eso no es realmente lo que sucede, y es mucho más interesante que eso. Y hay una lección que aprender para hacer ese código más rápido en la vida real.
La forma principal de Python para descartar objetos que ya no se necesitan es el conteo de referencias. El recolector de basura, que se está deshabilitando aquí, es para ciclos de referencia, que el recuento de referencia no detectará. Y aquí no hay ningún ciclo, por lo que todo se descarta mediante el conteo de referencias y el recolector de basura en realidad no recolecta basura.
Veamos algunas cosas. Primero, reproduzcamos el tiempo mucho más rápido al deshabilitar el recolector de basura nosotros mismos.
Código de configuración común (todos los bloques de código adicionales deben ejecutarse directamente después de esto en una nueva ejecución, no los combine):
import gc from timeit import default_timer as timer n = 10**7 a = list(range(n)) b = list(range(n)) pairs = list(zip(a, b))Temporización con recolección de basura habilitada (el valor predeterminado):
t0 = timer() a[:], b[:] = zip(*pairs) t1 = timer() print(t1 - t0)Lo ejecuté tres veces, tardé 7,09, 7,03 y 7,09 segundos.
Temporización con recolección de basura deshabilitada :
t0 = timer() gc.disable() a[:], b[:] = zip(*pairs) gc.enable() t1 = timer() print(t1 - t0)Tomó 0.96, 1.02 y 0.99 segundos.
Entonces, ahora sabemos que es la recolección de basura la que de alguna manera toma la mayor parte del tiempo , aunque no está recolectando nada.
Aquí hay algo interesante: ya solo la creación del iterador zip es responsable la mayor parte del tiempo:
t0 = timer() z = zip(*pairs) t1 = timer() print(t1 - t0)Eso tomó 6.52, 6.51 y 6.50 segundos.
Tenga en cuenta que mantuve el iterador zip en una variable, por lo que aún no hay nada que descartar, ¡ni por el recuento de referencias ni por la recolección de basura!
¡¿Qué?! ¿Adónde va el tiempo, entonces?
Bueno... como dije, no hay ciclos de referencia, por lo que el recolector de basura en realidad no recolectará basura. ¡Pero el recolector de basura no lo sabe! Para darse cuenta de eso, ¡necesita verificar!
Dado que los iteradores pueden convertirse en parte de un ciclo de referencia, se registran para el seguimiento de la recolección de elementos no utilizados. Veamos cuántos objetos más se rastrean debido a la creación del zip (haciendo esto justo después del código de configuración común):
gc.collect() tracked_before = len(gc.get_objects()) z = zip(*pairs) print(len(gc.get_objects()) - tracked_before) El resultado: 10000003 nuevos objetos rastreados. Creo que ese es el objeto zip en sí, su tupla interna para contener los iteradores, su tupla de soporte de resultado interno y los 10 millones de iteradores.
Ok, entonces el recolector de basura rastrea todos estos objetos. Pero ¿qué significa eso? Bueno, de vez en cuando, después de un cierto número de creaciones de nuevos objetos, el recolector revisa los objetos rastreados para ver si algunos son basura y se pueden descartar. El coleccionista conserva tres "generaciones" de objetos rastreados. Los nuevos objetos pasan a la generación 0. Si sobreviven a una recopilación allí, pasan a la generación 1. Si sobreviven a una recopilación allí, pasan a la generación 2. Si sobreviven a más ejecuciones de recopilación allí, permanecen en la generación 2. Veamos las generaciones anteriores y posteriores:
gc.collect() print('collections:', [stats['collections'] for stats in gc.get_stats()]) print('objects:', [len(gc.get_objects(i)) for i in range(3)]) z = zip(*pairs) print('collections:', [stats['collections'] for stats in gc.get_stats()]) print('objects:', [len(gc.get_objects(i)) for i in range(3)])Salida (cada línea muestra valores para las tres generaciones):
collections: [13111, 1191, 2] objects: [17, 0, 13540] collections: [26171, 2378, 20] objects: [317, 2103, 10011140]El 10011140 muestra que la mayoría de los 10 millones de iteradores no solo se registraron para el seguimiento, sino que ya están en la generación 2. Por lo tanto, formaron parte de al menos dos ejecuciones de recolección de elementos no utilizados. Y la cantidad de recolecciones de generación 2 aumentó de 2 a 20, por lo que nuestros millones de iteradores fueron parte de hasta 20 ejecuciones de recolección de basura (dos para ingresar a la generación 2 y hasta 18 más mientras ya estaban en la generación 2). También podemos registrar una devolución de llamada para contar con más precisión:
checks = 0 def count(phase, info): if phase == 'start': global checks checks += len(gc.get_objects(info['generation'])) gc.callbacks.append(count) z = zip(*pairs) gc.callbacks.remove(count) print(checks) Eso me dijo 63,891,314 cheques en total (es decir, en promedio, cada iterador fue parte de más de 6 ejecuciones de recolección de basura). Eso es mucho trabajo. Y todo esto solo para crear el iterador zip , incluso antes de usarlo.
Mientras tanto, el bucle
for i, (a[i], b[i]) in enumerate(pairs): pass no crea casi ningún objeto nuevo. Veamos cuántas causas de enumerate de seguimiento:
gc.collect() tracked_before = len(gc.get_objects()) e = enumerate(pairs) print(len(gc.get_objects()) - tracked_before) Salida: 3 nuevos objetos rastreados (el objeto iterador de enumerate en sí mismo, el iterador único que crea para iterar sobre pairs y la tupla resultante que usará (código aquí )).
Diría que eso responde a la pregunta "¿Por qué los tiempos difieren totalmente de esa manera?" . La solución zip crea millones de objetos que pasan por múltiples ejecuciones de recolección de elementos no utilizados, mientras que la solución de bucle no lo hace. Entonces, deshabilitar el recolector de basura ayuda enormemente a la solución zip , mientras que a la solución de bucle no le importa.
Ahora sobre la segunda pregunta: " ¿Qué método de tiempo debo creer? ". Esto es lo que la documentación tiene que decir al respecto (énfasis mío):
De forma predeterminada,
timeit()desactiva temporalmente la recolección de elementos no utilizados durante el tiempo. La ventaja de este enfoque es que hace que los tiempos independientes sean más comparables. La desventaja es que GC puede ser un componente importante del desempeño de la función que se mide . Si es así, GC se puede volver a habilitar como la primera declaración en la cadena de configuración. Por ejemplo:timeit.Timer('for i in range(10): oct(i)', 'gc.enable()').timeit()
En nuestro caso aquí, el costo de la recolección de basura no proviene de algún otro código no relacionado. Es causado directamente por la llamada zip . Y pagas este precio en realidad, cuando ejecutas eso. Entonces, en este caso, lo considero un "componente importante del desempeño de la función que se mide" . Para responder directamente a la pregunta tal como se hizo: aquí creo que el método default_timer , no el método timeit . O dicho de otra manera: aquí se debe usar el método timeit para habilitar la recolección de basura como se sugiere en la documentación.
O... alternativamente, podríamos deshabilitar la recolección de basura como parte de la solución (no solo para la evaluación comparativa):
def f1(a, b, pairs): gc.disable() a[:], b[:] = zip(*pairs) gc.enable() ¿Pero es una buena idea? Esto es lo que dice la documentación de gc :
Dado que el recopilador complementa el recuento de referencias que ya se usa en Python, puede desactivar el recopilador si está seguro de que su programa no crea ciclos de referencia.
Suena como que es una buena cosa que hacer. Pero no estoy seguro de no crear ciclos de referencia en otra parte de mi programa, así que termino con gc.enable() para volver a activar la recolección de basura después de que termine. En ese momento, todos esos objetos temporales ya han sido descartados gracias al conteo de referencias. Entonces, todo lo que estoy haciendo es evitar muchos controles de recolección de basura sin sentido. Considero que esta es una lección valiosa y, de hecho, podría hacerlo en el futuro, si sé que solo creo temporalmente muchos objetos.
Finalmente, recomiendo leer la documentación del módulo gc y la guía para desarrolladores Design of CPython's Garbage Collector in Python. La mayor parte es fácil de entender, y lo encontré bastante interesante y esclarecedor.