Introduction à la Programmation Parallèle
Introduction à la Programmation Parallèle
* Gagner du temps
* Traiter des problèmes importants (dépasser la limitation mémoire d’une machine)
* Faire plusieurs tâches en même temps pour programmer simplement une simulation
Utilisation familiale PC : tirer partie des processeurs actuels nécessite l'utilisation des
techniques du parallélisme au sein de plus en plus d'applications (surf sur internet, jeux,
traitement image, compression MPEG4, etc.)
Plan du Cours
- Intro
- compléments :
- algorithmique distribuée (mémoire virtuellement partagée)
- approche mixte (MPI + thread, UPC)
Exemple le calcul de la météo : calculer f(x,y,z,t) -> température, pression, humidité, vent
Le problème se modélise en mécanique des fluides, on peut utiliser un modèle discret pour la
simulation :
- discrétiser l'espace (5.10^9 points = 1 point pour 2 km3 et 20km d’atmosphère)
- discrétiser le temps (par pas de 60s)
- algo itératif issu de la mécanique des fluides (100 flop pour un point)
janvier 2010
OS Type Native TFLOPS* x86 TFLOPS* Active CPUs Total CPUs
Windows 226 226 237301 3001105
Mac OS X/PowerPC 4 4 4470 133137
Mac OS X/Intel 23 23 7304 108400
Linux 69 69 40693 455888
ATI GPU 1163 1227 11398 110108
NVIDIA GPU 2026 4275 17028 166765
PLAYSTATION®3 1132 2389 40147 900532
Total 4643 8213 358341 4875935
Loi d'Amdahl: soit s la part séquentielle de l’application l’accélération est bornée par
1 / (s + (1-s)/#processeurs)
« si 1% de l'application est séquentielle on n’arrivera pas à aller 100 fois plus vite »
speedup max
S \ #P 2 4 8 16 32 64 128 1024 1048576
0,9 1,05 1,08 1,10 1,10 1,11 1,11 1,11 1,11 1,11
0,8 1,11 1,18 1,21 1,23 1,24 1,25 1,25 1,25 1,25
0,7 1,18 1,29 1,36 1,39 1,41 1,42 1,42 1,43 1,43
0,6 1,25 1,43 1,54 1,60 1,63 1,65 1,66 1,67 1,67
0,5 1,33 1,60 1,78 1,88 1,94 1,97 1,98 2,00 2,00
0,4 1,43 1,82 2,11 2,29 2,39 2,44 2,47 2,50 2,50
0,3 1,54 2,11 2,58 2,91 3,11 3,22 3,27 3,33 3,33
0,1 1,82 3,08 4,71 6,40 7,80 8,77 9,34 9,91 10,00
0,05 1,90 3,48 5,93 9,14 12,55 15,42 17,41 19,64 20,00
0,01 1,98 3,88 7,48 13,91 24,43 39,26 56,39 91,18 99,99
0,001 2,00 3,99 7,94 15,76 31,04 60,21 113,58 506,18 999,05
=> Problèmes réguliers : calcul prévisible ne dépendant pas de la valeur des résultats
intermédiaires (multiplication de matrice), on peut « tout » arranger à l’avance.
=> Problèmes irréguliers (ex. approximation d’une surface à partir de points, ou encore calcul
fractal), impossible de distribuer à priori (à la compilation ou au départ de l’exécution) de
façon équitable le travail, il faut le faire de façon dynamique en cours d’exécution.
Approche mémoire partagée
Programmation orientée threads [Link]
Objectif du chapitre :
* voir comment on peut distribuer une boucle for,
* faire un rappel sur les threads, s'amuser avec les barrières
* coût de la parallélisation
// pthread_exit(NULL);
}
int main()
{
…
for (int i = 0; i < NB_THREADS; i++)
pthread_create(&threads[I], NULL, appliquer_f, (void *)i);
...
}
La parallélisation est efficace lorsque le calcul est équilibré... mais imaginons qu'un thread ait
la moitié du travail alors le speedup serait limité à 2.
approche dynamique pour diminuer le risque de déséquilibre
pthread_mutex_t mutex;
int indice = 0;
int
obtenir_indice()
{
int k;
pthread_mutex_lock(&mutex);
k = indice++;
pthread_mutex_unlock(&mutex);
return (indice > NB_ELEM) ? -1 : indice;
}
int
main()
{
pthread_mutex_init(&mutex, NULL);
...
20,0
speedup
15,0
Séries1
10,0
Séries2
Séries3
5,0
0,0
1
3
5
7
9
11
13
15
17
19
21
23
nb
procs
Speed-up obtenus avec un programme trivial comprenant une section critique représentant 5%
10% et 20% du temps de calcul sur une machine à 24 processeurs.
Une solution intermédiaire est de régler la taille des tranches d'indices à distribuer => on
s'attaque ainsi à la granularité du calcul. Une autre façon de procéder est de rééquilibrer
dynamiquement le travail en utilisant des techniques de vol : par exemple un thread inactif
peut aller voler des indices à un autre thread.
Une technique radicalement différente est d’augmenter le nombre de threads (en créer n fois
plus que de coeurs) et laisser le système d'exploitation répartir la charge de travail et d’utiliser
un ordonnanceur préemptif. On perd cependant du contrôle sur l'application et on augmente
la charge du système (et les défauts de cache). En général les utilisateurs, même avancés,
n'aiment pas cela, c’est pour eux un signe qu’ils ne maîtrisent pas vraiment le problème.
Nouveau Problème on veut calculer f^k... on ne va pas gâcher du temps à créer nthreads à
chaque tours. Il s'agit de synchroniser tous les threads au même endroit dans le code... on
appelle cela une « barrière »
barrier_t b;
pthread_exit(NULL);
}
Implémentation d'une barrière
Les étudiants ayant suivi l’UE Système savent comment réaliser une barrière à l’aide de
condition, sémaphore et moniteur de Hoare… pour les autres voici un exemple :
typedef struct
{
pthread_cond_t condition;
pthread_mutex_t mutex;
int attendus;
int arrives;
} barrier ;
int
barrier_wait(barrier *b)
{
int val = 0;
pthread_mutex_lock(&b->mutex);
b->arrives++;
if ( b->arrives != b->attendus)
pthread_cond_wait(&b->condition, &b->mutex) ;
else
{
val=1;
b->arrives = 0;
pthread_cond_broadcast(b->condition);
}
pthread_mutex_unlock(&b->mutex);
return val;
}
Pour obtenir des performances il est fondamental de minimiser les parties séquentielles
(Amdahl) donc de minimiser la synchronisation entre les différents threads. Il faut faire la
chasse aux mutex et aux barrières ou tout au moins limiter le plus possible leur effet de
« séquencement ».
Imaginons qu'on traite un programme comme le "jeu de la vie"... ou si vous préférez l'étude
de la dissipation thermique sur une surface (équation de Laplace)… bref un truc du genre :
int T[2][DIM][DIM];
int debut = id * …
int fin = (id +1) * …
for (etape=0 ...)
{
int mes_cellules = 0;
for(i = debut ; i < fin ; i++)
…
mes_cellules++ ;
…
pthread_mutex_lock(&mutex_cell);
nb_cellules += mes_cellules;
pthread_mutex_unlock(&mutex_cell)
pthread_barrier_wait(&bar);
if (mon_ordre == 0)
{
printf(...);
nb_cellules = 0;
}
pthread_barrier_wait(&bar);
}
Le défaut de ce code est qu'on synchronise beaucoup les threads… une optimisation est de
commencer par le calcul du pourtour de la région considérée puis de signaler la fin de ce
calcul aux voisins puis de passer au calcul interne et enfin d’attendre que les autres threads
aient signalé la fin des calcul externes pour recommencer.
int pthread_barrier_wait_begin(barrier_t *bar);
int pthread_barrier_wait_end(barrier_t *bar);
Pb le nb_de cellules vivantes n'est plus toujours le bon => utiliser nb_cellules[out]
Implémentation de la barrière en deux temps
Le piège se situe dans la différence de vitesse entre les threads. Une restriction naturelle est
qu'un thread ne peut franchir la première barrière que si tous les autres threads ont
effectivement franchi la seconde.
typedef struct
{
pthread_cond_t conditionB;
pthread_cond_t conditionE;
pthread_mutex_t mutex;
int attendus;
int leftB;
int leftE;
} barrier ;
if (b->leftE)
pthread_cond_wait(&b->conditionB, &b->mutex) ;
ret = --b->leftB;
if (ret == 0)
{
b->leftE = b->attendu;
pthread_cond_broadcast(b->conditionE);
}
pthread_mutex_unlock(&b->mutex);
return ret;
}
if (b->leftB)
pthread_cond_wait(&b->conditionE, &b->mutex) ;
ret = --b->leftE;
if(b->letfE == 0)
{
b->leftB = = b->attendu;
pthread_cond_broadcast(b->conditionB);
}
pthread_mutex_unlock(&b->mutex);
return ret;
}
Remarques : On peut désynchroniser encore plus en mettant une barrière par frontière.
On peut surtout réduire le nombre de barrière en faisant se chevaucher les zones traitées par
chaque threads en se basant sur le fait qu’on peut calculer l’état d’une cellule à l’étape i + k si
on connaît l’étape i son état et l’état de ses voisins à distance k. Ainsi deux threads 0 ont en
commun 2.k lignes alors il suffit de les synchroniser que toutes les k + 1 étapes.
Pour s’en convaincre on peut marquer dans un tableau l’identité des threads qui possèdent la
véritable valeur de la cellule en fonction de l’étape ; par exemple au départ le thread 0
possède la valeur des cellules de 0 à 15 et le thread 1 les cellules 6 à 19, 10 cellules sont donc
partagées (k = 5) :
Etape\cellule 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
0 0 0 0 0 0 01 01 01 01 01 01 01 01 01 01 1 1 1 1
Un thread peut calculer l’état à l’étape i + 1 de toute cellule dont il connaît l’état du voisinage
à distance 1 à l’étape i on a donc :
Etape\cellule 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
0 0 0 0 0 0 01 01 01 01 01 01 01 01 01 01 1 1 1 1
1 0 0 0 0 0 0 01 01 01 01 01 01 01 01 1 1 1 1 1
2 0 0 0 0 0 0 0 01 01 01 01 01 01 1 1 1 1 1 1
3 0 0 0 0 0 0 0 0 01 01 01 01 1 1 1 1 1 1 1
4 0 0 0 0 0 0 0 0 0 01 01 1 1 1 1 1 1 1 1
5 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1
6 0 0 0 0 0 0 0 0 0 ? ? 1 1 1 1 1 1 1 1
A l’étape 6 on ne peut calculer l’état des cellules 10 et 11 sans échange d’information. On doit
synchroniser donc les threads qu’à ce moment là.
Etape\cellule 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
4 0 0 0 0 0 0 0 0 0 01 01 1 1 1 1 1 1 1 1
5 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1
Barrier() 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1
Copy() 0 0 0 0 0 01 01 01 01 01 01 01 01 01 01 1 1 1 1
6 0 0 0 0 0 0 01 01 01 01 01 01 01 01 1 1 1 1 1
7 0 0 0 0 0 0 0 01 01 01 01 01 01 1 1 1 1 1 1
On échange du « temps de synchronisation » contre du temps de calcul. Si le volume de
communication est identique, le nombre de communications est lui aussi divisé par k. Il s’agit
de déterminer le bon compromis.
OpenMP : open Multi Processing
[Link]
return EXIT_SUCCESS;
}
#include <omp.h>
int
main()
{
#pragma omp parallel
printf("bonjour\n");
printf("au revoir\n");
return EXIT_SUCCESS;
}
=> par défaut la portion parallèle sera exécutée par autant de threads qu'il y a de cœurs...
int T[2][DIM][DIM];
for(etape = 0; etape < ETAPE; etape++)
{
in = 1-in;
out = 1 - out;
nb_cellules = 0;
Un premier point fort d’OpenMP est de fournir un moyen simple de distribuer à une équipe de
threads les indices d'une boucle
static est adapté au calcul régulier (facilite les optimisations, diminue la contention) on utilise
dynamic dans les autres cas.
Pour l’accès concurrent à nb_cellule++ on peut utiliser soit une section critique soit une
opération atomique (si disponible le jeu d’instruction du processeur)
=> traduit par un mutex... attention s’il n’y a pas de nom on utilise le mutex par défaut
Mais on a déjà vu mieux : définir une variable locale et puis faire la synthèse à la fin. Cela se
fait de façon automatique au travers d'une réduction.
Il est possible d’exprimer qu’un bloc doit être réalisé une seule fois par le master seul
On peut faire sauter les barrières implicites à la fin des blocs en mettant une clause nowait
int x;
#pragma omp threadprivate(x)
int etape, j;
for(etape=0; etape < N; etape++)
{
#pragma omp for schedule(static) reduction(+:nb_cellules)
for(i=1; i < DIM-1; i++)
for(j=1; j < DIM-1; J++)
{
T[out][i][j] =f(T[in][i][j], T[in][i-1][j], ...)
if (T[out][i][j] > 0)
nb_cellules++;
}
//barrier implicite
#pragma omp master
{
printf("%d => %d", etape, nb_cellules);
{ nb_cellules = 0; in = 1-in; out = 1 - out;
}
}
Parallélisme imbriqué
Table 4 shows the speed-ups obtained by the LIBGOMP and I NTEL runtime systems, and dif-
ferent versions of the Cache scheduler that comes with F OREST GOMP, depending on the number
of threads created from outer and inner parallel regions. We tested the C class of the BT-MZ appli-
cation
Dansonl’inconscient
a quad-socket quad-core
collectif des computer.
programmeurs First,OpenMP
the performance
le modèle confirms the F OREST GOMP
de programmation c'est un
runtime was designed for nested parallelism as it behaves better, for
thread par processeur. De ce point de vue, le parallélisme imbriqué sert à déterminer any combination of outer
des and
inner number
zones of threads,
de travail than the
auxquelles onLIBGOMP
va associer anduneI NTEL libraries.
équipe The Cache scheduler behaves best
de threads.
with 32 teams of 8 threads. Creating more threads than processors in this application offers the
Cache scheduler ways to steal work when a processor idles, reaching this way a speed-up of 14.48.
omp_set_nested(1);
The column Cache + load info shows the performance obtained when the application programmer
provides#omp parallel num_threads(externe)
the workload associated to each zone to compute. This way, the Cache scheduler distribu-
{
tion algorithm takes the load into account when distributing the teams thus minimizing the need to
call the work stealingdes
// distributions algorithm. Such a distribution obtains a speed-up of 15.05. Alternatively, we
zones de travail
slightly modified the Cache distribution
#omp parallel for num_threads(interne)
algorithm to improve the load balance. In this version, in-
stead of distributing
{ all the teams over the core-level runqueues, we pick the most-loaded runqueue
of each NUMA node and
// parallélisation put itsauworkload
du travail sein d’une on
zonethe NUMA-level runqueue. This way, when a core
}
completes the jobs associated to its runqueue, it starts helping out the most-loaded core associated
to its NUMA
} node without having to call the work stealing algorithm. This strategy leads to a
speed-up of 15.25, the best performance we obtained on the BT-MZ application, thanks to the fact
that Cache
Avec cette distributes
technique, theonOpenMP
exploiteteams in a determinist
le parallélisme way that
imbriqué sur always
2 ou 3 leads to the
niveaux de same distri-à
telle façon
bution,
ce quethus improving
l’expert OpenMP locality.
puisseThisréaliser
strategy also guarantees that
algorithmiquement teams with the
l’équilibrage highest workload
de charge
will be executed first reducing the ending idle time.
(distribution statique des régions découpées de façon adhoc)… cette tactique n’est possible
que sur les pb à peu près réguliers. On peut vouloir utiliser plus de threads pour faire de
l'équilibrage
Table 4: Speed-ups de charge
obtainedmaiswith
c'estthe
contre
BT-MZ les habitudes de la communauté
(class C) application parce qu’au
on a quad-socket début (et
quad-core
même computer
Opteron encore maintenant)
dependingles on environnements
the number of threads n’étaient pas au
created point
from (le placement
outer des équipes
and inner parallel re-
de
gions. threads n’est pas optimisé).
Outer × Inner LIBGOMP 3 I NTEL Cache Cache + load info Tuned Cache + load info
4×4 9.4 13.8 14.1 14.1 14.1
16×1 14.1 13.9 14.1 14.1 14.1
16×2 11.8 9.2 14.1 14.2 14.3
16×4 11.6 6.1 14.1 14.9 14.9
16×8 11.5 4.0 14.4 15.0 15.2
32×1 12.6 10.3 13.5 13.8 13.8
32×2 11.6 5.9 14.2 14.2 14.3
32×4 11.2 3.4 14.3 14.8 14.8
32×8 10.9 2.8 14.5 14.7 14.7
BT-MZ : calcul en dynamique des fluides – 256 zones, quantité de travail déséquilibrée
(rapport
Several 25 entre les
OpenMP extrêmes).
language extensions have been proposed to control the allocation of work to
the participating threads. In order to favor affinities in a portable manner the NANOS compiler [1]
Parallélisme
allows externe
to associate = nombre
groups dewith
of threads paquets de zones
parallel regions in a static way in order to always execute
Parallélisme interne = nombre de threads travaillant
the same thread on the same core. The OpenUH Compiler zone
[4]apres zoneadans
proposes un paquet
mecanism to accurately
select the threads of a subteam to define the thread-core mapping for better data locality, although
Beaucoup de paquets => déséquilibre entre les paquets => il faut beaucoup de thread par
paquet pour récupérer la zone…
8
Peu de paquets et beaucoup de threads => dispersion des threads sur toute la machine
Section //
#pragma omp sections
{
#pragma omp section
{
...
}
#pragma omp section
{
...
}
}
Les sections sont distribuées de façon dynamique au threads, à l’image d’une distribution
d’indice.
Utilisation de la récursivité
Exemple fibonacci
int fib ( int n )
{
int x,y;
if ( n < 2 ) return n;
void foo ()
int foo () {
{ int a, b, c, x, y;
int a, b, c, x, y;
#pragma omp task shared(a)
a = A(); a = A();
b = B();
c = C(); #pragma omp task if (0) shared (b, c, x)
x = f1(b, c); {
y = f2(a, x); #pragma omp task shared(b)
b = B();
return y;
} #pragma omp task shared(c)
c = C();
avec pour dépendances
#pragma omp taskwait
A ------------------+ }
B ------+ |--> f2 x = f1 (b, c);
|--> f1 ---+
C ------+ #pragma omp taskwait
y = f2 (a, x);
}
Il existe plusieurs façons de répartir les tâches entre les threads d’une équipe
une file globale (contention ?) vs des files locales (équilibrage de charge ?)
Pour équilibrer les files locales, une technique efficace est d’utiliser un vol de travail à la Cilk
(Leiserson & al) via l’utilisation de DEQUE (double end queue) tout thread utilise sa DEQUE
comme une pile pour travailler et lorsque sa pile est vide il vole du travail à un autre voisin
en utilisant la DEQUE comme une FIFO.
=> exécution en profondeur d’abord et vol en largeur d’abord.
Favorise la localité du calcul, minimise à priori le nombre de vols.
/* same as always here */
[...]
if (length >= minimum)
return;
}
! new_path[hops-1]=current;
!
if(hops == n) {
$%&!'()%*)!+,-./00!'&-!123-)4-%56738'8!,3(!9%:''):-!5)
Intel TBB thread building blocks : cocktail C++ à base de Cilk et d’openMP
if (length < conçu
minimum)
>,!5)&,;6)3():!?-7(-)@!! pour
{
MutexType::scoped_lock lock(Mutex);
exploiter les processeurs multicore : A3!B:873>,38!)'3)!C)'-):!D%5)**)!='-!E%,0>)'-)3!0F:!9)
if(length < minimum)
De Cilk : vol de travail + notion de profondeur paramétrable $%&!G)C)'*'8)!H'3'=,=!'&-!5*%,!6'3-):*)8-@!
minimum = length;
!
D’OpenMP : distribution de boucle via découpage en 2 de l’intervalle }
} +,-./00! E%,0>)'-)3!'3!?)1,3()3
%&'(&)&*! +),-&..,)&*! /'(! -0)! 1)2&'(! 3&)4*5&-,5&*! %&)6&*7! 0*6! 6&
else {
Exemple : tâche tbb sur TSP 0*(&)!&'*&/!+),-&*(!6&)!84.9.!5&9:40(;! IJ!?-7(-)! IK!?-7(-)!
int count = n-hops;
! set_ref_count(1+count);
I! KM"J! N"M"#!
task * execute() { <'=&40! 84.9.!
int j = 0; 64=,*!5&.(,3:&*! '*!>!
#! #?! "! IM#K!
#@! JM#K! $A!
if(this->is_stolen_task()) {
B!for (int i=1; N!
B#C! i < n; i++) #MLI!
?C!{ PMIP! B@!
was_stolen = 1;
int ptr[anz]; A! if (!present(i,
BDAC! P! new_path)) { NM"K! #7?!
hops, @#!#MKP!
memcpy(ptr, new_path, (hops-1) * sizeof(int)); @! j++; ABDEC! J! #MK"!
EA! NMPO! C7B!
new_path = ptr; ?! A?EB#A! K! #MJQ!
##D! NMJJ! FC!
}
E! int dist = distanceArray[current+i*30];
A#C$?$C! L! #MKJ!
#@E! NMKK! FC!
if (length >= minimum) D! int tmp#G#GBB@#!
= length + dist;
Q! $#!
#MLN! NMQN! FC!
return; G! E@?BG?G@! EB!
O! #MOP!
TSPTask& a = *new(task::allocate_child() PMKP! FC!
) TSP
new_path[hops-1]=current; $! #AACAA$E@! I#! B@!
IMNP! KMPL! FC!
j < count ? spawn(a) : spawn_and_wait_for_all(a
#C!
} #?AABCAC@! II! D!"M#O! QMNO! FC!
if(hops == n) { ##! $EAD?#B?! E! FC!
if (length < minimum) { } I"! "MIP! I#M"O!
}! #B! ABG#C@AE! C! "!
MutexType::scoped_lock lock(Mutex);
! ! #A! ?$EGE?E!
IN! C!
IMOO! I#MKQ! "!
if(length < minimum)
minimum = length; ??B?$C!IP!
$%&!#@!'&((%)*&(+)*! C!"M#O!
,&(! -.&%/0&(12.%&)! IIMIP!
%)! "!,&)!
}
#?! #D#@@! IJ! C!!
8;7;;*&<+),&)!07;6!=&(>&88&(;!=&(8;4),5%/0&(:&%8&!,%& I#MOQ! "!
}
else { ! ! IK! ! !
int count = n-hops; ! ! '&(8%2)! "B!-;4,;&! "C!-;4
set_ref_count(1+count); $%&!8&E+&);%&55!
H.! /0..! 4:.,! &'*&*! 2R-'=%*)! +,-./00!5&2&*7!
%&'(&)&*! I49(,)! '3('>'):-! (%&! S'9)%,!
%&:J3&)!
B6DB!8! .J3%&)! %5! C)*;6
'*.!FG6C#
K&%'
int j = 0;
N'&2.(M3:&! .(4((L'*6&*7!
D6:)%(&! .J3&'*(! 6'&!
9):-)'*-! O4.(!
'&-! ,3(!5:&'J3/MP'5!
&2! )'3! =&)(&':(!
/9):6)%(! -0! .&'*;!(<
)3-&-)6-M!
;781!I!8;25&)! "6CC!8! J6CB!8
for (int i=1; i < n; i++) { .'*6! 84.9.! 4:.,!():)3!V):C%*-,38!='-!%**)=!12=R*'>'):-)3!T,&C%6*=
940/! *,(%&*6'5! 0*6! R&6&)! S&)*! LT)! .'J3! 9U**(&! 6'&
if (!present(i, hops, new_path)) { .&V0&*('&::!&):&6'5&*;! -.&&,+.! F6HC! F6"#!
(%&! F5):&R:'38)3! '3! (')! &)X,)3-')**)! Y,31-'23! R:2
K<<%L%&)L! G6HF! G6HG!
j++;
!"#"$ %&'()**+ /9):6)%(!9):&;6C'3()-@!!
!
int dist = distanceArray[current+i*30]; !
int tmp = length + dist; M%80&(! :7(! &%)! -.&&,+.! =2)! F6G"! NK<<%L%&)L! O! G6F#
W&'! 6&/! 8&/X:4(&! V):&'23! IJ!?-7(-)!
!"#"$$%$&'(#! 5'2(! &.! 6&*! Y4*5&"+4)4/&(&)!
L:7(!S>&(;(2<<&)6!,2/0!)+(!8&0(!1)7..R!T:&%!U(S), IK!?-7(-)!
%&:J3&)!
&'*&)!5)4'*.'-&!4)2&'(&(;!H.!'.(!&).()&2&*.%&)(!/U5:'J3.(!50(!6'&!'6&4:&!
TSPTask& a = *new(task::allocate_child() ) TSPTask(tmp,hops+1,new_path,i); ?)X,)3-')**! JMLJ!&! N#MKQ!&!
,%&!W)L705!,&(!$%&>8;405&!%8;!8&0(!02/0!2,&(!&%)!!"!
j < count ? spawn(a) : spawn_and_wait_for_all(a); W&'/! &'5&*3M*6'5&*! H).(&::&*! =,*!
-%&1!Z!;,-! 84.9.! 5'2(! &.! 6'&.&*! +4)4/&(&)!
#MJQ!&! NM"K!&! *'J
} )%/0;!=%&5R!
M3*:'J3&.!:&'J3(!'*(&5)'&)&*;!H'*!)*+,-''!2&-&'J3*&(!64.!W&&*6&*!=,*!&(%
} ?R))(,R!
$%&! OMOI!
W)L705! ,&(! $%&>8;405&! OMPI!X)! ,&(!
045;! 8%/0! *&(%)*R!
&'*&.!+4)4/&(&).;!Z/!I4::&!6&.!8[+!%M)&!&'*!\0("]LL!3&)40..X)'*5&*!40
}! !
!! $+(/057+<!@%;!)O"C!-;4,;&)!,%&!U&87@;7)L705!,&(!9
84.9&).(&::0*5! 0*6! 64.! I,)(LT3)&*! 6&)! 10L542&! '/! [&V0&*('&::&*;! K&
$%&! '&((%)*&(+)*! ,&(! -.&%/0&(12.%&)! %)! ,&)! 3455&)6! ,788! 1&%)!
&'*&.! 84.9.! +);&(8/0%&,&)!
90(&7,:&/08&5!
5:&'J3! )7/0! Y%=&7+!
6&/! 5&%T*.J3(&/! ,7(*&8;&55;R!
\0("]LL7! .,! %&)6&*!W>*&8&0&)!
9&'*&! S'*
CONCLUSION
8;7;;*&<+),&)!07;6!=&(>&88&(;!=&(8;4),5%/0&(:&%8&!,%&!?&(<2(@7)/&A! !
.,*6&)*!'*!6'&!.&V0&*('&::&!I0*9(',*!6&.!8[+.!T2&)5&54*5&*;!![,!34(!6
Souvent
! OpenMP est vendu ou compris comme un outil de parallélisation de programmes
6'&.&!4:.!1)2&'(!6&*!Y&.(!6&.!54*-&*!^&5&.!-0!2&)&J3*&*;!
'&(8%2)! "B!-;4,;&! "C!-;4,;&! !
séquentiels…
8&E+&);%&55!
du coup B6DB!8!
profanes dirent"D!-;4,;&!
on entend desFG6C#!8! N'&! « OpenMP
Z6&&! c’est
3'&)! 3'*(&)!
"FG6HC!8!
'.(7! facile, mais
5&*40! %'&! c’est
2&'/! pas6'&! Q&)5)UP&)0*5! 6&)
Y4*5&7!
performant, K)4X3&*! ('&L&)! :'&5&*6&*! S*,(&*_84.9.7! 0/! &'*&*! ]=&)3&46! 4*! Q&
;781!I!8;25&)!ça marche avec 2 ou 4 processeurs
"6CC!8! J6CB!8! maisH"6FJ!8!
au-delà faut prendre MPI… ». Certes
=&)/&'6&*;!
OpenMP-.&&,+.!permet de F6HC!réaliser des programmes
F6"#! parallèles
!
F6"B!assez facilement et de paralléliser
K<<%L%&)L! G6HF! G6HG! G6FJ!
progressivement
! une application. Cependant paralléliser brutalement un programme
séquentiel ne -.&&,+.!
M%80&(! :7(! &%)! permet=2)!généralement
F6G"! NK<<%L%&)L! O!pas d’obtenir
G6F#P! ,78! @7Q%@75!deK((&%/0;&R!
bons speedups…
$%&8&8! :%(,! Pour correctement
L:7(!S>&(;(2<<&)6!,2/0!)+(!8&0(!1)7..R!T:&%!U(S),&!1V));&)!758!&(8;&!K(154(+)*!,%&)&)A!
paralléliser un programme il s’agit de :
,%&!W)L705!,&(!$%&>8;405&!%8;!8&0(!02/0!2,&(!&%)!!"!#$%!128;&;!%@!'&(045;)%8!L+!7),&(&@!
connaître
)%/0;!=%&5R! des
techniques
de
bases
d’équilibrage
de
charge
et
de
minimisation
des
synchronisations
$%&! W)L705! ,&(! $%&>8;405&! 045;! 8%/0! *&(%)*R! X)! ,&(! 97>&55&! %8;! <S(! &%)&)! &Q&@.57(%8/0&)!
$+(/057+<!@%;!)O"C!-;4,;&)!,%&!U&87@;7)L705!,&(!97818!+),!,%&!W)L705!*&8;205&)&(!9781!
connaître
+);&(8/0%&,&)! un
m,7(*&8;&55;R!
)7/0! Y%=&7+! inimum
W>*&8&0&)!
d’architecture
=2)! ,&@! p arallèle
&(8;&)! pour
Y%=&7+! ne
755&!
:2! ,%&! pas
faire
de
contre-‐
emploi
(provoquer
bêtement
des
congestions
du
«
bus
»
mémoire,
utiliser
les
! caches)
"#!
revoir
les
algorithme
en
conséquence
afin
de
maximiser
le
parallélisme
!
(introduction
/
suppression
de
calcul
redondant)
Malheureusement, les performances viennent rarement gratuitement et OpenMP doit être
avant tout compris comme un outil qui permet programme d’obtenir un programme séquentiel
à partir d’un code parallèle.
Architecture mémoire partagée
• Computer
architecture:
a
quantitative
approach
par
John
L.
Hennessy,David
A.
Patterson,
Andrea
C.
Arpaci-‐Dusseau
• Cours
de
François
Pelegrini
[Link]
Plan :
- parallélisme
interne
au
processeur
- Technologie
des
caches
- Cohérence
mémoire
dans
les
multiprocesseurs
symétriques
- Machine
NUMA
Approche ILP
L'exécution d'une instruction peut être découpée en plusieurs phases, par exemple :
Fetch : lire l’instruction Fetch
Decode : l’analyser Decode
Read : chercher les arguments Execute
Execute : Réaliser l’opération Memory
Write : écrire le résultat WriteBack
C’est d’autant plus intéressant que la période de l'horloge doit être supérieure au temps de
traitement de l'étage le plus lent. Plus on coupe l’exécution des instructions en fines tranches
plus on peut augmenter la fréquence de l’horloge... Cependant certaines unités de traitement
sont difficiles à découper. Pour contourner ce problème on peut mettre plusieurs unités
fonctionnelles complexes en parallèle. Ainsi sur nos processeurs on a plusieurs unités de
calculs (flottants ou entier), on appelle cela une architecture super-scalaire.
Pour optimiser ce programme il faut être capable de déterminer si une case mémoire peut être
accédée par deux pointeurs différents… en fortran traditionnel pas de pb car pas de pointeur !
en C99 on le dit comme cela :
void somme (restricted int *a, restricted int *b, restricted int *c)
Notons que pour le moment gcc n’utilise pas cette information cependant les processeurs
modernes sont équipés de circuit pour détecter les conflits (disambiguition).
A l’exécution le processeur peut aussi parier sur le fait qu’un saut va être pris ou non pour
anticiper l’exécution de quelques instructions, on parle d’évaluation spéculative. Le
processeur parie qu’une branche va être prise plutôt qu’une autre et précharge des instructions
des données, réalise des calculs tout en retardant les écritures (mémoire / registre). Les
écritures ne seront réalisées que si le pari est gagné, introduction d’une phase de validation :
fetch decode read execute write back commit
On peut même exécuter en // plusieurs branches pour n’en valider qu’une seule. Cependant
évaluer plusieurs branches complexifie les circuits pour une efficacité relative (au final on a
des « bulles » partout sauf dans une branche). Les fondeurs ont privilégié une autre approche :
augmenter ses chances de gagner les paris. Il s’agit de déterminer avec une « bonne »
probabilité l'adresse de la prochaine instruction à réaliser, on parle de prédiction de
branchements.
Exemple :
Les derniers processeurs prennent aussi en compte l’historique globale comme le core 2 :
addresse du saut + historique global + local (ou compteur) prédicteur 2 bits.
If (c==1) …. ;
…
If (c == 1) … ; // repéré par une stratégie globale
NB. il faut noter l’important coût matériel de ce type de prédiction, core 2 se limite à pister
une dizaine de branchement – il faut donc faire la chasse aux branchements conditionnels
inutiles, regrouper les boucles.
De plus, pour prédire l’adresse d’un saut un historique est utilisé pour les pointeurs de
fonctions et les switchs, une pile interne au microprocesseur est utilisée pour l'adresse de
retour (calls / ret).
Temps d’accès
ns cycle
L1 1 2-4
L2 10 7 (opteron) - 22 (p4) - 14 (core 2)
Mémoire 60 240
Par exemple les latences mesurées des caches du K10 sont de 3 cycles pour le L1, 15 cycles
pour le L2, 30 à 45 cycles pour le L3.
Un défaut de cache coûte donc de l’ordre 200 cycles, il faut donc les éviter en pré-chargeant
le cache (instructions de prefetching) soit de façon logicielle soit au niveau du processeur.
Une technique orthogonale est de faire tourner plusieurs threads sur le même pipeline, on
parle de MultiThreading, il y en a différentes sortes :
• -‐
tourniquet
comme
machine
processeur
HEP
(1980),
machine
(Tera,
1998)
:
on
entrelace
de
facon
cyclique
jusqu'à
64
(128)
threads...
on
a
plus
besoin
de
cache.
- Super
threading
:
à
chaque
cycle
on
peut
changer
de
thread
(sun
Niagara,
graup
au
cremi)
- SimultaneousMT
:
les
instructions
sont
entremêlées
(Pentium
IV,
Nehalem
(infini
au
cremi),
Power
5
-‐
6)
SMT ainsi on a des flux indépendants et le réordonnancement des instructions peut jouer à
pleinement son rôle. Pour mettre en œuvre cette technique il faut ajouter du matériel:
- dupliquer PC et la TLB (pour les processus – ou indiquer le numéro ds la table)
+ augmenter le nombre de registres
+ augmenter les files d'attentes des unités de traitement
+ augmenter les caches
D'après la publicité d’Intel 5% de matériel en plus permet d’obtenir jusqu’à 20% de perf en
plus… mais ce n’est pas garanti parfois on observe des pertes de performances -
incompréhension du public 1 ou 2 processeurs ?
Difficultés de l’approche
- priorité des threads, équité => file d'attente disjointe pour traiter équitablement les threads ?
- cohabitation problématique (symbiotic threads - appariement)
Problème de concurrence sur le cache
Commentaire général
De nombreuses autres techniques d’optimisation sont utilisées dans les processeurs par
exemple sur le core 2 un cache de micro-operation (trace-cache) permet d’éviter de décoder
sans cesse les petites boucles et permet de fusionner certaines micro-instructions.
• Plus
une
mémoire
est
petite
plus
son
temps
de
réponse
est
faible,
Small
is
beautyfull…
• Tirer
parti
de
la
localité
spatio-‐temporelle
des
programmes
«
90%
de
l'exécution
se
déroule
dans
10%
du
code
»
Un cache c’est une mémoire très rapide qui contient en « copie » certaines zones de mémoire,
les données utilisées par le pipeline sont copiées dans le cache, éventuellement modifiée à
travers celui-ci et recopiées en mémoire lorsque nécessaire. Comme le cache est bien plus
petit que la mémoire, il arrive fatalement qu’une donnée en chasse une autre : on parle alors
de conflits de cache. Il faut absolument éviter les parties de ping-pong entre le cache et la
mémoire. Lorsqu’une donnée recherchée est dans le cache on parle d’échec (cache-hit)
autrement d’échec ou de défaut de cache (cache-miss).
Lorsque que toute adresse peut correspondre à toute entrée on parle de cache associatif :
l’entrée contient en fait toute l’adresse et on dispose d'un comparateur par entrée. Par exemple
le cache L1 de la TLB de l’opteron est un cache associatif, il ne contient que 40 entrées. En
cas de conflit un algorithme proche de LRU (Least Recent Used) est utilisé pour désigner une
victime et la remplacer une autre ligne de cache.
À l’opposé lorsque que chaque adresse ne peut correspondre qu’à une unique entrée on parle
de cache à correspondance directe et il suffit d’un seul comparateur pour l’ensemble des
entrées. Pour établir la correspondance adresse / entrée on utilise les bits de poids faible de
l’adresse cible sauf les 6 derniers car il n’y a aucun intérêt à choisir les bits de poids forts (très
forte probabilité de conflits).
Les caches modernes utilisent en fait un modèle mixte : à une adresse donnée peut
correspondre n entrées (n-way cache) et donc n comparateurs sont nécessaires.
Direct mapping
Set-associative mapping
4 way cache
Dans les schémas sont souvent représenté la largeur des champs (étiquette, index, offset) par
exemple pour une adresse physique sur 40 bits et un cache de 64 ko on a :
Cache à correspondance direct (étiquette, index, offset) = (24,10,6) car on a 1k d’entrées
6 bits de poids faibles pour les 64 octets dans le cache ;
10 bits de poids intermédiaires pour désigner une entrée parmi 1k ;
24 bits sont à mémoriser dans l’étiquette.
Cache semi associatif 2-way de 64ko 512 x 2 entrées de 64 bits (25, 9, 6)
Cache semi associatif 8-way de 64ko 128 x 8 entrées de 64 bits (27, 7, 6)
Les conflits
On dispose d’un cache de 10 entrées les lignes de cache font 1 octets
Cache direct : 2 adresses sont en conflit si elles terminent par le même chiffre
Cache 2-way : 2 adresses sont potentiellement en conflit si elle sont égale modulo 5
Soit la séquence : 1 3 5 13 12 24 34 3 4 13
Cache direct : 1 3 5 13 12 24 34 3 4 13 29 18
Cache 2-way + LRU : 1 3 5 13 12 24 34 3 4 13 29 18
On observe qu’un cache 2-way de taille T est aussi performant qu’un cache direct de taille 2
T.
Application au jeu de la vie : on a intérêt a travailler par petit bloc (des tuiles) qui tiennent
(exactement) dans le cache => introduction de boucles supplémentaires
for (I=... I += TI) // pour toutes les tuiles …
for(J=... J+=TJ) // toute les tuiles
for(i=I
for(j=J
vitesse commence par augmenter, passe par un maximum, puis qu’elle diminue
lorsque n augmente, jusqu’à se stabiliser à une valeur asymptotique sensible-
ment plus faible (figure 3.1). ceci est un effet typique du cache. Tant que la
vitesse
dimension
effet
Fig. de
3.1 la tailledu
– Effet des tuiles
cache sursur les performances
la vitesse de calcul.
[Link]
Remarque: les caches modernes sont capables de détecter des accès réguliers à la mémoire et
peuvent ainsi faire du prefetching : mémoire -> L2 -> L1
dimension reste suffisamment faible pour que les trois matrices puissent résider
La communauté a dégagédans
simultanément deux stylesladevitesse
le cache, programmation adaptés
de calcul croı̂t. Quelque aux caches
soit :
l’algorithme
algorithmes
choisi, unecache conscious,
fois que toutes les tenant compte de
pages contenant deslacoefficients
taille du cachedes matrices ont
été utiliséescache
algorithmes au moins une fois,pensés
oblivious, elles restent
pourdans le cache,
optimiser la etlocalité
c’est le du
débit élevé (Dived
travail de &
celui-ci qui détermine la performance. La localisation temporelle est maximale.
Conquer)
A l’inverse, lorsque la dimension devient très grande, il n’y a plus de localisation
temporelle possible, sauf pour les coefficients immédiatement réutilisés, et c’est
Les scientifiques
le débitutilisent des bibliothèques
de la mémoire spécialisées
centrale qui limite la vitessecaches-conscious
de calcul. les "basic linear
algebra system" BLAS.
BLAS1 : vecteur O(N) O(N)
Pour obtenir les meilleures performances, il faut faire en sorte de n’opérer
BLAS2 : vecteur matrice memoire O(N^2)
que sur des matrices suffisamment pour
petites parO(N^2)
rapport opérations
au cache et donc réaliser
BLAS3 : matrice matrice
les calculs par [Link] O(N^2)
On découpe pour O(N^3)
les matrices en P × Popérations
blocs de dimension n/p,
comme la matrice A dans l’équation ??, de sorte que le cache puisse contenir 3
Les BLASmatrices
de niveau
de 3 permettent
dimension n/Pde
, enmieux exploiter
supposant les cachesla :présentation
pour simplifier on peut obtenir
que ndes facteurs
10 en performance. Ramener le problème à des opérations matrice / matrice quitte à faire
plus d'opérations.
Machines multiprocesseurs à mémoire partagée
Deux types d'organisation dominent le marché SMP et NUMA (réseau de processeurs)
Type SMP (symetric multi pro) : Il y a d'un coté la mémoire et les processeurs de l’autre ;
l’interconnexion est classiquement un bus ou un réseau devant la mémoire voire devant le
cache. Un mécanisme d’arbitrage est chargé de gérer l’accès au bus et donc à la mémoire. Sur
certains systèmes, les processeurs peuvent verrouiller le bus pour faire de la synchro faire des
read/write atomiques. Une autre technique est de verrouiller le cache.
Il eut existé des système à bus unique comportant des dizaines de procs mais bon maintenant
dès qu'on dépasse 4 sockets on utilise plusieurs bus en étoile (voir l’exemple du quad core 2
d'intel => filtre au niveau du chipset pour pouvoir servir en parallèle plusieurs requêtes). Mais
cette approche en étoile est limitée. Une autre approche est de mettre le cache devant la
mémoire et utiliser un crossbar pour y accéder comme sur le processeur Niagara (graup au
CREMI) : 8 cores 4 cache (3 MB).
graup
dudley1
Instructions atomiques
type __sync_fetch_and_add (type *ptr, type value, ...)
type __sync_val_compare_and_swap (type *ptr, type oldval type newval, ...)
PB du faux partage (false sharing)
Deux variables a priori indépendantes peuvent se retrouver dans la même ligne cache
int x, y;
Cette ligne de cache peut alors faire du ping-pong entre deux processeurs.
-./$01&2)*+,
59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+,
5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+,
;.<$01 ;.<$0' ;.<$09 ;.<$06 ;.<$02 ;.<$0> ;.<$01 ;.<$0' ;.<$09 ;.<$06 ;.<$02 ;.<$0> ;.<$01 ;.<$0' ;.<$09 ;.<$06 ;.<$02 ;.<$0> ;.<$01 ;.<$0' ;.<$09 ;.<$06 ;.<$02 ;.<$0>
=01 =02 =0( =0'9 =0'7 =091 =0' =0> =0? =0'6 =0') =09' =09 =07 =0'1 =0'2 =0'( =099 =06 =0) =0'' =0'> =0'? =096
-./$0'&2)*+,
59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+,
5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+,
;.<$01 ;.<$0' ;.<$09 ;.<$06 ;.<$02 ;.<$0> ;.<$01 ;.<$0' ;.<$09 ;.<$06 ;.<$02 ;.<$0> ;.<$01 ;.<$0' ;.<$09 ;.<$06 ;.<$02 ;.<$0> ;.<$01 ;.<$0' ;.<$09 ;.<$06 ;.<$02 ;.<$0>
=092 =09( =069 =067 =021 =022 =09> =09? =066 =06) =02' =02> =097 =061 =062 =06( =029 =027 =09) =06' =06> =06? =026 =02)
-./$09&2)*+,
59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+,
5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+,
;.<$01 ;.<$0' ;.<$09 ;.<$06 ;.<$02 ;.<$0> ;.<$01 ;.<$0' ;.<$09 ;.<$06 ;.<$02 ;.<$0> ;.<$01 ;.<$0' ;.<$09 ;.<$06 ;.<$02 ;.<$0> ;.<$01 ;.<$0' ;.<$09 ;.<$06 ;.<$02 ;.<$0>
=02( =0>9 =0>7 =071 =072 =07( =02? =0>6 =0>) =07' =07> =07? =0>1 =0>2 =0>( =079 =077 =0)1 =0>' =0>> =0>? =076 =07) =0)'
-./$06&2)*+,
59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+, 59&61)9:+,
5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+, 5'&69:+,
;.<$01 ;.<$0' ;.<$09 ;.<$06 ;.<$02 ;.<$0> ;.<$01 ;.<$0' ;.<$09 ;.<$06 ;.<$02 ;.<$0> ;.<$01 ;.<$0' ;.<$09 ;.<$06 ;.<$02 ;.<$0> ;.<$01 ;.<$0' ;.<$09 ;.<$06 ;.<$02 ;.<$0>
=0)9 =0)7 =0(1 =0(2 =0(( =0?9 =0)6 =0)) =0(' =0(> =0(? =0?6 =0)2 =0)( =0(9 =0(7 =0?1 =0?2 =0)> =0)? =0(6 =0() =0?' =0?>
Comment obtenir une ligne de cache ?
Machine DASH : un circuit spécialisé permet de mémoriser l’emplacement actuel des
(groupes de) lignes de cache sur le Home Node (nœud où réside la mémoire contenant la
donnée). On dispose d’un vecteur de bit par ligne permet de désigner les caches détenteurs de
copies et un état de la ligne. : shared (au moins un cache l'a) uncached (aucun ne l'a) exclusive
(un seul cache le détient et la donnée a été modifée).
Sur les opteron & nehalem on s’adresse au Home Node qui fait un broadcast si nécessaire.
Voir [Link]
Allocation "first touch" il s’agit de déterminer (ou de découvrir par benchmark) les
dimensions où le programme à un comportement le plus régulier possible puis d’affecter de
façon définitive des jobs aux threads en utilisant une politique de scheduling statique. Ensuite
on fait une boucle d'initialisation à blanc pour fixer les pages sur les nœuds. Cette approche
est performante lorsqu’on a thread par cœur.
Stratégie de rééquilibrage de charge "move-on-next-touch" bien adapté aux pbs pas trop
irréguliers.
-> plus possible d'utiliser une technologie de type BUS pour l'accès à la mémoire
(contentions)
- Les constructeurs ont donc opté pour des architectures NUMA (Non
Uniform Memory Access) afin d'augmenter le nombre de processeurs.
-> SGI (Silicon Graphics Inc.) est probablement le constructeur le plus connu aujourd'hui
sur ce créneau (introduction d'une gamme de machines ccNUMAen 1996: Origin, puis Altix,
etc.), mais il y en a eu bien avant: Cray X-MP (4 processeurs, 1984), Sun, DASH, FireFLy,
etc.
-> Ces machines sont chère, peu extensibles, mais surtout sont bigrement difficiles à
programmer ! Il faut placer les données près des processus qui les manipulent. Ce n'est pas si
facile que ça, même quand on possède l'information. Alors, pour le système d'exploitation...
- Plusieurs constructeurs (dont Cray avec T3x, IBM avec SP, etc.)se sont orientés vers des
machines où on abandonne la cohérence mémoire, voire même la mémoire partagée: on
accède à sa mémoire rapidement, mais il faut utiliser des opérations explicites pour accéder à
la mémoire des autres processeurs. C'est possible soit par accès mémoire distant (remote read,
remote write), soit parenvoi de messages (send, recv).
- Le début des années 2000 a été fatal aux grosses machines multiprocesseur à mémoire
partagée : les architectures de type "grappe de PC", au rapport performance/coût très
supérieur, dominent le marché depuis...
-> En a-t-on pour autant fini avec les machines NUMA ? Non, mais vous saurez pourquoi
au prochain épisode !
- Dans le domaine du calcul scientifique, une large classe d'applications manipule des gros
volumes de données sous forme de vecteurs (ou de matrices) pour appliquer des opérations
similaires sur tous leurs éléments (addition de vecteurs, multiplication d'un scalaire par un
vecteur, etc.). C'est ce que l'on appelle le "parallélisme de donnée".
- Pour accélérer ces applications, ce qu'il faut, ce n'est pas d'aligner plusieurs processeurs
généralistes, mais plutôt d'offrir un support matériel spécifique pour appliquer en parallèle la
même opération à plusieurs données simultanément. C'est le concept du fonctionnement
SIMD (Simple Instruction - Multiple Data).
- Processeurs vectoriels
. Idée : on agrandit les registres pour contenir plusieurs mots/réels, et on invente des
instructions vectorielles (ADD,SCAL-MUL, etc.) qui appliquent la même opération sur
plusieurs éléments à la fois (e.g. sur 64 mots à la fois dans un CrayX-MP).
. Deux manières de faire : soit on multiplie les ALUs (64additionneurs pour faire 64 additions
en parallèle), soit on fait un additionneur super rapide et on pipeline lorsque plusieurs
instructions sont appliquées successivement aux vecteurs. Ex: A =k.B + C. C'est la technique
utilisée par les processeurs Cray.
. Notez que les processeurs vectoriels ont besoin d'un effort particulier pour être programmé.
Typiquement, le langage Fortran, qui permet de manipuler des vecteurs et des matrices en les
rendant visibles pour le compilateur (contrairement au C...) aété proposé pour ça. En Fortran,
lorsqu'on additionne deux vecteurs, on écrit C = A + B, et le compilateur génére des
instructions vectorielles... Hormis le compilateur, il reste la solution d'utiliser une
bibliothèque spécialisée, que les concepteurs ont implémenté en assembleur en utilisant les
bonnes instructions (ex: Basic Linear Algebra Subprograms: BLAS, FFT,etc.) Un peu de la
même manière que les jeux utilisent DirectX au lieu de programmer la carte graphique
directement...
- Les processeurs vectoriels au sens noble ont vu leursperformances dépassées par les
processeurs généralistes, car :
. la complexité de ces processeurs est telle que les instructions scalaires "normales" sont
pénalisées ;. la fréquence des processeurs scalaires a pu évoluer bien plus vite ;. le marché
était un marché de niche, donc les processeurs étaient très chers.
- Les processeurs scalaires ont donc repris le dessus au début desannées 1990 (dans les
machines parallèles).
- Toutefois, le marché du multimédia et des loisirs, qui est plus important que celui du HPC,
a lui aussi des besoins similaires :traitement d'image, de la vidéo et du son. Pour accélérer le
décodage d'un DVD ou certains jeux, les processeurs scalaires se sont vu adjoindre une
extension pour effectuer des opérations vectorielles. Ex: le jeu d'instruction MMX (64 bits,
uniquement sur les entiers, 1996, puis SSE, 128 bits (2 double ou 4float/int, 1999, PentiumIII)
dans les processeurs x86.
- Là encore, il faut que le compilateur soit capable d'utiliser ces instructions, et donc de
détecter où c'est possible dans un code C. Les compilateurs actuels (gcc -O3) ne sont pas
mauvais à cet exercice, et les boucles vectorielles simple sont assez facilement détectées et
optimisées.
- Note: l'émergence des cartes graphiques accélératrices a vite rendu le MMX peu utile pour
les jeux.
- Les années paisibles: depuis le milieu des années 90, l'homme et les machines parallèles
ont longtemps vécu en parfaite harmonie :les grappes de PC et les machines massivement
parallèles sont des architectures à mémoire distribuée qui forcent les programmeurs à
découper les applications en tâches qui communiquent explicitement au travers d'un réseau...
Bien que moins naturel que le modèle à mémoire partagée, le modèle de programmation à
mémoire distribuée a le mérite de forcer le programmeur à réfléchir aux coûts de
communication (très élevés) et à écriredes algorithmes qui supportent plus facilement le
"passage à l'échelle" sur des configurations de grande taille...
- Depuis le début des années 2000, l'univers du calcul parallèle a malheureusement subi
encore plusieurs bouleversements :l'émergence des technologies "multicoeurs", et l'utilisation
d'accélérateurs graphiques pour accélérer les calculs SIMD.
- C'est désormais définitif : le parallélisme est partout, même dans les téléphones.
- Les processeurs multicoeur on ré-introduit les effets NUMA des machines multiprocesseur
du temps jadis... Seulement,aujourd'hui, plus moyen d'y échapper : ces processeurs
sontpartout. Il faut donc faire un effort de programmation parallèlemême pour exploiter
efficacement un vulgaire core 2 duo.
- Pire, certains constructeurs ont introduit des processeurs multicoeur dans lesquels les
différents coeurs n'ont pas lesmêmes caractéristiques, voire ne partagent pas une mémoire
cohérente !
- C'est l'approche choisie par IBM pour la conception du processeurCell/BE (2005, qui
équipe la PS3).
. 1 Power Processing Element (PPEn dual-threaded) + 8 SPE(Synergistic Processing
Elements, SIMD-capable co-processors).
. Les SPE n'ont que 256Ko de mémoire locale. Ils doivent utiliserdes DMA (très rapides, 3
cycles de latence) pour accéder à lamémoire principale ainsi qu'à la mémoire des autres SPE.
. Le problème de la cohérence mémoire est reporté au niveau logiciel !
. 100 Gflops sur LINPACK
. Le code exécuté par les SPE est compilé avec un compilateur annexe, et le binaire est chargé
par le PPE sur le SPE.
. À noter que le Cell est utilisé dans la machine IBM Roadrunner(désormais n°2 au top 500 je
crois) 1.7 Petaflops théoriques, 1.45Peta soutenus. 12000 PowerXCell + 6000 AMD Opteron
Dual-core.
. IBM a annoncé récemment stopper le développement du processeur Cell, tout au moins dans
le contexte du HPC. (mais la Playstation4 se sera apparemment pas construite autour d'un
Cell, donc...)
. Ceci dit, le Cell préfigure probablement l'évolution prochaine des processeurs : quelques
gros coeurs généralistes (pour exécuter vite les codes séquentiels) et plusieurs petits cœurs
spécialisés (SIMD) pour les traitements parallèles.
. Intel a annoncé un processeur hybride Larrabee (48 coeurs) quiest composés de coeurs
généralistes couplés à des unités SIMD...
. En quelque sorte, on intègre des mini-GPU tout près des cœurs généralistes. Des minis-quoi
? G.P.U. !
- masquage de la latence
Les architectures à mémoire commune sont limitées par la difficulté d’assembler efficacement
un grand nombre de processeurs : plus le nombre de processeurs est important plus le fait de
vouloir assurer la cohérence est coûteux en temps et en argent. Actuellement (et en attendant
une révolution technologique) il est en fait plus rentable d’utiliser des machines en réseaux
pour construire des plateformes de calculs conséquentes. Les plateformes de calculs actuelles
sont soit des grappes (cluster) de SMP ou de petites NUMA (telles les machines infinis du
CREMI) de smp, de numas, ou soit des clusters de machines spécialisées (IBM Bluegene,
Roadrunner). Il y a aussi de plateformes de calcul basée sur internet : grille de calcul (cloud
computing) et réseaux pair à pair.
Voir [Link]
TOP500
Il s’agit ici de faire travailler un ensemble de machines à la résolution d’un même problème,
celles-ci devront donc communiquer pour collaborer. Deux approches majeures se
distinguent pour programmer ces plateformes de calcul : soit le programmeur écrit
explicitement les communications (communication par envoi de message), soit
l’environnement de programmation les lui cache (communication implicite). Cela traduit un
positionnement sur le compromis performance / facilité de développement : les applications
sont plus ou moins sensible aux communications suivant le volume et la fréquence des
données échangées et la nécessaire synchronisation des différentes machines. Pour fixer les
idées voici les deux paramètres mis en avant par les fabricants de cartes pour évaluer le coût
d’une communication :
la latence (le ping) : temps de transmission d’un octet (400 us – 1us)
le débit : nombre d’octets transmis par seconde en régime continu (1gb/s – 40 gb/s)
Les courbes des performances d’une carte spécialisée ressemblent à cela…
Pour calculer la latence au niveau de l’application, on utilise la technique du ping-pong qui
calcule le temps d’un aller-retour : pour calculer le débit on peut soit envoyer un gros paquet
et attendre la fin du send, soit utiliser la technique du ping-pong.
3° transmission fiable (message pas altéré, contrôle de flux) : les piles de protocoles
complexes comme TCP/IP/Ethernet peuvent être simplifiées car elles sont taillées pour
internet ; elles ont donc des spécificités inutiles pour la communication internes aux clusters
(ex. gestion de la congestion).
HelloWord
#include <stdio.h>
#include "mpi.h"
int main( int argc, char *argv[]){
int rank;int size;
MPI_Init( &argc, &argv );
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);
printf( "Hello world from process %d of %d\n", rank, size );
MPI_Finalize();
return 0;
}
> mpicc –o hello hello.c
> mpiexec –machinefile les-machines –n 10 hello
Jeton centralisé
if (rank == 0) {
for(i = 0; i < 3*(size-1); i++) {
MPI_Recv(&token, 1, MPI_CHAR, MPI_ANY_SOURCE, 2, MPI_COMM_WORLD, &etat);
MPI_Send(&token, 1, MPI_CHAR, etat.MPI_SOURCE, 2, MPI_COMM_WORLD);
MPI_Recv(&token, 1, MPI_CHAR, etat.MPI_SOURCE, 2, MPI_COMM_WORLD, &etat);
}
printf( " done \n");
} else
for(i = 0; i < 3; i++){
MPI_Send(&token, 1, MPI_CHAR, 0, 2, MPI_COMM_WORLD);
MPI_Recv(&token, 1, MPI_CHAR, 0, 2, MPI_COMM_WORLD, &etat);
printf( "Jeton chez %d \n", rank);
//sleep(1);
MPI_Send(&token, 1, MPI_CHAR, 0, 2, MPI_COMM_WORLD);
}
Schéma de calcul maître esclave : on remplace le token par le travail à réaliser
1/ Appels bloquants :
Send / Recv : termine une fois que buffer peut-être réutilisé / utilisé
Ssend / Recv : Ssend termine lorsque le buffer est réutilisable et que recv a commencé
Bsend / Recv : Bsend termine après recopie des données dans un buffer
MPI_Isend (&buf,count,datatype,dest,tag,comm,&request)
MPI_Irecv (&buf,count,datatype,dest,tag,comm,&request)
MPI_Wait (&request,&status)
MPI_Test (&request,&resultat,&status)
NB. Irsend Ibsend Issend : influe sur le résultat test/wait qui indique la fin de la réception
MPI_Request req[4] ;
MPI_Status sta[4] ;
…
Isend(cellule[out][XDIM+1], MPI_CHAR, DIM, haut, tag, MPI_COMM_WORLD, &req[0]) ;
Isend(cellule[out][0], MPI_CHAR, DIM, bas, tag, MPI_COMM_WORLD, &req[1]) ;
Irecv(cellule[in][XDMI+1], MPI_CHAR, DIM, haut, tag, MPI_COMM_WORLD, &req[2]) ;
Irecv(cellule[in][0], MPI_CHAR, DIM, bas, tag, MPI_COMM_WORLD, &req[3]) ;
…
MPI_Waitall(4,req,sta) ;
Quelques optimisations
Requêtes persistantes (15% de gain sur les petites requêtes)
MPI_Isend_init( …, &request)
MPI_Start / MPI_Startall
MPI_Request_free(request) ;
Scatter (&sendbuf,sendcnt,sendtype,&recvbuf,recvcnt,recvtype,root,comm)
Gather
Alltoall
Le Ready send : lorsque l’on sait que le recv correspondant est déjà en attente
Exemple demande du jeton : Maitre Esclave
Recv(&demande,1,MPI_CHAR,MPI_ANY_SOURCE,tag, Irecv(&jeton,maitre,&req)
MPI_COMM_WORLD,&status) Send(&demande,maitre)
Wait(&req,&status)
Rsend(&jeton, 1, MPI_CHAR, status.MPI_SOURCE,
tag, MPI_COMM_WORLD)
4 - Communications persistantes
Décrire un schéma persistant :
int MPI_[R,S,B]send_init(void *sendbuf,int count,MPI_Datatype datatype,int dest,int tag,MPI_Comm
comm,MPI_Request *request)
int MPI_Recv_init(void *recbuf,int count,MPI_Datatype datatype,int dest,int tag,MPI_Comm comm,MPI_Request
*request)
Démarrer une communication persistante :
int MPI_Start(MPI_Request *request)
Autres fonctions : MPI_Startall(), MPI_Request_free()
5 - Communications collectives
Diffusion générale d'un message :
int MPI_Bcast(void *buf,int count,MPI_Datatype datatype,int root,MPI_Comm comm)
Collecte de données :
int MPI_Gather(void *sendbuf,int sendcount,MPI_Datatype sendtype,void *recvbuf,int
recvcount,MPI_Datatype recvtype ,int root,MPI_Comm comm)
Diffusion sélective d'un message :
int MPI_Scatter(void *sendbuf,int sendcount,MPI_Datatype sendtype,void *recvbuf,int recvcount,MPI_Datatype
recvtype ,int root,MPI_Comm comm)
Collecte de données et rediffusion :
int MPI_Alltoall(void *sendbuf,int sendcount,MPI_Datatype sendtype,void *recvbuf,int recvcount,MPI_Datatype
recvtype,MPI_Comm comm)
Calcul d'une réduction :
int MPI_Reduce(void *sendbuf,void *recvbuf,int count,MPI_Datatype datatype,MPI_Op operation,int
root,MPI_Comm comm)
Calcul d'une réduction et rediffusion du résultat :
int MPI_Allreduce(void *sendbuf,void *recvbuf,int count,MPI_Datatype datatype,MPI_Op operation,MPI_Comm
comm)
Synchronisation de processus :
int MPI_Barrier(MPI_Comm comm)
6 - Types dérivés
Construire un type de données contigües :
int MPI_Type_contiguous(int nbre,MPI_Datatype ancien_type,MPI_Datatype *nouveau_type)
Type de données distantes d'un pas constant :
int MPI_Type_[h]vector(int nbre,int taille_bloc,MPI_Aint pas,MPI_Datatype
ancien_type,MPI_Datatype *nouveau_type)
Type de données distantes d'un pas variable :
int MPI_Type_[h]indexed(int nbre,int *taille_bloc,MPI_Aint *pas,MPI_Datatype ancien_type,MPI_Datatype *
nouveau_type)
Contruire un type structuré :
int MPI_Type_struct(int nbre,int *taille_bloc,MPI_Aint *pas,MPI_Datatype *anciens_types,MPI_Datatype
*nouveau_type)
Valider un type :
int MPI_Type_commit(MPI_Datatype *dataype)
Routine portable pour retourner l'adresse d'une variable :
int MPI_Address(void *variable, MPI_Aint *adresse)
Autres fonctions :
MPI_Type_free, MPI_Type_extent, MPI_Type_size(), MPI_Type_[u,l]b()
7 - Constantes
Jokers :
MPI_ANY_TAG, MPI_ANY_SOURCE
Datatypes élémentaires :
MPI_CHAR, MPI_SHORT, MPI_INT, MPI_LONG, MPI_FLOAT, MPI_DOUBLE, MPI_LONG_DOUBLE,
MPI_UNSIGNED, MPI_UNSIGNED_CHAR, MPI_UNSIGNED_SHORT ,MPI_UNSIGNED_LONG,
MPI_LOGICAL, MPI_BYTE, MPI_PACKED
Constantes reservées :
MPI_PROC_NULL, MPI_UNDEFINED
Communicateurs reservés :
MPI_COMM_WORLD, MPI_COMM_SELF
Opérateurs de MPI_Reduce et MPI_Allreduce :
MPI_MAX, MPI_MIN, MPI_SUM, MPI_PROD, MPI_BAND, MPI_BOR, MPI_BXOR, MPI_LAND, MPI_LOR,
MPI_LXOR
Une DSM est un système logiciel donnant l’illusion au programmeur de disposer d’une
machine à mémoire commune au-dessus d’un système à mémoire distribuée. L’objectif des
DSM est de faire de gagner sur le temps de développement, sur la lisibilité des programmes
voire sur la qualité du programmeur. Il est clair que l’approche implicite ne permet pas
d’atteindre les performances de l’approche explicite lorsque les communications représentent
une part non négligeable du temps de calcul.
Différents types de DSM : DSM à base de pages – DSM d’objets - SSI (openMOSIX,
kerrighed). Intel a lancé Cluster OpenMP qui permet d’utiliser OMP sur une grappe via une
DSM. Notons aussi qu’une machine NUMA est une DSM réalisée matériellement.
La solution la plus simple est d’utiliser le système de pagination disponible sur les
machines… c’est en effet l’unité mémoire gérée par au niveau du matériel. Par exemple
l’appel système mmap permet de placer la mémoire où l’on veut (si l’espace mémoire visée
est virtuellement libre) et donc il est facile d’avoir un système d’adressage virtuel cohérent sur
un ensemble de machine: il suffit d’utiliser de façon cohérente les mmaps. (Dessin : à une
adresse virtuelle correspond autant de case mémoire qu’il y a de machines.)
Le déterminisme du code généré par le compilateur fait que les adresses des variables
globales sont les mêmes d’une machine à une autre, il suffit alors d’instrumenter la
bibliothèque d’allocation pour faire en sorte que chaque machine alloue dans un espace
propre (chacune à son tas). Donc le seul vrai problème est d’assurer la cohérence de la
mémoire du système…
Une version simple : faire en sorte qu’une page ne soit disponible que sur une seule machine à
la fois. Lorsqu’une machine accède à une donnée, il faut déterminer si elle en est le
propriétaire et autrement qu’elle le devienne.
Idée de mise œuvre : utiliser la mmu pour invalider les pages dont la machine n’est pas le
propriétaire. L’appel système mprotect() permet de gérer les droits rwx des pages virtuelles. Il
suffit donc de supprimer les droits rw des pages dont on n’est pas propriétaire. Lorsque le
processus accèdera à une page invalide, il recevra un signal syssegv. Rappelons que lorsqu’on
a une interruption matérielle l’instruction fautive est retentée à la fin du traitement de
l’interruption… donc si on traite le signal en allant chercher la page chez son propriétaire (qui
invalidera à son tour sa copie) on a gagné. En fait, c’est exactement le mécanisme du swap
(c’est même une façon de faire du swap haute performance).
TLB à un niveau
[Link]
Dans ce cadre il suffit donc d’ajouter un mécanisme (données + protocole) pour pouvoir
déterminer le propriétaire de la page, voici différentes techniques :
Optimisations
Les performances peuvent se dégrader lorsque plusieurs processus se « disputent » une même
page. Une première optimisation est de s’adresser au propriétaire « probable » : on mémorise
le nom du processus à qui l’on a envoyé la page. On peut chaîner les propriétaires
probables… cependant il y a un risque d’avoir une requête non satisfaite (il faut que la requête
rattrape la page). Une possibilité est de renvoyer la requête au gestionnaire. Une autre
optimisation concerne les effets ping-pong pour de simples lectures : il s’agit de protéger en
« lecture seule » les pages à l’aide de l’appel système mprotect(). Avant de changer de
modifier une page, il faut prévenir les autres détenteurs exactement comme dans MSI.
Ordre causal (Lamport) est l’ordre lâche le plus facile à mettre en œuvre : il s’agit de
respecter l’ordre entre les événements dépendants.
b dépends directement de a
- si b est exécuté après a par le même thread
- si b est une réception correspondant à l’émission a
Exemple x = y = 0
W(x)1
W(y)2
R(X)1 R(Y)0
R(Y)2 R(X)0
W(x)1
R(x)1 W(y)2
R(y)2 R(x) 1 (imposé)
R(x) 1 R(y) 0 (non imposé)
Quand synchroniser ?
Pour mettre en œuvre une DSM basée sur l’ordre causal on profite des synchronisations
introduites par le programmeur pour resynchroniser la mémoire. NB cette technique ne
marche pas pour les lock free algorithms qui reposent sur l’utilisation des protocoles de
cohérence de cache à la MESI.
Que faire pour des mécanismes de synchronisation plus souples ? (lock / unlock) Une idée
simple faire comme dans CVS – SVN : individualiser les événements, distinguer la phase de
lecture et celle d’écriture.
approche paresseuse « Lazy Release Consitency » acquire va chercher les pages, latence
importante. (solution généralement adoptée)
Mettre les pages de la DSM en Read Only et passer en RW lors de l’écriture. On utilise le
traitement du signal sigsegv pour maintenir à jour une table des pages modifiées et n’envoyer
que celles-là. (voir TreadMarks [Link]
Cela peut coûter cher d’envoyer beaucoup de pages alors que seuls quelques octets par page
ont été modifiés… une optimisation possible : envoyer seulement les différences… dans le
traitant on enregistre une copie de la page que l’on va modifier (technique des patchs).
Mais il s’agit de calculer une bonne version… Pour CVS, c’est facile car système centralisé et
disposant d’un numéro de version de on gère les conflits à la main avant d’envoyer ses
modifications.
On ne peut pas attendre que chacun ait terminé ses acquires/release ou que chacun soit sorti
de toutes les sections critiques. Ce qui est important c’est que la cohérence causale soit
respectées modulo les release réalisés (Si P1 sait que P2 a fait un release ce release doit
apparaître) et que la cohérence séquentielle soit imposée par le programmeur au moyen des
verrous.
Solution décentralisée : le processus crie sur le réseau… tout le monde doit répondre, il
s’agit de déterminer la dernière version de chaque information…
Idée : un processus ne doit transmettre ses modifications qu’une seule fois… il s’agit de faire
le cumul des modifications… On peut conserver un numéro de version pour chaque
information… le processus diffuse le numéro de version qu’il a pour chaque information afin
de minimiser les envois…
…
upc_forall (i=0; i<100; i++; &a[i])
a[i] = b[i] + c[i];
…
Coût important upc_memcpy() memput() memget() pour minimiser le coût des traductions
Prédiction de branchement
Rappeler les différentes stratégies utilisées dans les microprocesseurs modernes pour tenter de prédire
si un branchement sera pris ou non. Comparer leur efficacité sur l’exemple suivant :
for(i=0 ; i < 1000 ; i++)
for(j=0 ; j < 10 ; j++)
if (j & 1) // i est impair
k++ ;
Décrire une expérience permettant d’apprécier la qualité de l’unité de prédiction d’un processeur.
Programmation OpenMP
Il s’agit de paralléliser le plus efficacement possible la boucle suivante (en modifiant au besoin le
code):
for(i=0 ; i < 1000 ; i++)
s += f(i) ;
3. En
supposant
que
le
temps
de
calcul
de
f(i)
ne
dépends
pas
de
la
valeur
de
i
;
4. En
supposant
que
le
temps
de
calcul
de
f(i+1)
est
toujours
(très)
supérieur
à
celui
de
f(i).
Programmation MPI
On désire paralléliser efficacement le traitement d’une séquence d’images en utilisant une grappe de pc
et en adoptant un schéma de programmation de type maître/esclave. Le maître distribue la séquence
image par image aux esclaves inactifs ; le travail (répétitif) d’un esclave consiste à réceptionner une
image, la traiter et la retourner au maître ; le maître remplace la version originale de chaque image par
celle traitée.
Écrire le pseudo code du programme en supposant qu’une image est codée par 3 tableaux (RVB) de
1024*768 octets et que l’on dispose de N esclaves. On précisera bien les appels MPI.
Instructions atomiques
Le jeu d’instructions des processeurs contemporains multicores contient une version atomique de
quelques instructions simples comme ADD, AND, CMPXCHG, DEC, INC, SUB, XOR, XCHG,…
Grâce à ces instructions on peut réaliser des verrous en espace utilisateur, comme l’illustre le verrou
suivant basé sur la fonction GETLOCK() qui elle même repose sur une version atomique de CMPXCHG.
if (GETLOCK(lock) != 0)
{
while (1)
{
if (*(volatile int*)lock) == 0)
{
if (GETLOCK(lock)) != 0)
{
goto PROTECTED_CODE;
}
}
}
BACK_OFF_LOCK(lock); // perdre du temps ou passer la main
}
PROTECTED_CODE:
En vous appuyant sur les deux courbes suivantes expliquer l’origine des gains de performance
obtenus par une application utilisant intensivement un verrou implémenté comme ci-dessus :
3) Via l’introduction de la procédure BACK_OFF_LOCK() qui fait à priori perdre du temps (tours de
boucle à vide ou passe la main à un autre thread si les appels à cette fonction sont nombreux et
rapprochés).
4) Via l’introduction d’un test à priori inutile sur la variable pointée par lock.
Performances obtenues avec ou sans la ligne Performances obtenues avec ou sans la ligne
BACK_OFF_LOCK(lock); if (*(volatile int*)lock) == 0)
Programmation OpenMP
Il s’agit de proposer un programme parallèle OpenMP qui place dans une matrice d’entiers C[N][N] la
somme de deux matrices A[N][N] et B[N][N] et qui place dans la variable NULS le nombre
d’éléments nuls de la matrice résultat C.
5) Donner deux versions de code parallèle : l’une simple et l’autre la plus optimisée possible.
Programmation MPI
Il s’agit de réaliser les communications nécessaires à la réalisation d’une DSM à base de pages. Cette
DSM sera fort simple puisque les pages ne seront pas dupliquées (un seul propriétaire à un moment
donné) et on supposera qu’il n’y a qu’un thread de calcul par processus (un seul défaut de page à la
fois par processus). En plus du thread de calcul chaque processus est équipé de deux threads de
service : un premier chargé de la communication des pages et un second chargé de la gestion des
propriétaires des pages. Lors d’un défaut de page, le thread de calcul est interrompu et le traitant
dsm_handler_readwrite()avec l’adresse fautive. Le thread de calcul émet alors une requête de page au
processus propriétaire de la page. Cette requête est réceptionnée par le thread serveur des pages qui, en
réponse, émet la page réclamée vers le processus. C’est le thread de calcul qui assurera la réception de
la page. Une fois sorti du traitant, le thread de calcul peut poursuivre son travail.
int dsm_mon_rang ;
// rang du processus par rapport à MPI_COMM_WORLD
int dsm_obtenir_proprietaire(page p) ;
// retourne le rang du propriétaire officiel de la page par rapport à MPI_COMM_WORLD