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?
c
efficiency
bitwise-operators
NicholasFolk
fuente
fuente
gcc -S
, el código paratest *= 2
realmente se compila ashll $1, %eax
Cuando se invoca congcc -O3 -S
ni siquiera hay un bucle. Las dos llamadas de reloj son una línea aparte:callq _clock
movq %rax, %rbx
callq _clock
Respuestas:
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.
fuente
El compilador reconoce constantes y convierte las multiplicaciones en turnos cuando corresponde.
fuente
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.
Pero si tuvieras más de dos bits ...
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.
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.
fuente
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 lostest
asuntos. 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 establecerruns = 100000000
, peroruns
tampoco 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 *= 2
realidad 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 -O3
versió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 atest
), lo único que quedaba eran las llamadas aclock()
, la conversión / sustracción y elprintf
.fuente
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).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:
Ambos pueden compilarse en
En GCC sin optimizaciones, es decir, utilizando el indicador "-O0", puede obtener esto:
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:
Lo que puede generar el siguiente código de ensamblaje:
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.
fuente
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:
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 :)
fuente
Intenta cronometrarlo con:
El compilador debe reconocer que el valor de
test
no cambia después de cada iteración del bucle, y el valor final detest
no se usa, y eliminar el bucle por completo.fuente
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
x
por dos" se puede implementar como:x
un lugar a la izquierda.x
ax
.Estas son operaciones atómicas básicas; uno no es más rápido que el otro.
Cámbielo a "multiplicar
x
por cuatro", (o cualquiera2^k, k>1
) y es un poco diferente:x
dos lugares a la izquierda.x
ax
y llamarloy
, añadiry
ay
.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
y
ay
hasta que sepamos lo quey
es.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, enO(1)
comparación con la adición repetida enO(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).
fuente
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)>>4
no 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>>4
probablemente generará un código más rápido que a/16
menos que el compilador pueda demostrar que los valores no pueden ser negativos.fuente
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
fuente
Tu metodología es defectuosa. Su incremento de bucle y la comprobación de la condición en sí misma está tomando tanto tiempo.
base
).s1
).s2
)Si todo va correctamente
base-s2
debería ser 10 veces más quebase-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:
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.
fuente