Cuando pruebo la diferencia de tiempo entre cambiar y multiplicar en C, no hay diferencia. ¿Por qué?

28

Me han enseñado que cambiar en binario es mucho más eficiente que multiplicar por 2 ^ k. Entonces quería experimentar, y usé el siguiente código para probar esto:

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

int main() {
    clock_t launch = clock();
    int test = 0x01;
    int runs;

    //simple loop that oscillates between int 1 and int 2
    for (runs = 0; runs < 100000000; runs++) {


    // I first compiled + ran it a few times with this:
    test *= 2;

    // then I recompiled + ran it a few times with:
    test <<= 1;

    // set back to 1 each time
    test >>= 1;
    }

    clock_t done = clock();
    double diff = (done - launch);
    printf("%f\n",diff);
}

Para ambas versiones, la impresión fue de aproximadamente 440000, más o menos 10000. No hubo (visualmente, al menos) una diferencia significativa entre las salidas de las dos versiones. Entonces mi pregunta es, ¿hay algo mal con mi metodología? ¿Debería haber incluso una diferencia visual? ¿Tiene esto algo que ver con la arquitectura de mi computadora, el compilador u otra cosa?

NicholasFolk
fuente
47
Quien te enseñó que estaba claramente equivocado. Esa creencia no ha sido cierta desde la década de 1970, para los compiladores utilizados típicamente en arquitecturas típicamente utilizadas. Bien por probar esta afirmación. He escuchado esta afirmación sin sentido sobre JavaScript por el amor de Dios.
Eric Lippert
21
La mejor manera de responder preguntas como estas es mirar el código de ensamblaje que está produciendo el compilador. Los compiladores generalmente tienen una opción para producir una copia del lenguaje ensamblador que están generando. Para los compiladores GNU GCC esto es '-S'.
Charles E. Grant
8
Cabe señalar que después de mirar esto con gcc -S, el código para test *= 2realmente se compila a shll $1, %eax Cuando se invoca con gcc -O3 -Sni siquiera hay un bucle. Las dos llamadas de reloj son una línea aparte:callq _clock movq %rax, %rbx callq _clock
66
"Me han enseñado que cambiar en binario es mucho más eficiente que multiplicar por 2 ^ k"; nos enseñan muchas cosas que resultan ser incorrectas (o al menos desactualizadas). Un compilador inteligente usará la misma operación de cambio para ambos.
John Bode
9
Siempre, siempre verifique el código de ensamblaje generado cuando trabaje en este tipo de optimización, para asegurarse de que está midiendo lo que cree que está midiendo. Una gran cantidad de preguntas de "por qué estoy viendo estos tiempos" en SO terminan reduciéndose al compilador, eliminando completamente las operaciones porque los resultados no se utilizan.
Russell Borogove

Respuestas:

44

Como se dijo en la otra respuesta, la mayoría de los compiladores optimizarán automáticamente las multiplicaciones para que se realicen con cambios de bits.

Esta es una regla muy general cuando se optimiza: la mayoría de las 'optimizaciones' realmente desviarán la compilación sobre lo que realmente quiere decir, e incluso podrían disminuir el rendimiento.

Solo optimice cuando haya notado un problema de rendimiento y haya medido cuál es el problema. (y la mayoría del código que escribimos no se ejecuta con tanta frecuencia, por lo que no tenemos que molestarnos)

La gran desventaja de la optimización es que el código 'optimizado' a menudo es mucho menos legible. Entonces, en tu caso, siempre busca la multiplicación cuando quieras multiplicar. Y vaya a cambiar de bit cuando desee mover bits.

Thirler
fuente
20
Utilice siempre la operación que sea semánticamente correcta. Si estaba manipulando máscaras de bits o colocando enteros pequeños dentro de enteros más grandes, shift es la operación adecuada.
ddyer
2
¿Alguna vez (prácticamente hablando) sería necesario optimizar una multiplicación a un operador de turno en una aplicación de software de alto nivel? Parece que, dado que el compilador ya está optimizado, el único momento en que es útil tener este conocimiento es cuando se programa a un nivel muy bajo (al menos, por debajo del compilador).
NicholasFolk
11
@NicholasFolk nop. Haz lo que sea más fácil de entender. Si estaba escribiendo el ensamblaje directamente, puede ser útil ... o si estaba escribiendo un compilador de optimización, nuevamente podría ser útil. Pero fuera de esos dos casos, es un truco que oscurece lo que estás haciendo y hace que el próximo programador (que es un asesino de hachas que sabe dónde vives ) maldiga tu nombre y piense en un pasatiempo.
2
@NicholasFolk: las optimizaciones en este nivel casi siempre se oscurecen o se vuelven discutibles por la arquitectura de la CPU de todos modos. ¿A quién le importa si ahorras 50 ciclos cuando solo recuperas los argumentos de la memoria y los vuelves a escribir toma más de 100? Las micro optimizaciones como esta tenían sentido cuando la memoria corría a (o cerca de) la velocidad de la CPU, pero hoy no tanto.
TMN
2
Porque estoy cansado de ver ese 10% de esa cita, y porque da en el clavo aquí: "No hay duda de que el grial de la eficiencia conduce al abuso. Los programadores pierden enormes cantidades de tiempo pensando o preocupándose aproximadamente, la velocidad de las partes no críticas de sus programas, y estos intentos de la eficiencia en realidad tienen un fuerte impacto negativo cuando se considera la depuración y el mantenimiento Nos. deberíamos olvidarnos de pequeñas eficiencias, por ejemplo alrededor del 97% del tiempo: la optimización prematura es la raíz de todo mal ...
cHao
25

El compilador reconoce constantes y convierte las multiplicaciones en turnos cuando corresponde.

ddyer
fuente
El compilador reconoce constantes que son potencias de 2 ... y las convierte en turnos. No todas las constantes se pueden cambiar en turnos.
rapid_now
44
@quickly_now: se pueden convertir en combinaciones de turnos y sumas / restas.
Mehrdad
2
Un error clásico del optimizador del compilador es convertir las divisiones en desplazamientos a la derecha, que funciona para dividendos positivos pero está desactivado por 1 para negativos.
ddyer
1
@quickly_now Creo que el término 'donde sea apropiado' cubre la idea de que algunas constantes no pueden reescribirse como turnos.
Pharap
21

Si el cambio es más rápido que la multiplicación depende de la arquitectura de su CPU. En los días del Pentium y antes, el cambio a menudo era más rápido que la multiplicación, dependiendo de la cantidad de 1 bit en su multiplicando. Por ejemplo, si su multiplicando era 320, eso es 101000000, dos bits.

a *= 320;               // Slower
a = (a<<7) + (a<<9);    // Faster

Pero si tuvieras más de dos bits ...

a *= 324;                        // About same speed
a = (a<<2) + (a<<7) + (a<<9);    // About same speed

a *= 340;                                 // Faster
a = (a<<2) + (a<<4) + (a<<7) + (a<<9);    // Slower

En un pequeño microcontrolador como un PIC18 con multiplicación de ciclo único, pero sin palanca de cambios , la multiplicación es más rápida si está cambiando más de 1 bit.

a  *= 2;   // Exactly the same speed
a <<= 1;   // Exactly the same speed

a  *= 4;   // Faster
a <<= 2;   // Slower

Tenga en cuenta que eso es lo contrario de lo que era cierto en las CPU Intel más antiguas.

Pero todavía no es tan simple. Si no recuerdo mal, debido a su arquitectura Superscalar, un Pentium pudo procesar una instrucción de multiplicación o dos instrucciones de cambio simultáneamente (siempre y cuando no fueran dependientes entre sí). Esto significa que si desea multiplicar dos variables por una potencia de 2, el cambio podría ser mejor.

a  *= 4;   // 
b  *= 4;   // 

a <<= 2;   // Both lines execute in a single cycle
b <<= 2;   // 
Rocketmagnet
fuente
55
+1 "Si el cambio es más rápido que la multiplicación depende de la arquitectura de su CPU". Gracias por entrar un poco en la historia y demostrar que la mayoría de los mitos informáticos tienen alguna base lógica.
Pharap
11

Tienes varios problemas con tu programa de prueba.

Primero, en realidad no estás usando el valor de test. No hay forma, dentro del estándar C, de que el valor de los testasuntos. El optimizador es completamente gratis para eliminarlo. Una vez que lo ha eliminado, su ciclo está realmente vacío. El único efecto visible sería establecer runs = 100000000, pero runstampoco se usa. Entonces, el optimizador puede (¡y debería!) Eliminar todo el bucle. Solución fácil: también imprima el valor calculado. Tenga en cuenta que un optimizador suficientemente determinado aún podría optimizar el ciclo (depende completamente de constantes conocidas en tiempo de compilación).

En segundo lugar, realiza dos operaciones que se cancelan entre sí. El optimizador puede notar esto y cancelarlo . De nuevo dejando un bucle vacío, y eliminado. Este es francamente difícil de arreglar. Puede cambiar a un unsigned int(por lo que el desbordamiento no es un comportamiento indefinido), pero eso por supuesto solo resulta en 0. Y las cosas simples (como, por ejemplo, test += 1) son lo suficientemente fáciles para que el optimizador las descubra, y lo hace.

Finalmente, asumes que en test *= 2realidad se va a compilar en una multiplicación. Esa es una optimización muy simple; Si el cambio de bits es más rápido, el optimizador lo usará en su lugar. Para evitar esto, tendría que usar algo como un ensamblaje en línea específico de la implementación.

O, supongo, simplemente revise la hoja de datos de su microprocesador para ver cuál es más rápido.

Cuando verifiqué el resultado del ensamblaje de compilar su programa con la gcc -S -O3versión 4.9, el optimizador vio todas las variaciones simples anteriores y varias más. En todos los casos, eliminó el bucle (asignando una constante a test), lo único que quedaba eran las llamadas a clock(), la conversión / sustracción y el printf.

derobert
fuente
1
Tenga en cuenta también que el optimizador puede (y lo hará) optimizar las operaciones en constantes (incluso en un bucle) como se muestra en sqrt c # vs sqrt c ++ donde el optimizador pudo reemplazar un bucle que sumaba un valor con la suma real. Para vencer esa optimización, debe usar algo determinado en tiempo de ejecución (como un argumento de línea de comando).
@MichaelT Sí. Eso es lo que quise decir con "Tenga en cuenta que un optimizador suficientemente determinado aún podría optimizar el ciclo (se basa completamente en constantes conocidas en tiempo de compilación)".
derobert
Entiendo lo que dices, pero no creo que el compilador esté eliminando todo el bucle. Puede probar fácilmente esta teoría simplemente aumentando el número de iteraciones. Verá que aumentar las iteraciones hace que el programa tarde más. Si el ciclo se eliminó por completo, este no sería el caso.
DollarAkshay
@AkshayLAradhya No puedo decir qué está haciendo su compilador, pero volví a confirmar que gcc -O3(ahora con 7.3) todavía elimina el bucle por completo. (Asegúrese de cambiar a un largo en lugar de int si es necesario, de lo contrario lo optimiza en un bucle infinito debido al desbordamiento).
derobert
8

Creo que sería más útil para el interlocutor tener una respuesta más diferenciada, porque veo varias suposiciones no examinadas en las preguntas y en algunas de las respuestas o comentarios.

El tiempo de ejecución relativo resultante de desplazamiento y multiplicación no tiene nada que ver con C. Cuando digo C, no me refiero a la instancia de una implementación específica, como esa o esa versión de GCC, sino el lenguaje. No pretendo tomar este anuncio absurdo, sino utilizar un ejemplo extremo para ilustrarlo: podría implementar un compilador de C completamente compatible con los estándares y hacer que la multiplicación tome una hora, mientras que el cambio toma milisegundos, o al revés. No conozco ninguna restricción de rendimiento en C o C ++.

Puede que no le importe este tecnicismo en la argumentación. Probablemente, su intención era simplemente probar el rendimiento relativo de hacer turnos versus multiplicaciones y eligió C, porque generalmente se percibe como un lenguaje de programación de bajo nivel, por lo que uno puede esperar que su código fuente se traduzca en instrucciones correspondientes más directamente. Estas preguntas son muy comunes y creo que una buena respuesta debería señalar que incluso en C su código fuente no se traduce en instrucciones tan directamente como podría pensar en una instancia determinada. Te he dado algunos posibles resultados de compilación a continuación.

Aquí es donde entran los comentarios que cuestionan la utilidad de sustituir esta equivalencia en el software del mundo real. Puede ver algunos en los comentarios a su pregunta, como el de Eric Lippert. Está en línea con la reacción que generalmente obtendrá de ingenieros más experimentados en respuesta a tales optimizaciones. Si usa cambios binarios en el código de producción como un medio general de multiplicar y dividir, la gente probablemente se encogerá ante su código y tendrá algún grado de reacción emocional ("He escuchado esta afirmación sin sentido sobre JavaScript por el amor de Dios") a eso puede no tener sentido para los programadores novatos, a menos que comprendan mejor las razones de esas reacciones.

Esas razones son principalmente una combinación de la disminución de la legibilidad y la inutilidad de dicha optimización, como ya habrá descubierto al comparar su rendimiento relativo. Sin embargo, no creo que la gente tenga una reacción tan fuerte si la sustitución del turno por la multiplicación fuera el único ejemplo de tales optimizaciones. Preguntas como la suya frecuentemente surgen en varias formas y en diversos contextos. Creo que a lo que los ingenieros más experimentados realmente reaccionan con tanta fuerza, al menos a veces lo hago, es que existe la posibilidad de un rango mucho más amplio de daños cuando las personas emplean tales micro-optimizaciones de manera liberal en toda la base del código. Si trabaja en una empresa como Microsoft en una base de código grande, pasará mucho tiempo leyendo el código fuente de otros ingenieros o intentará localizar cierto código en él. Incluso puede ser su propio código el que intentará tener sentido dentro de unos años, particularmente en algunos de los momentos más inoportunos, como cuando tiene que arreglar un corte de producción después de una llamada que recibió en el buscapersonas deber el viernes por la noche, a punto de salir para una noche de diversión con amigos ... Si pasa tanto tiempo leyendo el código, apreciará que sea lo más legible posible. Imagínese leer su novela favorita, pero el editor ha decidido lanzar una nueva edición donde usan abbrv. todo lo anterior gracias a tu agradecimiento svs spc. Eso es similar a las reacciones que otros ingenieros pueden tener a su código, si los rocía con tales optimizaciones. Como han señalado otras respuestas, es mejor indicar claramente lo que quiere decir,

Sin embargo, incluso en esos entornos, es posible que se encuentre resolviendo una pregunta de entrevista en la que se espera que sepa esta u otra equivalencia. Conocerlos no es malo y un buen ingeniero sería consciente del efecto aritmético del desplazamiento binario. Tenga en cuenta que no dije que esto sea un buen ingeniero, sino que un buen ingeniero lo sabría, en mi opinión. En particular, aún puede encontrar algún gerente, generalmente hacia el final de su ciclo de entrevistas, que le sonreirá ampliamente anticipando la delicia de revelarle este "truco" de ingeniería inteligente en una pregunta de codificación y demostrar que él / ella , también, solía ser o es uno de los ingenieros expertos y no "solo" un gerente. En esas situaciones, solo trate de parecer impresionado y agradézcale por la entrevista esclarecedora.

¿Por qué no viste una diferencia de velocidad en C? La respuesta más probable es que ambos resultaron en el mismo código de ensamblaje:

int shift(int i) { return i << 2; }
int multiply(int i) { return i * 2; }

Ambos pueden compilarse en

shift(int):
    lea eax, [0+rdi*4]
    ret

En GCC sin optimizaciones, es decir, utilizando el indicador "-O0", puede obtener esto:

shift(int):
    push    rbp
    mov rbp, rsp
    mov DWORD PTR [rbp-4], edi
    mov eax, DWORD PTR [rbp-4]
    sal eax, 2
    pop rbp
    ret
multiply(int):
    push    rbp
    mov rbp, rsp
    mov DWORD PTR [rbp-4], edi
    mov eax, DWORD PTR [rbp-4]
    add eax, eax
    pop rbp
    ret

Como puede ver, pasar "-O0" a GCC no significa que no sea algo inteligente sobre qué tipo de código produce. En particular, observe que incluso en este caso el compilador evitó el uso de una instrucción de multiplicación. Puede repetir el mismo experimento con cambios por otros números e incluso multiplicaciones por números que no son potencias de dos. Lo más probable es que en su plataforma verá una combinación de cambios y adiciones, pero no multiplicaciones. Parece una coincidencia que el compilador aparentemente evite usar multiplicaciones en todos esos casos si las multiplicaciones y los turnos realmente tienen el mismo costo, ¿no es así? Pero no pretendo proporcionar suposiciones como prueba, así que sigamos adelante.

Puede volver a ejecutar su prueba con el código anterior y ver si nota una diferencia de velocidad ahora. Aun así, no está probando cambio versus multiplicación, como puede ver por la ausencia de una multiplicación, sin embargo, pero el código que fue generado con un cierto conjunto de banderas por GCC para las operaciones C de cambio y multiplicación en una instancia particular . Por lo tanto, en otra prueba, puede editar el código de ensamblaje a mano y, en su lugar, usar una instrucción "imul" en el código para el método "multiplicar".

Si quisieras derrotar algunos de esos conocimientos del compilador, podrías definir un método de multiplicación y cambio más general y terminarás con algo como esto:

int shift(int i, int j) { return i << j; }
int multiply(int i, int j) { return i * j; }

Lo que puede generar el siguiente código de ensamblaje:

shift(int, int):
    mov eax, edi
    mov ecx, esi
    sal eax, cl
    ret
multiply(int, int):
    mov eax, edi
    imul    eax, esi
    ret

Aquí finalmente tenemos, incluso en el nivel de optimización más alto de GCC 4.9, la expresión en las instrucciones de ensamblaje que podría haber esperado cuando inició su prueba inicialmente. Creo que en sí mismo puede ser una lección importante en la optimización del rendimiento. Podemos ver la diferencia que hizo al sustituir variables por constantes concretas en nuestro código, en términos de la inteligencia que el compilador puede aplicar. Las microoptimizaciones como la sustitución shift-multiplicación son algunas optimizaciones de muy bajo nivel que un compilador generalmente puede hacer fácilmente por sí mismo. Otras optimizaciones que son mucho más impactantes en el rendimiento requieren una comprensión de la intención del códigoa menudo el compilador no puede acceder a él o solo puede ser adivinado por alguna heurística. Ahí es donde usted, como ingeniero de software, entra y ciertamente no implica sustituir las multiplicaciones por turnos. Involucra factores como evitar una llamada redundante a un servicio que produce E / S y puede bloquear un proceso. Si va a su disco duro o, Dios no lo quiera, a una base de datos remota para obtener algunos datos adicionales que podría haber derivado de lo que ya tiene en la memoria, el tiempo que pasa esperando supera la ejecución de un millón de instrucciones. Ahora, creo que nos hemos alejado un poco de su pregunta original, pero creo que señalar esto a un interlocutor, especialmente si suponemos que alguien que está empezando a comprender la traducción y la ejecución del código,

Entonces, ¿cuál será más rápido? Creo que es un buen enfoque que elegiste para probar la diferencia de rendimiento. En general, es fácil sorprenderse con el rendimiento en tiempo de ejecución de algunos cambios de código. Hay muchas técnicas que emplean los procesadores modernos y la interacción entre el software también puede ser compleja. Incluso si debe obtener resultados de rendimiento beneficiosos para un cierto cambio en una situación, creo que es peligroso concluir que este tipo de cambio siempre generará beneficios de rendimiento. Creo que es peligroso ejecutar tales pruebas una vez, diga "Bien, ¡ahora sé cuál es más rápido!" y luego aplique indiscriminadamente esa misma optimización al código de producción sin repetir sus mediciones.

Entonces, ¿qué pasa si el cambio es más rápido que la multiplicación? Ciertamente hay indicios de por qué eso sería cierto. GCC, como puede ver arriba, parece pensar (incluso sin optimización) que evitar una multiplicación directa en favor de otras instrucciones es una buena idea. El Manual de referencia de optimización de arquitecturas Intel 64 e IA-32 le dará una idea del costo relativo de las instrucciones de la CPU. Otro recurso, más centrado en la latencia y el rendimiento de la instrucción, es http://www.agner.org/optimize/instruction_tables.pdf. Tenga en cuenta que no son un buen predicador del tiempo de ejecución absoluto, sino del desempeño de las instrucciones entre sí. En un ciclo cerrado, ya que su prueba está simulando, la métrica de "rendimiento" debería ser más relevante. Es el número de ciclos que una unidad de ejecución estará típicamente atada al ejecutar una instrucción dada.

Entonces, ¿qué pasa si el cambio NO es más rápido que la multiplicación? Como dije anteriormente, las arquitecturas modernas pueden ser bastante complejas y cosas como la predicción de ramificaciones, el almacenamiento en caché, la canalización y las unidades de ejecución paralelas pueden dificultar la predicción del rendimiento relativo de dos piezas de código lógicamente equivalentes a veces. Realmente quiero enfatizar esto, porque aquí es donde no estoy contento con la mayoría de las respuestas a preguntas como estas y con el grupo de personas que dicen directamente que simplemente no es cierto (ya) que el cambio es más rápido que la multiplicación.

No, por lo que sé, no inventamos una salsa secreta de ingeniería en la década de 1970 o cuando sea para anular de repente la diferencia de costo de una unidad de multiplicación y una palanca de cambios. Una multiplicación general, en términos de puertas lógicas, y ciertamente en términos de operaciones lógicas, es aún más compleja que un cambio con una palanca de cambios en muchos escenarios, en muchas arquitecturas. Cómo esto se traduce en tiempo de ejecución general en una computadora de escritorio puede ser un poco opaco. No estoy seguro de cómo se implementan en procesadores específicos, pero aquí hay una explicación de una multiplicación: ¿la multiplicación entera es realmente la misma velocidad que la adición en la CPU moderna?

Si bien aquí hay una explicación de un Barrel Shifter . Los documentos a los que me he referido en el párrafo anterior dan otra visión sobre el costo relativo de las operaciones, por medio de instrucciones de CPU. Los ingenieros de Intel con frecuencia parecen tener preguntas similares: los foros de la zona de desarrolladores de Intel registran ciclos para la multiplicación y adición de enteros en el procesador core 2 duo

Sí, en la mayoría de los escenarios de la vida real, y casi seguramente en JavaScript, intentar explotar esta equivalencia por el rendimiento es probablemente una tarea inútil. Sin embargo, incluso si forzamos el uso de instrucciones de multiplicación y luego no vimos ninguna diferencia en el tiempo de ejecución, eso es más debido a la naturaleza de la métrica de costo que usamos, para ser precisos, y no porque no haya diferencia de costo. El tiempo de ejecución de extremo a extremo es una métrica y si es la única que nos importa, todo está bien. Pero eso no significa que todas las diferencias de costos entre multiplicación y desplazamiento simplemente hayan desaparecido. Y creo que ciertamente no es una buena idea transmitir esa idea a un interlocutor, por implicación o de otra manera, que obviamente está comenzando a tener una idea de los factores involucrados en el tiempo de ejecución y el costo del código moderno. La ingeniería siempre se trata de compensaciones. La investigación y la explicación de las compensaciones que los procesadores modernos han hecho para exhibir el tiempo de ejecución que nosotros, como usuarios, terminamos viendo, puede dar una respuesta más diferenciada. Y creo que se justifica una respuesta más diferenciada que "esto simplemente ya no es cierto" si queremos ver a menos ingenieros revisar el código micro-optimizado que borra la legibilidad, porque requiere una comprensión más general de la naturaleza de tales "optimizaciones" para detectar sus diversas y diversas encarnaciones que simplemente referirse a algunas instancias específicas como desactualizadas.

usuario2880576
fuente
6

Lo que ves es el efecto del optimizador.

El trabajo de los optimizadores es hacer que el código compilado resultante sea más pequeño o más rápido (pero rara vez ambos al mismo tiempo ... pero como muchas cosas ... DEPENDE de lo que es el código).

En PRINCIPIO, cualquier llamada a una biblioteca de multiplicación o, con frecuencia, incluso el uso de un multiplicador de hardware será más lento que simplemente hacer un cambio a nivel de bits.

Entonces ... si el ingenuo compilador generó una llamada a una biblioteca para la operación * 2, entonces, por supuesto, sería más lento que un cambio de bit *.

Sin embargo, los optimizadores están ahí para detectar patrones y descubrir cómo hacer que el código sea más pequeño / más rápido / lo que sea. Y lo que has visto es que el compilador detecta que * 2 es lo mismo que un cambio.

Solo como una cuestión de interés, hoy estaba mirando el ensamblador generado para algunas operaciones como * 5 ... en realidad no estaba mirando eso sino otras cosas, y en el camino noté que el compilador había convertido * 5 en:

  • cambio
  • cambio
  • agregar número original

Por lo tanto, el optimizador de mi compilador fue lo suficientemente inteligente (al menos para ciertas constantes pequeñas) para generar cambios en línea y agregar en lugar de llamadas a una biblioteca de multiplicación de propósito general.

El arte de los optimizadores de compiladores es un tema completamente diferente, lleno de magia y que realmente lo entienden unas 6 personas en todo el planeta :)

rápidamente_ahora
fuente
3

Intenta cronometrarlo con:

for (runs = 0; runs < 100000000; runs++) {
      ;
}

El compilador debe reconocer que el valor de testno cambia después de cada iteración del bucle, y el valor final de testno se usa, y eliminar el bucle por completo.

Russell Borogove
fuente
2

La multiplicación es una combinación de turnos y adiciones.

En el caso que ha mencionado, no creo que importe si el compilador lo optimiza o no: "multiplicar xpor dos" se puede implementar como:

  • Desplace las partes de xun lugar a la izquierda.
  • Añadir xa x.

Estas son operaciones atómicas básicas; uno no es más rápido que el otro.

Cámbielo a "multiplicar xpor cuatro", (o cualquiera 2^k, k>1) y es un poco diferente:

  • Desplazar bits de xdos lugares a la izquierda.
  • Añadir xa xy llamarlo y, añadir ya y.

En una arquitectura básica, es fácil de ver que el cambio es más eficiente - teniendo uno contra dos operaciones, ya que no podemos añadir ya yhasta que sepamos lo que yes.

Pruebe el último (o cualquiera 2^k, k>1), con las opciones apropiadas para evitar que los optimice para que sean lo mismo en la implementación. Debería encontrar que el cambio es más rápido, en O(1)comparación con la adición repetida en O(k).

Obviamente, donde el multiplicando no es una potencia de dos, es necesaria una combinación de cambios y sumas (una donde el número de cada uno no sea cero).

OJFord
fuente
1
¿Qué es una "operación atómica básica"? ¿No se podría argumentar que en un turno, la operación se puede aplicar a cada bit en paralelo, mientras que, además, los bits más a la izquierda dependen de los otros bits?
Bergi
2
@ Bergi: Supongo que quiere decir que tanto shift como add son instrucciones de una sola máquina. Tendría que mirar la documentación del conjunto de instrucciones para ver los recuentos de ciclos para cada uno, pero sí, un complemento suele ser una operación de varios ciclos, mientras que un cambio generalmente se realiza en un solo ciclo.
TMN
Sí, ese podría ser el caso, pero la multiplicación también es una instrucción de máquina única (aunque, por supuesto, podría necesitar más ciclos)
Bergi
@ Bergi, eso también depende del arco. ¿En qué arco está pensando que cambia en menos ciclos que la suma de 32 bits (o x-bit según corresponda)?
OJFord
No conozco ninguna arquitectura en particular, no (y mis cursos de ingeniería informática se han desvanecido), probablemente ambas instrucciones toman menos de un ciclo. Probablemente estaba pensando en términos de microcódigo o incluso puertas lógicas, donde un cambio probablemente sería más barato.
Bergi
1

La multiplicación de valores con signo o sin signo por potencias de dos es equivalente al desplazamiento a la izquierda, y la mayoría de los compiladores harán la sustitución. La división de valores sin signo, o valores con signo que el compilador puede probar que nunca son negativos , es equivalente al desplazamiento a la derecha, y la mayoría de los compiladores harán esa sustitución (aunque algunos no son lo suficientemente sofisticados como para probar cuando los valores con signo no pueden ser negativos) .

Cabe señalar, sin embargo, que la división de valores con signo potencialmente negativos no es equivalente al desplazamiento a la derecha. Una expresión como (x+8)>>4no es equivalente a (x+8)/16. El primero, en el 99% de los compiladores, asignará valores de -24 a -9 a -1, -8 a +7 a 0, y de +8 a +23 a 1 [redondeando números casi simétricamente alrededor de cero]. Este último asignará -39 a -24 a -1, -23 a +7 a 0, y +8 a +23 a +1 [muy asimétrico, y probablemente no lo que se pretendía]. Tenga en cuenta que incluso cuando no se espera que los valores sean negativos, el uso de >>4probablemente generará un código más rápido que a /16menos que el compilador pueda demostrar que los valores no pueden ser negativos.

Super gato
fuente
0

Alguna información más que acabo de ver.

En x86_64, el código de operación MUL tiene una latencia de 10 ciclos y un rendimiento de 1/2 ciclo. MOV, ADD y SHL tienen una latencia de 1 ciclo, con un rendimiento de 2.5, 2.5 y 1.7 ciclos.

Una multiplicación por 15 requeriría 3 operaciones SHL y 3 ADD como mínimo y probablemente un par de MOV.

https://gmplib.org/~tege/x86-timing.pdf

Rich Remer
fuente
0

Tu metodología es defectuosa. Su incremento de bucle y la comprobación de la condición en sí misma está tomando tanto tiempo.

  • Intente ejecutar un ciclo vacío y mida el tiempo (llámelo base).
  • Ahora agregue 1 operación de turno y mida el tiempo (llámelo s1).
  • Luego agregue 10 operaciones de turno y mida el tiempo (llámelo s2)

Si todo va correctamente base-s2debería ser 10 veces más que base-s1. De lo contrario, algo más entrará en juego aquí.

Ahora lo intenté yo mismo y pensé, si los bucles están causando un problema, ¿por qué no eliminarlos por completo? Así que seguí adelante e hice esto:

int main(){

    int test = 2;
    clock_t launch = clock();

    test << 6;
    test << 6;
    test << 6;
    test << 6;
    //.... 1 million times
    test << 6;

    clock_t done = clock();
    printf("Time taken : %d\n", done - launch);
    return 0;
}

Y ahí tienes tu resultado

¿1 millón de operaciones de turno en menos de 1 milisegundo? .

Hice lo mismo para la multiplicación por 64 y obtuve el mismo resultado. Entonces, probablemente el compilador ignora la operación por completo, ya que otros mencionaron que el valor de la prueba nunca cambia.

Operador Shiftwise Resultado

DollarAkshay
fuente