Pourquoi mon programme est-il lent lors de la boucle sur 8192 éléments exactement?

755

Voici l'extrait du programme en question. La matrice img[][]a la taille SIZE × SIZE et est initialisée à:

img[j][i] = 2 * j + i

Ensuite, vous créez une matrice res[][], et chaque champ ici est fait pour être la moyenne des 9 champs qui l'entourent dans la matrice img. La bordure est laissée à 0 pour plus de simplicité.

for(i=1;i<SIZE-1;i++) 
    for(j=1;j<SIZE-1;j++) {
        res[j][i]=0;
        for(k=-1;k<2;k++) 
            for(l=-1;l<2;l++) 
                res[j][i] += img[j+l][i+k];
        res[j][i] /= 9;
}

C'est tout ce qu'il y a au programme. Par souci d'exhaustivité, voici ce qui précède. Aucun code ne vient après. Comme vous pouvez le voir, ce n'est qu'une initialisation.

#define SIZE 8192
float img[SIZE][SIZE]; // input image
float res[SIZE][SIZE]; //result of mean filter
int i,j,k,l;
for(i=0;i<SIZE;i++) 
    for(j=0;j<SIZE;j++) 
        img[j][i] = (2*j+i)%8196;

Fondamentalement, ce programme est lent lorsque SIZE est un multiple de 2048, par exemple les temps d'exécution:

SIZE = 8191: 3.44 secs
SIZE = 8192: 7.20 secs
SIZE = 8193: 3.18 secs

Le compilateur est GCC. D'après ce que je sais, c'est à cause de la gestion de la mémoire, mais je ne sais pas trop sur ce sujet, c'est pourquoi je pose la question ici.

De plus, comment résoudre ce problème serait bien, mais si quelqu'un pouvait expliquer ces temps d'exécution, je serais déjà assez heureux.

Je connais déjà malloc / free, mais le problème n'est pas la quantité de mémoire utilisée, c'est simplement le temps d'exécution, donc je ne sais pas comment cela pourrait aider.

casperOne
la source
67
@bokan, cela se produit lorsque la taille est un multiple de la foulée critique du cache.
Luchian Grigore
5
@Mysticial, cela n'a pas d'importance, cela expose le même problème exact; Le code peut être différent, mais fondamentalement, les deux questions se posent à peu près en même temps (et leurs titres sont certainement similaires).
Griwes
33
Vous ne devez pas traiter l'image à l'aide d'un tableau à 2 dimensions si vous souhaitez des performances élevées. Considérez tous les pixels comme étant bruts et traitez-les comme un tableau à une dimension. Faites ce flou en deux passes. Ajoutez d'abord la valeur des pixels environnants en utilisant une somme glissante de 3 pixels: slideSum + = src [i + 1] -src [i-1]; dest [i] = slideSum ;. Faites ensuite la même chose verticalement et divisez en même temps: dest [i] = (src [i-width] + src [i] + src [i + width]) / 9. www-personal.engin.umd.umich.edu/~jwvm/ece581/18_RankedF.pdf
bokan
8
Il se passe en fait deux choses ici. Ce n'est pas seulement un super-alignement.
Mysticial
7
(Juste un petit coup de pouce sur votre réponse. Pour le premier segment de code, ce serait bien si toutes vos boucles for avaient des accolades.)
Trevor Boyd Smith

Réponses:

954

La différence est causée par le même problème de super-alignement des questions connexes suivantes:

Mais c'est uniquement parce qu'il y a un autre problème avec le code.

En partant de la boucle d'origine:

for(i=1;i<SIZE-1;i++) 
    for(j=1;j<SIZE-1;j++) {
        res[j][i]=0;
        for(k=-1;k<2;k++) 
            for(l=-1;l<2;l++) 
                res[j][i] += img[j+l][i+k];
        res[j][i] /= 9;
}

Remarquez d'abord que les deux boucles internes sont triviales. Ils peuvent être déroulés comme suit:

for(i=1;i<SIZE-1;i++) {
    for(j=1;j<SIZE-1;j++) {
        res[j][i]=0;
        res[j][i] += img[j-1][i-1];
        res[j][i] += img[j  ][i-1];
        res[j][i] += img[j+1][i-1];
        res[j][i] += img[j-1][i  ];
        res[j][i] += img[j  ][i  ];
        res[j][i] += img[j+1][i  ];
        res[j][i] += img[j-1][i+1];
        res[j][i] += img[j  ][i+1];
        res[j][i] += img[j+1][i+1];
        res[j][i] /= 9;
    }
}

Cela laisse donc les deux boucles externes qui nous intéressent.

Maintenant, nous pouvons voir que le problème est le même dans cette question: pourquoi l'ordre des boucles affecte-t-il les performances lors de l'itération sur un tableau 2D?

Vous itérez la matrice en colonnes plutôt qu'en lignes.


Pour résoudre ce problème, vous devez échanger les deux boucles.

for(j=1;j<SIZE-1;j++) {
    for(i=1;i<SIZE-1;i++) {
        res[j][i]=0;
        res[j][i] += img[j-1][i-1];
        res[j][i] += img[j  ][i-1];
        res[j][i] += img[j+1][i-1];
        res[j][i] += img[j-1][i  ];
        res[j][i] += img[j  ][i  ];
        res[j][i] += img[j+1][i  ];
        res[j][i] += img[j-1][i+1];
        res[j][i] += img[j  ][i+1];
        res[j][i] += img[j+1][i+1];
        res[j][i] /= 9;
    }
}

Cela élimine complètement tous les accès non séquentiels afin que vous n'obteniez plus de ralentissements aléatoires sur de grandes puissances de deux.


Core i7 920 à 3,5 GHz

Code d'origine:

8191: 1.499 seconds
8192: 2.122 seconds
8193: 1.582 seconds

Boucles externes échangées:

8191: 0.376 seconds
8192: 0.357 seconds
8193: 0.351 seconds
Mysticial
la source
217
Je noterai également que le déroulement des boucles internes n'a aucun effet sur les performances. Le compilateur le fait probablement automatiquement. Je les ai déroulés dans le seul but de m'en débarrasser pour faciliter la détection du problème avec les boucles extérieures.
Mysticial
29
Et vous pouvez accélérer ce code d'un autre facteur de trois en mettant en cache les sommes le long de chaque ligne. Mais cela et d'autres optimisations sortent du cadre de la question initiale.
Eric Postpischil
34
@ClickUpvote Il s'agit en fait d'un problème matériel (mise en cache). Cela n'a rien à voir avec la langue. Si vous l'essayez dans un autre langage qui compile ou JIT en code natif, vous verriez probablement les mêmes effets.
Mysticial
19
@ClickUpvote: Vous semblez plutôt mal orienté. Cette "deuxième boucle" était juste mystique déroulant les boucles intérieures à la main. C'est quelque chose que votre compilateur fera de toute façon, et Mystical ne l'a fait que pour rendre le problème avec les boucles externes plus évident. Ce n'est en aucun cas quelque chose que vous devriez vous soucier de faire vous-même.
Lily Ballard
154
CECI est un exemple parfait d'une bonne réponse sur SO: fait référence à des questions similaires, explique étape par étape comment vous l'avez abordé, explique le problème, explique comment CORRIGER le problème, a une excellente mise en forme et même un exemple du code en cours d'exécution sur votre machine. Nous vous remercions de votre contribution.
MattSayar
57

Les tests suivants ont été effectués avec le compilateur Visual C ++ car il est utilisé par l'installation par défaut de Qt Creator (je suppose sans indicateur d'optimisation). Lors de l'utilisation de GCC, il n'y a pas de grande différence entre la version de Mystical et mon code "optimisé". La conclusion est donc que les optimisations du compilateur prennent mieux soin de la micro-optimisation que les humains (moi enfin). Je laisse le reste de ma réponse pour référence.


Il n'est pas efficace de traiter les images de cette façon. Il est préférable d'utiliser des tableaux à une dimension. Le traitement de tous les pixels se fait en une seule boucle. L'accès aléatoire aux points pourrait se faire en utilisant:

pointer + (x + y*width)*(sizeOfOnePixel)

Dans ce cas particulier, il est préférable de calculer et de mettre en cache la somme de trois groupes de pixels horizontalement car ils sont utilisés trois fois chacun.

J'ai fait quelques tests et je pense que cela vaut la peine d'être partagé. Chaque résultat est une moyenne de cinq tests.

Code original par user1615209:

8193: 4392 ms
8192: 9570 ms

Version de Mystical:

8193: 2393 ms
8192: 2190 ms

Deux passes en utilisant un tableau 1D: première passe pour les sommes horizontales, seconde pour la somme verticale et moyenne. Adressage en deux passes avec trois pointeurs et uniquement des incréments comme celui-ci:

imgPointer1 = &avg1[0][0];
imgPointer2 = &avg1[0][SIZE];
imgPointer3 = &avg1[0][SIZE+SIZE];

for(i=SIZE;i<totalSize-SIZE;i++){
    resPointer[i]=(*(imgPointer1++)+*(imgPointer2++)+*(imgPointer3++))/9;
}

8193: 938 ms
8192: 974 ms

Deux passes en utilisant un tableau 1D et un adressage comme celui-ci:

for(i=SIZE;i<totalSize-SIZE;i++){
    resPointer[i]=(hsumPointer[i-SIZE]+hsumPointer[i]+hsumPointer[i+SIZE])/9;
}

8193: 932 ms
8192: 925 ms

Une passe de mise en cache des sommes horizontales juste une ligne en avant pour qu'elles restent en cache:

// Horizontal sums for the first two lines
for(i=1;i<SIZE*2;i++){
    hsumPointer[i]=imgPointer[i-1]+imgPointer[i]+imgPointer[i+1];
}
// Rest of the computation
for(;i<totalSize;i++){
    // Compute horizontal sum for next line
    hsumPointer[i]=imgPointer[i-1]+imgPointer[i]+imgPointer[i+1];
    // Final result
    resPointer[i-SIZE]=(hsumPointer[i-SIZE-SIZE]+hsumPointer[i-SIZE]+hsumPointer[i])/9;
}

8193: 599 ms
8192: 652 ms

Conclusion:

  • Aucun avantage d'utiliser plusieurs pointeurs et juste des incréments (je pensais que cela aurait été plus rapide)
  • Il est préférable de mettre en cache des sommes horizontales que de les calculer plusieurs fois.
  • Deux passes ne sont pas trois fois plus rapides, deux fois seulement.
  • Il est possible d'obtenir 3,6 fois plus rapidement en utilisant à la fois un seul passage et la mise en cache d'un résultat intermédiaire

Je suis sûr qu'il est possible de faire beaucoup mieux.

REMARQUE Veuillez noter que j'ai écrit cette réponse pour cibler les problèmes de performances généraux plutôt que le problème de cache expliqué dans l'excellente réponse de Mystical. Au début, c'était juste un pseudo-code. On m'a demandé de faire des tests dans les commentaires ... Voici une version complètement refactorisée avec des tests.

bokan
la source
9
"Je pense que c'est au moins 3 fois plus rapide" - vous voulez sauvegarder cette affirmation avec des mesures ou des citations?
Adam Rosenfield
8
@AdamRosenfield "Je pense" = supposition! = "C'est" = revendication. Je n'ai pas de métrique pour cela et j'aimerais voir un test. Mais le mien nécessite 7 incréments, 2 sous, 2 add et un div par pixel. Chaque boucle utilisant moins de var local qu'il n'y a de registre dans le CPU. Les autres nécessitent 7 incréments, 6 décréments, 1 div et entre 10 à 20 mul pour l'adressage en fonction de l'optimisation du compilateur. De plus, chaque instruction de la boucle nécessite le résultat de l'instruction précédente, ce qui élimine les avantages de l'architecture super-scalaire des Pentiums. Il faut donc que ce soit plus rapide.
bokan
3
La réponse à la question d'origine concerne les effets de mémoire et de cache. La raison pour laquelle le code OP est si lent est que son modèle d'accès à la mémoire passe par des colonnes plutôt que par des lignes, ce qui a une très mauvaise localité de référence de cache. C'est particulièrement mauvais à 8192 car les lignes consécutives finissent par utiliser les mêmes lignes de cache dans un cache à mappage direct ou un cache à faible associativité, de sorte que le taux d'échec du cache est encore plus élevé. L'échange des boucles fournit une énorme amélioration des performances en augmentant considérablement la localité du cache.
Adam Rosenfield
1
Bravo, ce sont des chiffres impressionnants. Comme vous l'avez constaté, tout dépend des performances de la mémoire - l'utilisation de plusieurs pointeurs avec des incréments n'a apporté aucun avantage.
Adam Rosenfield
2
@AdamRosenfield J'étais assez inquiet ce matin car je n'ai pas pu reproduire les tests. Il semble que l'augmentation des performances est uniquement avec le compilateur Visual C ++. Avec gcc, il n'y a qu'une petite différence.
bokan