¿Por qué el regreso temprano es más lento que otro?

180

Esta es una pregunta de seguimiento a una respuesta que di hace unos días . Editar: parece que el OP de esa pregunta ya usaba el código que le publiqué para hacerle la misma pregunta , pero no estaba al tanto. Disculpas Sin embargo, las respuestas proporcionadas son diferentes.

Sustancialmente observé que:

>>> def without_else(param=False):
...     if param:
...         return 1
...     return 0
>>> def with_else(param=False):
...     if param:
...         return 1
...     else:
...         return 0
>>> from timeit import Timer as T
>>> T(lambda : without_else()).repeat()
[0.3011460304260254, 0.2866089344024658, 0.2871549129486084]
>>> T(lambda : with_else()).repeat()
[0.27536892890930176, 0.2693932056427002, 0.27011704444885254]
>>> T(lambda : without_else(True)).repeat()
[0.3383951187133789, 0.32756996154785156, 0.3279120922088623]
>>> T(lambda : with_else(True)).repeat()
[0.3305950164794922, 0.32186388969421387, 0.3209099769592285]

... o en otras palabras: tener la elsecláusula es más rápida independientemente de la ifcondición que se active o no.

Supongo que tiene que ver con un código de bytes diferente generado por los dos, pero ¿alguien puede confirmar / explicar en detalle?

EDITAR: Parece que no todos pueden reproducir mis tiempos, por lo que pensé que podría ser útil dar información sobre mi sistema. Estoy ejecutando Ubuntu 11.10 64 bit con el python predeterminado instalado. pythongenera la siguiente información de versión:

Python 2.7.2+ (default, Oct  4 2011, 20:06:09) 
[GCC 4.6.1] on linux2

Estos son los resultados del desmontaje en Python 2.7:

>>> dis.dis(without_else)
  2           0 LOAD_FAST                0 (param)
              3 POP_JUMP_IF_FALSE       10

  3           6 LOAD_CONST               1 (1)
              9 RETURN_VALUE        

  4     >>   10 LOAD_CONST               2 (0)
             13 RETURN_VALUE        
>>> dis.dis(with_else)
  2           0 LOAD_FAST                0 (param)
              3 POP_JUMP_IF_FALSE       10

  3           6 LOAD_CONST               1 (1)
              9 RETURN_VALUE        

  5     >>   10 LOAD_CONST               2 (0)
             13 RETURN_VALUE        
             14 LOAD_CONST               0 (None)
             17 RETURN_VALUE        
Mac
fuente
1
había una pregunta idéntica sobre SO que no puedo encontrar ahora. Verificaron el bytecode generado y hubo un paso adicional. Las diferencias observadas fueron muy dependientes del probador (máquina, SO ...), a veces encontrando diferencias muy muy pequeñas.
joaquin
3
En 3.x, ambos producen un código de bytes idéntico, salvo para un código inalcanzable ( LOAD_CONST(None); RETURN_VALUEpero, como se dijo, nunca se alcanza) al final de with_else. Dudo mucho que el código muerto haga que una función sea más rápida. ¿Alguien podría proporcionar un dis2.7?
44
No pude reproducir esto. Funcionó con elsey Falsefue el más lento de todos (152ns). El segundo más rápido fue Truesin else(143ns) y otros dos fueron básicamente iguales (137ns y 138ns). No %timeitutilicé el parámetro predeterminado y lo midí en iPython.
rplnt
No puedo reproducir esos tiempos, a veces with_else es más rápido, a veces esta es la versión without_else, parece que son bastante similares para mí ...
Cédric Julien
1
Resultados agregados de desmontaje. Estoy usando Ubuntu 11.10, 64-bit, stock Python 2.7 - misma configuración que @mac. También estoy de acuerdo en que with_elsees notablemente más rápido.
Chris Morgan

Respuestas:

387

Es una suposición pura, y no he descubierto una manera fácil de verificar si es correcta, pero tengo una teoría para ti.

Probé su código y obtuve el mismo resultado, without_else()es repetidamente un poco más lento que with_else():

>>> T(lambda : without_else()).repeat()
[0.42015745017874906, 0.3188967452567226, 0.31984281521812363]
>>> T(lambda : with_else()).repeat()
[0.36009842032996175, 0.28962249392031936, 0.2927151355828528]
>>> T(lambda : without_else(True)).repeat()
[0.31709728471076915, 0.3172671387005721, 0.3285821242644147]
>>> T(lambda : with_else(True)).repeat()
[0.30939889008243426, 0.3035132258429485, 0.3046679117038593]

Teniendo en cuenta que el código de bytes es idéntico, la única diferencia es el nombre de la función. En particular, la prueba de tiempo busca el nombre global. Intente cambiar without_else()el nombre y la diferencia desaparecerá:

>>> def no_else(param=False):
    if param:
        return 1
    return 0

>>> T(lambda : no_else()).repeat()
[0.3359846013948413, 0.29025818923918223, 0.2921801513879245]
>>> T(lambda : no_else(True)).repeat()
[0.3810395594970828, 0.2969634408842694, 0.2960104566362247]

Supongo que without_elsetiene una colisión hash con algo más, por globals()lo que la búsqueda de nombres globales es un poco más lenta.

Editar : un diccionario con 7 u 8 teclas probablemente tiene 32 ranuras, por lo que, sobre esa base, without_elsetiene una colisión hash con __builtins__:

>>> [(k, hash(k) % 32) for k in globals().keys() ]
[('__builtins__', 8), ('with_else', 9), ('__package__', 15), ('without_else', 8), ('T', 21), ('__name__', 25), ('no_else', 28), ('__doc__', 29)]

Para aclarar cómo funciona el hash:

__builtins__ hashes a -1196389688 que redujo el módulo del tamaño de la tabla (32) significa que se almacena en la ranura # 8 de la tabla.

without_elsehashes a 505688136 que redujo el módulo 32 es 8, por lo que hay una colisión. Para resolver este cálculo de Python:

Empezando con:

j = hash % 32
perturb = hash

Repita esto hasta que encontremos un espacio libre:

j = (5*j) + 1 + perturb;
perturb >>= 5;
use j % 2**i as the next table index;

lo que le da 17 para usar como el próximo índice. Afortunadamente, es gratis, por lo que el ciclo solo se repite una vez. El tamaño de la tabla hash es una potencia de 2, por lo que 2**ies el tamaño de la tabla hash, ies el número de bits utilizados a partir del valor hash j.

Cada sonda en la tabla puede encontrar uno de estos:

  • La ranura está vacía, en ese caso la prueba se detiene y sabemos que el valor no está en la tabla.

  • La ranura no se usó, pero se usó en el pasado, en cuyo caso intentaremos el siguiente valor calculado como se indicó anteriormente.

  • El espacio está lleno, pero el valor hash completo almacenado en la tabla no es el mismo que el hash de la clave que estamos buscando (eso es lo que sucede en el caso de __builtins__vs without_else).

  • El espacio está lleno y tiene exactamente el valor hash que queremos, luego Python verifica si la clave y el objeto que estamos buscando son el mismo objeto (que en este caso serán porque las cadenas cortas que podrían ser identificadores están internados, por lo que los identificadores idénticos usan exactamente la misma cadena).

  • Finalmente, cuando el espacio está lleno, el hash coincide exactamente, pero las claves no son el objeto idéntico, entonces y solo entonces Python intentará compararlas para la igualdad. Esto es relativamente lento, pero en el caso de las búsquedas de nombres en realidad no debería suceder.

Duncan
fuente
9
@ Chris, no, la longitud de la cadena no debería ser significativa. La primera vez que hash hash una cadena tomará un tiempo proporcional a la longitud de la cadena, pero luego el hash calculado se almacena en caché en el objeto de cadena por lo que los hash posteriores son O (1).
Duncan
1
Ah ok, no estaba al tanto del almacenamiento en caché, pero eso tiene sentido
Chris Eberle
9
¡Fascinante! ¿Puedo llamarte Sherlock? ;) De todos modos, espero no olvidarme de darle algunos puntos adicionales con una recompensa tan pronto como la pregunta sea elegible.
Voo
44
@mac, no del todo. Agregaré un poco sobre la resolución hash (iba a incluirla en el comentario, pero es más interesante de lo que pensaba).
Duncan
66
@Duncan: muchas gracias por tomarse el tiempo para ilustrar el proceso de hash. Respuesta de primera clase! :)
mac