Nuestro profesor de informática dijo una vez que, por alguna razón, es más eficiente contar hacia atrás que contar. Por ejemplo, si necesita usar un bucle FOR y el índice del bucle no se usa en alguna parte (como imprimir una línea de N * en la pantalla) me refiero a ese código como este:
for (i = N; i >= 0; i--)
putchar('*');
es mejor que:
for (i = 0; i < N; i++)
putchar('*');
¿Es realmente cierto? Y si es así, ¿alguien sabe por qué?
c
performance
loops
Beto
fuente
fuente
putchar
está utilizando el 99.9999% del tiempo (más o menos).i
no está firmado, el primer bucle es infinito?Respuestas:
En la antigüedad, cuando las computadoras todavía se extraían manualmente de sílice fundida, cuando los microcontroladores de 8 bits recorrían la Tierra, y cuando su maestro era joven (o el maestro de su maestro era joven), había una instrucción de máquina común llamada decremento y omisión si cero (DSZ). Los programadores de ensamblaje Hotshot utilizaron esta instrucción para implementar bucles. Las máquinas posteriores recibieron instrucciones más sofisticadas, pero todavía había bastantes procesadores en los que era más barato comparar algo con cero que compararlo con cualquier otra cosa. (Es cierto incluso en algunas máquinas RISC modernas, como PPC o SPARC, que reservan un registro completo para que siempre sea cero).
Entonces, si manipulas tus bucles para compararlos con cero en lugar de
N
, ¿qué podría pasar?¿Es probable que estas diferencias den como resultado una mejora apreciable en programas reales en un procesador moderno fuera de servicio? Altamente improbable. De hecho, me impresionaría si pudieras mostrar una mejora medible incluso en un microbenchmark.
Resumen: ¡Golpeé a tu maestro al revés! No deberías aprender pseudo-hechos obsoletos sobre cómo organizar bucles. Debería estar aprendiendo que lo más importante de los bucles es asegurarse de que terminen , produzcan respuestas correctas y sean fáciles de leer . Desearía que tu maestro se concentrara en las cosas importantes y no en la mitología.
fuente
putchar
lleva muchos órdenes de magnitud más largos que la sobrecarga del bucle.j=N-i
muestra que los dos bucles son equivalentes.Esto es lo que puede suceder en algunos equipos, según lo que el compilador pueda deducir sobre el rango de los números que está utilizando: con el ciclo de incremento que tiene que probar
i<N
cada vez que se realiza el ciclo. Para la versión decreciente, la bandera de acarreo (establecida como efecto secundario de la resta) puede decirle automáticamente sii>=0
. Eso ahorra una prueba por vez alrededor del ciclo.En realidad, en el hardware del procesador moderno, es casi irrelevante, ya que no hay una simple asignación 1-1 de instrucciones a ciclos de reloj. (Aunque podría imaginarlo surgiendo si estuviera haciendo cosas como generar señales de video cronometradas con precisión desde un microcontrolador. Pero de todos modos escribiría en lenguaje ensamblador).
fuente
En el conjunto de instrucciones Intel x86, la construcción de un bucle para contar hasta cero generalmente se puede hacer con menos instrucciones que un bucle que cuenta hasta una condición de salida distinta de cero. Específicamente, el registro ECX se usa tradicionalmente como un contador de bucle en x86 asm, y el conjunto de instrucciones Intel tiene una instrucción especial de salto jcxz que prueba el registro ECX para cero y saltos en función del resultado de la prueba.
Sin embargo, la diferencia de rendimiento será insignificante a menos que su ciclo ya sea muy sensible a los recuentos de ciclos de reloj. La cuenta regresiva a cero puede reducir 4 o 5 ciclos de reloj en cada iteración del ciclo en comparación con la cuenta regresiva, por lo que es más una novedad que una técnica útil.
Además, un buen compilador de optimización en estos días debería ser capaz de convertir su código fuente de cuenta atrás en código de máquina de cuenta atrás a cero (dependiendo de cómo use la variable de índice de bucle), por lo que realmente no hay ninguna razón para escribir sus bucles en formas extrañas de exprimir un ciclo o dos aquí y allá.
fuente
Si..!!
Contar de N a 0 es un poco más rápido que contar de 0 a N en el sentido de cómo el hardware manejará la comparación.
Tenga en cuenta la comparación en cada bucle
La mayoría de los procesadores tienen comparación con la instrucción cero ... por lo que el primero se traducirá al código de la máquina como:
Pero el segundo necesita cargar N de memoria cada vez
Por lo tanto, no se debe a contar hacia atrás o hacia arriba ... sino a cómo se traducirá su código al código de máquina ...
Entonces, contar de 10 a 100 es lo mismo que contar de 100 a 10,
pero contar de i = 100 a 0 es más rápido que de i = 0 a 100, en la mayoría de los casos,
y contar de i = N a 0 es más rápido que de i = 0 a N
fuente
En C a psudo-ensamblaje:
se convierte en
mientras:
se convierte en
Tenga en cuenta la falta de comparación en el segundo psudoensamblaje. En muchas arquitecturas hay indicadores que se establecen mediante operaciones aritméticas (sumar, restar, multiplicar, dividir, incrementar, disminuir) que puede usar para saltos. Estos a menudo le dan lo que es esencialmente una comparación del resultado de la operación con 0 de forma gratuita. De hecho en muchas arquitecturas
es semánticamente lo mismo que
Además, la comparación con un 10 en mi ejemplo podría resultar en un código peor. Es posible que 10 tengan que vivir en un registro, por lo que si escasean, eso cuesta y puede dar lugar a un código adicional para mover las cosas o recargar el 10 cada vez a través del ciclo.
Los compiladores a veces pueden reorganizar el código para aprovechar esto, pero a menudo es difícil porque a menudo no pueden asegurarse de que invertir la dirección a través del bucle sea semánticamente equivalente.
fuente
i
no se use dentro del bucle, obviamente puedes voltearla, ¿no?Cuenta atrás más rápido en este caso:
porque se
someObject.getAllObjects.size()
ejecuta una vez al principio.Claro, se puede lograr un comportamiento similar llamando
size()
fuera del ciclo, como Peter mencionó:fuente
exec
.Tal vez. Pero mucho más del 99% de las veces no importará, por lo que debe usar la prueba más 'sensata' para terminar el ciclo, y por sensata, quiero decir que el lector necesita la menor cantidad de pensamiento para darse cuenta qué está haciendo el ciclo (incluido qué hace que se detenga). Haga que su código coincida con el modelo mental (o documentado) de lo que está haciendo el código.
Si el bucle está funcionando a través de una matriz (o una lista, o lo que sea), un contador incremental a menudo coincidirá mejor con cómo el lector podría estar pensando en lo que está haciendo el bucle: codifique su bucle de esta manera.
Pero si está trabajando a través de un contenedor que tiene
N
elementos y los está eliminando a medida que avanza, podría tener más sentido cognitivo trabajar el contador hacia abajo.Un poco más de detalle sobre el 'quizás' en la respuesta:
Es cierto que en la mayoría de las arquitecturas, probar un cálculo que resulta en cero (o pasar de cero a negativo) no requiere instrucciones de prueba explícitas; el resultado se puede verificar directamente. Si desea probar si un cálculo da como resultado algún otro número, la secuencia de instrucciones generalmente tendrá que tener una instrucción explícita para probar ese valor. Sin embargo, especialmente con las CPU modernas, esta prueba generalmente agregará menos tiempo adicional al nivel de ruido a una construcción en bucle. Particularmente si ese ciclo está realizando E / S.
Por otro lado, si cuenta desde cero y usa el contador como un índice de matriz, por ejemplo, puede encontrar que el código funciona en contra de la arquitectura de la memoria del sistema: las lecturas de memoria a menudo harán que un caché 'mire hacia adelante' varias ubicaciones de memoria más allá de la actual en previsión de una lectura secuencial. Si está trabajando hacia atrás a través de la memoria, el sistema de almacenamiento en caché podría no anticipar las lecturas de una ubicación de memoria en una dirección de memoria inferior. En este caso, es posible que el bucle 'hacia atrás' pueda dañar el rendimiento. Sin embargo, probablemente todavía codifique el bucle de esta manera (siempre y cuando el rendimiento no se convierta en un problema) porque la corrección es primordial, y hacer que el código coincida con un modelo es una excelente manera de ayudar a garantizar la corrección. El código incorrecto es tan poco optimizado como puede obtener.
Por lo tanto, tendería a olvidar los consejos del profesor (por supuesto, no en su examen, sin embargo, aún debe ser pragmático en lo que respecta al aula), a menos y hasta que el rendimiento del código realmente sea importante.
fuente
En algunas CPU antiguas hay / fueron instrucciones como
DJNZ
== "decrementar y saltar si no es cero". Esto permitió bucles eficientes en los que cargó un valor de recuento inicial en un registro y luego pudo administrar eficazmente un bucle decreciente con una instrucción. Sin embargo, estamos hablando de ISA de la década de 1980: su maestro está muy desconectado si cree que esta "regla de oro" todavía se aplica con las CPU modernas.fuente
Beto,
No hasta que esté haciendo microoptimizaciones, en ese momento tendrá el manual para su CPU a mano. Además, si estuviera haciendo ese tipo de cosas, probablemente no necesitaría hacer esta pregunta de todos modos. :-) Pero, tu maestro evidentemente no se suscribe a esa idea ...
Hay 4 cosas a considerar en su ejemplo de bucle:
La comparación es (como han indicado otros) relevante para arquitecturas de procesadores particulares . Hay más tipos de procesadores que los que ejecutan Windows. En particular, puede haber una instrucción que simplifique y acelere las comparaciones con 0.
En algunos casos, es más rápido ajustar hacia arriba o hacia abajo. Por lo general, un buen compilador lo resolverá y rehacerá el ciclo si puede. Sin embargo, no todos los compiladores son buenos.
Está accediendo a una syscall con putchar. Eso es masivamente lento. Además, está renderizando en la pantalla (indirectamente). Eso es aún más lento. Piensa en una relación de 1000: 1 o más. En esta situación, el cuerpo del bucle supera total y totalmente el costo del ajuste / comparación del bucle.
Un diseño de caché y memoria puede tener un gran efecto en el rendimiento. En esta situación, no importa. Sin embargo, si estaba accediendo a una matriz y necesita un rendimiento óptimo, le corresponde investigar cómo su compilador y su procesador distribuyeron los accesos a la memoria y ajustar su software para aprovecharlo al máximo. El ejemplo de stock es el que se da en relación con la multiplicación de matrices.
fuente
Lo que importa mucho más que si está aumentando o disminuyendo su contador es si está subiendo o bajando la memoria. La mayoría de los cachés están optimizados para subir memoria, no bajar memoria. Dado que el tiempo de acceso a la memoria es el cuello de botella que enfrentan la mayoría de los programas de hoy en día, esto significa que cambiar su programa para aumentar la memoria puede generar un aumento del rendimiento, incluso si esto requiere comparar su contador con un valor distinto de cero. En algunos de mis programas, vi una mejora significativa en el rendimiento al cambiar mi código para subir la memoria en lugar de bajarla.
¿Escéptico? Simplemente escriba un programa en los bucles de tiempo que suben / bajan la memoria. Aquí está la salida que obtuve:
(donde "mus" significa microsegundos) al ejecutar este programa:
Tanto
sum_abs_up
ysum_abs_down
hacer lo mismo (suma del vector de números) y se miden el tiempo de la misma manera con la única diferencia de que el sersum_abs_up
sube memoria mientrassum_abs_down
desciende la memoria. Incluso pasovec
por referencia para que ambas funciones accedan a las mismas ubicaciones de memoria. Sin embargo,sum_abs_up
es consistentemente más rápido quesum_abs_down
. Inténtalo tú mismo (lo compilé con g ++ -O3).Es importante tener en cuenta cuán apretado es el ciclo que estoy cronometrando. Si el cuerpo de un bucle es grande, entonces probablemente no importará si su iterador sube o baja la memoria, ya que el tiempo que lleva ejecutar el cuerpo del bucle probablemente dominará por completo. Además, es importante mencionar que con algunos bucles raros, bajar la memoria a veces es más rápido que subirlo. Pero incluso con tales bucles, nunca fue el caso que subir la memoria siempre fue más lento que bajar (a diferencia de los bucles de cuerpo pequeño que suben la memoria, para lo cual ocurre lo contrario con frecuencia; de hecho, para un pequeño puñado de bucles I ' cinco veces, el aumento en el rendimiento al aumentar la memoria fue de más del 40%).
El punto es, como regla general, si tiene la opción, si el cuerpo del bucle es pequeño, y si hay poca diferencia entre hacer que su bucle suba de memoria en lugar de bajar, entonces debería subir de memoria.
FYI
vec_original
está ahí para la experimentación, para facilitar el cambiosum_abs_up
ysum_abs_down
de una manera que los alterevec
sin permitir que estos cambios afecten los tiempos futuros. Le recomiendo jugar consum_abs_up
ysum_abs_down
y el momento los resultados.fuente
¡independientemente de la dirección, use siempre la forma de prefijo (++ i en lugar de i ++)!
o
Explicación: http://www.eskimo.com/~scs/cclass/notes/sx7b.html
Además puedes escribir
Pero esperaría que los compiladores modernos pudieran hacer exactamente estas optimizaciones.
fuente
Es una pregunta interesante, pero como cuestión práctica no creo que sea importante y no hace que un bucle sea mejor que el otro.
De acuerdo con esta página de Wikipedia: Segundo salto , "... el día solar se vuelve 1.7 ms más largo cada siglo debido principalmente a la fricción de las mareas". Pero si está contando días hasta su cumpleaños, ¿realmente le importa esta pequeña diferencia en el tiempo?
Es más importante que el código fuente sea fácil de leer y comprender. Esos dos bucles son un buen ejemplo de por qué la legibilidad es importante: no se repiten el mismo número de veces.
Apuesto a que la mayoría de los programadores leen (i = 0; i <N; i ++) y entienden de inmediato que esto se repite N veces. Un bucle de (i = 1; i <= N; i ++), para mí de todos modos, es un poco menos claro, y con (i = N; i> 0; i--) tengo que pensarlo por un momento . Es mejor si la intención del código va directamente al cerebro sin necesidad de pensar.
fuente
Curiosamente, parece que hay una diferencia. Al menos, en PHP. Considere el siguiente punto de referencia:
Los resultados son interesantes:
Si alguien sabe por qué, sería bueno saberlo :)
EDITAR : Los resultados son los mismos incluso si comienza a contar no desde 0, sino desde otro valor arbitrario. Entonces, ¿probablemente no solo haya comparación con cero, lo que hace la diferencia?
fuente
Se puede ser más rápido.
En el procesador NIOS II con el que estoy trabajando actualmente, el bucle tradicional para
produce el ensamblaje:
Si contamos
obtenemos un ensamblaje que necesita 2 instrucciones menos.
Si tenemos bucles anidados, donde el bucle interno se ejecuta mucho, podemos tener una diferencia medible:
Si el bucle interno se escribe como anteriormente, el tiempo de ejecución es: 0.12199999999999999734 segundos. Si el bucle interno se escribe de la manera tradicional, el tiempo de ejecución es: 0.17199999999999998623 segundos. Entonces, la cuenta regresiva del bucle es aproximadamente un 30% más rápida.
Pero: esta prueba se realizó con todas las optimizaciones de GCC desactivadas. Si los activamos, el compilador es realmente más inteligente que esta optimización práctica e incluso mantiene el valor en un registro durante todo el ciclo y obtendríamos un ensamblaje como
En este ejemplo en particular, el compilador incluso nota que la variable a siempre será 1 después de la ejecución del bucle y omite los bucles por completo.
Sin embargo, experimenté que, a veces, si el cuerpo del bucle es lo suficientemente complejo, el compilador no puede hacer esta optimización, por lo que la forma más segura de obtener siempre una ejecución rápida del bucle es escribir:
Por supuesto, esto solo funciona, si no importa que el ciclo se ejecute en reversa y, como dijo Betamoo, solo si está haciendo la cuenta regresiva a cero.
fuente
Lo que su maestro ha dicho fue una declaración oblicua sin mucha aclaración. NO es que decrementar sea más rápido que incrementar, pero puede crear un bucle mucho más rápido con decremento que con incremento.
Sin hablar en detalle sobre esto, sin necesidad de usar un contador de bucle, etc., lo que importa a continuación es solo la velocidad y el conteo de bucles (no cero).
Así es como la mayoría de la gente implementa el bucle con 10 iteraciones:
Para el 99% de los casos, es todo lo que uno puede necesitar, pero junto con PHP, PYTHON, JavaScript, existe todo un mundo de software crítico (generalmente integrado, SO, juegos, etc.) donde los ticks de la CPU realmente importan, así que mire brevemente el código de ensamblaje de:
después de la compilación (sin optimización) la versión compilada puede verse así (VS2015):
El ciclo completo tiene 8 instrucciones (26 bytes). En él, en realidad hay 6 instrucciones (17 bytes) con 2 ramas. Sí, sí, sé que se puede hacer mejor (es solo un ejemplo).
Ahora considere esta construcción frecuente que a menudo encontrará escrita por desarrolladores integrados:
También itera 10 veces (sí, sé que el valor es diferente en comparación con el bucle que se muestra, pero aquí nos importa el recuento de iteraciones). Esto se puede compilar en esto:
5 instrucciones (18 bytes) y solo una rama. En realidad, hay 4 instrucciones en el bucle (11 bytes).
Lo mejor es que algunas CPU (incluidas las compatibles con x86 / x64) tienen instrucciones que pueden disminuir un registro, luego comparar el resultado con cero y realizar una bifurcación si el resultado es diferente de cero. Prácticamente TODOS los PC cpus implementan esta instrucción. Al usarlo, el bucle es en realidad solo una (sí, una) instrucción de 2 bytes:
¿Tengo que explicar cuál es más rápido?
Ahora, incluso si una CPU en particular no implementa la instrucción anterior, todo lo que necesita para emularlo es una disminución seguida de un salto condicional si el resultado de la instrucción anterior es cero.
Entonces, independientemente de algunos casos, puede señalar como un comentario por qué estoy equivocado, etc., etc. DESTACO: SÍ, ES BENEFICIOSO DESPLAZARSE HACIA ABAJO si sabe cómo, por qué y cuándo.
PD. Sí, sé que el compilador inteligente (con el nivel de optimización apropiado) reescribirá para el bucle (con contador de bucle ascendente) en do..mientras equivalente para iteraciones de bucle constantes ... (o desenrollarlo) ...
fuente
No, eso no es realmente cierto. Una situación en la que podría ser más rápido es cuando llamaría a una función para verificar los límites durante cada iteración de un bucle.
Pero si es menos claro hacerlo de esa manera, no vale la pena. En los idiomas modernos, debe usar un bucle foreach cuando sea posible, de todos modos. Usted menciona específicamente el caso en el que debe usar un bucle foreach, cuando no necesita el índice.
fuente
for(int i=0, siz=myCollection.size(); i<siz; i++)
.El punto es que cuando se hace la cuenta regresiva no es necesario verificar por
i >= 0
separado para disminuiri
. Observar:Tanto la comparación como la disminución
i
se pueden hacer en una sola expresión.Vea otras respuestas de por qué esto se reduce a menos instrucciones x86.
En cuanto a si hace una diferencia significativa en su aplicación, supongo que eso depende de cuántos bucles tenga y cuán profundamente anidados estén. Pero para mí, es igual de legible hacerlo de esta manera, así que lo hago de todos modos.
fuente
Ahora, creo que tuvo suficientes conferencias de montaje :) Me gustaría presentarle otra razón para el enfoque de arriba a abajo.
La razón para ir desde la cima es muy simple. En el cuerpo del bucle, puede cambiar accidentalmente el límite, lo que puede terminar en un comportamiento incorrecto o incluso en un bucle sin terminación.
Mire esta pequeña porción de código Java (supongo que el idioma no importa):
Entonces, mi punto es que debería considerar preferir ir de arriba hacia abajo o tener una constante como límite.
fuente
for (int i=0; i < 999; i++) {
.for(int xa=0; xa<collection.size(); xa++) { collection.add(SomeObject); ... }
En un nivel de ensamblador, un bucle que cuenta hasta cero es generalmente un poco más rápido que uno que cuenta hasta un valor dado. Si el resultado de un cálculo es igual a cero, la mayoría de los procesadores establecerán un indicador de cero. Si restar uno hace un ajuste de cálculo alrededor de cero, esto normalmente cambiará el indicador de acarreo (en algunos procesadores lo establecerá en otros, lo borrará), por lo que la comparación con cero es esencialmente gratuita.
Esto es aún más cierto cuando el número de iteraciones no es una constante sino una variable.
En casos triviales, el compilador puede optimizar la dirección de conteo de un bucle automáticamente, pero en casos más complejos puede ser que el programador sepa que la dirección del bucle es irrelevante para el comportamiento general, pero el compilador no puede probarlo.
fuente