La question d'origine
Pourquoi une boucle est-elle tellement plus lente que deux boucles?
Conclusion:
Cas 1 est un problème d'interpolation classique qui s'avère être inefficace. Je pense également que c'était l'une des principales raisons pour lesquelles de nombreuses architectures de machines et développeurs ont fini par construire et concevoir des systèmes multicœurs avec la capacité de faire des applications multithread ainsi que la programmation parallèle.
Le regarder à partir de ce type d'approche sans impliquer comment le matériel, le système d'exploitation et les compilateurs fonctionnent ensemble pour effectuer des allocations de tas qui impliquent de travailler avec la RAM, le cache, les fichiers de page, etc. les mathématiques qui sont à la base de ces algorithmes nous montrent laquelle de ces deux est la meilleure solution.
Nous pouvons utiliser une analogie d'un Boss
être Summation
qui représentera un For Loop
qui doit voyager entre les travailleurs A
et B
.
Nous pouvons facilement voir que le cas 2 est au moins deux fois moins rapide, sinon un peu plus que le cas 1, en raison de la différence de distance à parcourir et du temps pris entre les travailleurs. Ces calculs s'alignent presque virtuellement et parfaitement avec le BenchMark Times ainsi qu'avec le nombre de différences dans les instructions de montage.
Je vais maintenant commencer à expliquer comment tout cela fonctionne ci-dessous.
Évaluer le problème
Le code de l'OP:
const int n=100000;
for(int j=0;j<n;j++){
a1[j] += b1[j];
c1[j] += d1[j];
}
Et
for(int j=0;j<n;j++){
a1[j] += b1[j];
}
for(int j=0;j<n;j++){
c1[j] += d1[j];
}
La prise en compte
Considérant la question originale du PO sur les 2 variantes des boucles for et sa question modifiée concernant le comportement des caches ainsi que de nombreuses autres excellentes réponses et commentaires utiles; J'aimerais essayer de faire quelque chose de différent ici en adoptant une approche différente de cette situation et de ce problème.
L'approche
Compte tenu des deux boucles et de toute la discussion sur le cache et le dépôt de pages, j'aimerais adopter une autre approche pour examiner les choses sous un angle différent. Celui qui n'implique pas le cache et les fichiers d'échange ni les exécutions pour allouer de la mémoire, en fait, cette approche ne concerne même pas du tout le matériel ou le logiciel.
La perspective
Après avoir regardé le code pendant un certain temps, il est devenu tout à fait évident quel est le problème et ce qui le génère. Décomposons cela en un problème algorithmique et examinons-le du point de vue de l'utilisation des notations mathématiques, puis appliquons une analogie aux problèmes mathématiques ainsi qu'aux algorithmes.
Ce que nous savons
Nous savons que cette boucle sera exécutée 100 000 fois. Nous savons aussi que a1
, b1
, c1
et d1
sont des pointeurs sur une architecture 64 bits. Dans C ++ sur une machine 32 bits, tous les pointeurs font 4 octets et sur une machine 64 bits, ils ont une taille de 8 octets car les pointeurs sont de longueur fixe.
Nous savons que nous avons 32 octets à allouer dans les deux cas. La seule différence est que nous allouons 32 octets ou 2 ensembles de 2 à 8 octets à chaque itération, dans le deuxième cas, nous allouons 16 octets pour chaque itération pour les deux boucles indépendantes.
Les deux boucles égalent toujours 32 octets dans les allocations totales. Avec ces informations, allons de l'avant et montrons les mathématiques générales, les algorithmes et l'analogie de ces concepts.
Nous savons le nombre de fois qu'un même ensemble ou groupe d'opérations devra être effectué dans les deux cas. Nous connaissons la quantité de mémoire qui doit être allouée dans les deux cas. Nous pouvons évaluer que la charge de travail globale des allocations entre les deux cas sera approximativement la même.
Ce que nous ne savons pas
Nous ne savons pas combien de temps cela prendra pour chaque cas, sauf si nous définissons un compteur et exécutons un test de référence. Cependant, les repères étaient déjà inclus à partir de la question initiale et de certaines réponses et commentaires également; et nous pouvons voir une différence significative entre les deux et c'est tout le raisonnement pour cette proposition à ce problème.
Enquêtons
Il est déjà évident que beaucoup l'ont déjà fait en examinant les allocations de tas, les tests de référence, la RAM, le cache et les fichiers d'échange. L'examen de points de données spécifiques et d'indices d'itération spécifiques a également été inclus et les diverses conversations sur ce problème spécifique ont amené de nombreuses personnes à s'interroger sur d'autres sujets connexes. Comment commencer à regarder ce problème en utilisant des algorithmes mathématiques et en lui appliquant une analogie? Nous commençons par faire quelques affirmations! Ensuite, nous construisons notre algorithme à partir de là.
Nos affirmations:
- Nous allons laisser notre boucle et ses itérations être une sommation qui commence à 1 et se termine à 100000 au lieu de commencer par 0 comme dans les boucles car nous n'avons pas à nous soucier du schéma d'indexation 0 de l'adressage de la mémoire, car nous sommes simplement intéressés par l'algorithme lui-même.
- Dans les deux cas, nous avons 4 fonctions avec lesquelles travailler et 2 appels de fonction avec 2 opérations en cours sur chaque appel de fonction. Nous allons définir ces fonctions et comme des appels à des fonctions comme suit:
F1()
, F2()
, f(a)
, f(b)
, f(c)
et f(d)
.
Les algorithmes:
1er cas: - Une seule sommation mais deux appels de fonction indépendants.
Sum n=1 : [1,100000] = F1(), F2();
F1() = { f(a) = f(a) + f(b); }
F2() = { f(c) = f(c) + f(d); }
2ème cas: - Deux sommations mais chacune a son propre appel de fonction.
Sum1 n=1 : [1,100000] = F1();
F1() = { f(a) = f(a) + f(b); }
Sum2 n=1 : [1,100000] = F1();
F1() = { f(c) = f(c) + f(d); }
Si vous avez remarqué qu'il F2()
n'existe que dans Sum
d' Case1
où F1()
est contenu dans Sum
de Case1
et dans les deux Sum1
et Sum2
de Case2
. Cela sera évident plus tard lorsque nous commencerons à conclure qu'il y a une optimisation qui se produit dans le deuxième algorithme.
Les itérations à travers les premiers Sum
appels de cas f(a)
qui s'ajouteront à lui-même f(b)
puis les appels f(c)
qui feront de même mais s'ajouteront f(d)
à chaque 100000
itération. Dans le second cas, nous avons Sum1
et Sum2
que les deux agissent de la même manière que s'ils étaient la même fonction appelée deux fois de suite.
Dans ce cas, nous pouvons traiter Sum1
et Sum2
comme tout simplement vieux Sum
où Sum
dans ce cas, cela ressemble à ceci: Sum n=1 : [1,100000] { f(a) = f(a) + f(b); }
et maintenant cela ressemble à une optimisation où nous pouvons simplement considérer que c'est la même fonction.
Résumé avec l'analogie
Avec ce que nous avons vu dans le deuxième cas, il semble presque qu'il y ait optimisation car les deux boucles ont la même signature exacte, mais ce n'est pas le vrai problème. La question n'est pas le travail qui est fait par f(a)
, f(b)
, f(c)
et f(d)
. Dans les deux cas et la comparaison entre les deux, c'est la différence de distance que doit parcourir la somme dans chaque cas qui vous donne la différence de temps d'exécution.
Pensez à l' For Loops
comme étant le Summations
qui fait les itérations comme étant un Boss
qui donne des ordres à deux personnes A
et B
et que leurs emplois sont à la viande C
et , D
respectivement , et de ramasser quelques paquet d'eux et de le retourner. Dans cette analogie, les boucles for ou les itérations de sommation et les vérifications de conditions elles-mêmes ne représentent pas réellement le Boss
. Ce qui représente réellement le Boss
n'est pas directement issu des algorithmes mathématiques réels, mais du concept réel de Scope
et Code Block
au sein d'une routine ou d'un sous-programme, d'une méthode, d'une fonction, d'une unité de traduction, etc.
Dans le premier cas sur chaque fiche d'appel, le Boss
va à A
et donne la commande et A
part chercher le B's
paquet puis le Boss
va à C
et donne les ordres de faire de même et de recevoir le paquet D
à chaque itération.
Dans le second cas, le programme Boss
travaille directement avec A
pour aller chercher le B's
paquet jusqu'à ce que tous les paquets soient reçus. Ensuite, Boss
fonctionne avec C
pour faire de même pour obtenir tous les D's
packages.
Étant donné que nous travaillons avec un pointeur de 8 octets et que nous traitons l'allocation de segment de mémoire, considérons le problème suivant. Disons que le Boss
est à 100 pieds A
et à A
500 pieds C
. Nous n'avons pas à nous soucier de la distance qui les Boss
sépare initialement en C
raison de l'ordre des exécutions. Dans les deux cas, le Boss
voyage d' A
abord du premier au ensuite B
. Cette analogie ne veut pas dire que cette distance est exacte; c'est juste un scénario de cas de test utile pour montrer le fonctionnement des algorithmes.
Dans de nombreux cas, lors de l'allocation de segments de mémoire et de l'utilisation du cache et des fichiers d'échange, ces distances entre les emplacements d'adresses peuvent ne pas varier beaucoup ou elles peuvent varier considérablement en fonction de la nature des types de données et des tailles de tableau.
Les cas de test:
Premier cas: lors de la première itération, leBoss
doit d'abord parcourir 100 pieds pour donner le bon de commandeA
et s'enA
va et fait son truc, mais ilBoss
doit ensuite parcourir 500 pieds pourC
lui donner son bon de commande. Ensuite, à la prochaine itération et à toutes les autres itérations après leBoss
doit faire 500 allers-retours entre les deux.
Deuxième cas: LeBoss
doit parcourir 100 pieds lors de la première itérationA
, mais après cela, il est déjà là et attend justeA
de revenir jusqu'à ce que tous les feuillets soient remplis. Ensuite, ilBoss
doit parcourir 500 pieds lors de la première itérationC
car il seC
trouve à 500 pieds deA
. Étant donné que celaBoss( Summation, For Loop )
est appelé juste après avoir travaillé avec,A
il attend juste là comme il l'a faitA
jusqu'à ce que tous lesbonsdeC's
commande soient effectués.
La différence de distances parcourues
const n = 100000
distTraveledOfFirst = (100 + 500) + ((n-1)*(500 + 500);
// Simplify
distTraveledOfFirst = 600 + (99999*100);
distTraveledOfFirst = 600 + 9999900;
distTraveledOfFirst = 10000500;
// Distance Traveled On First Algorithm = 10,000,500ft
distTraveledOfSecond = 100 + 500 = 600;
// Distance Traveled On Second Algorithm = 600ft;
La comparaison des valeurs arbitraires
Nous pouvons facilement voir que 600 est bien moins de 10 millions. Maintenant, ce n'est pas exact, car nous ne connaissons pas la différence réelle de distance entre quelle adresse de RAM ou de quel cache ou fichier d'échange chaque appel à chaque itération sera dû à de nombreuses autres variables invisibles. Il s'agit simplement d'une évaluation de la situation à prendre en compte et de l'examiner dans le pire des cas.
D'après ces chiffres, il semblerait presque que l'algorithme un devrait être 99%
plus lent que l'algorithme deux; Cependant, ce n'est que la Boss's
partie ou la responsabilité des algorithmes et il ne tient pas compte des travailleurs réels A
, B
, C
, et D
et ce qu'ils ont à faire sur chaque itération de la boucle. Le travail du patron ne représente donc qu'environ 15 à 40% du travail total effectué. La majeure partie du travail effectué par les travailleurs a un impact légèrement plus important sur le maintien du rapport des différences de taux de vitesse à environ 50-70%
L'observation: - Les différences entre les deux algorithmes
Dans cette situation, c'est la structure du processus de travail en cours. Cela montre que le cas 2 est plus efficace à la fois par l'optimisation partielle d'avoir une déclaration et une définition de fonction similaires où seules les variables diffèrent par leur nom et la distance parcourue.
Nous voyons également que la distance totale parcourue dans le cas 1 est beaucoup plus éloignée que dans le cas 2 et nous pouvons considérer cette distance parcourue comme notre facteur temps entre les deux algorithmes. Le cas 1 a beaucoup plus de travail à faire que le cas 2 .
Cela ressort de la preuve des ASM
instructions qui ont été montrées dans les deux cas. Avec ce qui a déjà été dit à propos de ces cas, cela ne tient pas compte du fait que dans le cas 1, le patron devra attendre les deux A
et C
revenir avant de pouvoir revenir à A
nouveau à chaque itération. Cela ne tient pas compte non plus du fait que si A
ou B
prend un temps extrêmement long, le Boss
ou les autres travailleurs sont inactifs en attente d'être exécutés.
Dans le cas 2, le seul qui est inactif est Boss
jusqu'à ce que le travailleur revienne. Donc, même cela a un impact sur l'algorithme.
Question (s) modifiée (s) du PO
EDIT: La question s'est avérée sans pertinence, car le comportement dépend fortement des tailles des tableaux (n) et du cache du processeur. Donc s'il y a plus d'intérêt, je reformule la question:
Pourriez-vous fournir des informations solides sur les détails qui conduisent aux différents comportements de cache, comme illustré par les cinq régions sur le graphique suivant?
Il pourrait également être intéressant de souligner les différences entre les architectures CPU / cache, en fournissant un graphique similaire pour ces CPU.
Concernant ces questions
Comme je l'ai démontré sans aucun doute, il y a un problème sous-jacent avant même que le matériel et les logiciels ne soient impliqués.
Maintenant, en ce qui concerne la gestion de la mémoire et de la mise en cache avec les fichiers de page, etc. qui fonctionnent tous ensemble dans un ensemble intégré de systèmes entre les éléments suivants:
The Architecture
{Matériel, micrologiciel, certains pilotes intégrés, noyaux et jeux d'instructions ASM}.
The OS
{Systèmes de gestion de fichiers et de mémoire, pilotes et registre}.
The Compiler
{Unités de traduction et optimisations du code source}.
- Et même le
Source Code
lui - même avec son ensemble d'algorithmes distinctifs.
On peut déjà voir qu'il ya un goulot d' étranglement qui se passe dans le premier algorithme avant d' appliquer même à une machine avec un arbitraire Architecture
, OS
et par Programmable Language
rapport au deuxième algorithme. Il existait déjà un problème avant d'impliquer les intrinsèques d'un ordinateur moderne.
Les résultats finaux
Toutefois; cela ne veut pas dire que ces nouvelles questions n'ont pas d'importance parce qu'elles le sont elles-mêmes et qu'elles jouent un rôle après tout. Ils ont un impact sur les procédures et les performances globales et cela est évident avec les différents graphiques et évaluations de beaucoup de ceux qui ont donné leur (s) réponse (s) et / ou commentaire (s).
Si vous avez fait attention à l'analogie entre le Boss
et les deux travailleurs A
& B
qui ont dû aller chercher des packages à partir de C
& D
respectivement et en considérant les notations mathématiques des deux algorithmes en question; vous pouvez voir sans l'implication du matériel informatique et des logiciels Case 2
est environ 60%
plus rapide que Case 1
.
Lorsque vous regardez les graphiques et les graphiques après que ces algorithmes ont été appliqués à du code source, compilés, optimisés et exécutés via le système d'exploitation pour effectuer leurs opérations sur un matériel donné, vous pouvez même voir un peu plus de dégradation entre les différences dans ces algorithmes.
Si l' Data
ensemble est assez petit, il peut ne pas sembler si mauvais d'une différence au premier abord. Cependant, étant donné qu'il Case 1
est à peu près 60 - 70%
plus lent que Case 2
nous pouvons regarder la croissance de cette fonction en termes de différences dans les exécutions de temps:
DeltaTimeDifference approximately = Loop1(time) - Loop2(time)
//where
Loop1(time) = Loop2(time) + (Loop2(time)*[0.6,0.7]) // approximately
// So when we substitute this back into the difference equation we end up with
DeltaTimeDifference approximately = (Loop2(time) + (Loop2(time)*[0.6,0.7])) - Loop2(time)
// And finally we can simplify this to
DeltaTimeDifference approximately = [0.6,0.7]*Loop2(time)
Cette approximation est la différence moyenne entre ces deux boucles à la fois algorithmiquement et les opérations machine impliquant des optimisations logicielles et des instructions machine.
Lorsque l'ensemble de données croît de façon linéaire, la différence de temps entre les deux augmente également. L'algorithme 1 a plus de récupérations que l'algorithme 2, ce qui est évident lorsque le Boss
doit parcourir la distance maximale entre A
et C
pour chaque itération après la première itération tandis que l'algorithme 2 Boss
doit parcourir A
une fois puis après avoir terminé, A
il doit voyager une distance maximale une seule fois pour aller de A
à C
.
Essayer de se Boss
concentrer sur deux choses similaires à la fois et de les jongler d'avant en arrière au lieu de se concentrer sur des tâches consécutives similaires va le mettre en colère à la fin de la journée car il a dû voyager et travailler deux fois plus. Par conséquent, ne perdez pas la portée de la situation en laissant votre patron entrer dans un goulot d'étranglement interpolé parce que le conjoint et les enfants du patron ne l'apprécieraient pas.
Amendement: principes de conception en génie logiciel
- La différence Local Stack
et les Heap Allocated
calculs au sein de l'itératif pour les boucles et la différence entre leurs usages, leurs efficacités et leur efficacité -
L'algorithme mathématique que j'ai proposé ci-dessus s'applique principalement aux boucles qui effectuent des opérations sur les données allouées sur le tas.
- Opérations de pile consécutives:
- Si les boucles effectuent des opérations sur les données localement dans un seul bloc de code ou portée qui se trouve dans le cadre de la pile, cela s'appliquera toujours, mais les emplacements de mémoire sont beaucoup plus proches où ils sont généralement séquentiels et la différence de distance parcourue ou de temps d'exécution est presque négligeable. Puisqu'aucune allocation n'est effectuée dans le tas, la mémoire n'est pas dispersée et la mémoire n'est pas récupérée via RAM. La mémoire est généralement séquentielle et relative au cadre de pile et au pointeur de pile.
- Lorsque des opérations consécutives sont effectuées sur la pile, un processeur moderne mettra en cache les valeurs répétitives et les adresses en conservant ces valeurs dans les registres de cache locaux. Le temps des opérations ou des instructions ici est de l'ordre de la nano-seconde.
- Opérations d'affectation de segments consécutives:
- Lorsque vous commencez à appliquer des allocations de tas et que le processeur doit récupérer les adresses mémoire lors d'appels consécutifs, selon l'architecture du CPU, du contrôleur de bus et des modules Ram, le temps des opérations ou de l'exécution peut être de l'ordre du micro à millisecondes. Par rapport aux opérations de pile en cache, elles sont assez lentes.
- Le CPU devra récupérer l'adresse mémoire de Ram et généralement tout ce qui se trouve sur le bus système est lent par rapport aux chemins de données internes ou aux bus de données au sein du CPU lui-même.
Ainsi, lorsque vous travaillez avec des données qui doivent se trouver sur le tas et que vous les parcourez en boucles, il est plus efficace de conserver chaque ensemble de données et ses algorithmes correspondants dans sa propre boucle unique. Vous obtiendrez de meilleures optimisations par rapport à la tentative de factoriser des boucles consécutives en mettant plusieurs opérations de différents ensembles de données qui sont sur le tas dans une seule boucle.
Il est correct de le faire avec les données qui sont sur la pile car elles sont fréquemment mises en cache, mais pas pour les données qui doivent avoir leur adresse mémoire interrogée à chaque itération.
C'est là que l'ingénierie logicielle et la conception de l'architecture logicielle entrent en jeu. C'est la capacité de savoir comment organiser vos données, de savoir quand mettre en cache vos données, de savoir quand allouer vos données sur le tas, de savoir comment concevoir et mettre en œuvre vos algorithmes, et de savoir quand et où les appeler.
Vous pourriez avoir le même algorithme qui appartient au même ensemble de données, mais vous voudrez peut-être une conception d'implémentation pour sa variante de pile et une autre pour sa variante allouée au tas juste en raison du problème ci-dessus qui est vu de son O(n)
complexité de l'algorithme lors du travail avec le tas.
D'après ce que j'ai remarqué au fil des ans, beaucoup de gens ne prennent pas ce fait en considération. Ils auront tendance à concevoir un algorithme qui fonctionne sur un ensemble de données particulier et ils l'utiliseront indépendamment de l'ensemble de données mis en cache localement sur la pile ou s'il a été alloué sur le tas.
Si vous voulez une véritable optimisation, oui, cela pourrait ressembler à une duplication de code, mais pour généraliser, il serait plus efficace d'avoir deux variantes du même algorithme. Un pour les opérations de pile et l'autre pour les opérations de tas qui sont effectuées dans des boucles itératives!
Voici un pseudo exemple: deux structures simples, un algorithme.
struct A {
int data;
A() : data{0}{}
A(int a) : data{a}{}
};
struct B {
int data;
B() : data{0}{}
A(int b) : data{b}{}
}
template<typename T>
void Foo( T& t ) {
// do something with t
}
// some looping operation: first stack then heap.
// stack data:
A dataSetA[10] = {};
B dataSetB[10] = {};
// For stack operations this is okay and efficient
for (int i = 0; i < 10; i++ ) {
Foo(dataSetA[i]);
Foo(dataSetB[i]);
}
// If the above two were on the heap then performing
// the same algorithm to both within the same loop
// will create that bottleneck
A* dataSetA = new [] A();
B* dataSetB = new [] B();
for ( int i = 0; i < 10; i++ ) {
Foo(dataSetA[i]); // dataSetA is on the heap here
Foo(dataSetB[i]); // dataSetB is on the heap here
} // this will be inefficient.
// To improve the efficiency above, put them into separate loops...
for (int i = 0; i < 10; i++ ) {
Foo(dataSetA[i]);
}
for (int i = 0; i < 10; i++ ) {
Foo(dataSetB[i]);
}
// This will be much more efficient than above.
// The code isn't perfect syntax, it's only psuedo code
// to illustrate a point.
C'est à cela que je faisais référence en ayant des implémentations distinctes pour les variantes de pile par rapport aux variantes de tas. Les algorithmes eux-mêmes n'ont pas trop d'importance, ce sont les structures en boucle que vous allez utiliser dans ce cas.