¿Es más rápido contar hacia atrás que contar?


131

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é?


66
¿Qué informático? ¿En qué publicación?
bmargulies

26
Es concebible que pueda ahorrar un nanosegundo por iteración, o casi tanto como un pelo en una familia de mamuts lanudos. El putcharestá utilizando el 99.9999% del tiempo (más o menos).
Mike Dunlavey

38
La optimización prematura es la fuente de todos los males. Use la forma que le parezca correcta, porque (como ya sabe) son lógicamente equivalentes. La parte más difícil de la programación es comunicar la teoría del programa a otros programadores (¡y a usted mismo!). El uso de una construcción que hace que usted u otro programador lo mire por más de un segundo es una pérdida neta. Nunca recuperará el tiempo que alguien pasa pensando "¿por qué esto cuenta atrás?"
David M

61
El primer bucle es obviamente más lento, ya que llama a putchar 11 veces, mientras que el segundo solo lo llama 10 veces.
Paul Kuliniewicz

17
¿Notó que si ino está firmado, el primer bucle es infinito?
Shahbaz

Respuestas:


371

¿Es realmente cierto? y si es así, ¿alguien sabe por qué?

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?

  • Puede guardar un registro
  • Es posible que obtenga una instrucción de comparación con una codificación binaria más pequeña
  • Si una instrucción anterior establece un indicador (probablemente solo en máquinas de la familia x86), es posible que ni siquiera necesite una instrucción de comparación explícita

¿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.


3
++ Y, además, putcharlleva muchos órdenes de magnitud más largos que la sobrecarga del bucle.
Mike Dunlavey

41
No es estrictamente mitología: si está haciendo algún tipo de sistema en tiempo real súper optimizado, sería útil. Pero ese tipo de hacker probablemente ya sabría todo esto y ciertamente no confundiría a los estudiantes de CS de nivel básico con los arcanos.
Paul Nathan

44
@Joshua: ¿De qué manera se podría detectar esta optimización? Como dijo el interrogador, el índice de bucle no se usa en el bucle en sí, por lo que siempre que el número de iteraciones sea el mismo, no hay cambio en el comportamiento. En términos de una prueba de corrección, la sustitución de la variable j=N-imuestra que los dos bucles son equivalentes.
Psmears

77
+1 para el resumen. No se preocupe porque en el hardware moderno prácticamente no hay diferencia. No hizo prácticamente ninguna diferencia hace 20 años tampoco. Si cree que debe preocuparse, mida el tiempo en ambos sentidos, no vea una diferencia clara y vuelva a escribir el código clara y correctamente .
Donal Fellows

3
No sé si debería votar a favor del cuerpo o votar a favor del resumen.
Danubian Sailor

29

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<Ncada 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 si i>=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).


2
¿No sería esa la bandera cero y no la bandera de acarreo?
Bob

2
@Bob En este caso, es posible que desee llegar a cero, imprima un resultado, disminuya aún más y luego descubra que ha ido uno por debajo de cero causando un acarreo (o préstamo). Pero escrito de manera ligeramente diferente, un bucle decreciente podría usar la bandera cero en su lugar.
sigfpe

1
Solo para ser perfectamente pedante, no todo el hardware moderno está conectado. Los procesadores integrados tendrán mucha más relevancia para este tipo de microoptimización.
Paul Nathan

@Paul Como tengo algo de experiencia con los AVR de Atmel, no me olvidé de mencionar los microcontroladores ...
sigfpe

27

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á.


2
He visto el compilador C ++ de Microsoft de hace unos años hacer esa optimización. Puede ver que el índice de bucle no se usa, por lo que lo reorganiza a la forma más rápida.
Mark Ransom

1
@Mark: El compilador de Delphi también, a partir de 1996.
dthorpe

44
@MarkRansom En realidad, el compilador puede implementar el bucle usando la cuenta atrás incluso si se usa la variable de índice del bucle, dependiendo de cómo se use en el bucle. Si la variable de índice de bucle se usa solo para indexar en matrices estáticas (matrices de tamaño conocido en tiempo de compilación), la indexación de la matriz se puede hacer como ptr + tamaño de matriz - var index index, que aún puede ser una sola instrucción en x86. ¡Es bastante salvaje depurar el ensamblador y ver el ciclo contando hacia atrás pero los índices de matriz subiendo!
dthorpe

1
En realidad, hoy tu compilador probablemente no usará las instrucciones loop y jecxz ya que son más lentas que un par dec / jnz.
fuz

1
@FUZxxl Razón de más para no escribir su bucle de manera extraña. Escriba un código claro legible para humanos y deje que el compilador haga su trabajo.
dthorpe

23

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

i>=0
i<N

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:

  1. Cargar i
  2. Compare y salte si es menor o igual a cero

Pero el segundo necesita cargar N de memoria cada vez

  1. cargar i
  2. carga N
  3. Sub i y N
  4. Compare y salte si es menor o igual a cero

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

  • Tenga en cuenta que hoy en día los compiladores pueden hacer esta optimización por usted (si es lo suficientemente inteligente)
  • Tenga en cuenta también que la tubería puede causar el efecto de anomalía de Belady (no puedo estar seguro de qué será mejor)
  • Por último: tenga en cuenta que los 2 bucles for que ha presentado no son equivalentes ... los primeros imprimen uno más * ...

Relacionado: ¿Por qué n ++ se ejecuta más rápido que n = n + 1?


66
Entonces, lo que está diciendo es que no es más rápido contar hacia atrás, es más rápido compararlo con cero que con cualquier otro valor. ¿Significa que contar de 10 a 100 y contar de 100 a 10 sería lo mismo?
Bob

8
Sí ... no se trata de "contar hacia atrás o hacia arriba" ... sino de "comparar con qué" ...
Betamoo

3
Si bien esto es cierto, el nivel de ensamblador. Dos cosas se combinan para hacerme falso en realidad: el hardware moderno que usa tuberías largas e instrucciones especulativas se colará en el "Sub i y N" sin incurrir en un ciclo adicional, e incluso el compilador más crudo optimizará el "Sub i y N "fuera de existencia.
James Anderson

2
@nico no tiene que ser un sistema antiguo. Solo tiene que ser un conjunto de instrucciones donde haya una operación de comparación con cero que de alguna manera sea más rápida / mejor que el valor de comparación con el valor equivalente. x86 lo tiene en jcxz. x64 todavía lo tiene. No es antiguo Además, las arquitecturas RISC suelen ser casos especiales cero. El chip DEC AXP Alpha (en la familia MIPS), por ejemplo, tenía un "registro cero": leer como cero, escribir no hace nada. La comparación con el registro cero en lugar de con un registro general que contiene un valor cero reduce las dependencias entre instrucciones y ayuda a la ejecución fuera de orden.
dthorpe

55
@Betamoo: a menudo me pregunto por qué no se aprecian mejor las respuestas correctas (que son suyas) y no se aprecian más con más votos y se llega a la conclusión de que con demasiada frecuencia en stackoverflow los votos están influenciados por la reputación (en puntos) de una persona que responde ( que es muy muy malo) y no por la corrección de la respuesta
Artur

12

En C a psudo-ensamblaje:

for (i = 0; i < 10; i++) {
    foo(i);
}

se convierte en

    clear i
top_of_loop:
    call foo
    increment i
    compare 10, i
    jump_less top_of_loop

mientras:

for (i = 10; i >= 0; i--) {
    foo(i);
}

se convierte en

    load i, 10
top_of_loop:
    call foo
    decrement i
    jump_not_neg top_of_loop

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

x = x - 0

es semánticamente lo mismo que

compare x, 0

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.


¿Es posible que haya una diferencia de 2 instrucciones en lugar de solo 1?
Pacerier

Además, ¿por qué es difícil estar seguro de eso? Mientras la var ino se use dentro del bucle, obviamente puedes voltearla, ¿no?
Pacerier

6

Cuenta atrás más rápido en este caso:

for (i = someObject.getAllObjects.size(); i >= 0; i--) {…}

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ó:

size = someObject.getAllObjects.size();
for (i = 0; i < size; i++) {…}

55
No es "definitivamente más rápido". En muchos casos, esa llamada size () se puede sacar del bucle al contar, por lo que solo se llamará una vez. Obviamente, esto depende del lenguaje y del compilador (y del código; por ejemplo, en C ++ no se izará si size () es virtual), pero está lejos de ser definitivo de cualquier manera.
Peter

3
@Peter: solo si el compilador sabe con certeza que size () es idempotente en todo el ciclo. Probablemente ese no sea el caso, a menos que el bucle sea muy simple.
Lawrence Dol

@LawrenceDol, el compilador definitivamente lo sabrá a menos que tenga un código dinámico compilatino usando exec.
Pacerier

4

¿Es más rápido contar hacia atrás que hacia arriba?

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 Nelementos 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.


3

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.


3

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:

for (i=N; 
 i>=0;             //thing 1
 i--)             //thing 2
{
  putchar('*');   //thing 3
}
  • Comparación

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.

  • Ajustamiento

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.

  • Cuerpo de bucle

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.

  • Cachés

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.


3

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:

Average Up Memory   = 4839 mus
Average Down Memory = 5552 mus

Average Up Memory   = 18638 mus
Average Down Memory = 19053 mus

(donde "mus" significa microsegundos) al ejecutar este programa:

#include <chrono>
#include <iostream>
#include <random>
#include <vector>

//Sum all numbers going up memory.
template<class Iterator, class T>
inline void sum_abs_up(Iterator first, Iterator one_past_last, T &total) {
  T sum = 0;
  auto it = first;
  do {
    sum += *it;
    it++;
  } while (it != one_past_last);
  total += sum;
}

//Sum all numbers going down memory.
template<class Iterator, class T>
inline void sum_abs_down(Iterator first, Iterator one_past_last, T &total) {
  T sum = 0;
  auto it = one_past_last;
  do {
    it--;
    sum += *it;
  } while (it != first);
  total += sum;
}

//Time how long it takes to make num_repititions identical calls to sum_abs_down().
//We will divide this time by num_repitions to get the average time.
template<class T>
std::chrono::nanoseconds TimeDown(std::vector<T> &vec, const std::vector<T> &vec_original,
                                  std::size_t num_repititions, T &running_sum) {
  std::chrono::nanoseconds total{0};
  for (std::size_t i = 0; i < num_repititions; i++) {
    auto start_time = std::chrono::high_resolution_clock::now();
    sum_abs_down(vec.begin(), vec.end(), running_sum);
    total += std::chrono::high_resolution_clock::now() - start_time;
    vec = vec_original;
  }
  return total;
}

template<class T>
std::chrono::nanoseconds TimeUp(std::vector<T> &vec, const std::vector<T> &vec_original,
                                std::size_t num_repititions, T &running_sum) {
  std::chrono::nanoseconds total{0};
  for (std::size_t i = 0; i < num_repititions; i++) {
    auto start_time = std::chrono::high_resolution_clock::now();
    sum_abs_up(vec.begin(), vec.end(), running_sum);
    total += std::chrono::high_resolution_clock::now() - start_time;
    vec = vec_original;
  }
  return total;
}

template<class Iterator, typename T>
void FillWithRandomNumbers(Iterator start, Iterator one_past_end, T a, T b) {
  std::random_device rnd_device;
  std::mt19937 generator(rnd_device());
  std::uniform_int_distribution<T> dist(a, b);
  for (auto it = start; it != one_past_end; it++)
    *it = dist(generator);
  return ;
}

template<class Iterator>
void FillWithRandomNumbers(Iterator start, Iterator one_past_end, double a, double b) {
  std::random_device rnd_device;
  std::mt19937_64 generator(rnd_device());
  std::uniform_real_distribution<double> dist(a, b);
  for (auto it = start; it != one_past_end; it++)
    *it = dist(generator);
  return ;
}

template<class ValueType>
void TimeFunctions(std::size_t num_repititions, std::size_t vec_size = (1u << 24)) {
  auto lower = std::numeric_limits<ValueType>::min();
  auto upper = std::numeric_limits<ValueType>::max();
  std::vector<ValueType> vec(vec_size);

  FillWithRandomNumbers(vec.begin(), vec.end(), lower, upper);
  const auto vec_original = vec;
  ValueType sum_up = 0, sum_down = 0;

  auto time_up   = TimeUp(vec, vec_original, num_repititions, sum_up).count();
  auto time_down = TimeDown(vec, vec_original, num_repititions, sum_down).count();
  std::cout << "Average Up Memory   = " << time_up/(num_repititions * 1000) << " mus\n";
  std::cout << "Average Down Memory = " << time_down/(num_repititions * 1000) << " mus"
            << std::endl;
  return ;
}

int main() {
  std::size_t num_repititions = 1 << 10;
  TimeFunctions<int>(num_repititions);
  std::cout << '\n';
  TimeFunctions<double>(num_repititions);
  return 0;
}

Tanto sum_abs_upy sum_abs_downhacer lo mismo (suma del vector de números) y se miden el tiempo de la misma manera con la única diferencia de que el ser sum_abs_upsube memoria mientras sum_abs_downdesciende la memoria. Incluso paso vecpor referencia para que ambas funciones accedan a las mismas ubicaciones de memoria. Sin embargo, sum_abs_upes consistentemente más rápido que sum_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_originalestá ahí para la experimentación, para facilitar el cambio sum_abs_upy sum_abs_downde una manera que los altere vecsin permitir que estos cambios afecten los tiempos futuros. Le recomiendo jugar con sum_abs_upy sum_abs_downy el momento los resultados.


2

¡independientemente de la dirección, use siempre la forma de prefijo (++ i en lugar de i ++)!

for (i=N; i>=0; --i)  

o

for (i=0; i<N; ++i) 

Explicación: http://www.eskimo.com/~scs/cclass/notes/sx7b.html

Además puedes escribir

for (i=N; i; --i)  

Pero esperaría que los compiladores modernos pudieran hacer exactamente estas optimizaciones.


Nunca había visto gente quejarse de eso antes. Pero después de leer el enlace, realmente tiene sentido :) Gracias.
Tommy Jakobsen

3
¿Por qué debería usar siempre la forma de prefijo? Si no hay una tarea en curso, son idénticos, y el artículo al que se vinculó incluso dice que el formulario de postfix es más común.
bobDevil

3
¿Por qué debería uno usar siempre el formulario de prefijo? En este caso, es semánticamente idéntico.
Ben Zotto

2
El formulario de postfix puede crear potencialmente una copia innecesaria del objeto, aunque si el valor nunca se usa, el compilador probablemente lo optimizará al formulario de prefijo de todos modos.
Nick Lewis

Por costumbre, siempre hago --i e i ++ porque cuando aprendí que las computadoras C generalmente tenían un registro anterior y posterior, pero no viceversa. Por lo tanto, * p ++ y * - p fueron más rápidos que * ++ p y * p-- porque los dos primeros se podían hacer en una instrucción de código de máquina 68000.
JeremyP

2

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.


Las dos construcciones son exactamente igual de fáciles de entender. Hay algunas personas que afirman que si tiene 3 o 4 repeticiones, es mejor copiar la instrucción que hacer un bucle porque es más fácil de entender.
Danubian Sailor

2

Curiosamente, parece que hay una diferencia. Al menos, en PHP. Considere el siguiente punto de referencia:

<?php

print "<br>".PHP_VERSION;
$iter = 100000000;
$i=$t1=$t2=0;

$t1 = microtime(true);
for($i=0;$i<$iter;$i++){}
$t2 = microtime(true);
print '<br>$i++ : '.($t2-$t1);

$t1 = microtime(true);
for($i=$iter;$i>0;$i--){}
$t2 = microtime(true);
print '<br>$i-- : '.($t2-$t1);

$t1 = microtime(true);
for($i=0;$i<$iter;++$i){}
$t2 = microtime(true);
print '<br>++$i : '.($t2-$t1);

$t1 = microtime(true);
for($i=$iter;$i>0;--$i){}
$t2 = microtime(true);
print '<br>--$i : '.($t2-$t1);

Los resultados son interesantes:

PHP 5.2.13
$i++ : 8.8842368125916
$i-- : 8.1797409057617
++$i : 8.0271911621094
--$i : 7.1027431488037


PHP 5.3.1
$i++ : 8.9625310897827
$i-- : 8.5790238380432
++$i : 5.9647901058197
--$i : 5.4021768569946

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?


La razón por la que es más lenta es que el operador de prefijo no necesita almacenar un temporal. Considere $ foo = $ i ++; Suceden tres cosas: $ i se almacena en un temporal, $ i se incrementa y luego $ foo se le asigna el valor de ese temporal. En el caso de $ i ++; un compilador inteligente podría darse cuenta de que lo temporal es innecesario. PHP simplemente no lo hace. Los compiladores de C ++ y Java son lo suficientemente inteligentes como para hacer esta simple optimización.
Compilador conspicuo

¿Y por qué $ i-- es más rápido que $ i ++?
ts.

¿Cuántas iteraciones de su punto de referencia ejecutó? ¿Recortó los salientes y tomó un promedio para cada resultado? ¿Tu computadora estaba haciendo algo más durante los puntos de referencia? Esa diferencia de ~ 0.5 podría ser el resultado de otra actividad de la CPU, o la utilización de la canalización, o ... o ... bueno, se entiende la idea.
Eight-Bit Guru

Sí, aquí estoy dando promedios. El punto de referencia se ejecutó en diferentes máquinas, y la diferencia es accidental.
ts.

@Conspicuous Compiler => sabes o supones?
ts.

2

Se puede ser más rápido.

En el procesador NIOS II con el que estoy trabajando actualmente, el bucle tradicional para

for(i=0;i<100;i++)

produce el ensamblaje:

ldw r2,-3340(fp) %load i to r2
addi r2,r2,1     %increase i by 1
stw r2,-3340(fp) %save value of i
ldw r2,-3340(fp) %load value again (???)
cmplti r2,r2,100 %compare if less than equal 100
bne r2,zero,0xa018 %jump

Si contamos

for(i=100;i--;)

obtenemos un ensamblaje que necesita 2 instrucciones menos.

ldw r2,-3340(fp)
addi r3,r2,-1
stw r3,-3340(fp)
bne r2,zero,0xa01c

Si tenemos bucles anidados, donde el bucle interno se ejecuta mucho, podemos tener una diferencia medible:

int i,j,a=0;
for(i=100;i--;){
    for(j=10000;j--;){
        a = j+1;
    }
}

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

addi r2,r2,-1
bne r2,zero,0xa01c

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:

register int i;
for(i=10000;i--;)
{ ... }

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.


2

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:

int i;
for (i = 0; i < 10; i++)
{
    //something here
}

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:

int i;
for (i = 0; i < 10; i++)
{
    //something here
}

después de la compilación (sin optimización) la versión compilada puede verse así (VS2015):

-------- C7 45 B0 00 00 00 00  mov         dword ptr [i],0  
-------- EB 09                 jmp         labelB 
labelA   8B 45 B0              mov         eax,dword ptr [i]  
-------- 83 C0 01              add         eax,1  
-------- 89 45 B0              mov         dword ptr [i],eax  
labelB   83 7D B0 0A           cmp         dword ptr [i],0Ah  
-------- 7D 02                 jge         out1 
-------- EB EF                 jmp         labelA  
out1:

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:

i = 10;
do
{
    //something here
} while (--i);

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:

00074EBC C7 45 B0 01 00 00 00 mov         dword ptr [i],1  
00074EC3 8B 45 B0             mov         eax,dword ptr [i]  
00074EC6 83 E8 01             sub         eax,1  
00074EC9 89 45 B0             mov         dword ptr [i],eax  
00074ECC 75 F5                jne         main+0C3h (074EC3h)  

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:

00144ECE B9 0A 00 00 00       mov         ecx,0Ah  
label:
                          // something here
00144ED3 E2 FE                loop        label (0144ED3h)  // decrement ecx and jump to label if not zero

¿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) ...


1

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.

for(int i=myCollection.size(); i >= 0; i--)
{
   ...
}

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.


1
Para ser claro y eficiente, debe tener el hábito de al menos for(int i=0, siz=myCollection.size(); i<siz; i++).
Lawrence Dol

1

El punto es que cuando se hace la cuenta regresiva no es necesario verificar por i >= 0separado para disminuir i. Observar:

for (i = 5; i--;) {
  alert(i);  // alert boxes showing 4, 3, 2, 1, 0
}

Tanto la comparación como la disminución ise 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.


Creo que este es un estilo pobre, porque depende de que el lector sepa que el valor de retorno de i-- es el valor anterior de i, para el posible valor de guardar un ciclo. Eso solo sería significativo si hubiera muchas iteraciones de bucle, y el ciclo fuera una fracción significativa de la duración de la iteración, y en realidad apareciera en el tiempo de ejecución. A continuación, alguien intentará (i = 5; --i;) porque ha escuchado que en C ++ es posible que desee evitar crear algo temporal cuando sea un tipo no trivial, y ahora está en tierra de errores teniendo descartadamente tu oportunidad de hacer que el código incorrecto parezca incorrecto.
mabraham

0

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):

    System.out.println("top->down");
    int n = 999;
    for (int i = n; i >= 0; i--) {
        n++;
        System.out.println("i = " + i + "\t n = " + n);
    }
    System.out.println("bottom->up");
    n = 1;
    for (int i = 0; i < n; i++) {
        n++;
        System.out.println("i = " + i + "\t n = " + n);
    }

Entonces, mi punto es que debería considerar preferir ir de arriba hacia abajo o tener una constante como límite.


Huh? !! Tu ejemplo fallido es realmente contra-intuitivo, es decir, un argumento de hombre de paja: nadie jamás escribiría esto. Uno escribiría for (int i=0; i < 999; i++) {.
Lawrence Dol

@Software Monkey imagina que n es el resultado de algunos cálculos ... por ejemplo, es posible que desee iterar sobre alguna colección y su tamaño es el límite, pero como efecto secundario, agrega nuevos elementos a la colección en el cuerpo del bucle.
Gabriel Ščerbák

Si eso es lo que pretendías comunicar, eso es lo que tu ejemplo debería ilustrar:for(int xa=0; xa<collection.size(); xa++) { collection.add(SomeObject); ... }
Lawrence Dol

@Software Monkey Quería ser más general que solo hablar particularmente sobre colecciones, porque lo que estoy razonando no tiene nada que ver con las colecciones
Gabriel Ščerbák

2
Sí, pero si va a razonar con el ejemplo, sus ejemplos deben ser creíbles e ilustrativos del punto.
Lawrence Dol

-1

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.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.