Parallélisme : travaux dirigés et pratiques

4. OpenMP

4.1. Introduction

Le support d'OpenMP est pris en compte dans la presque totalité des compilateurs C++/Fortran. Par exemple GCC 6.1 supporte OpenMP 4.5.

On pourra consulter les très intéressantes vidéos de Tim Mattson (Senior Research Scientist chez Intel) qui constituent un tutoriel très didactique.

afin d'utiliser OpenMP :

4.2. Team of threads

The #pragma directive is the method specified by the C standard for providing additional information to the compiler, beyond what is conveyed in the language itself. Three forms of this directive (commonly known as pragmas) are specified by the 1999 C standard. A C compiler is free to attach any meaning it likes to other pragmas.

L'utilisation du parallélisme est réalisé grace aux pragma (directives de programmation). La pragma omp parallel indique à OpenMP de créer un ensemble de threads (qualifié de team) qui seront exécutés en parallèle.

  1. #include <omp.h>
  2. #include <iostream>
  3. #include <cstdlib>
  4. using namespace std;
  5.  
  6. int main() {
  7.  
  8.     #pragma omp parallel
  9.     {
  10.             cout << "bonjour " << omp_get_thread_num() << endl;
  11.     }
  12.  
  13.     return EXIT_SUCCESS;
  14. }
  15.  
  16.  

Le résultat de l'exécution du programe précédent sur un Dual-Core avec Hyper-Threading est le suivant :

bonjour bonjour 03
bonjour 1

bonjour 2

On voit qu'il y a bien 4 threads 0,1,2,3 et que l'affichage est illisible étant donné que les threads partagent le même canal de communication cout.

Le modèle de fonctionnement est de type fork-join:

  • le thread qualifié de master exécute le code séquentiel
  • lorsqu'il rencontre une partie de code parallèle, il crée les threads nécessaires et exécute le code
  • la partie de code parallèle se termine lorsque tous les threads ont terminé leur exécution, il y a donc un join par défaut
  • puis le master thread reprend l'exécution séquentielle

4.3. Contrôle du nombre de threads

Plusieurs fonctions sont définies par OpenMP afin de manipuler le nombre total de threads :

  1. #include <omp.h>
  2. #include <iostream>
  3. #include <cstdlib>
  4. using namespace std;
  5.  
  6. int main() {
  7.  
  8.     cout << "max procs = " << omp_get_num_procs() << endl;
  9.     cout << "max threads = " << omp_get_max_threads() << endl;
  10.    
  11.     #pragma omp parallel
  12.     {
  13.             cout << "bonjour " << omp_get_thread_num() << "/" << omp_get_num_threads() << endl;
  14.     }
  15.  
  16.     return EXIT_SUCCESS;
  17. }
  18.  
  19.  
max procs = 4
max threads = 4
bonjour 1/4
bonjour 0/4
bonjour bonjour 2/4
3/4

4.4. Les sections critiques / synchronisation des threads

La synchronisation des threads est réalisée :

Comment utiliser OpenMP ?

Il suffit d'utiliser le parallélisme dans un des cadres suivants :

4.5. Les boucles for

Pour paralléliser les boucles on utilise #pragma omp for qui doit être introduite dans une section #pragma omp parallel. On peut également combiner les deux formes avec #pragma omp parallel for.

  1. #include <omp.h>
  2. #include <iostream>
  3. #include <cstdlib>
  4. using namespace std;
  5.  
  6. int main() {
  7.  
  8.     cout << "max procs = " << omp_get_num_procs() << endl;
  9.     cout << "max threads = " << omp_get_max_threads() << endl;
  10.    
  11.     const int SIZE = 100;
  12.     int tab[SIZE];
  13.    
  14.     // initialize
  15.     for (int i = 0; i < SIZE; ++i) {
  16.         tab[i] = (i+1);
  17.     }
  18.    
  19.     #pragma omp parallel
  20.     {
  21.         #pragma omp for
  22.         for (int i = 0; i < SIZE; ++i) {
  23.             #pragma omp critical
  24.             {
  25.                 cout << "thread " << omp_get_thread_num() << " treats index " << i << endl;
  26.             }
  27.             tab[i] = 2 * i;
  28.         }
  29.     }
  30.  
  31.     return EXIT_SUCCESS;
  32. }
  33.  
  34.  

4.5.1. Attributs de stockage des variables

On peut décrire la façon dont les variables sont accédées :

  1. #include <omp.h>
  2. #include <iostream>
  3. #include <cstdlib>
  4. using namespace std;
  5.  
  6. int main() {
  7.  
  8.     int a[4], b , c, d;
  9.    
  10.     b = 1234;
  11.     c = 5678;
  12.     d = 91011;
  13.    
  14.     #pragma omp parallel for num_threads(4) shared(a) private(b) firstprivate(c) lastprivate(d)
  15.     for (int i=0; i<4; ++i) {
  16.         #pragma omp critical
  17.         {
  18.             a[i] = (i + 1) * 5;
  19.             b += a[ i ];
  20.             c += a[ i ];
  21.             d += a[ i ];
  22.             cout << "execute thread " << omp_get_thread_num() << endl;
  23.             cout << "b=" << b << endl;
  24.             cout << "c=" << c << endl;
  25.             cout << "d=" << d << endl;
  26.         }
  27.     }
  28.  
  29.     cout << "after parallel section :" << endl;
  30.     for (int i=0; i<4; ++i) {
  31.         cout << "a[" << i << "]=" << a[i] << endl;
  32.     }
  33.     cout << "b=" << b << endl;
  34.     cout << "c=" << c << endl;
  35.     cout << "d=" << d << endl;
  36.     return EXIT_SUCCESS;
  37. }
  38.  
  39.  
execute thread 0
b=-1097711931
c=5683
d=32772
execute thread 3
b=20
c=5698
d=20
execute thread 2
b=15
c=5693
d=15
execute thread 1
b=10
c=5688
d=10
after parallel section :
a[0]=5
a[1]=10
a[2]=15
a[3]=20
b=1234
c=5678
d=20

4.5.2. la sous-directive collapse

Specifies how many loops in a nested loop should be collapsed into one large iteration space and divided according to the schedule clause. The sequential execution of the iterations in all associated loops determines the order of the iterations in the collapsed iteration space.

Dans l'exemple suivant on a $5 x 10$ éléments à traiter suivant deux boucles imbriquées :

  1. #include <iostream>
  2. #include <cstdlib>
  3. using namespace std;
  4. #include <omp.h>
  5.  
  6. int main() {
  7.     const int MAX_I = 5;
  8.     const int MAX_J = 10;
  9.     const int MAX_THREADS = 4;
  10.     int nbr_processed[MAX_THREADS];
  11.    
  12.     for (int i=0; i<MAX_THREADS; ++i) nbr_processed[i] = 0;
  13.    
  14.     #pragma omp parallel for
  15.     for (int i=0; i<MAX_I; ++i) {
  16.         for (int j=0; j<MAX_J; ++j) {
  17.             #pragma omp critical
  18.             {
  19.                 cout << i << " " << j << " " << omp_get_thread_num() << endl;
  20.                 ++nbr_processed[omp_get_thread_num() ];
  21.             }
  22.         }
  23.     }
  24.    
  25.     for (int i=0; i<MAX_THREADS; ++i) {
  26.         cout << "thread " << i << " processed " <<  nbr_processed[i] << " elements" << endl;
  27.     }
  28.    
  29.     return EXIT_SUCCESS;
  30. }
  31.  

Le résultat à l'exécution est le suivant : resultat. On dispose de 4 threads d'exécution et donc l'un thread va traiter $2$ fois $10$ éléments, soit deux fois plus que les autres.

thread 0 processed 20 elements
thread 1 processed 10 elements
thread 2 processed 10 elements
thread 3 processed 10 elements

On voit que la répartition de la charge de travail est déséquilibrée. Afin de mieux la répartir on peut considérer que les $5 x 10$ éléments à traiter ne font partie que d'une seule boucle. Dans ce cas, on utilise la sous-directive collapse :

  1. #include <iostream>
  2. #include <cstdlib>
  3. using namespace std;
  4. #include <omp.h>
  5.  
  6. int main() {
  7.     const int MAX_I = 5;
  8.     const int MAX_J = 10;
  9.     const int MAX_THREADS = 4;
  10.     int nbr_processed[MAX_THREADS];
  11.    
  12.     for (int i=0; i<MAX_THREADS; ++i) nbr_processed[i] = 0;
  13.    
  14.     #pragma omp parallel for collapse(2)
  15.     for (int i=0; i<MAX_I; ++i) {
  16.         for (int j=0; j<MAX_J; ++j) {
  17.             #pragma omp critical
  18.             {
  19.                 cout << i << " " << j << " " << omp_get_thread_num() << endl;
  20.                 ++nbr_processed[omp_get_thread_num() ];
  21.             }
  22.         }
  23.     }
  24.    
  25.     for (int i=0; i<MAX_THREADS; ++i) {
  26.         cout << "thread " << i << " processed " <<  nbr_processed[i] << " elements" << endl;
  27.     }
  28.    
  29.     return EXIT_SUCCESS;
  30. }
  31.  
thread 0 processed 13 elements
thread 1 processed 13 elements
thread 2 processed 12 elements
thread 3 processed 12 elements

Attention cependant avec des boucles du type suivant, collapse ne fonctionne pas, car dans la seconde boucle le nombre d'éléments à traiter n'est pas constant :


for (int i=1; i<MAX_I; ++i) {
	for (int j=0; j<i; ++j) {
		...
	}
}

4.6. Les sections

Les sections permettent d'exécuter du code en parallèle non lié à une boucle. Dans l'exemple on définit 3 sections indépendantes. Attention on distingue sections (au pluriel) qui engloble section (au singulier).


#pragma omp parallel sections
{
	#pragma omp section
	double a = compute_1();
	#pragma omp section
	double b = compute_2();
	#pragma omp section
	double c = compute_3();
}
double s = a + b + c;

4.7. Les taches

Le problème des sections est qu'elles ont tendance à ralentir l'exécution du code. En particulier si on dispose de 8 threads d'exécution et que seules deux sections sont exécutées en parallèle, alors les 6 autres threads sont en attente de la fin de l'exécution des sections. Pour remédier à ce problème on introduit les tâches avec OpenMP 3.0

La notion de tâche permet de paralléliser certains traitements qui étaient difficiles à paralléliser par le passé (boucle non contrainte, algorithme récursif). Elle repose sur le fait qu'un thread peut générer des tâches qui deviennent des threads pour être traités dans les boucles while par exemple. On introduit la notion de parentée entre les tâches liée à la synchronisation (voir ci-après).

Les tâches peuvent être exécutées immédiatement ou en différé.

La synchronisation des tâches est réalisée par :


LinkedList *l = ...;

while (l != nullptr) {
	process(l);
	l = l->next;
}

LinkedList *l = ...;

#pragma omp parallel
{
	#pragma omp single
	{
		while (l != nullptr) {
			#pragma omp task firstprivate(l)
			{
				process(l);
			}
			l = l->next;
		}
	}
}

Ici firstprivate permet de rendre le pointeur l local à chaque thread.

4.7.1. Trop de tâches tuent le parallélisme

Attention, il faut pouvoir gérer les tâches de manière intelligente sous peine de perdre tout le bénéfice de la parallélisation.

Dans ce premier exemple beaucoup trop de tâches seront générées. La conséquence est un temps d'exécution anormalement long.

  1. // Use of OpenMP tasks to compute fibonacci(45)
  2. #include <iostream>
  3. using namespace std;
  4.  
  5.  
  6. int fib(int n) {
  7.     int x, y;
  8.    
  9.     if (n < 2) return n;
  10.     #pragma omp task shared(x)
  11.     x = fib(n-1);
  12.     #pragma omp task shared(y)
  13.     y = fib(n-2);
  14.     #pragma omp taskwait
  15.     return x + y;
  16. }
  17.  
  18. int main() {
  19.     int res, n = 45;
  20.  
  21.     // parallel section but one thread will
  22.     // lauch the function fib()
  23.     #pragma omp parallel
  24.     {
  25.         #pragma omp single
  26.         {
  27.             res = fib(n);
  28.         }
  29.     }
  30.    
  31.     cout << "res=" << res << endl;
  32.     return 0;
  33. }
  34.  

Il faut donc limiter le nombre de tâches :

  1. // OpenMP task version of finoacci(45) with less tasks
  2. #include <iostream>
  3. using namespace std;
  4.  
  5. int fib(int n) {
  6.     int x, y;
  7.    
  8.     if (n < 2) return n;
  9.     if (n < 30) return fib(n-1) + fib(n-2);
  10.    
  11.     #pragma omp task shared(x)
  12.     x = fib(n-1);
  13.     #pragma omp task shared(y)
  14.     y = fib(n-2);
  15.     #pragma omp taskwait
  16.     return x + y;
  17. }
  18.  
  19. int main() {
  20.     int res, n = 45;
  21.    
  22.     #pragma omp parallel
  23.     {
  24.         #pragma omp single
  25.         {
  26.             res = fib(n);
  27.         }
  28.     }
  29.    
  30.     cout << "res=" << res << endl;
  31.     return 0;
  32. }
  33.  
g++ -o omp_fib_1.exe omp_fib_1.cpp -O2

# exécution séquentielle sans OpenMP
time ./omp_fib_1.exe
res=1134903170

real	0m4.148s
user	0m4.135s
sys	0m0.010s

g++ -o omp_fib_1.exe omp_fib_1.cpp -O2 -fopenmp

# exécution parallèle avec OpenMP avec des milliers de tâches
time ./omp_fib_1.exe
???? plus de 56 minutes sans résultat


g++ -o omp_fib_2.exe omp_fib_2.cpp -O2 -fopenmp

# exécution parallèle avec OpenMP version améliorée
time ./omp_fib_2.exe
res=1134903170

real	0m3.336s
user	0m7.085s
sys	0m0.010s

Avec la première version (omp_fib_1.cpp) trop de tâches sont créées, par exemple pour fib(30), on a 2.692.537 tâches.

4.8. La vectorisation

La vectorisation a été introduite avec OpenMP 4.0. On pourra consulter à ce sujet

  1. #include <iostream>
  2. #include <cstdlib>
  3. #include <cmath>
  4. #include <xmmintrin.h>
  5. using namespace std;
  6.  
  7. typedef float real;
  8.  
  9. const int TEN_MILLION = 10000000;
  10.  
  11. void process(real *x, real *y, real *z, int size) {
  12.     #pragma omp parallel for simd schedule(static,10)
  13.     for (int i=0; i<size; ++i) {
  14.         z[i] = (sin(x[i]) - cos(y[i])) / (sqrt(x[i] * x[i] + y[i] * y[i]));
  15.     }
  16. }
  17.  
  18. int main() {
  19.     real *a, *b, *c;
  20.    
  21.     a = new real [TEN_MILLION];
  22.     b = new real [TEN_MILLION];
  23.     c = new real [TEN_MILLION];
  24.    
  25.     for (int i=0; i<TEN_MILLION; ++i) {
  26.         a[i] = i % 1000;
  27.     }
  28.     for (int i=0; i<TEN_MILLION; ++i) {
  29.         b[i] = i % 133;
  30.     }
  31.    
  32.     process(a,b,c,TEN_MILLION);
  33.    
  34.     _mm_free(a);
  35.     _mm_free(b);
  36.     _mm_free(c);
  37.    
  38.     return EXIT_SUCCESS;
  39. }
  40.  
  41.  
g++ -o omp_parallel_simd.exe omp_parallel_simd.cpp -O2
time ./omp_parallel_simd.exe 

real	0m1.541s
user	0m1.524s
sys	0m0.016s

g++ -o omp_parallel_simd.exe omp_parallel_simd.cpp -O2 -fopenmp
time ./omp_parallel_simd.exe 

real	0m0.482s
user	0m1.708s
sys	0m0.020s


Autre exemple:

  1. #include <iostream>
  2. #include <cstdlib>
  3. #include <cmath>
  4. #include <xmmintrin.h>
  5. using namespace std;
  6. #include <getopt.h>
  7.  
  8. struct option long_options[] {
  9.     {"method", required_argument, 0, 'm'},
  10.     {"size", required_argument, 0, 's'},
  11.     {"iterations", required_argument, 0, 'i'},
  12.     {0,0,0,0}
  13. };
  14.  
  15. int method = 1;
  16. int vsize = 10*1024*1024;
  17. int iterations = 1;
  18.    
  19.    
  20.    
  21. typedef float real;
  22.  
  23. real prod1(real * x, real * y, real * z, int size) {
  24.     real total = 0.0;
  25.     for (int i=0; i<size; ++i) {
  26.         z[i] = (z[i] + x[i]) / y[i];
  27.         total += z[i];
  28. //      cout << "z[" << i << "]=" << z[i] << " " << total << endl;
  29.     }
  30.    
  31.     return total;
  32. }
  33.  
  34.  
  35. real prod2(real * x, real * y, real * z, int size) {
  36.     real total = 0.0;
  37.  
  38.     #pragma omp parallel for reduction(+:total)
  39.     for (int i=0; i<size; ++i) {
  40.         z[i] = (z[i] + x[i]) / y[i];
  41.         total += z[i];
  42.     }
  43.     return total;
  44. }
  45.  
  46. real prod3(real * x, real * y, real * z, int size) {
  47.     real total = 0.0;
  48.  
  49.     #pragma omp simd reduction(+:total)
  50.     for (int i=0; i<size; ++i) {
  51.         z[i] = (z[i] + x[i]) / y[i];
  52.         total += z[i];
  53.     }
  54.     return total;
  55. }
  56.  
  57. #pragma omp declare simd aligned(x,y,z:16) simdlen(4)
  58. real prod4(real * x, real * y, real * z, int size) {
  59.     real total = 0.0;
  60.  
  61.     #pragma omp parallel for simd reduction(+:total)
  62.     for (int i=0; i<size; ++i) {
  63.         z[i] = (z[i] + x[i]) / y[i];
  64.         total += z[i];
  65.     }
  66.    
  67.     return total;
  68. }
  69.  
  70. typedef real (*ProductType)(real *x, real *y, real *z, int size);
  71.  
  72. extern "C" {
  73. real prod_asm(real * x, real * y, real * z, int size);
  74. real prod_asm_unroll2(real * x, real * y, real * z, int size);
  75. real prod_asm_unroll4(real * x, real * y, real * z, int size);
  76. }
  77.  
  78. ProductType products[] = {
  79.     nullptr,
  80.     prod1,
  81.     prod2,
  82.     prod3,
  83.     prod4,
  84.     prod_asm,
  85.     prod_asm_unroll2,
  86.     prod_asm_unroll4
  87. };
  88.  
  89. int main(int argc, char *argv[]) {
  90.     int cc, index;
  91.    
  92.     while ((cc = getopt_long(argc, argv, "m:s:i:", long_options, &index)) != -1) {
  93.         switch (cc) {
  94.             case 'm':
  95.                 method = atoi(optarg);
  96.                 break;
  97.             case 's':
  98.                 vsize = atoi(optarg);
  99.                 break;
  100.             case 'i':
  101.                 iterations = atoi(optarg);
  102.                 break; 
  103.             default: /* '?' */
  104.                 exit(EXIT_FAILURE);
  105.         }
  106.     }
  107.     real *a, *b, *c;
  108.    
  109.  
  110.     a = (real *) _mm_malloc(vsize * sizeof(real), 16);
  111.     b = (real *) _mm_malloc(vsize * sizeof(real), 16);
  112.     c = (real *) _mm_malloc(vsize * sizeof(real), 16);
  113.    
  114.     for (int i=0; i<vsize; ++i) {
  115.         //a[i] = 1 + (i % 10);
  116.         a[i] = 0.2;
  117.     }
  118.     for (int i=0; i<vsize; ++i) {
  119.         //b[i] = 1 + (i % 133);
  120.         b[i] = 5;
  121.     }
  122.     for (int i=0; i<vsize; ++i) {
  123.         c[i] = 0;
  124.     }
  125.    
  126.     real result = 0;
  127.  
  128.     cout << "method=" << method << endl;   
  129.     for (int k=0; k<iterations; ++k) { 
  130.         cout << ".";
  131.         result = (*products[method])(a,b,c,vsize);
  132.     }
  133.     cout << endl;
  134.     cout << "result=" << std::fixed << result << endl;
  135.    
  136.     _mm_free(a);
  137.     _mm_free(b);
  138.     _mm_free(c);
  139.    
  140.     return EXIT_SUCCESS;
  141. }
  142.  
  143.  

On dispose de 7 implantations de la méthode de traitement, à savoir :

Intel(R) Core(TM) i5-7400 CPU @ 3.00GHz
Ubuntu 16.04.2 LTS (xenial)
g++ version 4.9.4
1000 itérations

float sse4.2
method ; result ; time
1 574328.500000 49.25
2 510569.562500 16.77
3 510569.562500 12.53
4 527646.750000 6.33
5 510569.562500 12.25
6 510569.562500 12.40
7 510569.562500 12.44

float avx2
method ; result ; time
1 574328.500000 13.23
2 510569.562500 6.32
3 529619.062500 6.43
4 522841.937500 6.04
5 510569.562500 12.31
6 510569.562500 12.35
7 510569.562500 12.33


double sse4.2
method ; result ; time
1 524287.999914 57.43
2 524288.000022 15.37
3 524287.999950 29.13
4 524288.000013 11.27
5 524287.999950 28.81
6 524287.999950 29.11
7 524287.999950 29.53

double avx2
method ; result ; time
1 524287.999914 16.38
2 524288.000022 12.08
3 524288.000022 13.79
4 524287.999995 12.14
5 524287.999950 29.39
6 524287.999950 29.46
7 524287.999950 29.12

On remarquera des résultats avec des erreurs d'arrondi et notamment pour la méthode 1 en float.

En outre le fait de compiler avec -msse4.2 ou -mavx2 gènère des temps de calcul très différents à l'exécution.

4.8.1. directives gérant la vectorisation

Vectorisation et réduction :

#pragma omp simd for reduction(+:sum)

4.9. Exercices

Exercice 4.1

Evaluer $π$ par la méthode de MonteCarlo qui consiste en une estimation statistique.

On considère le carré de côté 2 unités et le cercle de rayon 1 unité centré dans le carré. On génère des points dans le carré, seuls ceux dans le cercle seront utilisés pour évalué $π$ :

$$ \π = 4 × \text"points dans le cercle" / \text"points dans le carré" $$

Utiliser le code qui suit et écrire un programme mono thread. Mesurez le temps d'exécution si on estime $π$ avec $2 × 10^9$ points dans le carré.

  1.  
  2. #include <random>
  3. #include <cstdint>
  4. using namespace std;
  5.  
  6. typedef uint64_t u64;
  7.  
  8. // Fonction pour générer des points aléatoires dans le carré
  9. // et compter ceux qui sont dans le cercle
  10. u64 monte_carlo(u64 max_points_inside_square)
  11. {
  12.     std::random_device rd;
  13.     std::mt19937 gen(rd());
  14.     std::uniform_real_distribution<> dis(-1.0, 1.0);
  15.  
  16.     u64 points_inside_circle = 0;
  17.  
  18.     for (u64 i = 0; i < max_points_inside_square; ++i)
  19.     {
  20.         // génère un point dans le carré
  21.         double x = dis(gen);
  22.         double y = dis(gen);
  23.  
  24.         // est-il dans le cercle ?
  25.         if (x * x + y * y <= 1.0)
  26.         {
  27.             ++points_inside_circle;
  28.         }
  29.     }
  30.  
  31.     return points_inside_circle;
  32. }

Exercice 4.2

Ecrire une version OpenMP afin d'évaluer $π$ en parallèle.

Mesurer le temps d'exécution avec 2, 4, 8 puis 16 threads.