web-dev-qa-db-fra.com

Quelle est la différence entre un planning "statique" et "dynamique" dans OpenMP?

J'ai commencé à travailler avec OpenMP en utilisant C++.

J'ai deux questions:

  1. Quel est #pragma omp for schedule?
  2. Quelle est la différence entre dynamic et static?

Veuillez expliquer avec des exemples.

42
Lücks

Depuis, d'autres ont répondu à la plupart des questions, mais je voudrais signaler certains cas spécifiques où un type de planification particulier est plus adapté que les autres. Schedule contrôle la façon dont les itérations de boucle sont réparties entre les threads. Le choix du bon calendrier peut avoir un impact important sur la vitesse de l'application.

static planning signifie que les blocs d'itérations sont mappés statiquement aux threads d'exécution de manière circulaire. La bonne chose avec la planification statique est que l'exécution OpenMP garantit que si vous avez deux boucles distinctes avec le même nombre d'itérations et que vous les exécutez avec le même nombre de threads en utilisant la planification statique, alors chaque thread recevra exactement la même plage d'itération ( s) dans les deux régions parallèles. Ceci est assez important sur les systèmes NUMA: si vous touchez de la mémoire dans la première boucle, elle résidera sur le nœud NUMA où se trouvait le thread d'exécution. Ensuite, dans la deuxième boucle, le même thread pourrait accéder plus rapidement au même emplacement de mémoire car il résidera sur le même nœud NUMA.

Imaginez qu'il y ait deux nœuds NUMA: le nœud 0 et le nœud 1, par ex. une carte Intel Nehalem à deux sockets avec des processeurs à 4 cœurs dans les deux sockets. Les threads 0, 1, 2 et 3 résideront alors sur le noeud 0 et les threads 4, 5, 6 et 7 résideront sur le noeud 1:

|             | core 0 | thread 0 |
| socket 0    | core 1 | thread 1 |
| NUMA node 0 | core 2 | thread 2 |
|             | core 3 | thread 3 |

|             | core 4 | thread 4 |
| socket 1    | core 5 | thread 5 |
| NUMA node 1 | core 6 | thread 6 |
|             | core 7 | thread 7 |

Chaque cœur peut accéder à la mémoire de chaque nœud NUMA, mais l'accès à distance est plus lent (1,5x à 1,9x plus lent sur Intel) que l'accès au nœud local. Vous exécutez quelque chose comme ceci:

char *a = (char *)malloc(8*4096);

#pragma omp parallel for schedule(static,1) num_threads(8)
for (int i = 0; i < 8; i++)
   memset(&a[i*4096], 0, 4096);

4096 octets dans ce cas est la taille standard d'une page mémoire sous Linux sur x86 si les pages volumineuses ne sont pas utilisées. Ce code mettra à zéro l'ensemble du tableau 32 Ko a. L'appel malloc() réserve juste l'espace d'adressage virtuel mais ne "touche" pas réellement la mémoire physique (c'est le comportement par défaut sauf si une autre version de malloc est utilisée, par exemple une qui remet à zéro la mémoire comme calloc() le fait). Maintenant, ce tableau est contigu mais uniquement dans la mémoire virtuelle. Dans la mémoire physique, la moitié se trouverait dans la mémoire attachée au socket 0 et l'autre moitié dans la mémoire attachée au socket 1. En effet, différentes parties sont mises à zéro par des threads différents et ces threads résident sur des cœurs différents et il y a quelque chose appelé - première touche Stratégie NUMA, ce qui signifie que les pages mémoire sont allouées sur le nœud NUMA sur lequel réside le thread qui a "touché" la page mémoire en premier.

|             | core 0 | thread 0 | a[0]     ... a[4095]
| socket 0    | core 1 | thread 1 | a[4096]  ... a[8191]
| NUMA node 0 | core 2 | thread 2 | a[8192]  ... a[12287]
|             | core 3 | thread 3 | a[12288] ... a[16383]

|             | core 4 | thread 4 | a[16384] ... a[20479]
| socket 1    | core 5 | thread 5 | a[20480] ... a[24575]
| NUMA node 1 | core 6 | thread 6 | a[24576] ... a[28671]
|             | core 7 | thread 7 | a[28672] ... a[32768]

Permet maintenant d'exécuter une autre boucle comme celle-ci:

#pragma omp parallel for schedule(static,1) num_threads(8)
for (i = 0; i < 8; i++)
   memset(&a[i*4096], 1, 4096);

Chaque thread accédera à la mémoire physique déjà mappée et il aura le même mappage de thread à la région de mémoire que celui lors de la première boucle. Cela signifie que les threads n'accéderont qu'à la mémoire située dans leurs blocs de mémoire locale, ce qui sera rapide.

Imaginez maintenant qu'un autre schéma de planification soit utilisé pour la deuxième boucle: schedule(static,2). Cela "découpera" l'espace d'itération en blocs de deux itérations et il y aura 4 blocs de ce type au total. Ce qui se passera, c'est que nous aurons le thread suivant pour le mappage de l'emplacement de la mémoire (via le numéro d'itération):

|             | core 0 | thread 0 | a[0]     ... a[8191]  <- OK, same memory node
| socket 0    | core 1 | thread 1 | a[8192]  ... a[16383] <- OK, same memory node
| NUMA node 0 | core 2 | thread 2 | a[16384] ... a[24575] <- Not OK, remote memory
|             | core 3 | thread 3 | a[24576] ... a[32768] <- Not OK, remote memory

|             | core 4 | thread 4 | <idle>
| socket 1    | core 5 | thread 5 | <idle>
| NUMA node 1 | core 6 | thread 6 | <idle>
|             | core 7 | thread 7 | <idle>

Deux mauvaises choses se produisent ici:

  • les threads 4 à 7 restent inactifs et la moitié de la capacité de calcul est perdue;
  • les threads 2 et 3 accèdent à la mémoire non locale et cela leur prendra environ deux fois plus de temps pendant lequel les threads 0 et 1 resteront inactifs.

Ainsi, l'un des avantages de l'utilisation de l'ordonnancement statique est qu'il améliore la localisation de l'accès à la mémoire. L'inconvénient est qu'un mauvais choix de paramètres de planification peut ruiner les performances.

dynamic la planification fonctionne sur la base du "premier arrivé, premier servi". Deux exécutions avec le même nombre de threads peuvent produire (et très probablement) des mappages "espace d'itération" -> "threads" complètement différents, comme on peut facilement le vérifier:

$ cat dyn.c
#include <stdio.h>
#include <omp.h>

int main (void)
{
  int i;

  #pragma omp parallel num_threads(8)
  {
    #pragma omp for schedule(dynamic,1)
    for (i = 0; i < 8; i++)
      printf("[1] iter %0d, tid %0d\n", i, omp_get_thread_num());

    #pragma omp for schedule(dynamic,1)
    for (i = 0; i < 8; i++)
      printf("[2] iter %0d, tid %0d\n", i, omp_get_thread_num());
  }

  return 0;
}

$ icc -openmp -o dyn.x dyn.c

$ OMP_NUM_THREADS=8 ./dyn.x | sort
[1] iter 0, tid 2
[1] iter 1, tid 0
[1] iter 2, tid 7
[1] iter 3, tid 3
[1] iter 4, tid 4
[1] iter 5, tid 1
[1] iter 6, tid 6
[1] iter 7, tid 5
[2] iter 0, tid 0
[2] iter 1, tid 2
[2] iter 2, tid 7
[2] iter 3, tid 3
[2] iter 4, tid 6
[2] iter 5, tid 1
[2] iter 6, tid 5
[2] iter 7, tid 4

(le même comportement est observé lorsque gcc est utilisé à la place)

Si l'exemple de code de la section static a été exécuté avec la planification dynamic, il y aura seulement 1/70 (1,4%) de chance que la localité d'origine soit préservée et 69/70 (98,6% ) chance que l'accès à distance se produise. Ce fait est souvent ignoré et, par conséquent, les performances ne sont pas optimales.

Il existe une autre raison de choisir entre static et dynamic planification - équilibrage de la charge de travail. Si chaque itération prend une durée très différente de la durée moyenne, un déséquilibre de travail élevé peut se produire dans le cas statique. Prenons comme exemple le cas où le temps pour terminer une itération augmente linéairement avec le numéro d'itération. Si l'espace d'itération est divisé statiquement entre deux threads, le second aura trois fois plus de travail que le premier et donc pendant 2/3 du temps de calcul, le premier thread sera inactif. Le calendrier dynamique introduit des frais supplémentaires, mais dans ce cas particulier, la répartition de la charge de travail sera bien meilleure. Un type spécial de planification dynamic est le guided où des blocs d'itération de plus en plus petits sont donnés à chaque tâche au fur et à mesure que le travail progresse.

Étant donné que le code précompilé peut être exécuté sur diverses plates-formes, il serait bien que l'utilisateur final puisse contrôler la planification. C'est pourquoi OpenMP fournit la clause spéciale schedule(runtime). Avec la programmation runtime, le type est extrait du contenu de la variable d'environnement OMP_SCHEDULE. Cela permet de tester différents types de planification sans recompiler l'application et permet également à l'utilisateur final d'affiner sa plate-forme.

101
Hristo Iliev

Je pense que le malentendu vient du fait que vous manquez le point sur OpenMP. En une phrase, OpenMP vous permet d'exécuter votre programme plus rapidement en activant le parallélisme. Dans un programme, le parallélisme peut être activé de plusieurs manières et l'une d'elles consiste à utiliser des threads. Supposons que vous ayez et tableau:

[1,2,3,4,5,6,7,8,9,10]

et vous souhaitez incrémenter tous les éléments de 1 dans ce tableau.

Si vous allez utiliser

#pragma omp for schedule(static, 5)

cela signifie qu'à chacun des threads seront attribués 5 itérations contiguës. Dans ce cas, le premier fil prendra 5 numéros. Le second prendra 5 autres et ainsi de suite jusqu'à ce qu'il n'y ait plus de données à traiter ou que le nombre maximal de threads soit atteint (généralement égal au nombre de cœurs). Le partage de la charge de travail se fait lors de la compilation.

En cas de

#pragma omp for schedule(dynamic, 5)

Le travail sera partagé entre les threads mais cette procédure se produira lors de l'exécution. Impliquant ainsi plus de frais généraux. Le deuxième paramètre spécifie la taille du bloc de données.

N'étant pas très familier à OpenMP, je risque de supposer que le type dynamique est plus approprié lorsque le code compilé va s'exécuter sur le système qui a une configuration différente de celle sur laquelle le code a été compilé.

Je recommanderais la page ci-dessous où sont discutées les techniques utilisées pour paralléliser le code, les conditions préalables et les limitations

https://computing.llnl.gov/tutorials/parallel_comp/

Liens supplémentaires :
http://en.wikipedia.org/wiki/OpenMP
Différence entre le calendrier statique et dynamique dans openMP en C
http://openmp.blogspot.se/

22
Eugeniu Torica

Le schéma de partitionnement de boucle est différent. L'ordonnanceur statique diviserait une boucle sur N éléments en M sous-ensembles, et chaque sous-ensemble contiendrait alors strictement N/M éléments.

L'approche dynamique calcule la taille des sous-ensembles à la volée, ce qui peut être utile si les temps de calcul des sous-ensembles varient.

L'approche statique doit être utilisée si les temps de calcul ne varient pas beaucoup.

7
Sebastian Mach