Est-il plus rapide de compter à rebours que de compter à rebours?


131

Notre professeur d'informatique a dit un jour que pour une raison quelconque, il est plus efficace de compter à rebours que de compter à rebours. Par exemple, si vous devez utiliser une boucle FOR et que l'index de la boucle n'est pas utilisé quelque part (comme imprimer une ligne de N * à l'écran), je veux dire ce code comme ceci:

for (i = N; i >= 0; i--)  
  putchar('*');  

est mieux que:

for (i = 0; i < N; i++)  
  putchar('*');  

Est-ce vraiment vrai? Et si oui, est-ce que quelqu'un sait pourquoi?


6
Quel informaticien? Dans quelle publication?
bmargulies

26
Il est concevable que vous puissiez économiser une nanoseconde par itération, ou environ autant qu'un cheveu sur une famille de mammouths laineux. Le putcharutilise 99,9999% du temps (donner ou prendre).
Mike Dunlavey

38
L'optimisation prématurée est la racine de tout Mal. Utilisez la forme qui vous convient, car (comme vous le savez déjà), elles sont logiquement équivalentes. La partie la plus difficile de la programmation est de communiquer la théorie du programme à d'autres programmeurs (et à vous-même!). Utiliser une construction qui vous incite, ou un autre programmeur, à la regarder pendant plus d'une seconde est une perte nette. Vous ne récupérerez jamais le temps que quelqu'un passe à penser "pourquoi ce compte à rebours?"
David M

61
La première boucle est évidemment plus lente, puisqu'elle appelle putchar 11 fois, alors que la seconde ne l'appelle que 10 fois.
Paul Kuliniewicz le

17
Avez-vous remarqué que si elle in'est pas signée, la première boucle est infinie?
Shahbaz

Réponses:


371

Est-ce vraiment vrai? et si oui, est-ce que quelqu'un sait pourquoi?

Dans les temps anciens, lorsque les ordinateurs étaient encore découpés à la main dans de la silice fondue, lorsque des microcontrôleurs 8 bits parcouraient la Terre et que votre professeur était jeune (ou que l'enseignant de votre professeur était jeune), il y avait une instruction machine commune appelée décrémentation et saut. si zéro (DSZ). Les programmeurs d'assemblage Hotshot ont utilisé cette instruction pour implémenter des boucles. Les machines plus récentes recevaient des instructions plus sophistiquées, mais il y avait encore pas mal de processeurs sur lesquels il était moins cher de comparer quelque chose avec zéro que de comparer avec autre chose. (C'est vrai même sur certaines machines RISC modernes, comme PPC ou SPARC, qui réservent un registre entier pour toujours être nul.)

Donc, si vous configurez vos boucles pour les comparer à zéro au lieu de N, que pourrait-il se passer?

  • Vous pourriez enregistrer un registre
  • Vous pourriez obtenir une instruction de comparaison avec un codage binaire plus petit
  • Si une instruction précédente arrive à définir un indicateur (probablement uniquement sur les machines de la famille x86), vous n'aurez peut-être même pas besoin d'une instruction de comparaison explicite

Ces différences sont-elles susceptibles d'entraîner une amélioration mesurable sur des programmes réels sur un processeur moderne en panne? Hautement improbable. En fait, je serais impressionné si vous pouviez montrer une amélioration mesurable même sur un microbenchmark.

Résumé: Je frappe votre professeur à l'envers de la tête! Vous ne devriez pas apprendre des pseudo-faits obsolètes sur la façon d'organiser les boucles. Vous devriez apprendre que la chose la plus importante à propos des boucles est d'être sûr qu'elles se terminent , produisent des réponses correctes et sont faciles à lire . J'aimerais que votre professeur se concentre sur les choses importantes et non sur la mythologie.


3
++ Et en plus, cela putcharprend plusieurs ordres de grandeur plus longtemps que la surcharge de la boucle.
Mike Dunlavey

41
Ce n'est pas strictement de la mythologie: s'il fait une sorte de système en temps réel ultra-optimisé, cela serait utile. Mais ce genre de hacker saurait probablement déjà tout cela et ne confondrait certainement pas les étudiants débutants en CS avec les arcanes.
Paul Nathan

4
@Joshua: De quelle manière cette optimisation serait-elle détectable? Comme l'a dit l'interlocuteur, l'index de boucle n'est pas utilisé dans la boucle elle-même, donc à condition que le nombre d'itérations soit le même, il n'y a pas de changement de comportement. En termes de preuve d'exactitude, faire la substitution de variable j=N-imontre que les deux boucles sont équivalentes.
psmears

7
+1 pour le résumé. Ne vous inquiétez pas, car sur le matériel moderne, cela ne fait pratiquement aucune différence. Cela ne faisait pratiquement aucune différence il y a 20 ans non plus. Si vous pensez que vous devez vous en soucier, chronométrez dans les deux sens, ne voyez aucune différence claire et recommencez à écrire le code clairement et correctement .
Donal Fellows

3
Je ne sais pas si je devrais voter pour le corps ou contre pour le résumé.
Danubian Sailor

29

Voici ce qui peut arriver sur certains matériels en fonction de ce que le compilateur peut déduire de la plage des nombres que vous utilisez: avec la boucle d'incrémentation, vous devez tester à i<Nchaque fois le tour de la boucle. Pour la version décrémentante, l'indicateur de retenue (défini comme effet secondaire de la soustraction) peut automatiquement vous indiquer si i>=0. Cela économise un test par fois autour de la boucle.

En réalité, sur le matériel de processeur en pipeline moderne, ce truc est presque certainement hors de propos car il n'y a pas un simple mappage 1-1 des instructions aux cycles d'horloge. (Bien que je puisse imaginer que cela se produirait si vous faisiez des choses comme générer des signaux vidéo minutés avec précision à partir d'un microcontrôleur. Mais alors vous écririez de toute façon en langage d'assemblage.)


2
ne serait-ce pas le drapeau zéro et non le drapeau de report?
Bob

2
@Bob Dans ce cas, vous voudrez peut-être atteindre zéro, imprimer un résultat, décrémenter davantage, puis trouver que vous êtes passé sous zéro provoquant un report (ou un emprunt). Mais écrit légèrement différemment, une boucle de décrémentation peut utiliser l'indicateur zéro à la place.
sigfpe

1
Juste pour être parfaitement pédant, tout le matériel moderne n'est pas en pipeline. Les processeurs embarqués auront beaucoup plus de pertinence pour ce type de microoptimisation.
Paul Nathan

@Paul Comme j'ai une certaine expérience avec les AVR Atmel, je n'ai pas oublié de mentionner les microcontrôleurs ...
sigfpe

27

Dans le jeu d'instructions Intel x86, la construction d'une boucle pour décompter jusqu'à zéro peut généralement être effectuée avec moins d'instructions qu'une boucle comptant jusqu'à une condition de sortie différente de zéro. Plus précisément, le registre ECX est traditionnellement utilisé comme compteur de boucle dans x86 asm, et le jeu d'instructions Intel a une instruction de saut jcxz spéciale qui teste le registre ECX pour zéro et saute en fonction du résultat du test.

Cependant, la différence de performances sera négligeable à moins que votre boucle ne soit déjà très sensible au nombre de cycles d'horloge. Le compte à rebours jusqu'à zéro peut réduire de 4 à 5 cycles d'horloge à chaque itération de la boucle par rapport au compte à rebours, donc c'est vraiment plus une nouveauté qu'une technique utile.

De plus, un bon compilateur d'optimisation de nos jours devrait être capable de convertir votre code source de boucle de décompte en code machine de décompte à zéro (selon la façon dont vous utilisez la variable d'index de boucle), donc il n'y a vraiment aucune raison d'écrire vos boucles dans étranges façons de presser un cycle ou deux ici et là.


2
J'ai vu le compilateur C ++ de Microsoft il y a quelques années faire cette optimisation. Il est capable de voir que l'index de boucle n'est pas utilisé, il le réorganise donc sous la forme la plus rapide.
Mark Ransom

1
@Mark: Le compilateur Delphi aussi, à partir de 1996.
dthorpe

4
@MarkRansom En fait, le compilateur peut être capable d'implémenter la boucle en utilisant le compte à rebours même si la variable d'index de boucle est utilisée, selon la façon dont elle est utilisée dans la boucle. Si la variable d'index de boucle est utilisée uniquement pour indexer dans des tableaux statiques (tableaux de taille connue au moment de la compilation), l'indexation du tableau peut être effectuée comme ptr + taille du tableau - index de la boucle var, qui peut toujours être une seule instruction dans x86. C'est assez sauvage de déboguer l'assembleur et de voir le compte à rebours de la boucle mais les indices du tableau augmenter!
dthorpe le

1
En fait, aujourd'hui, votre compilateur n'utilisera probablement pas les instructions loop et jecxz car elles sont plus lentes qu'une paire dec / jnz.
fuz le

1
@FUZxxl Raison de plus pour ne pas écrire votre boucle de manière étrange. Écrivez du code clair lisible par l'homme et laissez le compilateur faire son travail.
dthorpe le

23

Oui..!!

Compter de N à 0 est légèrement plus rapide que compter de 0 à N dans le sens de la façon dont le matériel gérera la comparaison.

Notez la comparaison dans chaque boucle

i>=0
i<N

La plupart des processeurs ont une comparaison avec zéro instruction ... donc le premier sera traduit en code machine comme:

  1. Charger i
  2. Comparer et sauter si inférieur ou égal à zéro

Mais le second a besoin de charger la mémoire de forme N à chaque fois

  1. charger je
  2. charge N
  3. Sous i et N
  4. Comparer et sauter si inférieur ou égal à zéro

Ce n'est donc pas à cause du compte à rebours ou à la hausse ... mais à cause de la façon dont votre code sera traduit en code machine.

Donc, compter de 10 à 100 équivaut à compter de 100 à 10
Mais compter de i = 100 à 0 est plus rapide que de i = 0 à 100 - dans la plupart des cas
Et compter de i = N à 0 est plus rapide que de i = 0 à N

  • Notez que de nos jours, les compilateurs peuvent faire cette optimisation pour vous (si c'est assez intelligent)
  • Notez également que le pipeline peut provoquer un effet semblable à une anomalie de Belady (je ne peux pas être sûr de ce qui sera mieux)
  • Enfin: notez que les 2 boucles for que vous avez présentées ne sont pas équivalentes .. la première en imprime une de plus * ....

Connexes: Pourquoi n ++ s'exécute-t-il plus rapidement que n = n + 1?


6
Donc, ce que vous dites, c'est que le compte à rebours n'est pas plus rapide, il est juste plus rapide de comparer à zéro que toute autre valeur. Cela signifie que compter de 10 à 100 et compter à rebours de 100 à 10 serait la même chose?
Bob

8
Oui .. il ne s'agit pas de "compter à rebours ou de monter" .. mais il s'agit de "comparer à quoi" ..
Betamoo

3
Bien que cela soit vrai, le niveau assembleur. Deux choses se combinent pour ne pas être vraies en réalité - le matériel moderne utilisant de longs tuyaux et des instructions spéculatives se faufilera dans les "Sub i et N" sans encourir un cycle supplémentaire - et - même le compilateur le plus grossier optimisera le "Sub i et N "hors de l'existence.
James Anderson

2
@nico n'a pas besoin d'être un ancien système. Il doit simplement s'agir d'un jeu d'instructions dans lequel il y a une opération de comparaison à zéro qui est d'une certaine manière plus rapide / meilleure que la valeur de comparaison équivalente au registre. x86 l'a dans jcxz. x64 l'a toujours. Pas ancien. De plus, les architectures RISC sont souvent des cas spéciaux zéro. La puce DEC AXP Alpha (de la famille MIPS), par exemple, avait un "registre zéro" - lu comme zéro, l'écriture ne fait rien. La comparaison avec le registre zéro plutôt qu'avec un registre général contenant une valeur nulle réduit les dépendances inter-instructions et aide à l'exécution dans le désordre.
dthorpe le

5
@Betamoo: Je me demande souvent pourquoi les réponses meilleures / plus correctes (qui sont les vôtres) ne sont pas plus appréciées par plus de votes et en viennent à la conclusion que trop souvent sur les votes stackoverflow sont influencés par la réputation (en points) d'une personne qui répond ( ce qui est très très mauvais) et pas par l'exactitude de la réponse
Artur

12

En C à psudo-assemblage:

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

se transforme en

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

tandis que:

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

se transforme en

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

Notez l'absence de comparaison dans le deuxième psudo-assembly. Sur de nombreuses architectures, il existe des indicateurs définis par des opérations arithmatiques (ajouter, soustraire, multiplier, diviser, incrémenter, décrémenter) que vous pouvez utiliser pour les sauts. Ceux-ci vous donnent souvent ce qui est essentiellement une comparaison gratuite du résultat de l'opération avec 0. En fait sur de nombreuses architectures

x = x - 0

est sémantiquement identique à

compare x, 0

De plus, la comparaison avec un 10 dans mon exemple pourrait entraîner un code pire. 10 peuvent devoir vivre dans un registre, donc s'ils sont rares, cela coûte et peut entraîner un code supplémentaire pour déplacer les choses ou recharger le 10 à chaque fois dans la boucle.

Les compilateurs peuvent parfois réorganiser le code pour en tirer parti, mais c'est souvent difficile car ils ne peuvent souvent pas être sûrs que l'inversion de la direction dans la boucle est sémantiquement équivalente.


Est-il possible qu'il y ait un diff de 2 instructions au lieu de seulement 1?
Pacerier

Aussi, pourquoi est-il difficile d'en être sûr? Tant que le var in'est pas utilisé dans la boucle, vous pouvez évidemment le retourner, n'est-ce pas?
Pacerier

6

Compte à rebours plus rapidement dans ce cas:

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

car someObject.getAllObjects.size()s'exécute une fois au début.


Bien sûr, un comportement similaire peut être obtenu en appelant size()hors de la boucle, comme Peter l'a mentionné:

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

5
Ce n'est pas «définitivement plus rapide». Dans de nombreux cas, cet appel de size () pouvait être sorti de la boucle lors du comptage, il ne serait donc appelé qu'une seule fois. De toute évidence, cela dépend du langage et du compilateur (et du code; par exemple, en C ++, il ne sera pas hissé si size () est virtuel), mais c'est loin d'être défini de toute façon.
Peter

3
@Peter: Seulement si le compilateur sait avec certitude que size () est idempotent à travers la boucle. Ce n'est probablement presque toujours pas le cas, à moins que la boucle ne soit très simple.
Lawrence Dol

@LawrenceDol, Le compilateur le saura certainement à moins que vous n'utilisiez du code dynamique compilatino exec.
Pacerier

4

Est-ce plus rapide de compter à rebours que d'augmenter?

Peut être. Mais bien plus de 99% du temps, cela n'aura pas d'importance, vous devriez donc utiliser le test le plus `` sensé '' pour terminer la boucle, et par sens, je veux dire qu'il faut le moins de réflexion d'un lecteur pour comprendre ce que fait la boucle (y compris ce qui la fait s'arrêter). Faites en sorte que votre code corresponde au modèle mental (ou documenté) de ce que fait le code.

Si la boucle fonctionne dans un tableau (ou une liste, ou autre), un compteur incrémentiel correspondra souvent mieux à la façon dont le lecteur pourrait penser à ce que fait la boucle - codez votre boucle de cette façon.

Mais si vous travaillez dans un conteneur contenant des Narticles et que vous les supprimez au fur et à mesure, il peut être plus logique de réduire le compteur.

Un peu plus de détails sur le «peut-être» dans la réponse:

Il est vrai que sur la plupart des architectures, tester un calcul aboutissant à zéro (ou passant de zéro à négatif) ne nécessite aucune instruction de test explicite - le résultat peut être vérifié directement. Si vous souhaitez tester si un calcul aboutit à un autre nombre, le flux d'instructions devra généralement avoir une instruction explicite pour tester cette valeur. Cependant, en particulier avec les processeurs modernes, ce test ajoutera généralement moins de temps supplémentaire au niveau de bruit à une construction en boucle. Surtout si cette boucle effectue des E / S.

D'un autre côté, si vous comptez à rebours à partir de zéro et utilisez le compteur comme un index de tableau, par exemple, vous pourriez trouver le code fonctionnant contre l'architecture mémoire du système - les lectures de mémoire amèneront souvent un cache à `` regarder en avant '' plusieurs emplacements de mémoire au-delà de celui actuel en prévision d'une lecture séquentielle. Si vous travaillez à rebours dans la mémoire, le système de mise en cache peut ne pas anticiper les lectures d'un emplacement mémoire à une adresse mémoire inférieure. Dans ce cas, il est possible qu'une boucle «en arrière» puisse nuire aux performances. Cependant, je coderais probablement la boucle de cette façon (tant que les performances ne sont pas devenues un problème) car l'exactitude est primordiale, et faire correspondre le code à un modèle est un excellent moyen de garantir l'exactitude. Un code incorrect est aussi non optimisé que possible.

J'aurais donc tendance à oublier les conseils du professeur (bien sûr, pas sur son test cependant - vous devriez toujours être pragmatique en ce qui concerne la classe), à ​​moins et jusqu'à ce que la performance du code ait vraiment d'importance.


3

Sur certains processeurs plus anciens, il y a / existait des instructions comme DJNZ== "décrémenter et sauter sinon zéro". Cela permettait des boucles efficaces où vous chargiez une valeur de comptage initiale dans un registre et vous pouviez ensuite gérer efficacement une boucle de décrémentation avec une instruction. Nous parlons cependant des ISA des années 1980 - votre professeur est sérieusement déconnecté s'il pense que cette "règle de base" s'applique toujours aux processeurs modernes.


3

Bob,

Pas avant d'avoir fait des microoptimisations, à quel point vous aurez le manuel de votre CPU à portée de main. De plus, si vous faisiez ce genre de chose, vous n'auriez probablement pas besoin de poser cette question de toute façon. :-) Mais, votre professeur ne souscrit évidemment pas à cette idée ...

Il y a 4 choses à considérer dans votre exemple de boucle:

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

La comparaison est (comme d'autres l'ont indiqué) pertinente pour des architectures de processeur particulières . Il existe plus de types de processeurs que ceux qui exécutent Windows. En particulier, il peut y avoir une instruction qui simplifie et accélère les comparaisons avec 0.

  • Ajustement

Dans certains cas, il est plus rapide d'ajuster vers le haut ou vers le bas. En général, un bon compilateur le comprendra et refera la boucle s'il le peut. Cependant, tous les compilateurs ne sont pas bons.

  • Corps de boucle

Vous accédez à un appel système avec putchar. C'est extrêmement lent. De plus, vous effectuez un rendu sur l'écran (indirectement). C'est encore plus lent. Pensez à un rapport de 1000: 1 ou plus. Dans cette situation, le corps de boucle dépasse totalement et totalement le coût de l'ajustement / comparaison de la boucle.

  • Caches

Une disposition du cache et de la mémoire peut avoir un effet important sur les performances. Dans cette situation, cela n'a pas d'importance. Cependant, si vous accédiez à un tableau et que vous aviez besoin de performances optimales, il vous incomberait d'étudier comment votre compilateur et votre processeur ont organisé les accès à la mémoire et d'ajuster votre logiciel pour en tirer le meilleur parti. L'exemple de stock est celui donné en relation avec la multiplication matricielle.


3

Ce qui compte beaucoup plus que d'augmenter ou de réduire votre compteur, c'est de savoir si vous augmentez ou diminuez la mémoire. La plupart des caches sont optimisés pour augmenter la mémoire, et non pour la perte de mémoire. Étant donné que le temps d'accès à la mémoire est le goulot d'étranglement auquel la plupart des programmes sont confrontés aujourd'hui, cela signifie que modifier votre programme afin d'augmenter la mémoire peut entraîner une augmentation des performances même si cela nécessite de comparer votre compteur à une valeur non nulle. Dans certains de mes programmes, j'ai constaté une amélioration significative des performances en modifiant mon code pour augmenter la mémoire au lieu de la réduire.

Sceptique? Il suffit d'écrire un programme pour chronométrer les boucles de mémoire. Voici le résultat que j'ai obtenu:

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

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

(où "mus" signifie microsecondes) de l'exécution de ce programme:

#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;
}

Les deux sum_abs_upet sum_abs_downfaire la même chose (somme le vecteur des nombres) et sont cadencés de la même manière avec la seule différence étant que sum_abs_upmonte la mémoire tout sum_abs_downdescend mémoire. Je passe même vecpar référence pour que les deux fonctions accèdent aux mêmes emplacements mémoire. Néanmoins, sum_abs_upest toujours plus rapide que sum_abs_down. Exécutez-le vous-même (je l'ai compilé avec g ++ -O3).

Il est important de noter à quel point la boucle que je chronomètre est serrée. Si le corps d'une boucle est volumineux, le fait que son itérateur augmente ou diminue la mémoire n'a pas d'importance car le temps qu'il faut pour exécuter le corps de la boucle dominera probablement complètement. En outre, il est important de mentionner qu'avec certaines boucles rares, la réduction de la mémoire est parfois plus rapide que la remontée. Mais même avec de telles boucles, il n'a jamais été le cas que monter la mémoire était toujours plus lent que descendre (contrairement aux boucles de petite taille qui remontent la mémoire, pour lesquelles le contraire est souvent vrai; en fait, pour une petite poignée de boucles, je '' ve chronométré, l'augmentation des performances en augmentant la mémoire était de 40 +%).

Le point est, en règle générale, si vous avez l'option, si le corps de la boucle est petit, et s'il y a peu de différence entre faire remonter la mémoire de votre boucle au lieu de la réduire, alors vous devriez augmenter la mémoire.

FYI vec_originalest là pour l'expérimentation, pour faciliter le changement sum_abs_upet sum_abs_downd'une manière qui les modifie vectout en ne permettant pas à ces changements d'affecter les horaires futurs. Je recommande fortement de jouer avec sum_abs_upet sum_abs_downet de chronométrer les résultats.


2

quelle que soit la direction, utilisez toujours la forme du préfixe (++ i au lieu de i ++)!

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

ou

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

Explication: http://www.eskimo.com/~scs/cclass/notes/sx7b.html

De plus, vous pouvez écrire

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

Mais je m'attendrais à ce que les compilateurs modernes soient capables de faire exactement ces optimisations.


Jamais vu des gens se plaindre de ça auparavant. Mais après avoir lu le lien, cela a du sens :) Merci.
Tommy Jakobsen

3
Euh, pourquoi devrait-il toujours utiliser la forme de préfixe? S'il n'y a pas d'affectation en cours, elles sont identiques et l'article auquel vous avez lié indique même que le formulaire postfix est plus courant.
bobDevil

3
Pourquoi devrait-on toujours utiliser la forme du préfixe? Dans ce cas, c'est sémantiquement identique.
Ben Zotto

2
Le formulaire postfix peut potentiellement créer une copie inutile de l'objet, bien que si la valeur n'est jamais utilisée, le compilateur l'optimisera probablement en forme de préfixe de toute façon.
Nick Lewis

Par habitude, je fais toujours --i et i ++ parce que quand j'ai appris C, les ordinateurs avaient généralement un registre de prédécrémentation et de postincrémentation, mais pas l'inverse. Ainsi, * p ++ et * - p étaient plus rapides que * ++ p et * p-- parce que les deux premiers pouvaient être exécutés dans une instruction de code machine 68000.
JeremyP

2

C'est une question intéressante, mais en pratique, je ne pense pas que ce soit important et ne rend pas une boucle meilleure que l'autre.

Selon cette page wikipedia: Leap second , "... le jour solaire devient 1,7 ms plus long chaque siècle en raison principalement du frottement des marées." Mais si vous comptez les jours jusqu'à votre anniversaire, vous souciez-vous vraiment de cette petite différence de temps?

Il est plus important que le code source soit facile à lire et à comprendre. Ces deux boucles sont un bon exemple de l'importance de la lisibilité: elles ne bouclent pas le même nombre de fois.

Je parierais que la plupart des programmeurs lisent (i = 0; i <N; i ++) et comprennent immédiatement que cela boucle N fois. Une boucle de (i = 1; i <= N; i ++), pour moi en tout cas, est un peu moins claire, et avec (i = N; i> 0; i--) je dois y réfléchir un instant . Il est préférable que l'intention du code entre directement dans le cerveau sans aucune réflexion requise.


Les deux constructions sont exactement aussi faciles à comprendre. Il y a des gens qui prétendent que si vous avez 3 ou 4 répétitions, il vaut mieux copier l'instruction que de faire une boucle car c'est pour eux plus facile à comprendre.
Danubian Sailor

2

Curieusement, il semble qu'il y ait une différence. Au moins, en PHP. Pensez à suivre le benchmark:

<?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);

Les résultats sont intéressants:

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 quelqu'un sait pourquoi, ce serait bien de savoir :)

EDIT : Les résultats sont les mêmes même si vous commencez à compter non pas à partir de 0, mais d'une autre valeur arbitraire. Il n'y a donc probablement pas que la comparaison à zéro qui fait la différence?


La raison pour laquelle il est plus lent est que l'opérateur de préfixe n'a pas besoin de stocker un fichier temporaire. Considérez $ foo = $ i ++; Trois choses se produisent: $ i est stocké dans un temporaire, $ i est incrémenté, puis $ foo reçoit la valeur de ce temporaire. Dans le cas de $ i ++; un compilateur intelligent pourrait réaliser que le temporaire n'est pas nécessaire. PHP ne le fait pas. Les compilateurs C ++ et Java sont suffisamment intelligents pour effectuer cette simple optimisation.
Compilateur remarquable

et pourquoi $ i-- est plus rapide que $ i ++?
ts.

Combien d'itérations de votre benchmark avez-vous exécutées? Avez-vous découpé des cavaliers et pris une moyenne pour chaque résultat? Votre ordinateur a-t-il fait autre chose pendant les tests de performance? Cette différence d'environ 0,5 pourrait simplement être le résultat d'une autre activité du processeur, ou de l'utilisation du pipeline, ou ... ou ... eh bien, vous voyez l'idée.
Gourou de huit bits

Oui, ici je donne des moyennes. Le Benchmark a été exécuté sur différentes machines, et la différence est accidentelle.
ts.

@Conspicuous Compiler => vous savez ou vous supposez?
ts.

2

Cela peut être plus rapide.

Sur le processeur NIOS II avec lequel je travaille actuellement, la boucle for traditionnelle

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

produit l'assemblage:

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 on compte à rebours

for(i=100;i--;)

nous obtenons un assemblage qui nécessite 2 instructions de moins.

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

Si nous avons des boucles imbriquées, où la boucle interne est beaucoup exécutée, nous pouvons avoir une différence mesurable:

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

Si la boucle interne est écrite comme ci-dessus, le temps d'exécution est: 0,12199999999999999734 secondes. Si la boucle interne est écrite de manière traditionnelle, le temps d'exécution est: 0,17199999999999998623 secondes. Ainsi, le compte à rebours de la boucle est environ 30% plus rapide.

Mais: ce test a été fait avec toutes les optimisations GCC désactivées. Si nous les activons, le compilateur est en fait plus intelligent que cette optimisation pratique et conserve même la valeur dans un registre pendant toute la boucle et nous obtiendrions un assemblage comme

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

Dans cet exemple particulier, le compilateur remarque même que la variable a sera toujours 1 après l'exécution de la boucle et saute toutes les boucles.

Cependant, j'ai constaté que parfois, si le corps de la boucle est suffisamment complexe, le compilateur n'est pas capable de faire cette optimisation, donc le moyen le plus sûr d'obtenir toujours une exécution rapide de la boucle est d'écrire:

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

Bien sûr, cela ne fonctionne que si cela n'a pas d'importance que la boucle soit exécutée à l'envers et comme Betamoo l'a dit, seulement si vous comptez à rebours jusqu'à zéro.


2

Ce que votre professeur a dit était une déclaration oblique sans beaucoup de clarification. Ce n'est PAS que la décrémentation soit plus rapide que l'incrémentation, mais vous pouvez créer une boucle beaucoup plus rapide avec décrémentation qu'avec incrémentation.

Sans en parler longuement, sans avoir besoin d'utiliser un compteur de boucles, etc. - ce qui compte ci-dessous, c'est juste la vitesse et le nombre de boucles (non nul).

Voici comment la plupart des gens implémentent la boucle avec 10 itérations:

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

Dans 99% des cas, c'est tout ce dont on peut avoir besoin, mais avec PHP, PYTHON, JavaScript, il y a tout le monde des logiciels critiques (généralement embarqués, OS, jeux, etc.) où les ticks CPU comptent vraiment, alors regardez brièvement le code d'assemblage de:

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

après compilation (sans optimisation), la version compilée peut ressembler à ceci (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:

La boucle entière est de 8 instructions (26 octets). Il contient en fait 6 instructions (17 octets) avec 2 branches. Oui, je sais que cela peut être mieux fait (c'est juste un exemple).

Maintenant, considérez cette construction fréquente que vous trouverez souvent écrite par un développeur embarqué:

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

Il itère également 10 fois (oui, je sais que la valeur i est différente de celle indiquée pour la boucle, mais nous nous soucions du nombre d'itérations ici). Cela peut être compilé dans ceci:

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 instructions (18 octets) et une seule branche. En fait, il y a 4 instructions dans la boucle (11 octets).

La meilleure chose est que certains processeurs (compatibles x86 / x64 inclus) ont des instructions qui peuvent décrémenter un registre, comparer ultérieurement le résultat à zéro et effectuer une branche si le résultat est différent de zéro. Pratiquement TOUS les processeurs PC implémentent cette instruction. En l'utilisant, la boucle n'est en fait qu'une instruction (oui un) de 2 octets:

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

Dois-je expliquer ce qui est le plus rapide?

Maintenant, même si un processeur particulier n'implémente pas l'instruction ci-dessus, tout ce dont il a besoin pour l'émuler est un décrément suivi d'un saut conditionnel si le résultat de l'instruction précédente s'avère être zéro.

Donc, indépendamment de certains cas que vous pouvez signaler en commentaire, pourquoi je me trompe, etc. J'insiste sur le fait qu'il est avantageux de faire une boucle vers le bas si vous savez comment, pourquoi et quand.

PS. Oui, je sais que le compilateur sage (avec le niveau d'optimisation approprié) réécrira pour la boucle (avec le compteur de boucle ascendant) en do..tandis que l'équivalent pour les itérations de boucle constante ... (ou le déroulera) ...


1

Non, ce n'est pas vraiment vrai. Une situation où cela pourrait être plus rapide est lorsque vous appelleriez autrement une fonction pour vérifier les limites à chaque itération d'une boucle.

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

Mais s'il est moins clair de le faire de cette façon, cela n'en vaut pas la peine. Dans les langues modernes, vous devez utiliser une boucle foreach lorsque cela est possible, de toute façon. Vous mentionnez spécifiquement le cas où vous devriez utiliser une boucle foreach - lorsque vous n'avez pas besoin de l'index.


1
Pour être clair et efficace, vous devez au moins en avoir l'habitude for(int i=0, siz=myCollection.size(); i<siz; i++).
Lawrence Dol

1

Le fait est que lors du décompte, vous n'avez pas besoin de vérifier i >= 0séparément pour décrémenter i. Observer:

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

La comparaison et la décrémentation ipeuvent être effectuées dans une seule expression.

Voir d'autres réponses pour savoir pourquoi cela se résume à moins d'instructions x86.

Quant à savoir si cela fait une différence significative dans votre application, eh bien je suppose que cela dépend du nombre de boucles que vous avez et de la profondeur de leur imbrication. Mais pour moi, c'est tout aussi lisible de le faire de cette façon, alors je le fais quand même.


Je pense que c'est un style médiocre, car cela dépend du lecteur sachant que la valeur de retour de i-- est l'ancienne valeur de i, pour la valeur possible de la sauvegarde d'un cycle. Ce ne serait significatif que s'il y avait beaucoup d'itérations de boucle, et le cycle représentait une fraction significative de la longueur de l'itération, et apparaissait réellement au moment de l'exécution. Ensuite, quelqu'un essaiera pour (i = 5; --i;) parce qu'il a entendu dire qu'en C ++, vous voudrez peut-être éviter de créer du temporaire lorsque i est un type non trivial, et maintenant vous êtes au pays des bogues ayant vous avez complètement gâché votre opportunité de faire paraître un mauvais code.
mabraham

0

Maintenant, je pense que vous avez eu assez de conférences d'assemblée :) Je voudrais vous présenter une autre raison pour une approche descendante.

La raison d'aller du haut est très simple. Dans le corps de la boucle, vous pouvez accidentellement modifier la limite, ce qui peut aboutir à un comportement incorrect ou même à une boucle sans fin.

Regardez cette petite partie du code Java (le langage n'a pas d'importance je suppose pour cette raison):

    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);
    }

Donc, mon point est que vous devriez envisager de préférer aller du haut vers le bas ou avoir une constante comme limite.


Hein? !! Votre exemple raté est vraiment contre-intuitif, c'est-à-dire un argument d'homme de paille - personne n'écrirait jamais cela. On écrirait for (int i=0; i < 999; i++) {.
Lawrence Dol

@Software Monkey imagine que n est le résultat d'un calcul ... par exemple, vous voudrez peut-être parcourir une collection et sa taille est la limite, mais comme effet secondaire, vous ajoutez de nouveaux éléments à la collection dans le corps de la boucle.
Gabriel Ščerbák

Si c'est ce que vous aviez l'intention de communiquer, alors c'est ce que votre exemple devrait illustrer:for(int xa=0; xa<collection.size(); xa++) { collection.add(SomeObject); ... }
Lawrence Dol

@Software Monkey Je voulais être plus général que de parler en particulier des collections, parce que ce sur quoi je raisonne n'a rien à voir avec les collections
Gabriel Ščerbák

2
Oui, mais si vous voulez raisonner par l'exemple, vos exemples doivent être crédibles et illustratifs de ce point.
Lawrence Dol

-1

Au niveau de l'assembleur, une boucle qui compte à rebours jusqu'à zéro est généralement légèrement plus rapide qu'une boucle qui compte jusqu'à une valeur donnée. Si le résultat d'un calcul est égal à zéro, la plupart des processeurs définiront un indicateur zéro. Si en soustrayant un, le calcul passe autour de zéro, cela changera normalement l'indicateur de report (sur certains processeurs, il le mettra sur d'autres, il l'effacera), de sorte que la comparaison avec zéro est essentiellement gratuite.

Ceci est encore plus vrai lorsque le nombre d'itérations n'est pas une constante mais une variable.

Dans des cas triviaux, le compilateur peut être en mesure d'optimiser automatiquement la direction de comptage d'une boucle, mais dans des cas plus complexes, il se peut que le programmeur sache que la direction de la boucle n'est pas pertinente pour le comportement global, mais le compilateur ne peut pas le prouver.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.