web-dev-qa-db-fra.com

Pourquoi les ajouts élément par élément sont-ils beaucoup plus rapides dans des boucles séparées que dans une boucle combinée?

Supposons que a1, b1, c1 et d1 pointent sur la mémoire vive et que mon code numérique ait la boucle centrale suivante.

const int n = 100000;

for (int j = 0; j < n; j++) {
    a1[j] += b1[j];
    c1[j] += d1[j];
}

Cette boucle est exécutée 10 000 fois via une autre boucle externe for. Pour l'accélérer, j'ai changé le code en:

for (int j = 0; j < n; j++) {
    a1[j] += b1[j];
}

for (int j = 0; j < n; j++) {
    c1[j] += d1[j];
}

Compilé sur MS Visual C++ 10. avec optimisation complète et SSE2 activé pour 32 bits sur un Intel Core 2 Duo (x64), premier exemple prend 5,5 secondes et l’exemple de la double boucle ne prend que 1,9 seconde. Ma question est la suivante: (Veuillez vous reporter à la question ma reformulée en bas)

PS: Je ne suis pas sûr, si cela aide:

Le désassemblage de la première boucle ressemble en gros à ceci (ce bloc est répété environ cinq fois dans le programme complet):

movsd       xmm0,mmword ptr [edx+18h]
addsd       xmm0,mmword ptr [ecx+20h]
movsd       mmword ptr [ecx+20h],xmm0
movsd       xmm0,mmword ptr [esi+10h]
addsd       xmm0,mmword ptr [eax+30h]
movsd       mmword ptr [eax+30h],xmm0
movsd       xmm0,mmword ptr [edx+20h]
addsd       xmm0,mmword ptr [ecx+28h]
movsd       mmword ptr [ecx+28h],xmm0
movsd       xmm0,mmword ptr [esi+18h]
addsd       xmm0,mmword ptr [eax+38h]

Chaque boucle de l'exemple de double boucle produit ce code (le bloc suivant est répété environ trois fois):

addsd       xmm0,mmword ptr [eax+28h]
movsd       mmword ptr [eax+28h],xmm0
movsd       xmm0,mmword ptr [ecx+20h]
addsd       xmm0,mmword ptr [eax+30h]
movsd       mmword ptr [eax+30h],xmm0
movsd       xmm0,mmword ptr [ecx+28h]
addsd       xmm0,mmword ptr [eax+38h]
movsd       mmword ptr [eax+38h],xmm0
movsd       xmm0,mmword ptr [ecx+30h]
addsd       xmm0,mmword ptr [eax+40h]
movsd       mmword ptr [eax+40h],xmm0

La question s’est avérée sans pertinence, car le comportement dépend fortement de la taille des tableaux (n) et du cache du processeur. Donc, s’il ya un intérêt supplémentaire, je reformule la question:

Pourriez-vous fournir des informations concrètes sur les détails qui conduisent aux différents comportements du cache, comme illustré par les cinq régions du 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.

PPS: Voici le code complet. Il utilise TBBTick_Count pour un cadencement de résolution plus élevé, qui peut être désactivé en ne définissant pas le TBB_TIMING Macro:

#include <iostream>
#include <iomanip>
#include <cmath>
#include <string>

//#define TBB_TIMING

#ifdef TBB_TIMING   
#include <tbb/tick_count.h>
using tbb::tick_count;
#else
#include <time.h>
#endif

using namespace std;

//#define preallocate_memory new_cont

enum { new_cont, new_sep };

double *a1, *b1, *c1, *d1;


void allo(int cont, int n)
{
    switch(cont) {
      case new_cont:
        a1 = new double[n*4];
        b1 = a1 + n;
        c1 = b1 + n;
        d1 = c1 + n;
        break;
      case new_sep:
        a1 = new double[n];
        b1 = new double[n];
        c1 = new double[n];
        d1 = new double[n];
        break;
    }

    for (int i = 0; i < n; i++) {
        a1[i] = 1.0;
        d1[i] = 1.0;
        c1[i] = 1.0;
        b1[i] = 1.0;
    }
}

void ff(int cont)
{
    switch(cont){
      case new_sep:
        delete[] b1;
        delete[] c1;
        delete[] d1;
      case new_cont:
        delete[] a1;
    }
}

double plain(int n, int m, int cont, int loops)
{
#ifndef preallocate_memory
    allo(cont,n);
#endif

#ifdef TBB_TIMING   
    tick_count t0 = tick_count::now();
#else
    clock_t start = clock();
#endif

    if (loops == 1) {
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++){
                a1[j] += b1[j];
                c1[j] += d1[j];
            }
        }
    } else {
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                a1[j] += b1[j];
            }
            for (int j = 0; j < n; j++) {
                c1[j] += d1[j];
            }
        }
    }
    double ret;

#ifdef TBB_TIMING   
    tick_count t1 = tick_count::now();
    ret = 2.0*double(n)*double(m)/(t1-t0).seconds();
#else
    clock_t end = clock();
    ret = 2.0*double(n)*double(m)/(double)(end - start) *double(CLOCKS_PER_SEC);
#endif

#ifndef preallocate_memory
    ff(cont);
#endif

    return ret;
}


void main()
{   
    freopen("C:\\test.csv", "w", stdout);

    char *s = " ";

    string na[2] ={"new_cont", "new_sep"};

    cout << "n";

    for (int j = 0; j < 2; j++)
        for (int i = 1; i <= 2; i++)
#ifdef preallocate_memory
            cout << s << i << "_loops_" << na[preallocate_memory];
#else
            cout << s << i << "_loops_" << na[j];
#endif

    cout << endl;

    long long nmax = 1000000;

#ifdef preallocate_memory
    allo(preallocate_memory, nmax);
#endif

    for (long long n = 1L; n < nmax; n = max(n+1, long long(n*1.2)))
    {
        const long long m = 10000000/n;
        cout << n;

        for (int j = 0; j < 2; j++)
            for (int i = 1; i <= 2; i++)
                cout << s << plain(n, m, j, i);
        cout << endl;
    }
}

(Il montre FLOP/s pour différentes valeurs de n.)

enter image description here

2145
Johannes Gerer

Après une analyse plus approfondie de cela, je pense que cela est (au moins partiellement) causé par l'alignement des données des quatre pointeurs. Cela entraînera un certain niveau de conflits entre la banque de cache et le chemin.

Si j'ai bien deviné comment vous affectez vos tableaux, ils sont susceptibles d'être alignés sur la ligne de la page .

Cela signifie que tous vos accès dans chaque boucle tomberont sur le même mode de cache. Cependant, les processeurs Intel ont une associativité de cache L1 8 voies depuis un certain temps. Mais en réalité, la performance n'est pas complètement uniforme. L'accès à 4 voies est toujours plus lent que 2 voies.

EDIT: En fait, on dirait que vous allouez tous les tableaux séparément. Habituellement, lorsque de telles allocations sont demandées, l’allocateur demandera de nouvelles pages au système d’exploitation. Par conséquent, il y a de fortes chances que de grandes allocations apparaissent avec le même décalage par rapport à une limite de page.

Voici le code de test:

int main(){
    const int n = 100000;

#ifdef ALLOCATE_SEPERATE
    double *a1 = (double*)malloc(n * sizeof(double));
    double *b1 = (double*)malloc(n * sizeof(double));
    double *c1 = (double*)malloc(n * sizeof(double));
    double *d1 = (double*)malloc(n * sizeof(double));
#else
    double *a1 = (double*)malloc(n * sizeof(double) * 4);
    double *b1 = a1 + n;
    double *c1 = b1 + n;
    double *d1 = c1 + n;
#endif

    //  Zero the data to prevent any chance of denormals.
    memset(a1,0,n * sizeof(double));
    memset(b1,0,n * sizeof(double));
    memset(c1,0,n * sizeof(double));
    memset(d1,0,n * sizeof(double));

    //  Print the addresses
    cout << a1 << endl;
    cout << b1 << endl;
    cout << c1 << endl;
    cout << d1 << endl;

    clock_t start = clock();

    int c = 0;
    while (c++ < 10000){

#if ONE_LOOP
        for(int j=0;j<n;j++){
            a1[j] += b1[j];
            c1[j] += d1[j];
        }
#else
        for(int j=0;j<n;j++){
            a1[j] += b1[j];
        }
        for(int j=0;j<n;j++){
            c1[j] += d1[j];
        }
#endif

    }

    clock_t end = clock();
    cout << "seconds = " << (double)(end - start) / CLOCKS_PER_SEC << endl;

    system("pause");
    return 0;
}

Résultats:

EDIT: Résultats sur une machine d'architecture Core 2 real :

2 x Intel Xeon X5482 Harpertown @ 3.2 GHz:

#define ALLOCATE_SEPERATE
#define ONE_LOOP
00600020
006D0020
007A0020
00870020
seconds = 6.206

#define ALLOCATE_SEPERATE
//#define ONE_LOOP
005E0020
006B0020
00780020
00850020
seconds = 2.116

//#define ALLOCATE_SEPERATE
#define ONE_LOOP
00570020
00633520
006F6A20
007B9F20
seconds = 1.894

//#define ALLOCATE_SEPERATE
//#define ONE_LOOP
008C0020
00983520
00A46A20
00B09F20
seconds = 1.993

Observations:

  • 6,206 secondes avec une boucle et 2.116 secondes avec deux boucles. Ceci reproduit exactement les résultats du PO.

  • Dans les deux premiers tests, les tableaux sont alloués séparément. Vous remarquerez qu'ils ont tous le même alignement par rapport à la page.

  • Dans les deux derniers tests, les tableaux sont regroupés pour rompre cet alignement. Ici, vous remarquerez que les deux boucles sont plus rapides. De plus, la deuxième (double) boucle est maintenant la plus lente, comme on pourrait s'y attendre.

Comme @Stephen Cannon le souligne dans les commentaires, il est très probable que cet alignement provoque faux aliasing dans les unités de chargement/stockage ou dans le cache. J'ai cherché sur Google pour cela et découvert qu'Intel avait en réalité un compteur matériel pour aliasing d'adresse partielle ];

http://software.intel.com/sites/products/documentation/doclib/stdxe/2013/~amplifierxe/pmw_dp/events/partial_address_alias.html


5 régions - Explications

Région 1:

Celui-ci est facile. Le jeu de données est si petit que les performances sont dominées par les frais généraux tels que les boucles et les branchements.

Région 2:

Ici, à mesure que la taille des données augmente, la surcharge relative diminue et la performance "sature". Ici, deux boucles sont plus lentes car elles ont deux fois plus de temps système.

Je ne sais pas exactement ce qui se passe ici ... L'alignement pourrait quand même avoir un effet, comme le dit Agner Fog conflits de banque de cache . (Ce lien concerne Sandy Bridge, mais l'idée devrait toujours s'appliquer à Core 2.)

Région 3:

À ce stade, les données ne tiennent plus dans le cache L1. Les performances sont donc limitées par la bande passante du cache L1 <-> L2.

Région 4:

Nous observons la baisse de performance dans la boucle unique. Et comme mentionné, cela est dû à l'alignement qui (le plus probable) provoque faux aliasing stalls dans les unités de chargement/stockage du processeur.

Cependant, pour que les faux alias se produisent, il faut que la distance entre les jeux de données soit suffisamment grande. C'est pourquoi vous ne voyez pas cela dans la région 3.

Région 5:

À ce stade, rien ne rentre dans le cache. Donc, vous êtes lié par la bande passante de la mémoire.


2 x Intel X5482 Harpertown @ 3.2 GHzIntel Core i7 870 @ 2.8 GHzIntel Core i7 2600K @ 4.4 GHz

1638
Mysticial

OK, la bonne réponse doit absolument faire quelque chose avec le cache du processeur. Mais utiliser l'argument cache peut être assez difficile, surtout sans données.

Il y a beaucoup de réponses qui ont conduit à beaucoup de discussions, mais avouons-le: les problèmes de cache peuvent être très complexes et ne sont pas unidimensionnels. Elles dépendent fortement de la taille des données, ma question était donc injuste: elle s’est avérée être à un point très intéressant du graphique en cache.

La réponse de @ Mysticial a convaincu beaucoup de gens (y compris moi), probablement parce que c'était le seul qui semblait s'appuyer sur des faits, mais ce n'était qu'un "point de données" de la vérité.

C'est pourquoi j'ai combiné son test (en utilisant une attribution continue ou distincte) et les conseils de @James 'Answer.

Les graphiques ci-dessous montrent que la plupart des réponses, et en particulier la majorité des commentaires, peuvent être considérées comme totalement fausses ou vraies, en fonction du scénario et des paramètres utilisés.

Notez que ma question initiale était à n = 100.0. Ce point (par accident) présente un comportement particulier:

  1. Il possède le plus grand écart entre la version à une et deux boucles (presque un facteur de trois)

  2. C'est le seul point où une boucle (à savoir avec une affectation continue) bat la version à deux boucles. (Cela rendait la réponse de Mysticial possible.)

Le résultat utilisant des données initialisées:

Enter image description here

Le résultat utilisant des données non initialisées (c'est ce que Mysticial a testé):

Enter image description here

Il s’agit d’un problème difficile à expliquer: des données initialisées, allouées une fois pour toutes et réutilisées pour chaque test élémentaire suivant, de taille de vecteur différente:

Enter image description here

Proposition

Chaque question liée aux performances de bas niveau sur le dépassement de pile devrait être obligée de fournir des informations MFLOPS pour toute la gamme des tailles de données pertinentes du cache! Tout le monde perd son temps à réfléchir aux réponses et surtout à en discuter avec les autres sans cette information.

214
Johannes Gerer

La deuxième boucle implique beaucoup moins d'activité de la mémoire cache, il est donc plus facile pour le processeur de répondre aux besoins en mémoire.

76
Puppy

Imaginez que vous travaillez sur une machine où n était juste la bonne valeur pour qu'il soit possible de conserver deux matrices en même temps en mémoire, mais que la mémoire totale disponible, via la mise en cache disque, était toujours suffisante pour tenez les quatre.

En supposant une simple politique de mise en cache LIFO, ce code:

for(int j=0;j<n;j++){
    a[j] += b[j];
}
for(int j=0;j<n;j++){
    c[j] += d[j];
}

dans un premier temps, a et b seront chargés dans RAM et seront ensuite entièrement traités en RAM. Lorsque la deuxième boucle commence, c et d seront alors chargés du disque dans RAM et exploités.

l'autre boucle

for(int j=0;j<n;j++){
    a[j] += b[j];
    c[j] += d[j];
}

mettra en page deux tableaux et une page dans les deux autres à chaque fois autour de la boucle. Ce serait évidemment beaucoup plus lent.

Vous ne voyez probablement pas la mise en cache du disque dans vos tests, mais vous constatez probablement les effets secondaires d'une autre forme de mise en cache.


Il semble y avoir un peu de confusion/malentendu ici, donc je vais essayer de développer un peu en utilisant un exemple.

Dites n = 2 et nous travaillons avec des octets. Dans mon scénario, nous avons donc seulement 4 octets de RAM et le reste de notre mémoire est nettement plus lent (disons un accès 100 fois plus long).

En supposant une politique de mise en cache assez stupide de si l'octet n'est pas dans le cache, mettez-le là et obtenez l'octet suivant aussi pendant que nous y sommes vous obtenez un scénario qui ressemble à ceci:

  • Avec

    for(int j=0;j<n;j++){
     a[j] += b[j];
    }
    for(int j=0;j<n;j++){
     c[j] += d[j];
    }
    
  • cache a[0] et a[1] puis b[0] et b[1] et mettre a[0] = a[0] + b[0] dans le cache - il y a maintenant quatre octets dans le cache, a[0], a[1] et b[0], b[1]. Coût = 100 + 100.

  • mettre a[1] = a[1] + b[1] dans le cache. Coût = 1 + 1.
  • Répétez l'opération pour c et d.
  • Coût total = (100 + 100 + 1 + 1) * 2 = 404

  • Avec

    for(int j=0;j<n;j++){
     a[j] += b[j];
     c[j] += d[j];
    }
    
  • cache a[0] et a[1] puis b[0] et b[1] et mettre a[0] = a[0] + b[0] dans le cache - il y a maintenant quatre octets dans le cache, a[0], a[1] et b[0], b[1]. Coût = 100 + 100.

  • éjectez a[0], a[1], b[0], b[1] du cache et cache c[0] et c[1] puis d[0] et d[1] et définissez c[0] = c[0] + d[0] dans le cache. Coût = 100 + 100.
  • Je suppose que vous commencez à voir où je vais.
  • Coût total = (100 + 100 + 100 + 100) * 2 = 800

Ceci est un scénario de thrash en cache classique.

45
OldCurmudgeon

Ce n'est pas à cause d'un code différent, mais à cause de la mise en cache: RAM est plus lent que les registres de la CPU et une mémoire cache se trouve à l'intérieur de la CPU afin d'éviter d'écrire le RAM à chaque fois qu'une variable change. Mais le cache n’est pas aussi volumineux que le RAM, par conséquent, il ne mappe qu’une fraction de celui-ci.

Le premier code modifie les adresses de mémoire distantes en les alternant à chaque boucle, ce qui nécessite d'invalider en permanence le cache.

Le second code n’alterne pas: il se contente d’écouler deux fois sur des adresses adjacentes. Cela rend tout le travail à terminer dans le cache, en l'invalidant seulement après le début de la deuxième boucle.

30
Emilio Garavaglia

Je ne peux pas reproduire les résultats discutés ici.

Je ne sais pas si le code de référence médiocre est à blâmer, ou quoi, mais les deux méthodes sont proches de 10% l'une de l'autre sur ma machine à l'aide du code suivant, et une boucle est généralement légèrement plus rapide que deux - comme vous le feriez attendre.

Les tailles de tableau allaient de 2 ^ 16 à 2 ^ 24, en utilisant huit boucles. J'ai pris soin d'initialiser les tableaux source afin que l'assignation += ne demande pas à FPU d'ajouter une mémoire mémoire interprétée comme un double.

J'ai joué avec différents schémas, comme mettre l'assignation de b[j], d[j] dans InitToZero[j] à l'intérieur des boucles, ainsi que d'utiliser += b[j] = 1 et += d[j] = 1, et j'ai eu des résultats assez cohérents.

Comme on pouvait s'y attendre, l'initialisation de b_ et d dans la boucle à l'aide de InitToZero[j] a conféré à la combinaison un avantage, puisqu'elles ont été effectuées dos à dos avant les assignations à a et c, mais toujours dans les 10%. Allez comprendre.

Le matériel est Dell XPS 85 avec la génération 3 Core i7 @ 3,4 GHz et 8 Go de mémoire. Pour 2 ^ 16 à 2 ^ 24, avec huit boucles, le temps cumulé était respectivement de 44,987 et de 40,965. Visual C++ 2010, entièrement optimisé.

PS: J'ai changé les boucles pour décompter à zéro et la méthode combinée était légèrement plus rapide. Me gratter la tête. Notez le nouveau dimensionnement et le nombre de boucles du tableau.

// MemBufferMystery.cpp : Defines the entry point for the console application.
//
#include "stdafx.h"
#include <iostream>
#include <cmath>
#include <string>
#include <time.h>

#define  dbl    double
#define  MAX_ARRAY_SZ    262145    //16777216    // AKA (2^24)
#define  STEP_SZ           1024    //   65536    // AKA (2^16)

int _tmain(int argc, _TCHAR* argv[]) {
    long i, j, ArraySz = 0,  LoopKnt = 1024;
    time_t start, Cumulative_Combined = 0, Cumulative_Separate = 0;
    dbl *a = NULL, *b = NULL, *c = NULL, *d = NULL, *InitToOnes = NULL;

    a = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    b = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    c = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    d = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    InitToOnes = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    // Initialize array to 1.0 second.
    for(j = 0; j< MAX_ARRAY_SZ; j++) {
        InitToOnes[j] = 1.0;
    }

    // Increase size of arrays and time
    for(ArraySz = STEP_SZ; ArraySz<MAX_ARRAY_SZ; ArraySz += STEP_SZ) {
        a = (dbl *)realloc(a, ArraySz * sizeof(dbl));
        b = (dbl *)realloc(b, ArraySz * sizeof(dbl));
        c = (dbl *)realloc(c, ArraySz * sizeof(dbl));
        d = (dbl *)realloc(d, ArraySz * sizeof(dbl));
        // Outside the timing loop, initialize
        // b and d arrays to 1.0 sec for consistent += performance.
        memcpy((void *)b, (void *)InitToOnes, ArraySz * sizeof(dbl));
        memcpy((void *)d, (void *)InitToOnes, ArraySz * sizeof(dbl));

        start = clock();
        for(i = LoopKnt; i; i--) {
            for(j = ArraySz; j; j--) {
                a[j] += b[j];
                c[j] += d[j];
            }
        }
        Cumulative_Combined += (clock()-start);
        printf("\n %6i miliseconds for combined array sizes %i and %i loops",
                (int)(clock()-start), ArraySz, LoopKnt);
        start = clock();
        for(i = LoopKnt; i; i--) {
            for(j = ArraySz; j; j--) {
                a[j] += b[j];
            }
            for(j = ArraySz; j; j--) {
                c[j] += d[j];
            }
        }
        Cumulative_Separate += (clock()-start);
        printf("\n %6i miliseconds for separate array sizes %i and %i loops \n",
                (int)(clock()-start), ArraySz, LoopKnt);
    }
    printf("\n Cumulative combined array processing took %10.3f seconds",
            (dbl)(Cumulative_Combined/(dbl)CLOCKS_PER_SEC));
    printf("\n Cumulative seperate array processing took %10.3f seconds",
        (dbl)(Cumulative_Separate/(dbl)CLOCKS_PER_SEC));
    getchar();

    free(a); free(b); free(c); free(d); free(InitToOnes);
    return 0;
}

Je ne sais pas pourquoi il a été décidé que MFLOPS était un indicateur pertinent. Je pensais que l’idée était de se concentrer sur les accès mémoire, alors j’ai essayé de minimiser le temps de calcul en virgule flottante. Je suis parti dans le +=, mais je ne sais pas pourquoi.

Une affectation simple sans calcul constituerait un test plus clair du temps d'accès à la mémoire et créerait un test uniforme quel que soit le nombre de boucles. J'ai peut-être manqué quelque chose dans la conversation, mais cela vaut la peine d'y réfléchir à deux fois. Si le plus est laissé en dehors de l'affectation, le temps cumulé est presque identique à 31 secondes chacun.

19
user1899861

C'est parce que le processeur n'a pas tellement de cache manquants (où il doit attendre que les données du tableau proviennent des puces RAM.). Il serait intéressant pour vous de régler continuellement la taille des tableaux afin de dépasser les tailles de cache de niveau 1 (L1), puis de cache de niveau 2 ( L2), de votre CPU et tracez le temps nécessaire à votre code pour s’exécuter par rapport à la taille des tableaux. Le graphique ne doit pas être une ligne droite comme on peut s'y attendre.

16
James

La première boucle alterne l'écriture dans chaque variable. Les deuxième et troisième ne font que de petits sauts de taille d'élément.

Essayez d’écrire deux lignes parallèles de 20 croix avec un stylo et du papier séparés de 20 cm. Essayez de finir une ligne puis une autre et essayez une autre fois en écrivant une croix alternativement dans chaque ligne.

13
Guillaume Kiz

La question originale

Pourquoi une boucle est-elle tellement plus lente que deux boucles?


Conclusion:

Cas 1 est un problème d’interpolation classique et inefficace. Je pense aussi que c'était l'une des principales raisons pour lesquelles de nombreuses architectures et développeurs de machines ont fini par construire et concevoir des systèmes multicœurs avec la possibilité de réaliser des applications multithreads ainsi que de la programmation parallèle.

Ce type d’approche s’applique sans que le matériel, le système d’exploitation et le (s) compilateur (s) ne travaillent ensemble pour effectuer des allocations de tas impliquant le travail 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 solutions est la meilleure. Nous pouvons utiliser une analogie où un Boss ou Summation qui représentera un For Loop qui doit voyager entre les travailleurs A et B nous pouvons facilement voir que - Cas 2 est au moins 1/2 aussi vite sinon un peu plus de Cas 1 en raison de la différence de distance nécessaire pour voyager et du temps pris entre les travailleurs. Ce calcul correspond presque virtuellement et parfaitement à la fois au Bench Mark Times et à la quantité de différences entre les instructions de montage.

Je vais maintenant commencer à expliquer comment tout cela fonctionne ci-dessous.


évaluation du 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 considération

Considérant la question initiale du PO concernant les 2 variantes de la boucle for et sa question modifiée sur 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 la mise en cache et le classement des pages, je souhaiterais adopter une autre approche pour aborder la question sous un angle différent. Celle qui n’implique ni les fichiers de cache et de page, ni les exécutions permettant d’allouer de la mémoire; en fait, cette approche ne concerne même pas le matériel ni les logiciels.


La perspective

Après avoir examiné le code pendant un moment, il est devenu évident que le problème est et ce qui le génère. Décrivons cela en un problème algorithmique et examinons-le du point de vue de l’utilisation de notations mathématiques, puis appliquons une analogie aux problèmes mathématiques ainsi qu’aux algorithmes.


Ce que nous savons

Nous savons que sa boucle fonctionnera 100 000 fois. Nous savons également que a1, b1, c1 & d1 sont des pointeurs sur une architecture 64 bits. Dans C++, sur une machine 32 bits, tous les pointeurs ont une taille de 4 octets et sur une machine 64 bits, ils ont une taille de 8 octets, car les pointeurs ont une 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, alors que dans le deuxième cas, nous allouons 16 octets pour chaque itération pour les deux boucles indépendantes. Ainsi, les deux boucles représentent toujours 32 octets d'allocation totale. Avec cette information, allons de l'avant et montrons la mathématique générale, son algorithme et son analogie. Nous savons combien de fois le même ensemble ou groupe d'opérations devra être exécuté dans les deux cas. Nous connaissons la quantité de mémoire à allouer 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 fixons un compteur et effectuons un test d'évaluation. Cependant, les points de repère ont déjà été inclus dans la question initiale et dans certaines des réponses et commentaires, et nous pouvons constater une différence significative entre les deux. C’est l’intégralité du raisonnement de cette question à ce problème et à sa réponse. commencer avec.


enquêtons

Il est déjà évident que beaucoup l'ont déjà fait en examinant les allocations de tas, les tests d'évaluation, la RAM, le cache et les fichiers de page. L'examen de points de données spécifiques et d'index d'itération spécifiques a également été inclus et les différentes discussions sur ce problème spécifique ont amené de nombreuses personnes à s'interroger sur d'autres aspects connexes du problème. Alors, comment pouvons-nous commencer à regarder ce problème en utilisant des algorithmes mathématiques et en y appliquant une analogie? Nous commençons par faire quelques affirmations! Ensuite, nous construisons notre algorithme à partir de là.


Nos assertions:

  • Nous allons laisser notre boucle et ses itérations être une sommation qui commence à 1 et se termine à 100000 au lieu de 0, car dans les boucles car nous n'avons pas à nous soucier du schéma d'indexation 0 de l'adressage en 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, 2 opérations étant effectuées pour chaque appel de fonction. Nous allons donc les configurer comme fonctions et appels de fonctions comme étant F1(), F2(), f(a), f(b), f(c) et f(d).

Les algorithmes:

1er cas: - Un seul cumul 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);  }

2nd Case: - 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é que F2() n'existe que dans SumSum1 et Sum2 ne contiennent que F1(). Cela sera également évident par la suite lorsque nous commencerons à conclure qu'il y a une sorte d'optimisation à partir du deuxième algorithme.

Les itérations à travers le premier cas Sum appelle f(a) qui s'ajoutera à son auto f(b) puis il appellera f(c) qui fera de même mais ajoutera f(d) à lui-même pour chaque 100000 iterations. Dans le second cas, nous avons Sum1 et Sum2 et 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 SumSum ressemble dans ce cas à ceci: Sum n=1 : [1,100000] { f(a) = f(a) + f(b); } et cela ressemble maintenant à un optimisation où nous pouvons simplement considérer que c'est la même fonction.


Résumé avec analogie

Avec ce que nous avons vu dans le second cas, il semble presque y avoir une optimisation puisque les deux boucles for ont la même signature, mais ce n’est pas le vrai problème. Le problème n’est pas le travail effectué par f(a), f(b), f(c) & f(d) dans les deux cas et la comparaison entre les deux, c’est la différence de distance la sommation doit voyager dans les deux cas, ce qui vous donne la différence de temps d'exécution.

Pensez au For Loops comme étant le Summations qui fait les itérations comme étant un Boss qui donne des ordres à deux personnes A & B et que leur travail sont à viande C & D respectivement et à ramasser un paquet d'entre eux et le retourner. Dans l'analogie ci-dessous, les itérations de boucle for ou de sommation et les vérifications de condition elles-mêmes ne représentent pas réellement le Boss. Ce qui représente réellement le Boss ne provient pas directement des algorithmes mathématiques actuels, mais du concept actuel de Scope et Code Block dans une routine ou une sous-routine, une méthode, une fonction, une unité de traduction , etc. Le premier algorithme a 1 portée et le 2e algorithme a 2 portées consécutives.

Dans le premier cas de chaque appel, le Boss va à A et donne la commande et A s'éteint pour aller chercher le package B's puis le BossC et donne l'ordre de faire de même et de recevoir le package de D à chaque itération.

Dans le second cas, le Boss fonctionne directement avec A pour aller chercher le package B's jusqu'à ce que tous les packages soient reçus. Ensuite, Boss fonctionne avec C pour en faire autant pour obtenir tous les packages D's.

Puisque nous travaillons avec un pointeur de 8 octets et traitons avec l'allocation de tas, considérons ce problème ici. Disons que la Boss est à 100 pieds de A et que A est à 500 pieds de C. Nous n'avons pas à nous soucier de la distance entre Boss et C en raison de l'ordre d'exécution. Dans les deux cas, Boss se déplace initialement de A d'abord à B. Cette analogie ne veut pas dire que cette distance est exacte; c'est simplement un scénario de cas d'utilisation pour montrer le fonctionnement des algorithmes. Dans de nombreux cas, lors de l'allocation de tas et de l'utilisation du cache et des fichiers de page, les distances entre les emplacements d'adresses peuvent ne pas trop varier ou peuvent être très significatives en fonction de la nature des types de données et de la taille des tableaux.


Les cas de test:

Premier cas: À la première itération, le Boss doit initialement aller à 100 pieds pour donner le bon de commande à A et A s'éteint et fait son travail, mais ensuite le Boss doit parcourir 500 pieds jusqu'à C pour lui donner son bon de commande. Ensuite, à la prochaine itération et à toutes les autres itérations après la Boss doit faire un va-et-vient de 500 pieds entre les deux.

Deuxième cas: The Boss doit parcourir 100 pieds à la première itération pour A, mais après cela, il est déjà là et attend que A soit rétabli jusqu'à ce que tous les bordereaux soient remplis. Ensuite, la Boss doit parcourir 500 pieds lors de la première itération pour C car C est à 500 pieds de A car cette Boss( Summation, For Loop ) est appelée juste après le travail. avec A et attend ensuite comme il l'a fait avec A jusqu'à ce que tous les C's ordres soient terminé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, c'est beaucoup moins que 10 millions. Or ce n’est pas exact, car nous ne connaissons pas la différence de distance entre l’adresse de RAM ou le cache ou le fichier de page, chaque appel à chaque itération sera dû à de nombreuses autres occultations. variables, mais il s’agit simplement d’une évaluation de la situation à prendre en compte et d’essayer de la regarder dans le pire des cas.

Donc, à en juger par ces chiffres, on aurait presque l'impression que l'algorithme 1 devrait être 99% plus lent que l'algorithme 2; cependant, il ne s'agit que de la partie The Boss's ou de la responsabilité des algorithmes et ne tient pas compte des travailleurs réels A, B, C, & D et ce qu'ils doivent faire à chaque itération de la boucle. Ainsi, le travail des patrons ne représente que 15 à 40% du travail total accompli. Ainsi, la majeure partie du travail effectué par les ouvriers a un impact légèrement plus important sur le maintien du ratio des différences de taux de vitesse entre environ 50 et 70%.


The Observation: - Les différences entre les deux algorithmes

Dans cette situation, il s’agit de la structure du processus de travail en cours et cela montre que Cas 2 est plus efficace à la fois de l’optimisation partielle de la déclaration et de la définition d’une fonction similaire où elle se trouve. seules les variables qui diffèrent par leur nom. Et nous voyons aussi que la distance totale parcourue en Cas 1 est beaucoup plus éloignée qu’elle ne l’est en Cas 2 et nous pouvons considérer cette distance parcourue par notre Facteur de temps entre les deux algorithmes. Cas 1 a beaucoup plus de travail à faire que Cas 2. Cela a également été constaté dans le témoignage de ASM qui a été démontré entre les deux cas. Même avec ce qui a déjà été dit à propos de ces cas, cela ne tient pas compte non plus du fait que dans Cas 1, le patron devra attendre à la fois A & C pour revenir avant de pouvoir revenir à A à la prochaine itération et cela ne tient pas compte non plus du fait que si A ou B prend un temps extrêmement long, les deux Boss et le ou les autres travailleurs attendent également au ralenti. Dans Cas 2 le seul qui est inactif est le Boss jusqu'au retour du travailleur. Donc, même cela a un impact sur l'algorithme.



Question (s) modifiée (s) sur les PO

EDIT: La question s’est avérée sans pertinence, car le comportement dépend énormément de la taille des tableaux (n) et du cache du processeur. Donc, s’il ya un intérêt supplémentaire, je reformule la question:

Pourriez-vous fournir des informations concrètes sur les détails qui conduisent aux différents comportements de cache, comme illustré par les cinq régions du 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 existe un problème sous-jacent avant même que le matériel et les logiciels n'interviennent. Maintenant, en ce qui concerne la gestion de la mémoire et la mise en cache avec les fichiers de page, etc., qui fonctionnent tous ensemble dans un ensemble intégré de systèmes entre: The Architecture {Matériel, micrologiciels, certains pilotes intégrés, noyaux et jeux d'instructions ASM}, The OS {Systèmes de gestion de fichiers et de la 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 ses ensembles de caractères distinctifs. algorithmes; nous pouvons déjà voir qu'il existe un goulot d'étranglement dans le premier algorithme avant même de l'appliquer à une machine quelconque quelconque Architecture, OS et Programmable Language quelconque par rapport au second algorithme . Il existait donc déjà un problème impliquant les éléments intrinsèques d’un ordinateur moderne.


Les résultats finaux

Pourtant; cela ne veut pas dire que ces nouvelles questions n’ont pas d’importance parce qu’elles le sont et qu’elles jouent un rôle après tout. Ils ont un impact sur les procédures et la performance globale et cela est évident avec les divers graphiques et évaluations de nombreux auteurs qui ont donné leur réponse et/ou leurs commentaires. Si vous faites attention à l'analogie de Boss et des deux ouvriers A & B qui devaient aller récupérer les paquets de C & D et respectivement Compte tenu des notations mathématiques des deux algorithmes en question, vous pouvez constater que, même sans la participation de l'ordinateur, Case 2 est environ 60% plus rapide que Case 1 et lorsque vous regardez les graphiques et les diagrammes après ces algorithmes. été appliqué au code source, compilé et optimisé et exécuté via le système d’exploitation pour effectuer des opérations sur le matériel donné, vous voyez même un peu plus de dégradation entre les différences entre ces algorithmes.

Maintenant, si le jeu de "données" est assez petit, cela ne vous semblera peut-être pas si différent au début, mais puisque Case 1 est environ 60 - 70% plus lent que Case 2, nous pouvons examiner la croissance de cette fonction étant en termes de différences dans les exécutions temporelles:

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)

Et cette approximation est la différence moyenne entre ces deux boucles, tant sur le plan algorithmique que sur les opérations de la machine, impliquant des optimisations logicielles et des instructions de la machine. Ainsi, lorsque la quantité de données augmente de manière linéaire, la différence de temps entre les deux augmente également. L'algorithme 1 a plus d'extraits 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 le Boss devait se rendre à A une fois, puis après avoir terminé avec A il ne devait parcourir une distance maximale qu'une fois pour passer de A à C .

Donc, essayer de faire en sorte que la Boss se concentre sur deux choses similaires en même temps et qu’elle les jongle entre elles au lieu de se concentrer sur des tâches consécutives similaires le mettra très en colère à la fin de la journée car il devait voyager et travailler deux fois plus. Par conséquent, ne perdez pas de vue l'ampleur de la situation en laissant votre patron entrer dans un goulot d'étranglement interpolé car son épouse et ses enfants ne l'apprécieraient pas.

5
Francis Cugler

Il s’agit peut-être de vieux C++ et d’optimisations. Sur mon ordinateur, j'ai obtenu presque la même vitesse:

Une boucle: 1,577 ms

Deux boucles: 1,507 ms

J'exécute Visual Studio 2015 sur un processeur E5-1620 de 3,5 GHz avec 16 Go de RAM.

1
mathengineer