Velocidades de << >> multiplicación y división

9

Puede usar <<para multiplicar y >>dividir números en python cuando los cronometrado y encuentro que usar la forma de desplazamiento binario es 10 veces más rápido que dividir o multiplicar la forma regular.

¿Por qué está usando <<y >>mucho más rápido que *y /?

¿Cuáles son los procesos detrás de escena que se producen *y son /tan lentos?

Crizly
fuente
2
Bit shift es más rápido en todos los idiomas, no solo Python. Muchos procesadores tienen una instrucción nativa de cambio de bits que la logrará en uno o dos ciclos de reloj.
Robert Harvey
44
Sin embargo, debe tenerse en cuenta que el desplazamiento de bits, en lugar de utilizar los operadores normales de división y multiplicación, generalmente es una mala práctica y puede dificultar la legibilidad.
Azar
66
@crizly Porque, en el mejor de los casos, es una microoptimización y existe una buena posibilidad de que el compilador lo cambie a un cambio en el código de bytes de todos modos (si es posible). Hay excepciones a esto, como cuando el código es extremadamente crítico para el rendimiento, pero la mayoría de las veces todo lo que está haciendo es ofuscar su código.
Azar
77
@Crizly: cualquier compilador con un optimizador decente reconocerá las multiplicaciones y divisiones que se pueden hacer con cambios de bits y generará el código que las usa. No malgastes tu código tratando de burlar al compilador.
Blrfl
2
En esta pregunta en StackOverflow, un microbenchmark encontró un rendimiento ligeramente mejor en Python 3 para la multiplicación por 2 que para un desplazamiento a la izquierda equivalente, para números lo suficientemente pequeños. Creo que rastreé la razón hasta pequeñas multiplicaciones (actualmente) optimizadas de manera diferente a los cambios de bits. Solo muestra que no puede dar por sentado lo que se ejecutará más rápido según la teoría.
Dan Getz

Respuestas:

15

Veamos dos pequeños programas en C que cambian un poco y se dividen.

#include <stdlib.h>

int main(int argc, char* argv[]) {
        int i = atoi(argv[0]);
        int b = i << 2;
}
#include <stdlib.h>

int main(int argc, char* argv[]) {
        int i = atoi(argv[0]);
        int d = i / 4;
}

Estos se compilan gcc -Spara ver cuál será el ensamblaje real.

Con la versión bit shift, desde la llamada atoihasta la devolución:

    callq   _atoi
    movl    $0, %ecx
    movl    %eax, -20(%rbp)
    movl    -20(%rbp), %eax
    shll    $2, %eax
    movl    %eax, -24(%rbp)
    movl    %ecx, %eax
    addq    $32, %rsp
    popq    %rbp
    ret

Mientras que la versión dividida:

    callq   _atoi
    movl    $0, %ecx
    movl    $4, %edx
    movl    %eax, -20(%rbp)
    movl    -20(%rbp), %eax
    movl    %edx, -28(%rbp)         ## 4-byte Spill
    cltd
    movl    -28(%rbp), %r8d         ## 4-byte Reload
    idivl   %r8d
    movl    %eax, -24(%rbp)
    movl    %ecx, %eax
    addq    $32, %rsp
    popq    %rbp
    ret

Con solo mirar esto, hay varias instrucciones más en la versión dividida en comparación con el cambio de bits.

La clave es ¿qué hacen?

En la versión de cambio de bit, la instrucción clave es shll $2, %eaxel cambio lógico a la izquierda: existe la división, y todo lo demás es solo mover valores.

En la versión de división, puede ver el idivl %r8d- pero justo encima de eso hay un cltd(convertir de largo a doble) y algo de lógica adicional alrededor del derrame y la recarga. Este trabajo adicional, sabiendo que estamos tratando con una matemática en lugar de bits, a menudo es necesario para evitar varios errores que pueden ocurrir al hacer solo una matemática de bits.

Hagamos una multiplicación rápida:

#include <stdlib.h>

int main(int argc, char* argv[]) {
    int i = atoi(argv[0]);
    int b = i >> 2;
}
#include <stdlib.h>

int main(int argc, char* argv[]) {
    int i = atoi(argv[0]);
    int d = i * 4;
}

En lugar de pasar por todo esto, hay una línea diferente:

$ diff mult.s bit.s
24c24
> shll $ 2,% eax
---
<sarl $ 2,% eax

Aquí el compilador pudo identificar que las matemáticas podían hacerse con un cambio, sin embargo, en lugar de un cambio lógico, hace un cambio aritmético. La diferencia entre estos sería obvia si los ejecutamos: sarlconserva el signo. Para que -2 * 4 = -8mientras el shllno lo haga.

Veamos esto en un script rápido de perl:

#!/usr/bin/perl

$foo = 4;
print $foo << 2, "\n";
print $foo * 4, "\n";

$foo = -4;
print $foo << 2, "\n";
print $foo * 4, "\n";

Salida:

dieciséis
dieciséis
18446744073709551600
-dieciséis

Um ... -4 << 2es lo 18446744073709551600que no es exactamente lo que esperas cuando se trata de multiplicación y división. Es correcto, pero no es una multiplicación entera.

Y por lo tanto, tenga cuidado con la optimización prematura. Deje que el compilador se optimice por usted: sabe lo que realmente está tratando de hacer y probablemente lo hará mejor, con menos errores.


fuente
12
Puede ser más claro emparejarse << 2con * 4y >> 2con / 4para mantener las direcciones de cambio iguales en cada ejemplo.
Greg Hewgill el
5

Las respuestas existentes realmente no abordaron el lado del hardware de las cosas, así que aquí hay un poco en ese ángulo. La sabiduría convencional es que la multiplicación y la división son mucho más lentas que los cambios, pero la historia real de hoy es más matizada.

Por ejemplo, es cierto que la multiplicación es una operación más compleja de implementar en hardware, pero no siempre termina más lentamente . Como resultado, addtambién es significativamente más complejo de implementar que xor(o en general cualquier operación bit a bit), pero add(y sub) generalmente obtienen suficientes transistores dedicados a su operación que terminan siendo tan rápidos como los operadores bit a bit. Por lo tanto, no puede simplemente mirar la complejidad de la implementación de hardware como una guía para la velocidad.

Así que veamos en detalle el desplazamiento frente a los operadores "completos" como la multiplicación y el desplazamiento.

Cambiando

En casi todo el hardware, el cambio en una cantidad constante (es decir, una cantidad que el compilador puede determinar en tiempo de compilación) es rápido . En particular, generalmente sucederá con una latencia de un solo ciclo y con un rendimiento de 1 por ciclo o mejor. En algunos equipos (p. Ej., Algunos chips Intel y ARM), ciertos cambios por una constante pueden incluso ser "gratuitos", ya que pueden integrarse en otra instrucción ( leaen Intel, las capacidades especiales de cambio de la primera fuente en ARM).

El desplazamiento en una cantidad variable es más un área gris. En hardware antiguo, esto a veces era muy lento y la velocidad cambiaba de generación en generación. Por ejemplo, en el lanzamiento inicial de Intel P4, el cambio en una cantidad variable fue notoriamente lento, ¡lo que requiere un tiempo proporcional a la cantidad de cambio! En esa plataforma, el uso de multiplicaciones para reemplazar los turnos podría ser rentable (es decir, el mundo se ha vuelto al revés). En chips Intel anteriores, así como en generaciones posteriores, el cambio en una cantidad variable no fue tan doloroso.

En los chips Intel actuales, el cambio en una cantidad variable no es particularmente rápido, pero tampoco es terrible. La arquitectura x86 está limitada en lo que respecta a los cambios variables, porque definieron la operación de una manera inusual: las cantidades de cambios de 0 no modifican los indicadores de condición, pero todos los demás cambios sí. Esto inhibe el cambio de nombre eficiente del registro de banderas, ya que no se puede determinar hasta que se ejecute el turno si las instrucciones posteriores deben leer los códigos de condición escritos por el turno, o alguna instrucción previa. Además, los turnos solo escriben en parte del registro de banderas, lo que puede causar un bloqueo parcial de banderas.

El resultado es que en las arquitecturas recientes de Intel, el cambio en una cantidad variable requiere tres "microoperaciones", mientras que la mayoría de las otras operaciones simples (agregar, operaciones bit a bit, incluso multiplicación) solo toman 1. Tales cambios pueden ejecutarse como máximo una vez cada 2 ciclos .

Multiplicación

La tendencia en el hardware moderno de computadoras de escritorio y portátiles es hacer que la multiplicación sea una operación rápida. En los recientes chips Intel y AMD, de hecho, se puede emitir una multiplicación cada ciclo (a esto le llamamos rendimiento recíproco ). La latencia , sin embargo, de una multiplicación es de 3 ciclos. Eso significa que obtienes el resultado de cualquier multiplicación dada 3 ciclos después de comenzar, pero puedes comenzar una nueva multiplicación en cada ciclo. El valor (1 ciclo o 3 ciclos) es más importante depende de la estructura de su algoritmo. Si la multiplicación es parte de una cadena de dependencia crítica, la latencia es importante. De lo contrario, el rendimiento recíproco u otros factores pueden ser más importantes.

La conclusión clave es que en los chips de portátiles modernos (o mejores), la multiplicación es una operación rápida y es probable que sea más rápida que la secuencia de instrucciones 3 o 4 que emitiría un compilador para "obtener el redondeo" correcto para los cambios de fuerza reducida. Para los cambios variables, en Intel, la multiplicación también se preferiría generalmente debido a los problemas mencionados anteriormente.

En plataformas de factor de forma más pequeñas, la multiplicación aún puede ser más lenta, ya que construir un multiplicador completo y rápido de 32 bits o especialmente de 64 bits requiere muchos transistores y potencia. Si alguien puede completar los detalles del rendimiento de la multiplicación en chips móviles recientes, sería muy apreciado.

Dividir

La división es una operación más compleja, en cuanto a hardware, que la multiplicación y también es mucho menos común en el código real, lo que significa que es probable que se le asignen menos recursos. La tendencia en los chips modernos sigue siendo hacia divisores más rápidos, pero incluso los chips modernos de primera línea tardan entre 10 y 40 ciclos en dividirse, y solo están parcialmente canalizados. En general, las divisiones de 64 bits son incluso más lentas que las de 32 bits. A diferencia de la mayoría de las otras operaciones, la división puede tomar un número variable de ciclos dependiendo de los argumentos.

¡Evite las divisiones y reemplácelas con turnos (o deje que el compilador lo haga, pero es posible que deba verificar el ensamblaje) si puede!

BeeOnRope
fuente
2

BINARY_LSHIFT y BINARY_RSHIFT son procesos más simples algorítmicamente que BINARY_MULTIPLY y BINARY_FLOOR_DIVIDE y pueden tomar menos ciclos de reloj. Es decir, si tiene algún número binario y necesita cambiar bits por N, todo lo que tiene que hacer es desplazar los dígitos sobre esos espacios y reemplazarlos con ceros. La multiplicación binaria es en general más complicada , aunque técnicas como el multiplicador Dadda lo hacen bastante rápido.

De acuerdo, es posible que un compilador optimizador reconozca casos cuando multiplica / divide por potencias de dos y reemplaza con el desplazamiento apropiado izquierda / derecha. Al observar el código de byte desmontado, python aparentemente no hace esto:

>>> dis.dis(lambda x: x*4)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (4)
              6 BINARY_MULTIPLY     
              7 RETURN_VALUE        

>>> dis.dis(lambda x: x<<2)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (2)
              6 BINARY_LSHIFT       
              7 RETURN_VALUE        


>>> dis.dis(lambda x: x//2)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (2)
              6 BINARY_FLOOR_DIVIDE 
              7 RETURN_VALUE        

>>> dis.dis(lambda x: x>>1)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (1)
              6 BINARY_RSHIFT       
              7 RETURN_VALUE        

Sin embargo, en mi procesador, encuentro que la multiplicación y el desplazamiento hacia la izquierda / derecha tienen un tiempo similar, y la división del piso (por una potencia de dos) es aproximadamente un 25% más lenta:

>>> import timeit

>>> timeit.repeat("z=a + 4", setup="a = 37")
[0.03717184066772461, 0.03291916847229004, 0.03287005424499512]

>>> timeit.repeat("z=a - 4", setup="a = 37")
[0.03534698486328125, 0.03207516670227051, 0.03196907043457031]

>>> timeit.repeat("z=a * 4", setup="a = 37")
[0.04594111442565918, 0.0408930778503418, 0.045324087142944336]

>>> timeit.repeat("z=a // 4", setup="a = 37")
[0.05412912368774414, 0.05091404914855957, 0.04910898208618164]

>>> timeit.repeat("z=a << 2", setup="a = 37")
[0.04751706123352051, 0.04259490966796875, 0.041903018951416016]

>>> timeit.repeat("z=a >> 2", setup="a = 37")
[0.04719185829162598, 0.04201006889343262, 0.042105913162231445]
dr jimbob
fuente