web-dev-qa-db-fra.com

Multiplication de matrice: petite différence de taille de matrice, grande différence de synchronisation

J'ai un code de multiplication de matrice qui ressemble à ceci:

for(i = 0; i < dimension; i++)
    for(j = 0; j < dimension; j++)
        for(k = 0; k < dimension; k++)
            C[dimension*i+j] += A[dimension*i+k] * B[dimension*k+j];

Ici, la taille de la matrice est représentée par dimension. Maintenant, si la taille des matrices est 2000, il faut 147 secondes pour exécuter ce morceau de code, alors que si la taille des matrices est 2048, cela prend 447 secondes. Donc, alors que la différence dans non. des multiplications est (2048 * 2048 * 2048)/(2000 * 2000 * 2000) = 1,073, la différence dans les timings est 447/147 = 3. Quelqu'un peut-il expliquer pourquoi cela se produit? Je m'attendais à ce qu'il évolue linéairement, ce qui ne se produit pas. Je n'essaie pas de créer le code de multiplication de matrice le plus rapide, j'essaie simplement de comprendre pourquoi cela se produit.

Spécifications: nœud dual core AMD Opteron (2,2 GHz), 2 Go de RAM, gcc v 4.5.0

Programme compilé comme gcc -O3 simple.c

J'ai également exécuté cela sur le compilateur icc d'Intel et j'ai vu des résultats similaires.

ÉDITER:

Comme suggéré dans les commentaires/réponses, j'ai exécuté le code avec dimension = 2060 et cela prend 145 secondes.

Voici le programme complet:

#include <stdlib.h>
#include <stdio.h>
#include <sys/time.h>

/* change dimension size as needed */
const int dimension = 2048;
struct timeval tv; 

double timestamp()
{
        double t;
        gettimeofday(&tv, NULL);
        t = tv.tv_sec + (tv.tv_usec/1000000.0);
        return t;
}

int main(int argc, char *argv[])
{
        int i, j, k;
        double *A, *B, *C, start, end;

        A = (double*)malloc(dimension*dimension*sizeof(double));
        B = (double*)malloc(dimension*dimension*sizeof(double));
        C = (double*)malloc(dimension*dimension*sizeof(double));

        srand(292);

        for(i = 0; i < dimension; i++)
                for(j = 0; j < dimension; j++)
                {   
                        A[dimension*i+j] = (Rand()/(Rand_MAX + 1.0));
                        B[dimension*i+j] = (Rand()/(Rand_MAX + 1.0));
                        C[dimension*i+j] = 0.0;
                }   

        start = timestamp();
        for(i = 0; i < dimension; i++)
                for(j = 0; j < dimension; j++)
                        for(k = 0; k < dimension; k++)
                                C[dimension*i+j] += A[dimension*i+k] *
                                        B[dimension*k+j];

        end = timestamp();
        printf("\nsecs:%f\n", end-start);

        free(A);
        free(B);
        free(C);

        return 0;
}
74
jitihsk

Voici ma conjecture sauvage: cache

Il se peut que vous puissiez insérer 2 rangées de 2000 doubles dans le cache. Ce qui est légèrement inférieur au cache L1 de 32 Ko. (tout en laissant de la place d'autres choses nécessaires)

Mais lorsque vous le déplacez jusqu'en 2048, il utilise le cache entier (et vous en renversez certains parce que vous avez besoin de place pour d'autres des choses)

En supposant que la stratégie de cache est LRU, le déversement du cache juste un peu entraînera le vidage et le rechargement répétés de la ligne entière dans le cache L1.

L'autre possibilité est l'associativité du cache en raison de la puissance de deux. Bien que je pense que le processeur est associatif L1 à 2 voies, je ne pense pas que cela soit important dans ce cas. (mais je vais lancer l'idée de toute façon)

Explication possible 2: Le cache de conflit manque en raison d'un super-alignement sur le cache L2.

Votre tableau B est en cours d'itération sur la colonne. L'accès est donc limité. La taille totale de vos données est 2k x 2k, soit environ 32 Mo par matrice. C'est beaucoup plus grand que votre cache L2.

Lorsque les données ne sont pas parfaitement alignées, vous aurez une localité spatiale décente sur B. Bien que vous sautiez des lignes et n'utilisiez qu'un élément par cacheline, la cacheline reste dans le cache L2 pour être réutilisée par la prochaine itération de la boucle du milieu.

Cependant, lorsque les données sont parfaitement alignées (2048), ces sauts atterriront tous sur le même "chemin de cache" et dépasseront de loin votre associativité de cache L2. Par conséquent, les lignes de cache accédées de B ne resteront pas dans le cache pour la prochaine itération. Au lieu de cela, ils devront être tirés à fond depuis le bélier.

81
Mysticial

Vous obtenez certainement ce que j'appelle une résonance de cache . Ceci est similaire à l'aliasing , mais pas exactement le même. Laisse-moi expliquer.

Les caches sont des structures de données matérielles qui extraient une partie de l'adresse et l'utilisent comme index dans une table, un peu comme un tableau dans un logiciel. (En fait, nous les appelons des tableaux dans le matériel.) Le tableau de cache contient des lignes de données de cache et des balises - parfois une entrée de ce type par index dans le tableau (mappage direct), parfois plusieurs de ce type (associativité de l'ensemble N-way). Une deuxième partie de l'adresse est extraite et comparée à l'étiquette stockée dans le tableau. Ensemble, l'index et la balise identifient de manière unique une adresse de mémoire de ligne de cache. Enfin, le reste des bits d'adresse identifie les octets de la ligne de cache qui sont adressés, ainsi que la taille de l'accès.

L'index et la balise sont généralement de simples champs binaires. Donc, une adresse mémoire ressemble à

  ...Tag... | ...Index... | Offset_within_Cache_Line

(Parfois, l'index et la balise sont des hachages, par exemple quelques XOR d'autres bits dans les bits de milieu de gamme qui sont l'index. Beaucoup plus rarement, parfois l'index, et plus rarement la balise, sont des choses comme prendre l'adresse de ligne de cache modulo a nombre premier. Ces calculs d'index plus compliqués sont des tentatives de lutte contre le problème de résonance, que j'explique ici. Tous souffrent d'une certaine forme de résonance, mais les schémas d'extraction de champs de bits les plus simples souffrent de résonance sur les modèles d'accès communs, comme vous l'avez trouvé.)

Donc, les valeurs typiques ... il existe de nombreux modèles différents de "Opteron Dual Core", et je ne vois rien ici qui spécifie celui que vous avez. En choisissant un au hasard, le manuel le plus récent que je vois sur le site Web d'AMD, Bios and Kernel Developer's Guide (BKDG) for AMD Family 15h Models 00h-0Fh , 12 mars 2012.

(Famille 15h = famille Bulldozer, le processeur haut de gamme le plus récent - le BKDG mentionne le dual core, bien que je ne connaisse pas le numéro de produit qui est exactement ce que vous décrivez. Mais, de toute façon, la même idée de résonance s'applique à tous les processeurs, c'est juste que les paramètres comme la taille du cache et l'associativité peuvent varier un peu.)

À partir de la p.33:

Le processeur AMD Family 15h contient un cache de données L1 de 16 Ko, à 4 voies, avec deux ports 128 bits. Il s'agit d'un cache à écriture immédiate qui prend en charge jusqu'à deux charges de 128 octets par cycle. Il est divisé en 16 banques de 16 octets de large chacune. [...] Un seul chargement peut être effectué à partir d'une banque donnée du cache L1 en un seul cycle.

Pour résumer:

  • Ligne de cache de 64 octets => 6 bits de décalage dans la ligne de cache

  • 16 Ko/4 voies => la résonance est de 4 Ko.

    C'est à dire. les bits d'adresse 0-5 sont le décalage de la ligne de cache.

  • 16 Ko/64 B de lignes de cache => 2 ^ 14/2 ^ 6 = 2 ^ 8 = 256 lignes de cache dans le cache.
    (Bugfix: J'ai initialement mal calculé cela comme 128. que j'ai corrigé toutes les dépendances.)

  • 4 voies associatives => 256/4 = 64 index dans le tableau de cache. J'appelle (Intel) ces "ensembles".

    c'est-à-dire que vous pouvez considérer le cache comme un tableau de 32 entrées ou ensembles, chaque entrée contenant 4 lignes de cache et leurs balises. (C'est plus compliqué que ça, mais ça va).

(Soit dit en passant, les termes "set" et "way" ont définitions différentes .)

  • il y a 6 bits d'index, les bits 6-11 dans le schéma le plus simple.

    Cela signifie que toutes les lignes de cache qui ont exactement les mêmes valeurs dans les bits d'index, les bits 6 à 11, seront mappées sur le même ensemble de cache.

Regardez maintenant votre programme.

C[dimension*i+j] += A[dimension*i+k] * B[dimension*k+j];

La boucle k est la boucle la plus intérieure. Le type de base est double, 8 octets. Si dimension = 2048, soit 2K, alors les éléments successifs de B[dimension*k+j] accessible par la boucle sera à 2048 * 8 = 16K octets d'intervalle. Ils mapperont tous au même ensemble du cache L1 - ils auront tous le même index dans le cache. Ce qui signifie qu'au lieu de 256 lignes de cache disponibles dans le cache, il n'y en aura que 4 - "l'associativité à 4 voies" du cache.

C'est à dire. vous obtiendrez probablement un échec de cache toutes les 4 itérations autour de cette boucle. Pas bon.

(En fait, les choses sont un peu plus compliquées. Mais ce qui précède est une bonne première compréhension. Les adresses des entrées de B mentionnées ci-dessus sont une adresse virtuelle. Il peut donc y avoir des adresses physiques légèrement différentes. De plus, Bulldozer a un moyen de cache prédictif, utilisant probablement des bits d'adresses virtuelles pour ne pas avoir à attendre une traduction d'adresse virtuelle en adresse physique. Mais, dans tous les cas: votre code a une "résonance" de 16 Ko. Le cache de données L1 a une résonance de 16 Ko. Pas bon .)]

Si vous modifiez un peu la dimension, par ex. à 2048 + 1, les adresses du tableau B seront réparties sur tous les ensembles du cache. Et vous obtiendrez beaucoup moins de ratés de cache.

C'est une optimisation assez courante pour garnir vos tableaux, par exemple changer 2048 en 2049, pour éviter ce srt de résonance. Mais "le blocage du cache est une optimisation encore plus importante. http://suif.stanford.edu/papers/lam-asplos91.pdf


En plus de la résonance de la ligne de cache, il se passe d'autres choses ici. Par exemple, le cache L1 a 16 banques de 16 octets chacune. Avec dimension = 2048, les accès B successifs dans la boucle interne iront toujours vers la même banque. Donc, ils ne peuvent pas aller en parallèle - et si l'accès A arrive à la même banque, vous perdrez.

Je ne pense pas, en le regardant, que c'est aussi grand que la résonance du cache.

Et, oui, peut-être, il peut y avoir un aliasing. Par exemple. le STLF (Store To Load Forwarding buffers) peut comparer uniquement en utilisant un petit champ binaire et obtenir de fausses correspondances.

(En fait, si vous y réfléchissez, la résonance dans le cache est comme un alias, lié à l'utilisation de champs de bits. La résonance est causée par plusieurs lignes de cache mappant le même ensemble, n'étant pas réparties. L'alisaing est provoqué par une correspondance basée sur une adresse incomplète morceaux.)


Dans l'ensemble, ma recommandation pour le réglage:

  1. Essayez de bloquer le cache sans autre analyse. Je dis cela parce que le blocage du cache est facile, et il est très probable que c'est tout ce que vous auriez besoin de faire.

  2. Après cela, utilisez VTune ou OProf. Ou Cachegrind. Ou ...

  3. Mieux encore, utilisez une routine de bibliothèque bien réglée pour multiplier la matrice.

31
Krazy Glew

Il y a plusieurs explications possibles. Une explication probable est ce que Mysticial suggère: épuisement d'une ressource limitée (cache ou TLB). Une autre possibilité probable est un blocage de faux alias, qui peut se produire lorsque les accès à la mémoire consécutifs sont séparés par un multiple d'une puissance de deux (souvent 4 Ko).

Vous pouvez commencer à affiner ce qui fonctionne en traçant le temps/la dimension ^ 3 pour une plage de valeurs. Si vous avez effacé un cache ou épuisé la portée TLB, vous verrez une section plus ou moins plate suivie d'une forte augmentation entre 2000 et 2048, suivie d'une autre section plate. Si vous voyez des décrochages liés à l'aliasing, vous verrez un graphique plus ou moins plat avec un pic étroit vers le haut à 2048.

Bien sûr, cela a un pouvoir diagnostique, mais ce n'est pas concluant. Si vous voulez savoir de manière concluante quelle est la source du ralentissement, vous voudrez en savoir plus sur les compteurs de performance , qui peuvent répondre définitivement à ce genre de question.

17
Stephen Canon

Je sais que c'est trop vieux, mais je vais prendre une bouchée. C'est (comme cela a été dit) un problème de cache qui cause le ralentissement à environ deux puissances. Mais il y a un autre problème: c'est trop lent. Si vous regardez votre boucle de calcul.

for(i = 0; i < dimension; i++)
    for(j = 0; j < dimension; j++)
        for(k = 0; k < dimension; k++)
            C[dimension*i+j] += A[dimension*i+k] * B[dimension*k+j];

La boucle la plus interne change k de 1 à chaque itération, ce qui signifie que vous accédez à seulement 1 double du dernier élément que vous avez utilisé de A mais toute une "dimension" double du dernier élément de B. Cette ne tire aucun avantage de la mise en cache des éléments de B.

Si vous changez ceci en:

for(i = 0; i < dimension; i++)
    for(j = 0; j < dimension; j++)
        for(k = 0; k < dimension; k++)
            C[dimension*i+k] += A[dimension*i+j] * B[dimension*j+k];

Vous obtenez exactement les mêmes résultats (erreurs d'associativité par double ajout modulo), mais c'est beaucoup plus compatible avec le cache ( local ). Je l'ai essayé et cela donne des améliorations substantielles. Cela peut se résumer comme suit:

Ne multipliez pas les matrices par définition, mais plutôt par lignes


Exemple d'accélération (j'ai changé votre code pour prendre la dimension en argument)

$ diff a.c b.c
42c42
<               C[dimension*i+j] += A[dimension*i+k] * B[dimension*k+j];
---
>               C[dimension*i+k] += A[dimension*i+j] * B[dimension*j+k];
$ make a
cc     a.c   -o a
$ make b
cc     b.c   -o b
$ ./a 1024

secs:88.732918
$ ./b 1024

secs:12.116630

En prime (et ce qui rend cela lié à cette question), c'est que cette boucle ne souffre pas du problème précédent.

Si vous saviez déjà tout cela, alors je m'excuse!

8
Guido

Quelques réponses ont mentionné des problèmes de cache L2.

Vous pouvez réellement vérifier cela avec une simulation de cache . L'outil cachegrind de Valgrind peut le faire.

valgrind --tool=cachegrind --cache-sim=yes your_executable

Réglez les paramètres de ligne de commande pour qu'ils correspondent aux paramètres L2 de votre CPU.

Testez-le avec différentes tailles de matrice, vous verrez probablement une augmentation soudaine du taux d'échec L2.

8
Karoly Horvath