3. C++ et le monde extérieur
sommaire chapitre 3
3.2. Programmation parallèle
3.2.1. Introduction
Depuis 2005 avec l'arrivée des Pentium D (Smithfield = 2 Pentium 4 Prescott), les processeurs d'architecture x86
disposent aujourd'hui pour la plupart de plusieurs coeurs de calcul. En réalisant l'exécution du code
sur plusieurs processeurs ou plusieurs coeurs on diminue généralement le temps de calcul, il faut pour cela
paralléliser nos algorithmes.
On parle de programmation parallèle ou concurrentielle.
La programmation parallèle et les architectures parallèles remontent cependant aux années 50, mais son utilisation
tend à décourager le néophyte bien qu'on puisse parfois en une seule ligne de code (telles les #pragma d'OpenMP) introduire du parallèlisme dans un programme.
On peut prendre par analogie la caisse de supermarché :
Les solutions existantes reposent principalement sur l'utilisation de threads si on utilise un processeur multi-coeurs.
Un thread ou fil (d'exécution) ou tâche est similaire à un processus car tous deux représentent l'exécution d'un ensemble d'instructions du langage machine d'un processeur. Du point de vue de l'utilisateur, ces exécutions semblent se dérouler en parallèle. Toutefois, là où chaque processus possède sa propre mémoire virtuelle, les threads d'un même processus se partagent sa mémoire virtuelle.
Par contre, tous les threads possèdent leur propre pile d’appel. (Wikipedia)
- bas niveau :
- pThread (POSIX threads) (cf tutoriel LLNL)
- les threads (C++11) : classe reposant sur pThread
- MPI (Message Passing Interface) : communication entre threads mais plus adaptée à la communication entre machines
- calcul sur carte graphique : technologie CUDA de
NVidia
- calcul sur super-processeur Intel Xeon Phi
- haut niveau :
- Intel Threading Building Blocks : bibliothèque C++ qui utilise les templates
- OpenMP : directives de compilation
Pour information, fin 2014, Intel lance le Xeon E5-2699v3 doté de 18 coeurs (36 threads) avec un cache L3 de 45 Mo, TDP 145 W, $4115.
3.2.2. Les différents cas de parallèlisme
calculs indépendants
Dans l'exemple qui suit, chaque calcul est indépendant des autres, on peut donc réaliser
l'ensemble des affectations et calculs (corps de la boucle) en parallèle.
for (int i=0; i<N; ++i) {
a[i] = f(i);
}
synchronisation
La synchronisation de threads est un mécanisme qui vise à bloquer l'exécution des différents threads
à des points précis du programme de manière à ce que tous les threads passent les étapes bloquantes au
moment prévu par le programmeur.
La synchronisation dans un environnement multi-threads vise notamment à assurer la cohérence des données.
coopération
La coopération entre threads est l'une des choses les plus difficile à réaliser. Elle nécessite d'utiliser
la synchronisation ou la communication entre threads.
3.2.3. Les problèmes liés au parallèlisme
L'exclusion mutuelle
L'exclusion mutuelle correspond au cas où un seul thread doit exécuter une
série d'instructions alors que les autres threads doivent attendre leur tour
pour exécuter la même série d'instructions.
C'est généralement le cas lors des affectations.
Dans l'exemple suivant chaque thread augmente la contenu d'une variable de 1.
- si l'exclusion mutuelle est utilisée alors avec 2 threads, la variable contenant
initialement la valeur 1 passe à la valeur 3
- si l'exclusion mutuelle n'est pas utilisée, on peut rencontrer des problèmes de
cohérence
Quand ne pas utiliser le parallèlisme
On peut considérer qu'il n'est pas nécessaire d'utiliser le parallèlisme dans les cas suivants :
- lorsque le nombre d'itérations d'une boucle est petit
- lorsque le que le temps de traitement du corps de la boucle est faible : on peut utiliser la vectorisation qui sera plus rapide
Dans l'exemple suivant, on vérifie que :
- l'affectation (test1, 11.45s) avec vectorisation (test 1 avec -ftree-vectorize, 4.39s) est plus rapide qu'avec utilisation des threads (test 2 -fopenmp, 6.11s)
- un calcul complexe dans le corps de la boucle est moins rapide avec vectorisation (test 3 -ftree-vectorize, 302.66s) qu'avec les threads (test 4 -fopenmp, 66.60s)
#include <iostream>
using namespace std;
#include <cmath>
#include <cstdlib>
float function(float x) {
return sin(x*x/(1+x));
}
int test_no = 1;
// ----------------------------------------------
// corps de boucle avec traitement rapide
// ici une affectation
// l'exécution est séquentielle
// ----------------------------------------------
void test1() {
const int size = 10000000;
float *tab = new float [ size ];
for (int i=0; i<size; ++i) {
tab[i] = i;
}
delete [] tab;
}
// ----------------------------------------------
// corps de boucle avec traitement rapide
// ici une affectation
// l'exécution est effectuée en parallèle
// ----------------------------------------------
void test2() {
const int size = 10000000;
float *tab = new float [ size ];
#pragma omp parallel for num_threads(4)
for (int i=0; i<size; ++i) {
tab[i] = i;
}
delete [] tab;
}
// ----------------------------------------------
// corps de boucle avec traitement long
// ici calcul d'une fonction
// l'exécution est séquentielle
// ----------------------------------------------
void test3() {
const int size = 10000000;
float *tab = new float [ size ];
for (int i=0; i<size; ++i) {
tab[i] = function(i);
}
delete [] tab;
}
// ----------------------------------------------
// corps de boucle avec traitement long
// ici calcul d'une fonction
// l'exécution est effectuée en parallèle
// ----------------------------------------------
void test4() {
const int size = 10000000;
float *tab = new float [ size ];
#pragma omp parallel for num_threads(4)
for (int i=0; i<size; ++i) {
tab[i] = function(i);
}
delete [] tab;
}
// ----------------------------------------------
// main function
// ----------------------------------------------
int main(int argc , char *argv[]) {
if (argc > 1) {
test_no = atoi(argv[1]);
}
clock_t start = clock();
int loop = 1000;
while (--loop) {
switch(test_no) {
case 1: test1(); break;
case 2: test2(); break;
case 3: test3(); break;
case 4: test4(); break;
};
}
clock_t stop = clock();
float sec = static_cast<float>(stop - start) / 1000000;
cerr << "elapsed = " << sec << endl;
return 0;
}
test 1 |
11.45 |
-O2 |
court en série |
test 1 |
4.39 |
-O2 -ftree-vectorize |
court en série |
test 2 |
6.11 |
-O2 -fopenmp |
court en parallèle |
test 3 |
302.66 |
-O2 -ftree-vectorize |
long en série |
test 4 |
66.02 |
-O2 -ftree-vectorize -fopenmp |
long en parallèle |
test 4 |
66.60 |
-O2 -fopenmp |
long en parallèle |
temps d'exécution des tests sur Core i5 4570
3.3. OpenMP
OpenMP (Open Multi-Processing) est une interface de programmation pour le calcul parallèle
pour les langages de programmation C, C++ et Fortran.
Sur le principe OpenMP repose sur un partage des variables associées au threads.
L'intérêt d'OpenMP est qu'il permet à moindre coût de parallèliser le code en ajoutant
des directives de programmation au code existant notamment sur les boucles.
Le compilateur gcc supporte OpenMP depuis la version 4.2.
OpenMP.org
"We have designed modern OpenMP to be the best parallel language for the three general purpose languages C, C++ and Fortran", said Michael Wong, OpenMP CEO. "It is a language that enables you to access all capabilities of your machine without dropping to another language. This Challenge will convince you of that or show us where we need to improve. Either way, the industry benefits." (17 Nov. 2014)
Voici un premier exemple pour comprendre : on dispose d'une boucle for à l'intérieur de laquelle on
affiche l'indice de la boucle. On veut montrer comment paralléliser la boucle avec différentes technologies.
3.3.0.a Code séquentiel : non parallélisé
#include <iostream>
using namespace std;
int main() {
for (int i=0; i<10; ++i) {
cerr << "i = " << i << endl;
}
return 0;
}
Le code est exécuté sur un seul coeur, on obtient donc le résultat séquentiel suivant :
i = 0
i = 1
i = 2
i = 3
i = 4
i = 5
i = 6
i = 7
i = 8
i = 9
3.3.0.b Code parallélisé mais affichage désordonné
On modifie le programme notamment en ajoutant la directive
#pragma omp parallel for qui indique que le contenu de la
boucle for doit être exécuté en parallèle. Le code est
exécuté par 4 threads différents (0 à 3) car on utilise un processeur Quad Core.
On compile avec l'option -fopenmp
#include <iostream>
using namespace std;
#include <omp.h>
int main() {
#pragma omp parallel for
for (int i=0; i<10; ++i) {
cerr << "i = " << i << ", thread " << omp_get_thread_num() << endl;
}
return 0;
}
Cependant l'affichage est perturbé car les quatre threads partagent le
même flux de sortie.
i = 0, thread 0i = 6, thread 2
i = 7, thread 2
i =
i = 1, thread 0
i = 2, thread 0
i = 8, thread 3
i = 9, thread 3
3, thread 1
i = 4, thread 1
i = 5, thread 1
3.3.0.c Code parallélisé et affichage ordonné
On modifie le code en ajoutant la directive #pragma omp critical
au niveau de la partie de code dédiée à l'affichage qui doit être exécutée sans être interrompue
par d'autres threads.
#include <iostream>
using namespace std;
#include <omp.h>
int main() {
#pragma omp parallel for
for (int i=0; i<10; ++i) {
#pragma omp critical
{
cerr << "i = " << i << ", thread " << omp_get_thread_num() << endl;
}
}
return 0;
}
i = 0, thread 0
i = 1, thread 0
i = 8, thread 3
i = 9, thread 3
i = 2, thread 0
i = 3, thread 1
i = 4, thread 1
i = 5, thread 1
i = 6, thread 2
i = 7, thread 2
On voit bien à présent la répartition des tâches :
- thread 0 : i= 0, 1, 2
- thread 1 : i= 3, 4, 5
- thread 2 : i= 6, 7
- thread 3 : i= 8, 9
On remarque également que le traitement des threads n'est pas séquentiel : thread 3 exécuté avant thread 1 par exemple.
3.3.0.d Version thread C++11
On utilise la classe thread du C++11. La gestion des threads est laissée à l'utilisateur, il est nécessaire :
- de créer les threads
- de créer le sous-programme qui exécute le code en parallèle
Compiler avec -std=c++11 -pthread
#include <iostream>
using namespace std;
#include <thread>
// thread to execute
void code(int i) {
cerr << "i = " << i << endl;
}
// main
int main() {
// array of threads
thread *t[10];
// create threads
for (int i=0; i<10; ++i) {
t[i] = new thread(code,i);
}
return 0;
}
3.3.0.e Version Pthread (POSIX)
La gestion des threads est laissée à l'utilisateur : il faut créer les threads ainsi qu'une structure de données qui contient
les données liées au thread.
Compiler avec -lpthread
#include <iostream>
using namespace std;
#include <pthread.h>
// data structure used to pass argument to thread
typedef struct {
int value;
} thread_data;
// thread to execute
void *code(void *arg) {
// get data
thread_data *data = (thread_data *) arg;
cerr << "i = " << data->value << endl;
}
int main() {
// array of threads
pthread_t t[10];
// array of data for threads
thread_data td[10];
// create threads
for (int i=0; i<10; ++i) {
td[i].value = i;
pthread_create(&t[i], NULL, code, (void *)&td[i]);
}
return 0;
}
3.3.1. Contrôle du nombre de threads exécutant le code
On peut controler le nombre de threads qui exécutent la partie parallèle du programme :
- soit en spécifiant une variable d'environnement dans le terminal avant de lancer le programme : export OMP_NUM_THREADS=4
- soit en appelant une fonction qui spécifie le nombre de threads : omp_set_num_threads(4);
- soit au niveau de la directive #pragma omp parallel num_threads(4)
double tab[100];
#pragma omp parallel num_threads(4)
{
ind thread_id = omp_get_thread_num();
process(thread_id, tab);
}
3.3.2. Synchronization des threads
La synchronisation est réalisée :
- soit de manière critique (exclusion mutuelle) : #pragma omp critical, un thread à la fois exécute la zone critique
#pragma omp critical
{
do_something(...);
}
- soit de manière atomique : #pragma omp atomic, un thread à la fois met à jour un emplacement mémoire
#pragma omp atomic
x = x + y;
- soit en attendant que l'ensemble des threads aient terminé leur travail #pragma omp barrier avant de continuer l'exécution du code à venir
- soit en utilisant le mécanisme des verrous (lock) qui ne sera pas vu ici
omp_lock_t lock;
omp_init_lock(&lock);
#pragma omp parallel private (tmp, thread_id)
{
thread_id = omp_get_thread_num();
int tmp = f(thread_id);
omp_set_lock(&lock);
printf("%d %d", thread_id, tmp);
omp_unset_lock(&lock);
}
omp_destroy_lock(&lock);
3.3.3. Combinaison de parallélisme et partage des tâches
Les codes suivants sont équivalents :
double tab[MAX];
int i;
#pragma omp parallel
{
#pragma omp for
for (i = 0; i<MAX; i++) {
tab[i] = f(i);
}
}
double tab[MAX];
int i;
#pragma omp parallel for
for (i = 0; i<MAX; i++) {
tab[i] = f(i);
}
3.3.4. Réduction
La réduction correspond au fait qu'une variable est critique au coeur de la boucle : cela concerne
le calcul de la somme des éléments d'un tableau, la recherche du minimum ou du maximum, ...
double tab[MAX];
double sum = 0.0;
int i;
for (i = 0; i<MAX; i++) {
sum += tab[i];
}
Dans ce cas on utilise la directive reduction(operation : list-of-variables), où
- operation est l'opération à réaliser (par exemple +,*,-,&,|,&&,||)
- list-of-variables est la liste des variables partagées
double tab[MAX];
double sum = 0.0;
int i;
#pragma omp parallel for reduction (+:sum)
for (i = 0; i<MAX; i++) {
sum += tab[i];
}
3.4. MPI
MPI (The Message Passing Interface),
conçue en 1993-94, est une norme définissant une bibliothèque de fonctions,
utilisable avec les langages C, C++ et Fortran. Elle permet d'exploiter des ordinateurs distants ou multiprocesseur
par passage de messages (Wikipedia).
Le terme passage de message signifie que l'on échange des données, notamment on transmet aux autres
programmes le contenu de certaines variables.
Lors de l'exécution le même programme est envoyé sur différentes machines/processeurs (éventuellement sur la même machine si elle dispose d'un processeur avec plusieurs coeurs) qui échangeront le contenu de variables via le réseau.
L'ensemble des fonctions peut être trouvé à cette adresse MPI Doc
3.4.1. installation
sudo apt-get install libopenmpi-dev openmpi-bin openmpi-doc
3.4.2. compilation
Voici deux exemples simples qui affichent un message avec un temps de latence (instruction sleep) pour chaque thread / processeur. Le premier utilise des fonctions C, le
second utilise des espaces de noms et des objets
- tout programme commence par l'appel de la fonction MPI_Init
- suivi de l'appel aux fonctions
- MPI_Comm_rank qui permet de récupérer l'identifiant du programme
- MPI_Comm_size qui permet de récupérer le nombre de programmes qui s'exécutent en parallèle
- tout programme se termine par l'appel à la fonction MPI_Finalize
#include <iostream>
#include <cstdlib>
#include <unistd.h> // for sleep
using namespace std;
#include <mpi.h>
// ==============================================================
// version C
// ==============================================================
int main(int argc, char ** argv) {
int proc_id, n_procs;
char name[80];
int length;
// initialization
MPI_Init(&argc, &argv);
// get id of this processor/core
MPI_Comm_rank(MPI_COMM_WORLD,&proc_id);
// get number of processors/cores that are used
MPI_Comm_size(MPI_COMM_WORLD,&n_procs);
// get name of processor
MPI_Get_processor_name(name, &length);
sleep(proc_id);
cerr << "running on " << name << " with id = " << proc_id << " / " << n_procs;
cerr << " on " << name << endl;
MPI_Finalize();
exit(EXIT_SUCCESS);
}
#include <iostream>
#include <cstdlib>
#include <unistd.h> // for sleep
using namespace std;
#include <mpi.h>
// ==============================================================
// version C++
// ==============================================================
int main(int argc, char ** argv) {
int proc_id, n_procs;
char name[MPI_MAX_PROCESSOR_NAME];
int length;
MPI::Init(argc, argv);
n_procs = MPI::COMM_WORLD.Get_size();
proc_id = MPI::COMM_WORLD.Get_rank();
memset(name, 0, MPI_MAX_PROCESSOR_NAME);
MPI::Get_processor_name(name,length);
sleep(proc_id);
cerr << "running on " << name << " with id = " << proc_id << " / " << n_procs;
cerr << " on " << name << endl;
MPI::Finalize();
exit(EXIT_SUCCESS);
}
mpic++ -o c3_mpi_ex_1.exe c3_mpi_ex_1.cpp
3.4.3. exécution
Pour exécuter un programme compilé avec MPI il faut utiliser mpirun en spécifiant le nombre de processeurs grâce à l'option -n ou -np suivant les systèmes :
mpirun -n 4 ./c3_mpi_ex_1.exe
Voici le résultat à l'affichage si on lance sur une seule machine Intel Core i3-2375M CPU @ 1.50GHz (Dual Core + HyperThreading, soit 4 threads) :
running on inspiron with id = 3 / 4running on inspiron with id = 0 / 4
running on running on inspiron with id = 2 / 4
Sur un cluster on utilisera par exemple (-pe = parallel environment):
qsub -pe mpi 16 test_mpi.sh
Pour obtenir une section critique pour l'affichage on utilise la fonction MPI_Barrier qui bloque l'appelant kusqu'à ce que tous les autres programmes aient appelé cette fonction :
#include <iostream>
#include <cstdlib>
using namespace std;
#include <mpi.h>
int main(int argc, char ** argv) {
int proc_id, n_procs;
char name[80];
int length;
MPI_Init(&argc, &argv);
// get id of this processor/core
MPI_Comm_rank(MPI_COMM_WORLD,&proc_id);
// get number of processors/cores that are used
MPI_Comm_size(MPI_COMM_WORLD,&n_procs);
MPI_Get_processor_name(name, &length);
// display with critical section
int id = 0;
while (id < n_procs) {
if (id == proc_id) {
cerr << "running on " << name << " with id = " << proc_id << " / " << n_procs << endl;
}
++id;
MPI_Barrier(MPI_COMM_WORLD);
}
MPI_Finalize();
exit(EXIT_SUCCESS);
}
running on inspiron with id = 0 / 4
running on inspiron with id = 1 / 4
running on inspiron with id = 2 / 4
running on inspiron with id = 3 / 4
3.4.4. échange de donneés Send, Recv
Pour transmettre des données entre les processeurs on utilise deux fonctions MPI_Send et MPI_Recv :
int MPI_Send(
void* data,
int count,
MPI_Datatype datatype,
int destination,
int tag,
MPI_Comm communicator)
int MPI_Recv(
void* data,
int count,
MPI_Datatype datatype,
int source,
int tag,
MPI_Comm communicator,
MPI_Status* status)
- data est un pointeur sur la donnée à envoyer
- count est le nombre d'occurrences
- pour le datatype = MPI_INT, MPI_BYTE, MPI_FLOAT, MPI_DOUBLE, MPI_UNSIGNED, ...)
- identifiant du processor auquel on envoie les données
- tag correspond au type de message (ex: MPI_ANY_TAG), en général entier non négatif
- source est l'identifiant du processeur qui envoie le message (ex: MPI_ANY_SOURCE)
- communicator est le canal de communication utilisé (ex: MPI_COMM_WORLD)
3.4.5. Réduction
# include <cstdlib>
# include <iostream>
# include <iomanip>
# include <ctime>
using namespace std;
# include "mpi.h"
int main ( int argc, char *argv[] );
void timestamp ( );
//****************************************************************************80
int main ( int argc, char *argv[] )
//****************************************************************************80
//
// Purpose:
//
// MAIN is the main program for SUM.
//
// Discussion:
//
// SUM is an example of using the MPI message passing interface library.
//
// Licensing:
//
// This code is distributed under the GNU LGPL license.
//
// Modified:
//
// 01 May 2003
//
// Author:
//
// John Burkardt
//
// Reference:
//
// Forrest Hoffman,
// Message Passing with MPI and PVM,
// LINUX Magazine,
// Volume 4, Number 4, April 2002, pages 38-41, 63.
//
// William Gropp, Ewing Lusk, Anthony Skjellum,
// Using MPI: Portable Parallel Programming with the
// Message-Passing Interface,
// Second Edition,
// MIT Press, 1999,
// ISBN: 0262571323.
//
{
# define N 100
double array[N];
int i;
int id;
int p;
double PI = 3.141592653589793238462643;
double seed;
MPI::Status status;
double sum;
double sum_all;
int tag;
//
// Initialize MPI.
//
MPI::Init ( argc, argv );
//
// Get the number of processes.
//
p = MPI::COMM_WORLD.Get_size ( );
//
// Determine the rank of this process.
//
id = MPI::COMM_WORLD.Get_rank ( );
//
// Say hello.
//
if ( id == 0 )
{
timestamp ( );
cout << "\n";
cout << "SUM - Master process:\n";
cout << " C++ version\n";
cout << "\n";
cout << " An MPI example program.\n";
cout << " The master process computes some coefficients,\n";
cout << " sends them to each worker process, which sums them.\n";
cout << "\n";
cout << " Compiled on " << __DATE__ << " at " << __TIME__ << ".\n";
cout << "\n";
cout << " The number of processes available is " << p << "\n";
}
//
// The master process initializes the array.
//
if ( id == 0 )
{
seed = 1.2345;
for ( i = 0; i < N; i++ )
{
array[i] = ( double ) i * seed * PI;
}
}
//
// The master process broadcasts the computed initial values
// to all the other processes.
//
MPI::COMM_WORLD.Bcast ( array, N, MPI::DOUBLE, 0 );
//
// Each process adds up its entries.
//
sum = 0.0;
for ( i = 0; i < N; i++ )
{
sum = sum + array[i] * ( double ) id;
}
cout << "\n";
cout << "SUM - Process " << id << ":\n";
cout << " My contribution to the sum is " << sum << "\n";
//
// Each worker process sends its sum back to the master process.
//
if ( id != 0 )
{
MPI::COMM_WORLD.Send ( &sum, 1, MPI::DOUBLE, 0, 1 );
}
else
{
sum_all = sum;
for ( i = 1; i < p; i++ )
{
tag = 1;
MPI::COMM_WORLD.Recv ( &sum, 1, MPI::DOUBLE, MPI::ANY_SOURCE, tag,
status );
sum_all = sum_all + sum;
}
}
if ( id == 0 )
{
cout << "\n";
cout << "SUM - Master process:\n";
cout << " The total sum is " << sum_all << "\n";
}
//
// Terminate MPI.
//
MPI::Finalize ( );
//
// Terminate.
//
if ( id == 0 )
{
cout << "\n";
cout << "SUM - Master process:\n";
cout << " Normal end of execution.\n";
cout << "\n";
timestamp ( );
}
return 0;
# undef N
}
//****************************************************************************80
void timestamp ( )
//****************************************************************************80
//
// Purpose:
//
// TIMESTAMP prints the current YMDHMS date as a time stamp.
//
// Example:
//
// 31 May 2001 09:45:54 AM
//
// Licensing:
//
// This code is distributed under the GNU LGPL license.
//
// Modified:
//
// 08 July 2009
//
// Author:
//
// John Burkardt
//
// Parameters:
//
// None
//
{
# define TIME_SIZE 40
static char time_buffer[TIME_SIZE];
const struct std::tm *tm_ptr;
size_t len;
std::time_t now;
now = std::time ( NULL );
tm_ptr = std::localtime ( &now );
len = std::strftime ( time_buffer, TIME_SIZE, "%d %B %Y %I:%M:%S %p", tm_ptr );
std::cout << time_buffer << "\n";
return;
# undef TIME_SIZE
}
3.5. Intel TBB (Threading Building Block)
Intel Threading Building Block est un framework C++ qui aide à la parallèlisation
du code.
Voir le tutoriel
3.5.1. Hello world ! avec TBB
#include "tbb/tbb.h"
#include <iostream>
using namespace tbb;
using namespace std;
class first_task : public task {
public:
task* execute( ) {
cout << "Hello World!\n";
return NULL;
}
};
int main( )
{
task_scheduler_init init(task_scheduler_init::automatic);
first_task& f1 = *new(tbb::task::allocate_root()) first_task( );
tbb::task::spawn_root_and_wait(f1);
return 0;
}
3.5.1.a Application d'une fonction sur les éléments d'un tableau : parallel_for
#include "tbb/tbb.h"
#include <iostream>
using namespace tbb;
using namespace std;
// ----------------------------------------------
// task that multiplies by 2 the value of A[i]:
// A[i] = 2*A[i] for all i
// ----------------------------------------------
class TaskMultiplyBy2 {
float *array;
public:
// function to apply
void operator()(const blocked_range<size_t>& r ) const {
float *a = array;
for( size_t i=r.begin(); i!=r.end(); ++i ) {
a[i] *= 2;
cout << "** " << i << endl;
}
}
// constructor
TaskMultiplyBy2(float *a) : array(a) {
}
};
// ----------------------------------------------
// main function
// ----------------------------------------------
int main( ) {
// create array of 10 integers
const int N = 10;
float *A = new float[ N ];
for (size_t i=0; i<N; ++i) A[i] = i;
// perform computation in parallel
parallel_for(blocked_range<size_t>(0,N), TaskMultiplyBy2(A));
// print result
for (size_t i=0; i<N; ++i) {
cout << A[i] << " ";
}
return 0;
}
* ** 50
** 1
** 2
** 3
** 6
** 8
** 9
** 4
** 7
0 2 4 6 8 10 12 14 16 18
3.5.2. Somme des éléments d'un tableau : parallel_reduce
#include "tbb/tbb.h"
#include <iostream>
using namespace tbb;
using namespace std;
#include <time.h>
#include <math.h>
// ----------------------------------------------
// Time
// ----------------------------------------------
double ts_start, ts_stop;
void chrono_start() {
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts_start = ts.tv_sec + 1e-9*ts.tv_nsec;
}
void chrono_stop() {
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts_stop = ts.tv_sec + 1e-9*ts.tv_nsec;
}
double chrono_get_seconds() {
return (ts_stop - ts_start);
}
float function(float x) {
return sin(x*x/(1+x));
}
// ----------------------------------------------
// Task
// ----------------------------------------------
class TaskSum {
protected:
float *array;
float sum;
public:
// initial constructor with array
TaskSum(float *a) : array(a), sum(0.0) {
}
// constructor for subtask, use split and assign 0 to sum
// and distinguish from copy constructor
TaskSum(const TaskSum& t, split) : array(t.array), sum(0) {
}
// function to apply
void operator()(const blocked_range<size_t>& r) {
float *a = array;
float local_sum = sum; // important see page 23 of tbb_toturial.pdf
for(size_t i=r.begin(); i!=r.end(); ++i) {
local_sum += function(a[i]);
}
sum = local_sum;
}
// join threads at the end
void join(const TaskSum& t) {
sum += t.sum;
}
// return result
float get_sum() {
return sum;
}
};
// ----------------------------------------------
// main function
// ----------------------------------------------
int main(int argc, char *argv[]) {
const int TEN_MILLION = 10000000;
int N = TEN_MILLION;
if (argc > 2) {
N = atoi(argv[2]);
}
// create and fill array
float *A = new float[ N ];
for (size_t i=0; i<N; ++i) A[i] = 1.0;
// perform treatment
if (atoi(argv[1]) == 1) {
// without TBB
float sum = 0.0;
chrono_start();
for (size_t i=0; i<N; ++i) {
sum += function(A[i]);
}
chrono_stop();
cerr << "without TBB:" << endl;
cerr << "N = " << N << endl;
cerr << "elapsed = " << chrono_get_seconds() << endl;
cerr << "sum = " << sum << endl;
} else {
// with TBB
chrono_start();
TaskSum task(A);
parallel_reduce(blocked_range<size_t>(0,N), task);
chrono_stop();
cerr << "with TBB: " << endl;
cerr << "N = " << N << endl;
cout << "elapsed=" << chrono_get_seconds() << endl;
cout << "sum=" << task.get_sum() << endl;
}
return 0;
}
> ./tbb_example_3.exe 1
without TBB:
N = 10000000
elapsed = 0.0130236
sum = 4.77843e+06
> ./tbb_example_3.exe 2
with TBB:
N = 10000000
elapsed=0.00438952
sum=4.79802e+06
Sur le graphique suivant on peut voir que le parallèlisme devient intéressant
à mesure que la taille des données augmente.
temps d'exécution (s) en fonction de la taille des données
3.5.3. Un cas concret avec la phylogénie
La SBL (Standard Bioinformatics Library) est un package logiciel écrit par Jean-Michel Richer et qui offre un ensemble de classes C++ pour la gestion des séquences et la reconstruction phylogénétique avec Maximum de Parcimonie (Maximum Parsimony).
On utilise pour résoudre le problème du Maximum de Parcimonie des méta-heuristiques (Recherche Locale, Algorithmes génétiques, mémétiques, Recuit Simulé, ...) car il s'agit d'un problème d'Optimisation Combinatoire :
- les solutions à traiter sont des arbres binaires dont les feuilles sont les séquences en entrées et les noeuds interne des séquences hypothétiques
- la fonction objectif est la fonction de Fitch qui compte le nombre de mutations dans l'arbre
- la fonction de voisinage est donnée par l'un des voisinages suivants :
- NNI (Nearest Neighbor Interchange)
- SPR (Subtree Pruning and Regraphting), voisinage utilisé dans le cas présent
- TBR (Tree Bisection and Reconnection)
Le but du problème est de trouver le ou les arbres pour lesquels la fonction objectif est minimale. C'est à dire l'arbre le plus parcimonieux ou encore l'abre qui possède le moins de mutations.
Le voisinage SPR consiste à dégrapher un sous-arbre $S$ de l'arbre initial $T$ et à le regrapher sur un autre noeud de l'arbre résultant du dégraphe $(T-S)$ de manière à ce que le nouvel arbre obtenu ait un score inférieur à $T$.
Cette opération peut être réalisée en parallèle : pour être plus efficace il ne faut pas regrapher $S$ sur $T-S$ mais réaliser une évaluation du regraphe.
La modification du code est réduite puisqu'il suffit d'ajouter un #pragma omp parallel for devant la boucle à paralléliser.
$T^{⋆} = T$ // meilleur arbre de score minimum
1. pour chaque sous-arbre $S$ de l'arbre $T$ faire
$T^' = T-S$
#pragma omp parallel for
2. pour chaque sous-arbre $U$ de $T^'$ faire
$T^{⋄} = T^' + U ⋈ S$ // regraphe de $S$ sur $U$
#pragma omp critical
si $ score(T^{⋄}) < score(T^{⋆})$ alors
$T^{⋆} = T^{⋄}$
fin si
fin pour
fin pour
Nous avons effectué des tests de performance pour une descente stricte avec implantation des arbres sous forme statique sur le problème m1038.sg (297 taxa de 2021 résidus) pour les processeurs suivants :
- un AMD Phenom II 1090T Hexa core @ 3.20GHz
- un Intel i5 4570 Quad core @ 3.20GHz
- un Intel i7 4790 Quad Core + HyperThreading @ 3.60GHz , soit 4 coeurs + 4 HT
- un Intel Xeon E5 2670 v2 @ 2.50GHz Deca Core (doté de 10 coeurs) avec HyperThreading, soit potentiellement 20 threads (10C + 10HT). Le noeud du cluster sur lequel on a réalisé les calculs est un bi-socket, soit deux Xeon E5, ce qui fait potentiellement 40 threads (20 + 20).
temps d'exécution en fonction du nombre de threads
3.5.3.a AMD Phenom II 1090T
L'AMD Phenom II 1090T est le moins puissant des processeurs étudiés, il dispose cependant de 6 coeurs.
temps (s) |
328 |
186 |
112 |
91 |
facteur |
- |
1.76 |
2.92 |
3.06 |
pourcentage |
- |
-43% |
-66% |
-72% |
Temps d'exécution (s) sur AMD Phenom II 1090T
3.5.3.b Intel i5 4570
temps (s) |
174 |
98 |
58 |
facteur |
- |
1.77 |
3.00 |
pourcentage |
- |
-44% |
-67% |
Temps d'exécution (s) sur i5 4570
3.5.3.c Intel i7 4790
Sur l'Intel i7-4790, l'utilisation de 8 threads est à proscrire car 4 threads sont des threads HyperThreading et ont tendance à dégrader les performances, il faut donc se limiter à 4 threads
temps (s) |
160 |
90 |
52 |
74 |
facteur |
- |
1.77 |
3.07 |
2.16 |
pourcentage |
- |
-44% |
-68% |
-54% |
Temps d'exécution (s) sur i7 4790
3.5.3.d Intel Xeon E5 2670
Sur le Xeon E5 2670, la situation est très complexe avec des fluctuations importantes entre deux exécutions consécutives si le nombre de threads est supérieur à 10. La répartition des coeurs est la suivante :
0 |
0 |
0 |
1 |
1 |
0 |
2 |
0 |
1 |
3 |
1 |
1 |
... |
|
|
20 |
0 (HT) |
0 |
21 |
1 (HT) |
0 |
... |
|
|
Identification des coeurs sur Xeon E5 2670
En d'autres termes, les threads se répartissent :
- CPU 0: 0, 2, 4, 6, 8, 10, 12, 14, 16, 18
- CPU 0 (HT) : 20, 22, 24, 26, 28, 30, 32, 34, 36, 38
- CPU 1: 1, 3, 5, 7, 9, 11, 13, 15, 17, 19
- CPU 1 (HT) : 21, 23, 25, 27, 29, 31, 33, 35, 37, 39
temps (s) |
207 |
120 |
67 |
41 |
36 |
36 |
39 |
43 |
47 |
49 |
60 |
facteur |
- |
1.72 |
3.08 |
5.04 |
5.75 |
- |
- |
- |
- |
- |
- |
pourcentage |
- |
- 42% |
- 68% |
- 80% |
- 83% |
- 83% |
- 81% |
- 79% |
- 77% |
- 76% |
- 71% |
Temps d'exécution (s) sur Xeon E5 2670
Que peut on déduire des résultats ?
- jusqu'à 10 (voire 12) threads on note une diminution du temps de calcul
- que l'on soit sur un seul CPU ou sur deux CPU (le tout sans HT) on obtient les mêmes résultats :
- 8 threads : 41 secondes avec taskset -c 0-7 ou taskset -c 0,2,4,6,8,10,12,14
- 10 threads : 36 secondes avec taskset -c 0-9 ou taskset -c 0,2,4,6,8,10,12,14,16,18
- si on utilise l'HyperThreading on dégrade les performances :
- 8 threads : 1m08s avec taskset -c 0-3,20-23 (4C + 4HT sur le même CPU)
- 10 threads : 1m06s avec taskset -c 0-4,20-24 (5C + 5HT sur le même CPU)
3.5.3.e Loi de Amdhal
Si on suit la loi de Amdhal, $R = 1/((1-S) + S/N)$ le gain obtenu devrait être le suivant sachant que :
- $S = 0,92$ est le pourcentage de temps d'exécution parallélisable (càd grossièrement que 92% du temps d'exécution est parallèlisable)
- et $N$ le nombre de threads :
Loi de Amdhal, $R$ (Gain) en fonction de $N$ (nombre de threads) pour $S=0.92$
$N$ |
$R$ |
% |
$Δ$ % |
2 | 1.85 | 46 | 46 |
4 | 3.23 | 69 | 23 |
8 | 5.13 | 80 | 11 |
10 | 5.81 | 83 | 3 |
12 | 6.38 | 84 | 1 |
20 | 7.94 | 87 | 3 |
Notons que plus $S$ est proche de 1, plus $R$ est grand quand $N$ augmente, cf. l'image ci-dessous..
#threads | S=0.5 | S=0.6 | S=0.7 | S=0.8 | S=0.9 | S=0.92 | S=0.95 | S=0.99 |
2 | 1.33 | 1.43 | 1.54 | 1.67 | 1.82 | 1.85 | 1.90 | 1.98 |
4 | 1.60 | 1.82 | 2.11 | 2.50 | 3.08 | 3.23 | 3.48 | 3.88 |
8 | 1.78 | 2.11 | 2.58 | 3.33 | 4.71 | 5.13 | 5.93 | 7.48 |
10 | 1.82 | 2.17 | 2.70 | 3.57 | 5.26 | 5.81 | 6.90 | 9.17 |
12 | 1.85 | 2.22 | 2.79 | 3.75 | 5.71 | 6.38 | 7.74 | 10.81 |
20 | 1.90 | 2.33 | 2.99 | 4.17 | 6.90 | 7.94 | 10.26 | 16.81 |
Tableau de valeurs pour la loi de Amdhal
Par exemple avec 99% du temps d'exécution parallélisable, on peut diviser le temps d'exécution par un facteur 16.81 lorsque l'on utilise 20 threads/processeurs.