¿Es "x <y <z" más rápido que "x <y e y <z"?

129

Desde esta página , sabemos que:

Las comparaciones encadenadas son más rápidas que usar el andoperador. Escribe en x < y < zlugar de x < y and y < z.

Sin embargo, obtuve un resultado diferente al probar los siguientes fragmentos de código:

$ python -m timeit "x = 1.2" "y = 1.3" "z = 1.8" "x < y < z"
1000000 loops, best of 3: 0.322 usec per loop
$ python -m timeit "x = 1.2" "y = 1.3" "z = 1.8" "x < y and y < z"
1000000 loops, best of 3: 0.22 usec per loop
$ python -m timeit "x = 1.2" "y = 1.3" "z = 1.1" "x < y < z"
1000000 loops, best of 3: 0.279 usec per loop
$ python -m timeit "x = 1.2" "y = 1.3" "z = 1.1" "x < y and y < z"
1000000 loops, best of 3: 0.215 usec per loop

Parece que x < y and y < zes más rápido que x < y < z. ¿Por qué?

Después de buscar algunas publicaciones en este sitio (como este ), sé que "evaluado solo una vez" es la clave x < y < z, sin embargo, todavía estoy confundido. Para seguir estudiando, desarme estas dos funciones usando dis.dis:

import dis

def chained_compare():
        x = 1.2
        y = 1.3
        z = 1.1
        x < y < z

def and_compare():
        x = 1.2
        y = 1.3
        z = 1.1
        x < y and y < z

dis.dis(chained_compare)
dis.dis(and_compare)

Y la salida es:

## chained_compare ##

  4           0 LOAD_CONST               1 (1.2)
              3 STORE_FAST               0 (x)

  5           6 LOAD_CONST               2 (1.3)
              9 STORE_FAST               1 (y)

  6          12 LOAD_CONST               3 (1.1)
             15 STORE_FAST               2 (z)

  7          18 LOAD_FAST                0 (x)
             21 LOAD_FAST                1 (y)
             24 DUP_TOP
             25 ROT_THREE
             26 COMPARE_OP               0 (<)
             29 JUMP_IF_FALSE_OR_POP    41
             32 LOAD_FAST                2 (z)
             35 COMPARE_OP               0 (<)
             38 JUMP_FORWARD             2 (to 43)
        >>   41 ROT_TWO
             42 POP_TOP
        >>   43 POP_TOP
             44 LOAD_CONST               0 (None)
             47 RETURN_VALUE

## and_compare ##

 10           0 LOAD_CONST               1 (1.2)
              3 STORE_FAST               0 (x)

 11           6 LOAD_CONST               2 (1.3)
              9 STORE_FAST               1 (y)

 12          12 LOAD_CONST               3 (1.1)
             15 STORE_FAST               2 (z)

 13          18 LOAD_FAST                0 (x)
             21 LOAD_FAST                1 (y)
             24 COMPARE_OP               0 (<)
             27 JUMP_IF_FALSE_OR_POP    39
             30 LOAD_FAST                1 (y)
             33 LOAD_FAST                2 (z)
             36 COMPARE_OP               0 (<)
        >>   39 POP_TOP
             40 LOAD_CONST               0 (None)

Parece que x < y and y < ztiene menos comandos disimulados que x < y < z. ¿Debo considerar x < y and y < zmás rápido que x < y < z?

Probado con Python 2.7.6 en una CPU Intel (R) Xeon (R) E5640 @ 2.67GHz.

zangw
fuente
8
Más comandos desmontados no significa más complejidad ni un código más lento. Sin embargo, viendo sus timeitpruebas me interesé en esto.
Marco Bonelli
66
Supuse que la diferencia de velocidad de "evaluado una vez" se produce cuando yno se trata solo de una búsqueda variable, sino de un proceso más costoso como una llamada de función. Es decir, 10 < max(range(100)) < 15es más rápido que 10 < max(range(100)) and max(range(100)) < 15porque max(range(100))se llama una vez para ambas comparaciones.
zehnpaard
2
@MarcoBonelli Lo hace cuando el código desmontado 1) no contiene bucles y 2) cada bytecode es muy muy rápido, porque en ese punto la sobrecarga del bucle principal se vuelve significativa.
Bakuriu
2
La predicción de ramificación puede arruinar sus pruebas.
Corey Ogburn
2
@zehnpaard Estoy de acuerdo contigo. Cuando "y" es más que un valor simple (por ejemplo, una llamada a una función o un cálculo), esperaría que el hecho de que "y" se evalúe una vez en x <y <z tenga un impacto mucho más notable. En el caso presentado, estamos dentro de las barras de error: dominan los efectos de la predicción de rama (fallida), el código de bytes menos que óptimo y otros efectos específicos de la plataforma / CPU. Eso no invalida la regla general de que evaluar una vez es mejor (además de ser más legible), pero muestra que esto puede no agregar tanto valor en los extremos.
MartyMacGyver

Respuestas:

111

La diferencia es que x < y < z ysolo se evalúa una vez. Esto no hace una gran diferencia si y es una variable, pero lo hace cuando se trata de una llamada de función, lo que lleva algún tiempo calcular.

from time import sleep
def y():
    sleep(.2)
    return 1.3
%timeit 1.2 < y() < 1.8
10 loops, best of 3: 203 ms per loop
%timeit 1.2 < y() and y() < 1.8
1 loops, best of 3: 405 ms per loop
Robar
fuente
18
Por supuesto, también podría haber una diferencia semántica. No solo podría y () devolver dos valores diferentes, sino que con una variable la evaluación del operador menor que en x <y podría cambiar el valor de y. Es por eso que a menudo no está optimizado en el código de bytes (si y es una variable no local o una que participa en un cierre, por ejemplo)
Random832
Solo por curiosidad, ¿por qué necesitabas una sleep()función interna?
Profesor
@Prof Eso es simular una función que lleva algún tiempo calcular el resultado. Si las funciones regresan de inmediato, no habrá mucha diferencia entre los dos resultados de tiempo.
Rob
@Rob ¿Por qué no habría mucha diferencia? Sería 3 ms frente a 205 ms, eso lo demuestra bastante bien, ¿no?
Prof
@Prof Se pierde el punto de que y () se calcula dos veces, por lo que es 2x200ms en lugar de 1x200ms. El resto (3/5 ms) es ruido irrelevante en la medición de tiempo.
Rob
22

El código de bytes óptimo para las dos funciones que definió sería

          0 LOAD_CONST               0 (None)
          3 RETURN_VALUE

porque el resultado de la comparación no se usa. Hagamos que la situación sea más interesante devolviendo el resultado de la comparación. Tengamos también que el resultado no se pueda conocer en tiempo de compilación.

def interesting_compare(y):
    x = 1.1
    z = 1.3
    return x < y < z  # or: x < y and y < z

Una vez más, las dos versiones de la comparación son semánticamente idénticas, por lo que el código de bytes óptimo es el mismo para ambas construcciones. Lo mejor que puedo resolver, se vería así. He anotado cada línea con el contenido de la pila antes y después de cada código de operación, en notación Forth (la parte superior de la pila a la derecha, --divide antes y después, el seguimiento ?indica algo que podría o no estar allí). Tenga en cuenta que RETURN_VALUEdescarta todo lo que queda en la pila debajo del valor devuelto.

          0 LOAD_FAST                0 (y)    ;          -- y
          3 DUP_TOP                           ; y        -- y y
          4 LOAD_CONST               0 (1.1)  ; y y      -- y y 1.1
          7 COMPARE_OP               4 (>)    ; y y 1.1  -- y pred
         10 JUMP_IF_FALSE_OR_POP     19       ; y pred   -- y
         13 LOAD_CONST               1 (1.3)  ; y        -- y 1.3
         16 COMPARE_OP               0 (<)    ; y 1.3    -- pred
     >>  19 RETURN_VALUE                      ; y? pred  --

Si una implementación del lenguaje, CPython, PyPy, lo que sea, no genera este bytecode (o su propia secuencia equivalente de operaciones) para ambas variaciones, eso demuestra la mala calidad de ese compilador de bytecode . Obtener de las secuencias de código de bytes que publicó en lo anterior es un problema resuelto (creo que todo lo que necesita para este caso es un plegado constante , eliminación de código muerto y un mejor modelado del contenido de la pila; la eliminación de subexpresión común también sería barata y valiosa ), y realmente no hay excusa para no hacerlo en una implementación de lenguaje moderno.

Ahora, sucede que todas las implementaciones actuales del lenguaje tienen compiladores de bytecode de baja calidad. ¡Pero debes ignorar eso mientras codificas! Imagine que el compilador de bytecode es bueno y escriba el código más legible . Probablemente será lo suficientemente rápido de todos modos. Si no es así, busque primero las mejoras algorítmicas y pruebe Cython en segundo lugar; eso proporcionará muchas más mejoras por el mismo esfuerzo que cualquier ajuste de nivel de expresión que pueda aplicar.

zwol
fuente
Bueno, la optimización más importante tendría que ser posible en primer lugar: la alineación. Y eso está lejos de ser un "problema resuelto" para los lenguajes dinámicos que permiten cambiar la implementación de una función dinámicamente (aunque factible, HotSpot puede hacer cosas similares y cosas como Graal están trabajando para hacer que este tipo de optimizaciones estén disponibles para Python y otros lenguajes dinámicos ) Y dado que la función en sí misma puede ser llamada desde diferentes módulos (¡o una llamada podría generarse dinámicamente!) Realmente no puede hacer estas optimizaciones allí.
Voo
1
@Voo Mi bytecode optimizado a mano tiene exactamente la misma semántica que el original incluso en presencia de dinamismo arbitrario (una excepción: se supone a <b ≡ b> a). Además, la alineación está sobrevalorada. Si haces demasiado, y es demasiado fácil hacer demasiado, explotas el caché I y pierdes todo lo que ganaste y algo más.
zwol
Tienes razón. Pensé que querías decir que querías optimizar tu interesting_comparecódigo de bytes simple en la parte superior (que solo funcionaría con la inserción). Completamente fuera de tema, pero: la integración es una de las optimizaciones más esenciales para cualquier compilador. Puede intentar ejecutar algunos puntos de referencia con say HotSpot en programas reales (no algunas pruebas de matemáticas que pasan el 99% de su tiempo en un bucle dinámico optimizado a mano [y, por lo tanto, no tiene más llamadas a funciones de todos modos]) cuando deshabilita su capacidad de alinear cualquier cosa: verá grandes regresiones.
Voo
@Voo Sí, se suponía que el simple bytecode en la parte superior era la "versión óptima del" código original del OP, no interesting_compare.
zwol
"una excepción: a <b ≡ b> a se supone" → que simplemente no es cierto en Python. Además, creo que CPython ni siquiera puede asumir que las operaciones yno cambian la pila, ya que tiene muchas herramientas de depuración.
Veedrac
8

Dado que la diferencia en el resultado parece deberse a la falta de optimización, creo que debería ignorar esa diferencia en la mayoría de los casos; podría ser que la diferencia desaparecerá. La diferencia es porque ysolo debe evaluarse una vez y eso se resuelve duplicándolo en la pila, lo que requiere un extra POP_TOP; LOAD_FASTsin embargo , la solución para usar podría ser posible.

Sin embargo, la diferencia importante es que en x<y and y<zel segundo ydebe evaluarse dos veces si se x<yevalúa como verdadero, esto tiene implicaciones si la evaluación ylleva un tiempo considerable o tiene efectos secundarios.

En la mayoría de los escenarios, debe usar a x<y<zpesar de que es algo más lento.

Rey del cielo
fuente
6

En primer lugar, su comparación no tiene sentido porque las dos construcciones diferentes no se introdujeron para proporcionar una mejora del rendimiento, por lo que no debe decidir si usar una en lugar de la otra en función de eso.

El x < y < zconstructo:

  1. Es más claro y más directo en su significado.
  2. Su semántica es lo que se espera del "significado matemático" de la comparación: evalute x, yy z una vez y comprobar si toda la condición se cumple. El uso andcambia la semántica al evaluar yvarias veces, lo que puede cambiar el resultado .

Por lo tanto, elija uno en lugar del otro según la semántica que desee y, si son equivalentes, si uno es más legible que el otro.

Dicho esto: un código más desmontado no implica un código más lento. Sin embargo, ejecutar más operaciones de bytecode significa que cada operación es más simple y, sin embargo, requiere una iteración del bucle principal. Esto significa que si las operaciones que está realizando son extremadamente rápidas (por ejemplo, búsqueda de variables locales como lo está haciendo allí), entonces la sobrecarga de ejecutar más operaciones de bytecode puede ser importante.

Pero tenga en cuenta que este resultado no se cumple en la situación más genérica, solo en el "peor de los casos" que le sucede al perfil. Como otros han señalado, si cambias ya algo que lleva incluso un poco más de tiempo, verás que los resultados cambian, porque la notación encadenada lo evalúa solo una vez.

Resumiendo:

  • Considere la semántica antes del desempeño.
  • Tener en cuenta la legibilidad.
  • No confíes en los micro puntos de referencia. Siempre perfile con diferentes tipos de parámetros para ver cómo se comporta una sincronización de función / expresión en relación con dichos parámetros y considere cómo planea usarla.
Bakuriu
fuente
55
Creo que su respuesta no incluye el hecho directo y relevante de que la página citada, en el caso particular de la pregunta, comparar flotadores, es simplemente incorrecta. La comparación encadenada no es más rápida como se ve en ambas mediciones y en el código de bytes generado.
pvg
30
Responder una pregunta etiquetada rendimiento con "tal vez no deberías pensar tanto en el rendimiento" no me parece útil. Está haciendo suposiciones potencialmente condescendientes sobre la comprensión del interrogador de los principios generales de programación y luego está hablando principalmente de ellos en lugar del tema en cuestión.
Ben Millwood
@Veerdac, estás leyendo mal el comentario. La optimización propuesta en el documento original en el que se basaba el OP es incorrecta, en el caso de flotadores como mínimo. No es mas rapido.
pvg