¿La multiplicación y división usando operadores de desplazamiento en C es realmente más rápida?

288

La multiplicación y la división se pueden lograr utilizando operadores de bits, por ejemplo

i*2 = i<<1
i*3 = (i<<1) + i;
i*10 = (i<<3) + (i<<1)

y así.

¿Es realmente más rápido usar say (i<<3)+(i<<1)para multiplicar por 10 que usar i*10directamente? ¿Hay algún tipo de entrada que no se pueda multiplicar o dividir de esta manera?

eku
fuente
8
En realidad, es posible una división barata por una constante que no sea una potencia de dos, pero es un tema complicado al que no le estás haciendo justicia con "/ División ... / dividido" en tu pregunta. Vea, por ejemplo, hackersdelight.org/divcMore.pdf (u obtenga el libro "Hacker's deleite" si puede).
Pascal Cuoq
46
Suena como algo que podría ser fácilmente probado.
juanchopanza
25
Como de costumbre, depende. Había una vez que probé esto en ensamblador en un Intel 8088 (IBM PC / XT) donde una multiplicación tomó un bazillion de relojes. Los cambios y las adiciones se ejecutan mucho más rápido, por lo que parecía una buena idea. Sin embargo, mientras se multiplicaba, la unidad de autobús era libre de llenar la cola de instrucciones y la siguiente instrucción podía comenzar inmediatamente. Después de una serie de cambios y adiciones, la cola de instrucciones estaría vacía y la CPU tendría que esperar a que la siguiente instrucción se recupere de la memoria (¡un byte a la vez!). Medida, medida, medida!
Bo Persson
19
Además, tenga en cuenta que el desplazamiento a la derecha solo está bien definido para enteros sin signo . Si tiene un entero con signo, no se define si 0 o el bit más alto se rellenan desde la izquierda. (¡Y no olvide el tiempo que le toma a otra persona (incluso a usted mismo) leer el código un año después!)
Kerrek SB
29
En realidad, un buen compilador optimizador implementará multiplicación y división con cambios cuando sean más rápidos.
Peter G.

Respuestas:

487

Respuesta corta: no es probable.

Respuesta larga: su compilador tiene un optimizador que sabe cómo multiplicarse tan rápido como su arquitectura de procesador de destino es capaz. Su mejor opción es decirle claramente al compilador su intención (es decir, i * 2 en lugar de i << 1) y dejar que decida cuál es la secuencia de código de ensamblaje / máquina más rápida. Incluso es posible que el procesador mismo haya implementado la instrucción de multiplicación como una secuencia de cambios y adiciones en microcódigo.

En pocas palabras: no pase mucho tiempo preocupándose por esto. Si quieres cambiar, cambia. Si quieres multiplicar, multiplica. Haga lo que sea semánticamente más claro: sus compañeros de trabajo se lo agradecerán más tarde. O, más probablemente, te maldiga más tarde si no lo haces.

Drew Hall
fuente
31
Sí, como se dijo, las posibles ganancias para casi todas las aplicaciones superarán totalmente la oscuridad introducida. No se preocupe por este tipo de optimización prematuramente. Construya lo que es extremadamente claro, identifique cuellos de botella y optimice a partir de ahí ...
Dave
44
De acuerdo, la optimización de la legibilidad y el mantenimiento probablemente le dará más tiempo para dedicar realmente a optimizar las cosas que, según el perfilador , son rutas de código activo.
doug65536
55
Estos comentarios hacen que parezca que estás renunciando al rendimiento potencial al decirle al compilador cómo hacer su trabajo. Este no es el caso. De hecho, obtienes un mejor código gcc -O3en x86 con return i*10que en la versión de turno . Como alguien que mira mucho la salida del compilador (vea muchas de mis respuestas de asm / optimización), no me sorprende. Hay momentos en los que puede ayudar sostener el compilador a mano en una forma de hacer las cosas , pero esta no es una de ellas. gcc es bueno en matemáticas de enteros, porque es importante.
Peter Cordes
Acabo de descargar un boceto arduino que tiene millis() >> 2; ¿Hubiera sido demasiado pedir simplemente dividir?
Paul Wieland
1
Probé i / 32vs i >> 5y i / 4vs i >> 2en gcc para cortex-a9 (que no tiene división de hardware) con optimización -O3 y el ensamblaje resultante fue exactamente el mismo. No me gustó usar divisiones primero, pero describe mi intención y el resultado es el mismo.
robsn
91

Solo un punto de medida concreto: muchos años atrás, comparé dos versiones de mi algoritmo de hash:

unsigned
hash( char const* s )
{
    unsigned h = 0;
    while ( *s != '\0' ) {
        h = 127 * h + (unsigned char)*s;
        ++ s;
    }
    return h;
}

y

unsigned
hash( char const* s )
{
    unsigned h = 0;
    while ( *s != '\0' ) {
        h = (h << 7) - h + (unsigned char)*s;
        ++ s;
    }
    return h;
}

En cada máquina en la que lo comparé, la primera fue al menos tan rápida como la segunda. Sorprendentemente, a veces era más rápido (por ejemplo, en un Sun Sparc). Cuando el hardware no admitía la multiplicación rápida (y la mayoría no lo hacía en ese entonces), el compilador convertía la multiplicación en las combinaciones apropiadas de turnos y suma / sub. Y debido a que conocía el objetivo final, a veces podría hacerlo en menos instrucciones que cuando escribiste explícitamente los turnos y los add / subs.

Tenga en cuenta que esto fue algo así como hace 15 años. Con suerte, los compiladores solo han mejorado desde entonces, por lo que puede contar con que el compilador haga lo correcto, probablemente mejor de lo que podría. (Además, la razón por la que el código parece tan C'ish es porque fue hace más de 15 años. Obviamente usaría std::stringe iteradores hoy).

James Kanze
fuente
55
Puede que le interese la siguiente publicación de blog, en la que el autor señala que los compiladores de optimización modernos parecen aplicar ingeniería inversa a patrones comunes que los programadores podrían usar pensando que son más eficientes en sus formas matemáticas para generar realmente la secuencia de instrucciones más eficiente para ellos . shape-of-code.coding-guidelines.com/2009/06/30/…
Pascal Cuoq
@PascalCuoq Nada realmente nuevo sobre esto. Descubrí casi lo mismo para Sun CC hace aproximadamente 20 años.
James Kanze
67

Además de todas las otras buenas respuestas aquí, permítanme señalar otra razón para no usar shift cuando se refiere a dividir o multiplicar. Nunca he visto a alguien introducir un error al olvidar la precedencia relativa de la multiplicación y la suma. He visto errores introducidos cuando los programadores de mantenimiento olvidaron que "multiplicar" a través de un turno es lógicamente una multiplicación pero no sintácticamente de la misma precedencia que la multiplicación. x * 2 + zy x << 1 + zson muy diferentes!

Si está trabajando en números , utilice operadores aritméticos como + - * / %. Si está trabajando en matrices de bits, use operadores de giro de bits como & ^ | >>. No los mezcles; Una expresión que tiene tanto bit twiddling como aritmética es un error que espera suceder.

Eric Lippert
fuente
55
¿Evitable con paréntesis simples?
Joel B
21
@ Joel: Claro. Si recuerdas que los necesitas. Mi punto es que es fácil olvidar que lo haces. Las personas que tienen el hábito mental de leer "x << 1" como si fuera "x * 2" tienen el hábito mental de pensar que << es la misma precedencia que la multiplicación, que no lo es.
Eric Lippert
1
Bueno, la expresión "(hi << 8) + lo" me parece más reveladora que "hi * 256 + lo". Probablemente sea una cuestión de gustos, pero a veces es más claro escribir un poco. En la mayoría de los casos, aunque estoy totalmente de acuerdo con tu punto.
Ivan Danilov
32
@Ivan: Y "(hola << 8) | lo" es aún más claro. Establecer los bits bajos de una matriz de bits no es la suma de enteros . Está configurando bits , así que escriba el código que establece los bits.
Eric Lippert
1
Guau. No lo pensé así antes. Gracias.
Ivan Danilov
50

Esto depende del procesador y el compilador. Algunos compiladores ya optimizan el código de esta manera, otros no. Por lo tanto, debe verificar cada vez que su código debe optimizarse de esta manera.

A menos que necesite optimizar desesperadamente, no codificaría mi código fuente solo para guardar una instrucción de ensamblaje o un ciclo de procesador.

Jens
fuente
3
Solo para agregar una estimación aproximada: en un procesador típico de 16 bits (80C166), agregar dos entradas viene en 1-2 ciclos, una multiplicación en 10 ciclos y una división en 20 ciclos. Además de algunas operaciones de movimiento si optimiza i * 10 en múltiples operaciones (cada movimiento otro ciclo +1). Los compiladores más comunes (Keil / Tasking) no se optimizan a menos que sean multiplicaciones / divisiones por una potencia de 2.
Jens
55
Y en general, el compilador optimiza el código mejor que tú.
user703016
Estoy de acuerdo en que al multiplicar "cantidades", el operador de multiplicación es generalmente mejor, pero al dividir los valores con signo por potencias de 2, el >>operador es más rápido /y, si los valores con signo pueden ser negativos, a menudo también es semánticamente superior. Si uno necesita el valor que x>>4produciría, eso es mucho más claro que x < 0 ? -((-1-x)/16)-1 : x/16;eso, y no puedo imaginar cómo un compilador podría optimizar esa última expresión para algo agradable.
supercat
38

¿Es realmente más rápido usar say (i << 3) + (i << 1) para multiplicar por 10 que usar i * 10 directamente?

Puede o no estar en su máquina; si le importa, mida su uso en el mundo real.

Un estudio de caso: del 486 al Core i7

La evaluación comparativa es muy difícil de hacer de manera significativa, pero podemos ver algunos hechos. De http://www.penguin.cz/~literakl/intel/s.html#SAL y http://www.penguin.cz/~literakl/intel/i.html#IMUL tenemos una idea de los ciclos de reloj x86 necesario para el cambio aritmético y la multiplicación. Digamos que nos atenemos a "486" (el más nuevo en la lista), registros de 32 bits e inmediatos, IMUL toma 13-42 ciclos e IDIV 44. Cada SAL toma 2 y agrega 1, por lo que incluso con algunos de ellos juntos cambian superficialmente como un ganador

En estos días, con el Core i7:

(de http://software.intel.com/en-us/forums/showthread.php?t=61481 )

La latencia es 1 ciclo para una suma entera y 3 ciclos para una multiplicación entera . Puede encontrar las latencias y el rendimiento en el Apéndice C del "Manual de referencia de optimización de arquitecturas Intel® 64 e IA-32", que se encuentra en http://www.intel.com/products/processor/manuals/ .

(de alguna propaganda de Intel)

Usando SSE, el Core i7 puede emitir instrucciones simultáneas de sumar y multiplicar, lo que resulta en una tasa máxima de 8 operaciones de punto flotante (FLOP) por ciclo de reloj

Eso te da una idea de cuán lejos han llegado las cosas. La trivia de optimización, como el cambio de bit versus *, que se tomó en serio incluso en los años 90, ahora es obsoleta. El cambio de bits es aún más rápido, pero para mul / div sin potencia de dos para el momento en que realiza todos sus cambios y agrega los resultados, es más lento nuevamente. Luego, más instrucciones significan más fallas de caché, más problemas potenciales en la canalización, más uso de registros temporales puede significar más ahorro y restauración del contenido del registro de la pila ... rápidamente se vuelve demasiado complicado cuantificar todos los impactos definitivamente, pero son predominantemente negativo

funcionalidad en código fuente vs implementación

En términos más generales, su pregunta está etiquetada con C y C ++. Como lenguajes de tercera generación, están diseñados específicamente para ocultar los detalles del conjunto de instrucciones de CPU subyacente. Para satisfacer sus estándares de idioma, deben admitir operaciones de multiplicación y desplazamiento (y muchas otras) incluso si el hardware subyacente no lo hace . En tales casos, deben sintetizar el resultado requerido utilizando muchas otras instrucciones. Del mismo modo, deben proporcionar soporte de software para operaciones de coma flotante si la CPU carece de ella y no hay FPU. Las CPU modernas son compatibles *y<<, por lo que esto puede parecer absurdamente teórico e histórico, pero lo importante es que la libertad de elegir la implementación va en ambos sentidos: incluso si la CPU tiene una instrucción que implementa la operación solicitada en el código fuente en el caso general, el compilador es libre de elige otra cosa que prefiera porque es mejor para el caso específico al que se enfrenta el compilador.

Ejemplos (con un lenguaje ensamblador hipotético)

source           literal approach         optimised approach
#define N 0
int x;           .word x                xor registerA, registerA
x *= N;          move x -> registerA
                 move x -> registerB
                 A = B * immediate(0)
                 store registerA -> x
  ...............do something more with x...............

Las instrucciones como exclusive o ( xor) no tienen relación con el código fuente, pero al hacer cualquier cosa en sí mismo se borran todos los bits, por lo que se puede usar para establecer algo en 0. El código fuente que implica que las direcciones de memoria no implican que se use.

Este tipo de hacks se han utilizado durante tanto tiempo como las computadoras han existido. En los primeros días de los 3GLs, para asegurar la aceptación del desarrollador, la salida del compilador tenía que satisfacer al desarrollador de lenguaje ensamblador hardcore optimizado a mano. comunidad que el código producido no era más lento, más detallado o peor. Los compiladores adoptaron rápidamente muchas optimizaciones excelentes: se convirtieron en una tienda mejor centralizada de lo que podría ser cualquier programador de lenguaje ensamblador individual, aunque siempre existe la posibilidad de que pierdan una optimización específica que resulta crucial en un caso específico; los humanos a veces pueden enloquece y busca algo mejor mientras los compiladores simplemente hacen lo que se les ha dicho hasta que alguien les transmita esa experiencia.

Entonces, incluso si cambiar y agregar aún es más rápido en algún hardware en particular, es probable que el escritor del compilador haya funcionado exactamente cuando es seguro y beneficioso.

Mantenibilidad

Si su hardware cambia, puede volver a compilar y mirará la CPU de destino y tomará otra mejor decisión, mientras que es poco probable que desee volver a visitar sus "optimizaciones" o enumerar qué entornos de compilación deberían usar la multiplicación y cuáles deberían cambiar. ¡Piense en todas las "optimizaciones" desplazadas en bits sin potencia de dos escrito hace más de 10 años que ahora están ralentizando el código en el que se encuentran mientras se ejecuta en procesadores modernos ...!

Afortunadamente, los buenos compiladores como GCC generalmente pueden reemplazar una serie de cambios de bits y aritmética con una multiplicación directa cuando se habilita cualquier optimización (es decir, ...main(...) { return (argc << 4) + (argc << 2) + argc; }-> imull $21, 8(%ebp), %eax), por lo que una recompilación puede ayudar incluso sin corregir el código, pero eso no está garantizado.

El extraño código de cambio de bits que implementa la multiplicación o división es mucho menos expresivo de lo que intentaba lograr conceptualmente, por lo que otros desarrolladores se sentirán confundidos por eso, y es más probable que un programador confuso introduzca errores o elimine algo esencial en un esfuerzo por restaurar la aparente cordura. Si solo haces cosas no obvias cuando son realmente beneficiosas y luego las documentas bien (pero no documentas otras cosas que son intuitivas de todos modos), todos serán más felices.

Soluciones generales versus soluciones parciales

Si usted tiene algún conocimiento adicional, como que su intvoluntad en realidad sólo puede almacenar valores x, yy z, a continuación, puede ser capaz de trabajar a cabo algunas instrucciones que el trabajo de esos valores y se obtiene el resultado más rápidamente que cuando el compilador de no tiene esa idea y necesita una implementación que funcione para todos los intvalores. Por ejemplo, considere su pregunta:

La multiplicación y la división se pueden lograr utilizando operadores de bits ...

Ilustras la multiplicación, pero ¿qué tal la división?

int x;
x >> 1;   // divide by 2?

De acuerdo con el estándar C ++ 5.8:

-3- El valor de E1 >> E2 es E1 posiciones de bit E2 desplazadas a la derecha. Si E1 tiene un tipo sin signo o si E1 tiene un tipo con signo y un valor no negativo, el valor del resultado es la parte integral del cociente de E1 dividido por la cantidad 2 elevada a la potencia E2. Si E1 tiene un tipo con signo y un valor negativo, el valor resultante está definido por la implementación.

Por lo tanto, su cambio de bit tiene un resultado definido de implementación cuando xes negativo: es posible que no funcione de la misma manera en diferentes máquinas. Pero, /funciona mucho más predecible. (Puede que tampoco sea perfectamente consistente, ya que diferentes máquinas pueden tener diferentes representaciones de números negativos y, por lo tanto, diferentes rangos incluso cuando hay el mismo número de bits que componen la representación).

Puede decir "No me importa ... eso intes almacenar la edad del empleado, nunca puede ser negativo". Si tiene ese tipo de información especial, entonces sí, >>el compilador podría pasar por alto su optimización segura a menos que lo haga explícitamente en su código. Pero es arriesgado y rara vez útil la mayor parte del tiempo, no tendrá este tipo de información, y otros programadores que trabajan en el mismo código no sabrán que ha apostado la casa por algunas expectativas inusuales de los datos que usted ' estaré manejando ... lo que parece un cambio totalmente seguro para ellos podría ser contraproducente debido a su "optimización".

¿Hay algún tipo de entrada que no se pueda multiplicar o dividir de esta manera?

Sí ... como se mencionó anteriormente, los números negativos tienen un comportamiento definido de implementación cuando se "divide" por desplazamiento de bits.

Tony Delroy
fuente
2
Muy buena respuesta. ¡La comparación de Core i7 vs. 486 es esclarecedora!
Drew Hall
En todas las arquitecturas comunes, intVal>>1tendrá la misma semántica que difiere de las de intVal/2una manera que a veces es útil. Si uno necesita calcular de manera portátil el valor que generarían las arquitecturas comunes intVal >> 1, la expresión debería ser bastante más complicada y más difícil de leer, y probablemente generaría un código sustancialmente inferior al producido para intVal >> 1.
supercat
35

Acabo de probar en mi máquina compilando esto:

int a = ...;
int b = a * 10;

Al desmontarlo produce salida:

MOV EAX,DWORD PTR SS:[ESP+1C] ; Move a into EAX
LEA EAX,DWORD PTR DS:[EAX+EAX*4] ; Multiply by 5 without shift !
SHL EAX, 1 ; Multiply by 2 using shift

Esta versión es más rápida que su código optimizado a mano con puro cambio y adición.

Realmente nunca se sabe con qué se va a encontrar el compilador, por lo que es mejor simplemente escribir una multiplicación normal y dejar que optimice de la manera que quiera, excepto en casos muy precisos en los que sabe que el compilador no puede optimizar.

usuario703016
fuente
1
Habrías recibido un gran voto a favor si hubieras omitido la parte del vector. Si el compilador puede arreglar la multiplicación, también puede ver que el vector no cambia.
Bo Persson
¿Cómo puede un compilador saber que el tamaño de un vector no cambiará sin hacer algunas suposiciones realmente peligrosas? ¿O nunca has oído hablar de concurrencia ...
Charles Goodwin
1
Ok, ¿entonces recorres un vector global sin bloqueos? Y recorro un vector local cuya dirección no se ha tomado, y llamo solo a las funciones de miembro constante. Al menos mi compilador se da cuenta de que el tamaño del vector no cambiará. (y pronto alguien probablemente nos marcará para chatear :-).
Bo Persson
1
@BoPersson Finalmente, después de todo este tiempo, eliminé mi declaración acerca de que el compilador no podía optimizar vector<T>::size(). ¡Mi compilador era bastante antiguo! :)
user703016
21

El cambio es generalmente mucho más rápido que multiplicar en un nivel de instrucción, pero es posible que esté perdiendo el tiempo haciendo optimizaciones prematuras. El compilador puede realizar estas optimizaciones en tiempo de compilación. Hacerlo usted mismo afectará la legibilidad y posiblemente no tenga ningún efecto en el rendimiento. Probablemente solo valga la pena hacer cosas como esta si ha realizado un perfil y ha encontrado que se trata de un cuello de botella.

En realidad, el truco de la división, conocido como 'división mágica', en realidad puede generar grandes ganancias. Una vez más, debe hacer un perfil primero para ver si es necesario. Pero si lo usa, existen programas útiles para ayudarlo a descubrir qué instrucciones se necesitan para la misma semántica de división. Aquí hay un ejemplo: http://www.masm32.com/board/index.php?topic=12421.0

Un ejemplo que he sacado del hilo del OP en MASM32:

include ConstDiv.inc
...
mov eax,9999999
; divide eax by 100000
cdiv 100000
; edx = quotient

Generaría:

mov eax,9999999
mov edx,0A7C5AC47h
add eax,1
.if !CARRY?
    mul edx
.endif
shr edx,16
Mike Kwan
fuente
77
@Drew por alguna razón tu comentario me hizo reír y derramar mi café. Gracias.
asawyer
30
No hay hilos de foro aleatorios sobre el gusto por las matemáticas. Cualquiera a quien le gusten las matemáticas sabe lo difícil que es generar un hilo de foro "aleatorio" verdadero.
Joel B
1
Probablemente solo valga la pena hacer cosas como esta si ha perfilado y encontrado que esto es un cuello de botella e implementado las alternativas y el perfil nuevamente y obtiene al menos 10 veces la ventaja de rendimiento .
Lie Ryan
12

Las instrucciones de multiplicación de números enteros y de cambio tienen un rendimiento similar en la mayoría de las CPU modernas: las instrucciones de multiplicación de números enteros fueron relativamente lentas en la década de 1980, pero en general esto ya no es cierto. Las instrucciones de multiplicación de enteros pueden tener una latencia más alta , por lo que aún puede haber casos en los que sea preferible un cambio. Lo mismo ocurre con los casos en que puede mantener ocupadas más unidades de ejecución (aunque esto puede reducir en ambos sentidos).

Sin embargo, la división entera todavía es relativamente lenta, por lo que usar un cambio en lugar de la división por una potencia de 2 sigue siendo una victoria, y la mayoría de los compiladores implementarán esto como una optimización. Sin embargo, tenga en cuenta que para que esta optimización sea válida, el dividendo no debe estar firmado o debe ser positivo. ¡Para un dividendo negativo, el desplazamiento y la división no son equivalentes!

#include <stdio.h>

int main(void)
{
    int i;

    for (i = 5; i >= -5; --i)
    {
        printf("%d / 2 = %d, %d >> 1 = %d\n", i, i / 2, i, i >> 1);
    }
    return 0;
}

Salida:

5 / 2 = 2, 5 >> 1 = 2
4 / 2 = 2, 4 >> 1 = 2
3 / 2 = 1, 3 >> 1 = 1
2 / 2 = 1, 2 >> 1 = 1
1 / 2 = 0, 1 >> 1 = 0
0 / 2 = 0, 0 >> 1 = 0
-1 / 2 = 0, -1 >> 1 = -1
-2 / 2 = -1, -2 >> 1 = -1
-3 / 2 = -1, -3 >> 1 = -2
-4 / 2 = -2, -4 >> 1 = -2
-5 / 2 = -2, -5 >> 1 = -3

Entonces, si desea ayudar al compilador, asegúrese de que la variable o expresión en el dividendo esté explícitamente sin signo.

Paul R
fuente
44
Las multiplicaciones de enteros se microcodifican, por ejemplo, en la PPU de PlayStation 3 y bloquean toda la tubería. Se recomienda evitar la multiplicación de enteros en algunas plataformas todavía :)
Maister
2
Muchas divisiones sin signo son, suponiendo que el compilador sepa cómo, implementadas usando multiplicaciones sin signo. Una o dos multiplicaciones @ unos pocos ciclos de reloj cada una puede hacer el mismo trabajo que una división @ 40 ciclos cada una y más.
Olof Forshell
1
@Olof: cierto, pero solo válido para la división por una constante de tiempo de compilación, por supuesto
Paul R
4

Depende completamente del dispositivo de destino, el idioma, el propósito, etc.

¿Crujido de píxeles en un controlador de tarjeta de video? Muy probablemente sí!

¿Aplicación comercial .NET para su departamento? Absolutamente no hay razón para siquiera mirarlo.

Para un juego de alto rendimiento para un dispositivo móvil, puede valer la pena analizarlo, pero solo después de que se hayan realizado optimizaciones más fáciles.

Brady Moritz
fuente
2

No lo haga a menos que sea absolutamente necesario y la intención de su código requiera cambios en lugar de multiplicación / división.

En un día típico, podría ahorrar potentemente algunos ciclos de la máquina (o perder, ya que el compilador sabe mejor qué optimizar), pero el costo no vale la pena: pasa tiempo en detalles menores en lugar del trabajo real, mantener el código se vuelve más difícil y tus compañeros de trabajo te maldecirán.

Es posible que deba hacerlo para cálculos de alta carga, donde cada ciclo guardado significa minutos de tiempo de ejecución. Pero, debe optimizar un lugar a la vez y hacer pruebas de rendimiento cada vez para ver si realmente lo hizo más rápido o si rompió la lógica de los compiladores.

Kromster
fuente
1

Hasta donde yo sé en algunas máquinas, la multiplicación puede necesitar hasta 16 a 32 ciclos de máquina. Entonces , , dependiendo del tipo de máquina, los operadores de desplazamiento de bits son más rápidos que la multiplicación / división.

Sin embargo, ciertas máquinas tienen su procesador matemático, que contiene instrucciones especiales para la multiplicación / división.

iammilind
fuente
77
Es probable que las personas que escriben compiladores para esas máquinas también hayan leído Hackers Delight y optimicen en consecuencia.
Bo Persson
1

Estoy de acuerdo con la respuesta marcada por Drew Hall. Sin embargo, la respuesta podría usar algunas notas adicionales.

Para la gran mayoría de los desarrolladores de software, el procesador y el compilador ya no son relevantes para la pregunta. La mayoría de nosotros estamos mucho más allá del 8088 y MS-DOS. Tal vez solo sea relevante para aquellos que aún están desarrollando procesadores integrados ...

En mi empresa de software, Math (add / sub / mul / div) debería usarse para todas las matemáticas. Mientras que Shift debe usarse al convertir entre tipos de datos, por ejemplo. ushort al byte como n >> 8 y no n / 256.

deegee
fuente
Estoy de acuerdo con usted también. Sigo la misma pauta inconscientemente, aunque nunca he tenido un requisito formal para hacerlo.
Drew Hall
0

En el caso de los enteros con signo y el desplazamiento a la derecha frente a la división, puede marcar la diferencia. Para números negativos, el desplazamiento redondea hacia el infinito negativo mientras que la división se redondea hacia cero. Por supuesto, el compilador cambiará la división a algo más barato, pero generalmente lo cambiará a algo que tenga el mismo comportamiento de redondeo que la división, porque no puede probar que la variable no será negativa o simplemente no cuidado. Entonces, si puede probar que un número no será negativo o si no le importa de qué manera se redondeará, puede hacer esa optimización de una manera que sea más probable que marque la diferencia.

harold
fuente
o echa el número aunsigned
Lie Ryan
44
¿Estás seguro de que el comportamiento cambiante está estandarizado? Tenía la impresión de que el desplazamiento a la derecha en entradas negativas está definido por la implementación.
Kerrek SB
1
Si bien quizás debería mencionar que el código que se basa en cualquier comportamiento particular para los números negativos de desplazamiento a la derecha debería documentar ese requisito, la ventaja de desplazar a la derecha es enorme en los casos en que naturalmente produce el valor correcto y el operador de división generaría código para desperdiciar tiempo calculando un valor no deseado que el código de usuario tendría que perder más tiempo ajustando para obtener lo que el cambio hubiera dado en primer lugar. En realidad, si tuviera mis druthers, los compiladores tendrían la opción de graznar ante los intentos de realizar una división firmada, desde ...
supercat el
1
... el código que sabe que los operandos son positivos podría mejorar la optimización si se convierte en sin signo antes de la división (posiblemente volviendo a firmar después), y el código que sabe que los operandos podrían ser negativos generalmente debería tratar ese caso explícitamente de todos modos (en cuyo caso uno también puede asumir que son positivos).
supercat el
0

Prueba de Python que realiza la misma multiplicación 100 millones de veces contra los mismos números aleatorios.

>>> from timeit import timeit
>>> setup_str = 'import scipy; from scipy import random; scipy.random.seed(0)'
>>> N = 10*1000*1000
>>> timeit('x=random.randint(65536);', setup=setup_str, number=N)
1.894096851348877 # Time from generating the random #s and no opperati

>>> timeit('x=random.randint(65536); x*2', setup=setup_str, number=N)
2.2799630165100098
>>> timeit('x=random.randint(65536); x << 1', setup=setup_str, number=N)
2.2616429328918457

>>> timeit('x=random.randint(65536); x*10', setup=setup_str, number=N)
2.2799630165100098
>>> timeit('x=random.randint(65536); (x << 3) + (x<<1)', setup=setup_str, number=N)
2.9485139846801758

>>> timeit('x=random.randint(65536); x // 2', setup=setup_str, number=N)
2.490908145904541
>>> timeit('x=random.randint(65536); x / 2', setup=setup_str, number=N)
2.4757170677185059
>>> timeit('x=random.randint(65536); x >> 1', setup=setup_str, number=N)
2.2316000461578369

Entonces, al hacer un cambio en lugar de multiplicación / división por una potencia de dos en python, hay una ligera mejora (~ 10% para la división; ~ 1% para la multiplicación). Si es un no poder de dos, es probable que haya una desaceleración considerable.

Nuevamente, estos # cambiarán dependiendo de su procesador, su compilador (o intérprete, lo hizo en python por simplicidad).

Como con todos los demás, no optimices prematuramente. Escriba un código muy legible, perfil si no es lo suficientemente rápido, y luego intente optimizar las partes lentas. Recuerde, su compilador es mucho mejor en optimización que usted.

dr jimbob
fuente
0

Hay optimizaciones que el compilador no puede hacer porque solo funcionan para un conjunto reducido de entradas.

Debajo hay un código de muestra de c ++ que puede hacer una división más rápida haciendo una "Multiplicación por el recíproco" de 64 bits. Tanto el numerador como el denominador deben estar por debajo de cierto umbral. Tenga en cuenta que debe compilarse para usar instrucciones de 64 bits para que sea realmente más rápido que la división normal.

#include <stdio.h>
#include <chrono>

static const unsigned s_bc = 32;
static const unsigned long long s_p = 1ULL << s_bc;
static const unsigned long long s_hp = s_p / 2;

static unsigned long long s_f;
static unsigned long long s_fr;

static void fastDivInitialize(const unsigned d)
{
    s_f = s_p / d;
    s_fr = s_f * (s_p - (s_f * d));
}

static unsigned fastDiv(const unsigned n)
{
    return (s_f * n + ((s_fr * n + s_hp) >> s_bc)) >> s_bc;
}

static bool fastDivCheck(const unsigned n, const unsigned d)
{
    // 32 to 64 cycles latency on modern cpus
    const unsigned expected = n / d;

    // At least 10 cycles latency on modern cpus
    const unsigned result = fastDiv(n);

    if (result != expected)
    {
        printf("Failed for: %u/%u != %u\n", n, d, expected);
        return false;
    }

    return true;
}

int main()
{
    unsigned result = 0;

    // Make sure to verify it works for your expected set of inputs
    const unsigned MAX_N = 65535;
    const unsigned MAX_D = 40000;

    const double ONE_SECOND_COUNT = 1000000000.0;

    auto t0 = std::chrono::steady_clock::now();
    unsigned count = 0;
    printf("Verifying...\n");
    for (unsigned d = 1; d <= MAX_D; ++d)
    {
        fastDivInitialize(d);
        for (unsigned n = 0; n <= MAX_N; ++n)
        {
            count += !fastDivCheck(n, d);
        }
    }
    auto t1 = std::chrono::steady_clock::now();
    printf("Errors: %u / %u (%.4fs)\n", count, MAX_D * (MAX_N + 1), (t1 - t0).count() / ONE_SECOND_COUNT);

    t0 = t1;
    for (unsigned d = 1; d <= MAX_D; ++d)
    {
        fastDivInitialize(d);
        for (unsigned n = 0; n <= MAX_N; ++n)
        {
            result += fastDiv(n);
        }
    }
    t1 = std::chrono::steady_clock::now();
    printf("Fast division time: %.4fs\n", (t1 - t0).count() / ONE_SECOND_COUNT);

    t0 = t1;
    count = 0;
    for (unsigned d = 1; d <= MAX_D; ++d)
    {
        for (unsigned n = 0; n <= MAX_N; ++n)
        {
            result += n / d;
        }
    }
    t1 = std::chrono::steady_clock::now();
    printf("Normal division time: %.4fs\n", (t1 - t0).count() / ONE_SECOND_COUNT);

    getchar();
    return result;
}
usuario2044859
fuente
0

Creo que en el caso de que desee multiplicar o dividir por una potencia de dos, no puede equivocarse con el uso de operadores de desplazamiento de bits, incluso si el compilador los convierte a MUL / DIV, porque algunos procesadores microcódigo (realmente, un macro) de todos modos, por lo que para esos casos logrará una mejora, especialmente si el cambio es más de 1. O más explícitamente, si la CPU no tiene operadores de desplazamiento de bits, será un MUL / DIV de todos modos, pero si la CPU tiene operadores bithift, evitas una rama de microcódigo y estas son algunas instrucciones menos.

Estoy escribiendo un código en este momento que requiere muchas operaciones de duplicación / reducción a la mitad porque está trabajando en un árbol binario denso, y sospecho que hay una operación más que podría ser más óptima que una adición: una izquierda (la potencia de dos se multiplica ) cambio con una adición. Esto se puede reemplazar con un desplazamiento a la izquierda y un xor si el desplazamiento es más ancho que el número de bits que desea agregar, un ejemplo fácil es (i << 1) ^ 1, que agrega uno a un valor duplicado. Por supuesto, esto no se aplica a un desplazamiento a la derecha (potencia de dos divisiones) porque solo un desplazamiento a la izquierda (little endian) llena el vacío con ceros.

En mi código, estos se multiplican / dividen por dos y las potencias de dos operaciones se usan de manera muy intensiva y debido a que las fórmulas ya son bastante cortas, cada instrucción que se puede eliminar puede ser una ganancia sustancial. Si el procesador no admite estos operadores de desplazamiento de bits, no se producirá ninguna ganancia, pero tampoco habrá una pérdida.

Además, en los algoritmos que estoy escribiendo, representan visualmente los movimientos que ocurren para que, en ese sentido, sean más claros. El lado izquierdo de un árbol binario es más grande y el derecho es más pequeño. Además de eso, en mi código, los números pares e impares tienen un significado especial, y todos los hijos de la izquierda en el árbol son impares y todos los hijos de la derecha, y la raíz, son pares. En algunos casos, que aún no he encontrado, pero que, oh, en realidad, ni siquiera pensé en esto, x & 1 puede ser una operación más óptima en comparación con x% 2. x & 1 en un número par producirá cero, pero producirá 1 para un número impar.

Yendo un poco más allá de la identificación impar / par, si obtengo cero para x & 3, sé que 4 es un factor de nuestro número, y lo mismo para x% 7 para 8, y así sucesivamente. Sé que estos casos probablemente tengan una utilidad limitada, pero es bueno saber que puede evitar una operación de módulo y usar una operación lógica bit a bit en su lugar, porque las operaciones bit a bit son casi siempre las más rápidas y es menos probable que sean ambiguas para el compilador.

Estoy inventando el campo de los árboles binarios densos, por lo que espero que las personas no comprendan el valor de este comentario, ya que muy raramente quieren realizar factorizaciones solo con poderes de dos, o solo poderes de multiplicar / dividir de dos.

Louki Sumirniy
fuente
0

Si es realmente más rápido depende del hardware y el compilador realmente utilizado.

Albert van der Horst
fuente
0

Si compara la salida para x + x, x * 2 yx << 1 sintaxis en un compilador gcc, obtendrá el mismo resultado en el ensamblado x86: https://godbolt.org/z/JLpp0j

        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        mov     eax, DWORD PTR [rbp-4]
        add     eax, eax
        pop     rbp
        ret

Por lo tanto, puede considerar que gcc es lo suficientemente inteligente como para determinar su mejor solución independientemente de lo que escribió.

Buridan
fuente
0

Yo también quería ver si podía vencer a la casa. Este es un bit a bit más general para cualquier número por cualquier multiplicación de números. Las macros que hice son aproximadamente un 25% más o dos veces más lentas de lo normal * multiplicación. como lo han dicho otros, si es cercano a un múltiplo de 2 o está compuesto por algunos múltiplos de 2, puede ganar. como X * 23 compuesto por (X << 4) + (X << 2) + (X << 1) + X va a ser más lento que X * 65 compuesto por (X << 6) + X.

#include <stdio.h>
#include <time.h>

#define MULTIPLYINTBYMINUS(X,Y) (-((X >> 30) & 1)&(Y<<30))+(-((X >> 29) & 1)&(Y<<29))+(-((X >> 28) & 1)&(Y<<28))+(-((X >> 27) & 1)&(Y<<27))+(-((X >> 26) & 1)&(Y<<26))+(-((X >> 25) & 1)&(Y<<25))+(-((X >> 24) & 1)&(Y<<24))+(-((X >> 23) & 1)&(Y<<23))+(-((X >> 22) & 1)&(Y<<22))+(-((X >> 21) & 1)&(Y<<21))+(-((X >> 20) & 1)&(Y<<20))+(-((X >> 19) & 1)&(Y<<19))+(-((X >> 18) & 1)&(Y<<18))+(-((X >> 17) & 1)&(Y<<17))+(-((X >> 16) & 1)&(Y<<16))+(-((X >> 15) & 1)&(Y<<15))+(-((X >> 14) & 1)&(Y<<14))+(-((X >> 13) & 1)&(Y<<13))+(-((X >> 12) & 1)&(Y<<12))+(-((X >> 11) & 1)&(Y<<11))+(-((X >> 10) & 1)&(Y<<10))+(-((X >> 9) & 1)&(Y<<9))+(-((X >> 8) & 1)&(Y<<8))+(-((X >> 7) & 1)&(Y<<7))+(-((X >> 6) & 1)&(Y<<6))+(-((X >> 5) & 1)&(Y<<5))+(-((X >> 4) & 1)&(Y<<4))+(-((X >> 3) & 1)&(Y<<3))+(-((X >> 2) & 1)&(Y<<2))+(-((X >> 1) & 1)&(Y<<1))+(-((X >> 0) & 1)&(Y<<0))
#define MULTIPLYINTBYSHIFT(X,Y) (((((X >> 30) & 1)<<31)>>31)&(Y<<30))+(((((X >> 29) & 1)<<31)>>31)&(Y<<29))+(((((X >> 28) & 1)<<31)>>31)&(Y<<28))+(((((X >> 27) & 1)<<31)>>31)&(Y<<27))+(((((X >> 26) & 1)<<31)>>31)&(Y<<26))+(((((X >> 25) & 1)<<31)>>31)&(Y<<25))+(((((X >> 24) & 1)<<31)>>31)&(Y<<24))+(((((X >> 23) & 1)<<31)>>31)&(Y<<23))+(((((X >> 22) & 1)<<31)>>31)&(Y<<22))+(((((X >> 21) & 1)<<31)>>31)&(Y<<21))+(((((X >> 20) & 1)<<31)>>31)&(Y<<20))+(((((X >> 19) & 1)<<31)>>31)&(Y<<19))+(((((X >> 18) & 1)<<31)>>31)&(Y<<18))+(((((X >> 17) & 1)<<31)>>31)&(Y<<17))+(((((X >> 16) & 1)<<31)>>31)&(Y<<16))+(((((X >> 15) & 1)<<31)>>31)&(Y<<15))+(((((X >> 14) & 1)<<31)>>31)&(Y<<14))+(((((X >> 13) & 1)<<31)>>31)&(Y<<13))+(((((X >> 12) & 1)<<31)>>31)&(Y<<12))+(((((X >> 11) & 1)<<31)>>31)&(Y<<11))+(((((X >> 10) & 1)<<31)>>31)&(Y<<10))+(((((X >> 9) & 1)<<31)>>31)&(Y<<9))+(((((X >> 8) & 1)<<31)>>31)&(Y<<8))+(((((X >> 7) & 1)<<31)>>31)&(Y<<7))+(((((X >> 6) & 1)<<31)>>31)&(Y<<6))+(((((X >> 5) & 1)<<31)>>31)&(Y<<5))+(((((X >> 4) & 1)<<31)>>31)&(Y<<4))+(((((X >> 3) & 1)<<31)>>31)&(Y<<3))+(((((X >> 2) & 1)<<31)>>31)&(Y<<2))+(((((X >> 1) & 1)<<31)>>31)&(Y<<1))+(((((X >> 0) & 1)<<31)>>31)&(Y<<0))
int main()
{
    int randomnumber=23;
    int randomnumber2=23;
    int checknum=23;
    clock_t start, diff;
    srand(time(0));
    start = clock();
    for(int i=0;i<1000000;i++)
    {
        randomnumber = rand() % 10000;
        randomnumber2 = rand() % 10000;
        checknum=MULTIPLYINTBYMINUS(randomnumber,randomnumber2);
        if (checknum!=randomnumber*randomnumber2)
        {
            printf("s %i and %i and %i",checknum,randomnumber,randomnumber2);
        }
    }
    diff = clock() - start;
    int msec = diff * 1000 / CLOCKS_PER_SEC;
    printf("MULTIPLYINTBYMINUS Time %d milliseconds", msec);
    start = clock();
    for(int i=0;i<1000000;i++)
    {
        randomnumber = rand() % 10000;
        randomnumber2 = rand() % 10000;
        checknum=MULTIPLYINTBYSHIFT(randomnumber,randomnumber2);
        if (checknum!=randomnumber*randomnumber2)
        {
            printf("s %i and %i and %i",checknum,randomnumber,randomnumber2);
        }
    }
    diff = clock() - start;
    msec = diff * 1000 / CLOCKS_PER_SEC;
    printf("MULTIPLYINTBYSHIFT Time %d milliseconds", msec);
    start = clock();
    for(int i=0;i<1000000;i++)
    {
        randomnumber = rand() % 10000;
        randomnumber2 = rand() % 10000;
        checknum= randomnumber*randomnumber2;
        if (checknum!=randomnumber*randomnumber2)
        {
            printf("s %i and %i and %i",checknum,randomnumber,randomnumber2);
        }
    }
    diff = clock() - start;
    msec = diff * 1000 / CLOCKS_PER_SEC;
    printf("normal * Time %d milliseconds", msec);
    return 0;
}
AlexanderLife
fuente