web-dev-qa-db-fra.com

Pourquoi y a-t-il une différence significative dans ce C ++ pour le temps d'exécution de la boucle?

Je passais par des boucles et j'ai trouvé une différence significative dans l'accès aux boucles. Je ne peux pas comprendre quelle est la chose qui cause une telle différence dans les deux cas?

Premier exemple:

Temps d'exécution; 8 secondes

for (int kk = 0; kk < 1000; kk++)
{
    sum = 0;
    for (int i = 0; i < 1024; i++)
        for (int j = 0; j < 1024; j++)
        {
            sum += matrix[i][j];
        }
}

Deuxième exemple:

Temps d'exécution: 23 secondes

for (int kk = 0; kk < 1000; kk++)
{
    sum = 0;
    for (int i = 0; i < 1024; i++)
        for (int j = 0; j < 1024; j++)
        {
            sum += matrix[j][i];
        }
}

Ce qui cause tant de différence de temps d'exécution juste l'échange

matrix[i][j] 

à

matrix[j][i]

?

58
Massab

C'est un problème de cache mémoire.

matrix[i][j] a de meilleurs accès au cache que matrix[j][i], puisque matrix[i][j] a plus de chances d'accéder à la mémoire en continu.

Par exemple, lorsque nous accédons à matrix[i][0], le cache peut charger un segment continu de mémoire contenant matrix[i][0], donc, accéder à matrix[i][1], matrix[i][2], ..., bénéficieront de la vitesse de mise en cache, car matrix[i][1], matrix[i][2], ... sont proches de matrix[i][0].

Cependant, lorsque nous accédons à matrix[j][0], c'est loin d'être matrix[j - 1][0] et peut ne pas avoir été mis en cache et ne peut pas bénéficier de la vitesse de mise en cache. En particulier, une matrice est normalement stockée en tant que gros segment continu de mémoire, et le cacher peut prédire le comportement d'accès à la mémoire et toujours mettre en cache la mémoire.

C'est pourquoi matrix[i][j] est plus rapide. Ceci est typique de l'optimisation des performances basée sur le cache CPU.

111
Peixu Zhu

La différence de performances est due à la stratégie de mise en cache de l'ordinateur.

Le tableau bidimensionnel matrix[i][j] est représenté comme une longue liste de valeurs dans la mémoire.

Par exemple, le tableau A[3][4] ressemble à:

1 1 1 1   2 2 2 2   3 3 3 3

Dans cet exemple, chaque entrée de A [0] [x] est définie sur 1, chaque entrée de A [1] [x] est définie sur 2, ...

Si votre première boucle est appliquée à cette matrice, l'ordre d'accès est le suivant:

1 2 3 4   5 6 7 8   9 10 11 12

Alors que le deuxième ordre d'accès aux boucles ressemble à ceci:

1 4 7 10  2 5 8 11  3 6 9 12

Lorsque le programme accède à un élément du tableau, il charge également les éléments suivants.

Par exemple. si vous accédez à A[0][1], A[0][2] et A[0][3] sont également chargés.

Ainsi, la première boucle doit effectuer moins d'opérations de chargement, car certains éléments sont déjà dans le cache lorsque cela est nécessaire. La deuxième boucle charge dans le cache des entrées qui ne sont pas nécessaires à ce moment, ce qui entraîne davantage d'opérations de chargement.

54
H4kor

D'autres personnes ont bien expliqué pourquoi une forme de votre code utilise plus efficacement le cache mémoire que l'autre. Je voudrais ajouter quelques informations générales que vous ne connaissez peut-être pas: vous ne réalisez probablement pas à quel point les accès à la mémoire principale sont chers de nos jours.

Les chiffres affichés dans cette question semblent être dans le bon ordre, et je vais les reproduire ici parce qu'ils sont si importants:

Core i7 Xeon 5500 Series Data Source Latency (approximate)
L1 CACHE hit, ~4 cycles
L2 CACHE hit, ~10 cycles
L3 CACHE hit, line unshared ~40 cycles
L3 CACHE hit, shared line in another core ~65 cycles
L3 CACHE hit, modified in another core ~75 cycles remote
remote L3 CACHE ~100-300 cycles
Local Dram ~60 ns
Remote Dram ~100 ns

Notez le changement d'unités pour les deux dernières entrées. Selon le modèle que vous possédez, ce processeur fonctionne à 2,9–3,2 GHz; pour simplifier les calculs, appelons-le simplement 3 GHz. Un cycle représente donc 0,33333 nanoseconde. Les accès DRAM sont donc également de 100 à 300 cycles.

Le fait est que le CPU aurait pu exécuter des centaines d'instructions dans le temps qu'il faut pour lire une ligne de cache de la mémoire principale. C'est ce qu'on appelle le mur de mémoire . De ce fait, une utilisation efficace du cache mémoire est plus importante que tout autre facteur dans les performances globales des processeurs modernes.

34
zwol

La réponse dépend un peu de la façon exacte dont le matrix est défini. Dans un tableau entièrement alloué dynamiquement, vous auriez:

T **matrix;
matrix = new T*[n];
for(i = 0; i < n; i++)
{
   t[i] = new T[m]; 
}

Ainsi, chaque matrix[j] Nécessitera une nouvelle recherche de mémoire pour le pointeur. Si vous faites la boucle j à l'extérieur, la boucle intérieure peut réutiliser le pointeur pour matrix[j] Tous pour la boucle intérieure entière.

Si la matrice est un simple tableau 2D:

T matrix[n][m];

alors matrix[j] sera simplement une multiplication par 1024 * sizeof(T) - ce qui peut être fait en ajoutant 1024 * sizeof(T) l'index de boucle dans le code optimisé, donc devrait être relativement rapide dans les deux cas .

En plus de cela, nous avons des facteurs de localité de cache. Les caches ont des "lignes" de données qui sont généralement de 32 à 128 octets par ligne. Donc, si votre code lit l'adresse X, le cache se chargera avec des valeurs de 32 à 128 octets autour de X. Donc, si la prochaine chose dont vous avez besoin est seulement sizeof(T) en avant de l'emplacement actuel, il est très probable déjà dans le cache [et les processeurs modernes détectent également que vous parcourez en boucle la lecture de chaque emplacement de mémoire, et pré -charge les données].

Dans le cas de j boucle interne, vous lisez un nouvel emplacement de sizeof(T)*1024 distance pour chaque boucle [ou possiblya une plus grande distance si elle est allouée dynamiquement]. Cela signifie que les données en cours de chargement ne seront pas utiles pour la prochaine boucle, car elles ne se trouvent pas dans les 32 à 128 octets suivants.

Et enfin, il est tout à fait possible que la première boucle soit plus optimisée, grâce aux instructions SSE ou similaires, qui permettent d'exécuter le calcul encore plus rapidement. Mais cela est probablement marginal pour une matrice aussi grande , car les performances sont fortement liées à la mémoire à cette taille.

18
Mats Petersson

Le matériel de mémoire n'est pas optimisé pour fournir des adresses individuelles: il a plutôt tendance à fonctionner sur de plus gros morceaux de mémoire continue appelés lignes de cache. Chaque fois que vous lisez une entrée de votre matrice, toute la ligne de cache dans laquelle elle se trouve est également chargée dans le cache avec elle.

L'ordre de boucle plus rapide est configuré pour lire la mémoire dans l'ordre; chaque fois que vous chargez une ligne de cache, vous utilisez toutes les entrées de cette ligne de cache. Chaque passage à travers la boucle externe, vous ne lisez chaque entrée de matrice qu'une seule fois.

Cependant, l'ordre de boucle plus lent n'utilise qu'une seule entrée de chaque ligne de cache avant de continuer. Ainsi, chaque ligne de cache doit être chargée plusieurs fois, une fois pour chaque entrée de matrice dans la ligne. par exemple. si un double est de 8 octets et une ligne de cache de 64 octets, alors chaque passage dans la boucle externe doit lire chaque entrée de matrice huit fois plutôt qu'une seule fois.


Cela dit, si vous aviez activé les optimisations, vous ne verriez probablement aucune différence: les optimiseurs comprennent ce phénomène, et les bons sont capables de reconnaître qu'ils peuvent échanger quelle boucle est la boucle intérieure et quelle boucle est la boucle extérieure pour ce particulier extrait de code.

(également, un bon optimiseur n'aurait effectué qu'un seul passage dans la boucle la plus externe, car il reconnaît que les 999 premières fois ne sont pas pertinentes pour la valeur finale de sum)

10
Hurkyl

La matrice est stockée en mémoire en tant que vecteur. En y accédant de la première façon, vous accédez séquentiellement à la mémoire. Pour y accéder de la deuxième façon, vous devez sauter autour des emplacements de mémoire. Voir http://en.wikipedia.org/wiki/Row-major_order

8
James

Si vous accédez à j - i, la dimension j est mise en cache afin que le code machine ne doive pas la changer à chaque fois, la deuxième dimension n'est pas mise en cache, vous supprimez donc le cache à chaque fois, ce qui cause la différence.

5
Etixpp

Sur la base du concept de localité de référence, il est très probable qu'un morceau de code accède à des emplacements de mémoire adjacents. Ainsi, plus de valeurs sont chargées dans le cache que ce qui est demandé. Cela signifie plus de hits de cache. Votre premier exemple satisfait bien ce problème alors que vous ne codez pas dans le deuxième exemple.

3
chandra_cst