La parallélisation des lectures aléatoires semble bien fonctionner - pourquoi?

18

Considérez le programme informatique très simple suivant:

for i = 1 to n:
    y[i] = x[p[i]]

Ici, et sont des tableaux d'octets à éléments, et est un tableau de mots à éléments. Ici, est grand, par exemple, (de sorte que seule une fraction négligeable des données tient dans n'importe quel type de mémoire cache).Xynpnnn=231

Supposons que constitué de nombres aléatoires , uniformément répartis entre et .p1n

Du point de vue du matériel moderne, cela devrait signifier ce qui suit:

  • la lecture de est bon marché (lecture séquentielle)p[je]
  • la lecture de est très coûteuse (lectures aléatoires; presque toutes les lectures sont des échecs de cache; nous devrons récupérer chaque octet individuel de la mémoire principale)X[p[je]]
  • écrire est bon marché (écriture séquentielle).y[je]

Et c'est bien ce que j'observe. Le programme est très lent par rapport à un programme qui ne fait que des lectures et des écritures séquentielles. Génial.

Vient maintenant la question: dans quelle mesure ce programme se parallélise-t-il sur les plates-formes multicœurs modernes?


Mon hypothèse était que ce programme ne se parallélise pas bien. Après tout, le goulot d'étranglement est la mémoire principale. Un seul cœur perd déjà la plupart de son temps à attendre des données de la mémoire principale.

Cependant, ce n'est pas ce que j'ai observé lorsque j'ai commencé à expérimenter certains algorithmes où le goulot d'étranglement était ce genre d'opération!

J'ai simplement remplacé la for-loop naïve par une for-loop parallèle OpenMP (essentiellement, elle divisera simplement la plage en parties plus petites et exécutera ces parties sur différents cœurs de CPU en parallèle).[1,n]

Sur les ordinateurs bas de gamme, les accélérations étaient en effet mineures. Mais sur les plates-formes haut de gamme, j'ai été surpris d'obtenir d'excellentes accélérations quasi linéaires. Quelques exemples concrets (les horaires exacts peuvent être un peu décalés, il y a beaucoup de variations aléatoires; ce ne sont que des expériences rapides):

  • 2 x 4 cœurs Xeon (au total 8 cœurs): accélérations de facteur 5 à 8 par rapport à la version à filetage unique.

  • 2 x Xeon à 6 cœurs (au total 12 cœurs): accélérations de facteur 8-14 par rapport à la version à filetage unique.

Maintenant, c'était totalement inattendu. Des questions:

  1. Justement, pourquoi ce type de programme se parallèle-t-il si bien ? Que se passe-t-il dans le matériel? (Ma supposition actuelle est quelque chose dans ce sens: les lectures aléatoires à partir de différents threads sont "pipelinées" et le taux moyen d'obtenir des réponses à celles-ci est beaucoup plus élevé que dans le cas d'un seul thread.)

  2. Est-il nécessaire d'utiliser plusieurs threads et plusieurs cœurs pour obtenir des accélérations? Si une sorte de pipelining a effectivement lieu dans l'interface entre la mémoire principale et le CPU, une application monothread ne pourrait-elle pas faire savoir à la mémoire principale qu'elle aura bientôt besoin de , x [ p [ i + 1 ] ] , ... et l'ordinateur pourrait commencer à récupérer les lignes de cache pertinentes de la mémoire principale? Si cela est possible en principe, comment puis-je y parvenir dans la pratique?X[p[je]]X[p[je+1]]

  3. Quel est le bon modèle théorique que nous pourrions utiliser pour analyser ce type de programmes (et faire des prédictions correctes de la performance)?


Edit: Il y a maintenant du code source et des résultats de benchmark disponibles ici: https://github.com/suomela/parallel-random-read

Quelques exemples de chiffres approximatifs ( ):n=232

  • environ. 42 ns par itération (lecture aléatoire) avec un seul thread
  • environ. 5 ns par itération (lecture aléatoire) avec 12 cœurs.
Jukka Suomela
la source

Réponses:

9

Oubliez un instant tous les problèmes liés à l'accès à la mémoire principale et au cache de niveau 3. D'un point de vue parallèle, en ignorant ces problèmes, le programme se parallélise parfaitement lors de l'utilisation de processeurs (ou cœurs), du fait qu'une fois que vous avez partitionné le travail à effectuer via la décomposition de domaine, chaque cœur doit traiter soit either npounnpéléments et il n'y a pas de surcharge de communication et / ou de synchronisation puisqu'il n'y a pas de dépendance fonctionnelle entre les processeurs. Par conséquent, en ignorant les problèmes de mémoire, vous vous attendez à une accélération égale àp.npp

Maintenant, prenons en compte les problèmes de mémoire. L'accélération super-linéaire que vous avez réellement observée sur votre nœud basé sur Xeon haut de gamme est justifiée comme suit.

nn/pp

n=231

n

Enfin, outre QSM (Queuing Shared Memory) , je ne connais aucun autre modèle parallèle théorique prenant en compte au même niveau la contention d'accès à la mémoire partagée (dans votre cas, lors de l'utilisation d'OpenMP la mémoire principale est partagée entre les cœurs , et le cache est toujours partagé également entre les cœurs). Quoi qu'il en soit, même si le modèle est intéressant, il n'a pas obtenu un grand succès.

Massimo Cafaro
la source
1
Il peut également être utile de considérer cela comme chaque cœur fournissant une quantité plus ou moins fixe de parallélisme au niveau de la mémoire, par exemple, 10 x [] charges en cours à un moment donné. Avec une probabilité de 0,5% de succès dans la L3 partagée, un seul thread aurait une chance de 0,995 ** 10 (95 +%) d'exiger que toutes ces charges attendent une réponse de la mémoire principale. Avec 6 cœurs fournissant un total de 60 x [] lectures en attente, il y a presque 26% de chances qu'au moins une lecture atteigne en L3. De plus, plus MLP est important, plus le contrôleur de mémoire peut planifier des accès pour augmenter la bande passante réelle.
Paul A. Clayton
5

J'ai décidé d'essayer __builtin_prefetch () moi-même. Je le poste ici comme réponse au cas où d'autres voudraient le tester sur leurs machines. Les résultats sont proches de ce que Jukka décrit: une diminution d'environ 20% du temps d'exécution lors de la pré-extraction de 20 éléments à l'avance par rapport à la pré-extraction de 0 éléments à l'avance.

Résultats:

prefetch =   0, time = 1.58000
prefetch =   1, time = 1.47000
prefetch =   2, time = 1.39000
prefetch =   3, time = 1.34000
prefetch =   4, time = 1.31000
prefetch =   5, time = 1.30000
prefetch =   6, time = 1.27000
prefetch =   7, time = 1.28000
prefetch =   8, time = 1.26000
prefetch =   9, time = 1.27000
prefetch =  10, time = 1.27000
prefetch =  11, time = 1.27000
prefetch =  12, time = 1.30000
prefetch =  13, time = 1.29000
prefetch =  14, time = 1.30000
prefetch =  15, time = 1.28000
prefetch =  16, time = 1.24000
prefetch =  17, time = 1.28000
prefetch =  18, time = 1.29000
prefetch =  19, time = 1.25000
prefetch =  20, time = 1.24000
prefetch =  19, time = 1.26000
prefetch =  18, time = 1.27000
prefetch =  17, time = 1.26000
prefetch =  16, time = 1.27000
prefetch =  15, time = 1.28000
prefetch =  14, time = 1.29000
prefetch =  13, time = 1.26000
prefetch =  12, time = 1.28000
prefetch =  11, time = 1.30000
prefetch =  10, time = 1.31000
prefetch =   9, time = 1.27000
prefetch =   8, time = 1.32000
prefetch =   7, time = 1.31000
prefetch =   6, time = 1.30000
prefetch =   5, time = 1.27000
prefetch =   4, time = 1.33000
prefetch =   3, time = 1.38000
prefetch =   2, time = 1.41000
prefetch =   1, time = 1.41000
prefetch =   0, time = 1.59000

Code:

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

void cracker(int *y, int *x, int *p, int n, int pf) {
    int i;
    int saved = pf;  /* let compiler optimize address computations */

    for (i = 0; i < n; i++) {
        __builtin_prefetch(&x[p[i+saved]]);
        y[i] += x[p[i]];
    }
}

int main(void) {
    int n = 50000000;
    int *x, *y, *p, i, pf, k;
    clock_t start, stop;
    double elapsed;

    /* set up arrays */
    x = malloc(sizeof(int)*n);
    y = malloc(sizeof(int)*n);
    p = malloc(sizeof(int)*n);
    for (i = 0; i < n; i++)
        p[i] = rand()%n;

    /* warm-up exercise */
    cracker(y, x, p, n, pf);

    k = 20;
    for (pf = 0; pf < k; pf++) {
        start = clock();
        cracker(y, x, p, n, pf);
        stop = clock();
        elapsed = ((double)(stop-start))/CLOCKS_PER_SEC;
        printf("prefetch = %3d, time = %.5lf\n", pf, elapsed);
    }
    for (pf = k; pf >= 0; pf--) {
        start = clock();
        cracker(y, x, p, n, pf);
        stop = clock();
        elapsed = ((double)(stop-start))/CLOCKS_PER_SEC;
        printf("prefetch = %3d, time = %.5lf\n", pf, elapsed);
    }

    return 0;
}
Pat Morin
la source
4
  1. L'accès DDR3 est en effet canalisé. http://www.eng.utah.edu/~cs7810/pres/dram-cs7810-protocolx2.pdf les diapositives 20 et 24 montrent ce qui se passe dans le bus mémoire pendant les opérations de lecture en pipeline.

  2. (partiellement faux, voir ci-dessous) Plusieurs threads ne sont pas nécessaires si l'architecture du processeur prend en charge la prélecture du cache. Les x86 et ARM modernes ainsi que de nombreuses autres architectures ont une instruction de prélecture explicite. Beaucoup tentent également de détecter des modèles dans les accès à la mémoire et effectuent la prélecture automatiquement. Le support logiciel est spécifique au compilateur, par exemple GCC et Clang ont intrinsèquement __builtin_prefech () pour la prélecture explicite.

L'hyperthreading de type Intel semble très bien fonctionner pour les programmes qui passent la plupart de leur temps à attendre des échecs de cache. D'après mon expérience, dans une charge de travail intensive en calcul, l'accélération va très peu au-dessus du nombre de cœurs physiques.

EDIT: je me suis trompé au point 2. Il semble que si la prélecture peut optimiser l'accès à la mémoire pour un seul cœur, la bande passante mémoire combinée de plusieurs cœurs est supérieure à la bande passante du cœur unique. Combien plus grand, dépend du CPU.

Le préfetcher matériel et les autres optimisations rendent le benchmarking très délicat. Il est possible de construire des cas où la prélecture explicite a un effet très visible ou inexistant sur les performances, ce benchmark étant l'un de ces derniers.

Juhani Simola
la source
__builtin_prefech semble très prometteur. Malheureusement, dans mes expériences rapides, cela n'a pas semblé beaucoup aider avec les performances d'un seul thread (<10%). Quelle amélioration de vitesse dois-je attendre dans ce type d'application?
Jukka Suomela
J'attendais plus. Comme je sais que la prélecture a un effet significatif sur les DSP et les jeux, j'ai dû m'expérimenter. Il s'est avéré que le trou du lapin est plus profond ...
Juhani Simola
Ma première tentative a été de créer un ordre aléatoire fixe stocké dans un tableau, puis d'itérer dans cet ordre avec et sans prélecture ( gist.github.com/osimola/7917602 ). Cela a apporté une différence d'environ 2% sur un Core i5. On dirait que la prélecture ne fonctionne pas du tout ou que le prédicteur matériel comprend l'indirection.
Juhani Simola
1
Ainsi, en testant cela, la deuxième tentative ( gist.github.com/osimola/7917568 ) accède à la mémoire en séquence générée par une graine aléatoire fixe. Cette fois, la version de pré-lecture était environ 2 fois plus rapide que la non-pré-lecture et 3 fois plus rapide que la pré-lecture 1 pas en avant. Notez que la version de prélecture effectue plus de calculs par accès mémoire que la version sans prélecture.
Juhani Simola
Cela semble dépendre de la machine. J'ai essayé le code de Pat Morin ci-dessous (je ne peux pas commenter cet article car je n'ai pas la réputation) et mon résultat est à 1,3% pour différentes valeurs de prélecture.
Juhani Simola