web-dev-qa-db-fra.com

Pourquoi l'ordre des boucles affecte-t-il les performances lors d'une itération sur un tableau 2D?

Vous trouverez ci-dessous deux programmes presque identiques, si ce n’est que j’ai inversé les variables i et j. Ils ont tous les deux une durée différente. Quelqu'un pourrait-il expliquer pourquoi cela se produit?

Version 1

#include <stdio.h>
#include <stdlib.h>

main () {
  int i,j;
  static int x[4000][4000];
  for (i = 0; i < 4000; i++) {
    for (j = 0; j < 4000; j++) {
      x[j][i] = i + j; }
  }
}

Version 2

#include <stdio.h>
#include <stdlib.h>

main () {
  int i,j;
  static int x[4000][4000];
  for (j = 0; j < 4000; j++) {
     for (i = 0; i < 4000; i++) {
       x[j][i] = i + j; }
   }
}
344
Mark

Comme d'autres l'ont dit, le problème est de stocker dans l'emplacement mémoire du tableau: x[i][j]. Voici un aperçu de pourquoi:

Vous avez un tableau à 2 dimensions, mais la mémoire de l'ordinateur est intrinsèquement à 1 dimension. Donc, pendant que vous imaginez votre tableau comme ceci:

0,0 | 0,1 | 0,2 | 0,3
----+-----+-----+----
1,0 | 1,1 | 1,2 | 1,3
----+-----+-----+----
2,0 | 2,1 | 2,2 | 2,3

Votre ordinateur le stocke en mémoire sur une seule ligne:

0,0 | 0,1 | 0,2 | 0,3 | 1,0 | 1,1 | 1,2 | 1,3 | 2,0 | 2,1 | 2,2 | 2,3

Dans le deuxième exemple, vous accédez au tableau en bouclant d'abord sur le deuxième nombre, c'est-à-dire:

x[0][0] 
        x[0][1]
                x[0][2]
                        x[0][3]
                                x[1][0] etc...

Ce qui signifie que vous les frappez tous dans l'ordre. Regardons maintenant la 1ère version. Tu fais:

x[0][0]
                                x[1][0]
                                                                x[2][0]
        x[0][1]
                                        x[1][1] etc...

En raison de la façon dont C a structuré la matrice 2-D en mémoire, vous lui demandez de sauter d'un endroit à l'autre. Mais maintenant pour le kicker: Pourquoi est-ce important? Tous les accès mémoire sont les mêmes, non?

Non: à cause des caches. Les données de votre mémoire sont transmises à la CPU par petits morceaux (appelés "lignes de cache"), généralement 64 octets. Si vous avez des entiers de 4 octets, cela signifie que vous obtenez 16 entiers consécutifs dans un petit paquet ordonné. Il est en fait assez lent pour récupérer ces morceaux de mémoire; votre processeur peut faire beaucoup de travail dans le temps qu'il faut pour charger une seule ligne de cache.

Examinons maintenant l’ordre des accès: Le deuxième exemple est (1) saisir un bloc de 16 pouces, (2) les modifier tous, (3) répéter 4000 * 4000/16 fois. C'est agréable et rapide, et le processeur a toujours quelque chose à travailler.

Le premier exemple est (1) saisir un bloc de 16 pouces, (2) modifier un seul d'entre eux, (3) répéter 4000 * 4000 fois. Cela nécessitera 16 fois le nombre de "récupérations" de mémoire. Votre processeur devra en fait passer du temps à attendre que cette mémoire apparaisse, et pendant que vous restez assis, vous perdez un temps précieux.

Remarque importante:

Maintenant que vous avez la réponse, voici une remarque intéressante: il n’ya aucune raison inhérente pour que votre deuxième exemple soit le plus rapide. Par exemple, en Fortran, le premier exemple serait rapide et le second, lent. En effet, au lieu d’élargir les choses en "rangées" conceptuelles comme le fait C, Fortran se développe en "colonnes", c’est-à-dire:

0,0 | 1,0 | 2,0 | 0,1 | 1,1 | 2,1 | 0,2 | 1,2 | 2,2 | 0,3 | 1,3 | 2,3

La mise en page de C s'appelle "row-major" et celle de Fortran, "column-major". Comme vous pouvez le constater, il est très important de savoir si votre langage de programmation est à lignes majeures ou à colonnes majeures! Voici un lien pour plus d'informations: http://en.wikipedia.org/wiki/Row-major_order

574
Robert Martin

Rien à voir avec l'Assemblée. Ceci est dû à cache misses .

Les tableaux multidimensionnels C sont stockés avec la dernière dimension comme la plus rapide. Donc, la première version manquera le cache à chaque itération, alors que la deuxième version ne le sera pas. La deuxième version devrait donc être beaucoup plus rapide.

Voir aussi: http://en.wikipedia.org/wiki/Loop_interchange .

66

La version 2 fonctionnera beaucoup plus rapidement car elle utilise mieux le cache de votre ordinateur que la version 1. Si vous y réfléchissez, les tableaux ne sont que des zones de mémoire contiguës. Lorsque vous demandez un élément dans un tableau, votre système d'exploitation apportera probablement une page mémoire dans le cache contenant cet élément. Cependant, comme les éléments suivants sont également sur cette page (car ils sont contigus), le prochain accès sera déjà en cache! C’est ce que la version 2 fait pour accélérer.

La version 1, en revanche, accède aux éléments par colonnes et non par lignes. Ce type d'accès n'est pas contigu au niveau de la mémoire et le programme ne peut donc pas tirer autant parti de la mise en cache du système d'exploitation.

22
Oleksi

La raison en est l'accès aux données en cache-local. Dans le second programme, vous parcourez linéairement la mémoire, ce qui profite de la mise en cache et du prélecture. Le modèle d'utilisation de la mémoire de votre premier programme est beaucoup plus étendu et son comportement en cache est donc pire.

12

Outre les autres excellentes réponses sur les requêtes en cache, il existe également une différence d'optimisation possible. Votre compilateur optimisera probablement votre deuxième boucle en quelque chose d’équivalent à:

  for (j=0; j<4000; j++) {
    int *p = x[j];
    for (i=0; i<4000; i++) {
      *p++ = i+j;
    }
  }

Cela est moins probable pour la première boucle, car il faudrait incrémenter le pointeur "p" de 4000 à chaque fois.

EDIT:p++ et même *p++ = .. peuvent être compilés en une seule instruction de la CPU dans la plupart des CPU. *p = ..; p += 4000 ne le peut pas, son optimisation présente donc moins d'avantages. C'est également plus difficile, car le compilateur doit connaître et utiliser la taille du tableau interne. Et cela ne se produit pas souvent dans la boucle interne dans le code normal (cela ne se produit que pour les tableaux multidimensionnels, où le dernier index est maintenu constant dans la boucle et l'avant-dernier pas à pas), l'optimisation est donc moins prioritaire. .

10
fishinear

Cette ligne le coupable:

x[j][i]=i+j;

La deuxième version utilise la mémoire continue sera donc sensiblement plus rapide.

J'ai essayé avec

x[50000][50000];

et le temps d'exécution est 13s pour la version 1 contre 0.6s pour la version2.

7
Nicolas Modrzyk

J'essaie de donner une réponse générique.

Parce que i[y][x] est un raccourci pour *(i + y*array_width + x) en C (essayez le chic int P[3]; 0[P] = 0xBEEF;).

Lorsque vous parcourez y, vous parcourez des morceaux de taille array_width * sizeof(array_element). Si vous avez cela dans votre boucle interne, vous aurez alors array_width * array_height itérations sur ces morceaux.

En retournant la commande, vous n'aurez que array_height chunk-iterations, et entre chaque itération de chunk, vous aurez array_width itérations de seulement sizeof(array_element).

Alors que sur de très vieux processeurs x86, cela importait peu, de nos jours, le x86 effectue beaucoup de prélecture et de mise en cache de données. Vous produisez probablement beaucoup de cache misses dans votre ordre d'itération plus lent.

4
Sebastian Mach