Calculs Parallèles et Algorithmes
Calculs Parallèles et Algorithmes
3.1 Introduction
Pour effectuer un calcul parallèle, il faut réussir à le découper en petites tâches et à répartir ces tâches sur
les différents processeurs. Sur une machine à mémoire distribuée, il faut aussi que les données nécessaires aux
calculs soient elles-mêmes réparties. La manière d’agencer cette répartie est parfois le problème principal du
calcul parallèle.
Le cas le plus simple est celui d’un tableau de taille n réparti entre p processeurs : chaque processeur possède
une portion de taille n/p du tableau. Plus précisément, le processeur de rang i possède A [i np ∶ (i + 1) np ]. On
parle alors de répartition « par bloc ».
Dans ce contexte, le motif générique de la « transformation » (map) est facile à paralléliser :
for (int i = 0; i < n; i++)
B[i] = f(A[i], i)
Tous les B[i] peuvent être calculés indépendamment les uns des autres en parallèle : chaque processeur calcule
« sa » portion du tableau B à partir de « sa » portion du tableau A. Les différents processeurs n’ont pas besoin
de communiquer. Si le temps de calcul de f ne dépend pas trop de la valeur de ses arguments, alors la charge
de calcul sera équitablement répartie.
Algorithme S (Somme d’un tableau). Il s’agit de calculer la somme d’un tableau A de n éléments sur une
machine où p processeurs partagent la mémoire. On suppose que n est un multiple de p pour se simplifier la vie.
S1. [Somme locale]. Pour 0 < i < p, le processeur #i fait : Scratch[i] ← sum(A[i*n/p : (i+1)*n/p]). Ceci
est fait en parallèle par tous les processeurs, il n’y a pas de dépendance de données.
33
S2. [Barrière]. Attendre que tous les processeurs aient fini l’étape précédente.
S3. [Portion séquentielle]. Le processeur #0 calcule la somme du tableau Scratch puis l’écrit dans la variable
sum.
S4. [Barrière]. Tous les processeurs attendent que le processeur #0 ait fini l’étape précédente.
Algorithme R (Somme d’un tableau, méthode en arbre). Mêmes hypothèses que pour l’algorithme S.
R1. [Initialisation]. Allouer un tableau Scratch de taille ⌈n/2⌉ dans la mémoire partagée.
R2. [Somme]. Pour tout 0 ≤ i < n/2, faire (en parallèle) : Scratch[i] ← A[2i] + A[2i + 1]. Enfin, si n est impair,
faire Scratch[n/2] ← A[n − 1].
R3. [Récursion]. Invoquer l’algorithme R récursivement pour calculer la somme du tableau Scratch (de taille
⌈n/2⌉). Stocker le résultat dans une variable x.
Le nombre d’appel récursif est précisément de ⌈log2 n⌉. Tant que p ≤ n/2, alors on peut affirmer que l’étape R2
n
nécessite un temps 2p avec p processeurs. Le tout va donc prendre un temps n/p+log2 p, qui est minimal lorsque
p = n/2 et vaut alors 1 + log2 n.
De plus, dans le cas d’une machine à mémoire distribuée, des communications seront fatalement nécessaires
entre les processeurs, car aucun ne possède entièrement le tableau A, donc aucun ne peut calculer sum tout seul.
La coopération est nécessaire. La solution la plus simple à programmer « à la main » est probablement celle de
l’algorithme S. Heureusement, il y a MPI_Reduce...
Combien de temps ceci va-t-il prendre ? Le plus gros problème se situe-t-il dans le terme de latence ou dans
celui de volume (ou les deux ?)
Les messages sont de taille n/p. D’après le modèle, le temps nécessaire à l’envoi d’un de ces messages est
α + n/p ⋅ β. Le processus racine garde sa part, et il envoie p − 1 messages aux autres avec leurs parts. Le tout
prend donc un temps :
n n
(p − 1) (α + β) = (p − 1)α + (p − 1) β
p p
Le terme de volume est le même que dans la borne inférieure : il est donc optimal. Par contre le temps de latence
est trop grand. Il est linéaire alors que la borne inférieure est logarithmique.
Pour gagner du temps, on peut exploiter du parallélisme. Cette stratégie d’organisation des communications est
assez générale et s’applique à de nombreuses situations.
Méthode de l’arbre binaire Supposons que les processus soient numérotés à partir de 1 (en pratique il
suffit d’ajouter un à leur rang). L’idée consiste à agencer les processus sur un arbre binaire complet. La racine
est le processus 1. Le fils gauche du processus i porte le numéro 2i et son fils droit porte le numéro 2i + 1.
t=0 1
t=1 2
t=2 4 3
t=3 8 5 6
t=4 9 10 12 7
t=5 11 13 14
t=6 15
L’idée c’est que 1 transmet à 2 un message qui contient non seulement la « portion » de 2, mais aussi de tous
ses descendants. Il s’agit donc d’un message dont la taille est environ la moitié de n. Ensuite, 1 envoie à 3 un
message qui contient sa « portion » ainsi que celle de ses descendants. Dès réception, 2 et 3 peuvent faire la
même chose, en envoyant des messages de taille ≈ n/4 à leurs descendants.
A priori, le premier et le dernier processus à recevoir leur portion sans en ré-emettre sont ceux qui se trouvent
le plus à gauche et le plus à droite, respectivement. Il s’agit de ceux qui portent les numéros 2h et 2h+1 − 1, où h
représente la hauteur de l’arbre, c’est-à-dire le nombre maximal d’arêtes à traverser pour atteindre une feuille
depuis la racine. On rappelle que h = ⌊log2 p⌋.
Il faut que tous les noeuds de la forme 2i aient reçu un message de leur père (de rang 2i−1 ), avant qu’ils puisse
en envoyer un à leur fils gauche (de rang 2i+1 ). Le nombre de messages qui doivent transiter avant que le noeud
2h ait reçu sa part est donc h.
Les noeuds de profondeur i ≥ 1 (c.a.d. qui sont reliés à la racine par un chemin qui traverse i arêtes) reçoivent
des messages qui contiennent :
p+1
Ni = i − 1
2
« portions » du tableau de départ (on rappelle qu’une portion est de taille n/p). Ceci pourrait se justifier par
recurrence sur i (c’est un bon exercice qui est laissé au lecteur !). Cependant, on va se contenter de dire que
les processus de profondeur i reçoivent moins de (p − 1)/2i portions. En effet, la racine cherche à répartir p − 1
portions sur les autres processus, et chaque processus transmet à ses enfants moins de la moitié de ce qu’il
reçoit, vu qu’il garde sa propre portion.
Le volume total de données qui circule sur le réseau avant que 2h ait reçu sa part est donc :
n h
V = ∑ Ni
p i=1
n h p−1
≤ ∑
p i=1 2i
n h 1
≤ ∑
p i=1 2i
n
≤ (p − 1)
p
Pour le noeud 2h+1 − 1, la différence est que tous les noeuds qui se trouvent sur le chemin qui le relie à la
racine vont d’abord envoyer un message à leur fils gauche, avant d’en envoyer un à leur fils droit. Le nombre de
messages envoyés avant que 2h+1 − 1 reçoive sa part est donc le double (2h), et le volume de données qui doit
circuler sur le réseau est aussi le double.
Voyons maintenant le temps que cette méthode requiert pour réaliser Scatter. Le volume total qui doit transiter
sur le réseau avant que le dernier processus ait reçu sa part est donc de l’ordre de 2n, tandis que le nombre de
messages qui doit circuler est de l’ordre de 2 log p. On va donc avoir un temps d’exécution qui ressemble à :
p−1
T ≤ 2 ⌊log2 p⌋ ⋅ α + 2n ⋅ ⋅ β.
p
Le terme de latence est logarithmique en p, ce qui est un gros progrès par rapport à la méthode précédente.
Par contre, le terme de volume est deux fois plus gros qu’avant. Si la taille des messages est très grande, cette
méthode risque de mettre deux fois plus longtemps que la précédente. En fait, on obtient exactement deux fois
la borne inférieure.
Un des avantages de cette méthode c’est qu’elle est très facile à programmer même si le nombre de processus
n’est pas de la forme 2k − 1 : il suffit d’ignorer tous les envois à destinations de processus inexistants, qui sont
tous au dernier niveau de l’arbre, sur la droite.
Méthode de l’arbre binomial On peut améliorer un peu la méthode précédente, en remarquant que le
processus racine, après avoir glorieusement envoyé deux messages, se tourne les pouces le reste du temps.
On peut donc lui confier davantage de travail. On suppose qu’il y a 2h noeuds numérotés de 0 à 2h − 1. Au
départ la racine doit disperser un tableau qu’elle possède sur les processus de l’intervalle [0; 2h [. La procédure
est la suivante :
— Pour disperser un tableau sur l’intervalle [a; b[, on repère le processus du milieu c = (a + b)/2.
— On envoie un message contenant les portions de l’intervalle [c; b] à c.
— On s’occupe ensuite de disperser l’intervalle [a; c[, tandis que c s’occupe de disperser l’intervalle [c; b[.
t=0 0
t=1 8
t=2 12 4
t=3 14 10 6 2
t=4 15 13 11 9 7 5 3 1
Cet arbre, appelé « arbre binomial » possède 2h noeuds et il est de hauteur h (il réapparaît dans une structure
de donnée amusante, les « tas binomiaux »).
Tous les noeuds qui sont à distance d de la racine reçoivent leurs portions en même temps. Pour voir que c’est
vrai, le plus simple est d’étudier la structure des arbres binomiaux. Ceci nous dépasse un peu, mais on va
admettre qu’un arbre binomial de hauteur h, qu’on notera Bh est formé d’une racine, sur laquelle sont branchés
h arbres binomiaux de hauteurs 0, 1, 2, . . . , h − 1. Un arbre binomial de hauteur zéro est formé d’un seul noeud.
Dans le dessin ci-dessus, « 0 » est la racine d’un B4 , tandis que « 8 » est celle d’un B3 ; « 12 » et « 4 » sont
celles d’un B2 , etc.
Ceci étant dit, on voit que les noeuds qui sont les racines de Bi reçoivent tous leurs messages en même temps
(ceci pourrait se justifier plus formellement par récurrence : faites-le !). Un Bi comporte 2i noeuds, donc les
messages reçus par les racines de Bi contiennent 2i portions de taille p/n. Prenons le Bi le plus à gauche, et
remontons le chemin qui le relie à la racine : il reçoit son message après que le Bi+1 qui est son père ait lui-même
reçu le message du Bi+2 qui est son propre père, etc.
Par conséquent, les Bi reçoivent leur message après que h − i messages aient été envoyés, et ces messages sont
de tailles respectives 2i , 2i+1 , . . . , 2h−1 . Envoyer ces messages prend un temps :
n h−1 i
T = α(h − i) + β ∑2
p k=i
On en déduit le temps d’exécution de la méthode pour faire un Scatter complet. Il suffit pour cela de regarder
quand est-ce que les racines des B0 , qui n’ont donc pas d’enfants, ont reçu leur message. D’après ce qu’on a dit
précédemment, c’est au bout d’un temps :
h−1
T = αh + ∑ 2i
k=0
n
= αh + (2h − 1) β
p
n
= αh + (p − 1) β.
p
Et ceci est en fait exactement la borne inférieure qu’on avait trouvée ci-dessus.
Algorithme de l’anneau L’idée est relativement simple. S’il y a p processus dans le communicateur, l’algo-
rithme s’exécute en p phases. Dans la première phase, le processus i envoie « ses » données au processus i + 1
(tout le monde fait ça en même temps). Du coup, le processus i reçoit les données du processus i − 1. Bien sûr,
tout ceci se fait avec les rangs modulo p.
Dans les phases suivantes, le processus i transmet au processus i + 1 les données qu’il a reçues dans la phase
précédente. De la sorte, au bout de p − 1 phases, tout le monde a reçu les données de tout le monde.
Notons n la quantité totale de données qui doit être rassemblée sur chacun des processus. La « contribution »
de chaque processus est donc de taille n/p. Dans chaque étape, chaque processus envoie et reçoit une quantité
de donnée n/p. Si on néglige la contention du réseau, tous ces échanges peuvent se faire simultanément.
On s’attend donc à ce que cela prenne un temps :
n
Tring = (p − 1)α + (p − 1) β
p
Dans cette expression, le terme de bande passante (celui avec β) ne peut pas être amélioré : chaque processus
doit recevoir n/p données depuis p − 1 autres processus. Par contre, le terme de latence (celui avec α) peut être
amélioré.
Algorithme du doublage récursif On peut en effet mettre sur pied un algorithme très classique avec
O (log p) phases au lieu de O (p) phases. On va supposer que le nombre de processus est une puissance de deux,
donc que p = 2k . Quand ce n’est pas le cas, la technique fonctionne aussi mais les détails sont sordides.
1: r ← MPI_Comm_Rank()
2: for 0 ≤ i < k do
3: t ← r XOR 2i
4: Envoyer au processus de rang t toutes les contributions connues
5: Ajouter ce qu’on reçoit de t à la liste des contributions connues
6: end for
0 1 2 3 4 5 6 7
i=0
i=1
i=2
Chaque processus envoie et reçoit k = log2 p messages, dont la taille double à chaque phase. Chaque processus
envoie et reçoit la même quantité de données que dans le cas précédent. Le temps que tout ceci prend est donc :
n
T2rec = α log2 p + (p − 1) β
p
Comparaison En théorie, il semble donc que la méthode du doublage récursif soit incontestablement meilleure
(puisque le terme de latence est toujours plus faible). La réalité est malheureusement un peu plus compliquée.
Quand les volumes de données sont petits, et que la latence domine les temps de communication, l’avantage de
la méthode de doublage récursif devrait être plus prononcé — et des études empiriques confirment ce fait.
Par contre, quand les volumes de données sont importants, la latence n’a plus beaucoup d’importance car c’est
alors la bande passante qui est le facteur déterminant. Les deux algorithmes ont pourtant la même bande
passante.... Mais voilà : dans l’algorithme en anneau, on ne communique qu’avec ses voisins, tandis que dans
l’algorithme de doublage récursif on communique avec des noeuds lointains. Selon la configuration du réseau
(liens directs avec les voisins...) on peut obtenir moins de congestion, et une meilleure bande passante avec
l’algorithme en anneau. Sur certaines machines, l’algorithme en anneau va deux fois plus vite dès que les
messages font quelques Mo.
« Flat-tree » La technique la plus simple pour faire le broadcast consiste à envoyer les données aux p − 1
autres processus successivement. Ceci prend un temps :
Tf lat = (p − 1)(α + nβ)
« Binomial-Tree » La technique en arbre est simple (surtout si p = 2k ). Elle s’accomplit en log2 p phases.
Chaque processus reçoit un message, et en expédie deux, de sorte à tracer un arbre binomial. Lors de la
première étape, root contacte root + p/2. Les deux processus se considèrent alors comme les deux racines de
deux nouveaux arbres, et contactent chacun le processus qui est « au milieu » de leurs sous-arbres respectifs, et
ainsi de suite.
Il y a log2 p étapes, et donc le tout prend un temps :
⌈log2 p⌉ (α + nβ)
On a donc un algorithme dont le terme de latence est optimal, mais le terme de bande passante est O (log p)
fois trop grand ! Cet algorithme est donc adapté aux petits messages.
Algorithme de Van de Geijn L’idée consiste à accomplir le broadcast en effectuant d’abord un scatter suivi
d’un all-gather. En effectuant le scatter avec l’algorithme optimal (en arbre binomial), on a un temps :
n
Tscatter = α ⋅ log2 p + (p − 1) β
p
Comme on s’intéresse en priorité au cas des grands messages, on peut supposer qu’on utilise l’algorithme en
anneau pour le all-gather. Cela donne donc un temps total pour le broadcast de :
n
T = α ⋅ (p − 1 + log2 p) + 2(p − 1) β
p
Le terme de bande passante est meilleur que dans l’algorithme de l’arbre binomial... Et il n’est plus que 2 fois
plus grand que la borne inférieure.
M x y
On peut utiliser au maximum n processeurs, car chaque processeur doit posséder au moins une ligne de la
matrice. En terme de calculs, chaque processus doit multiplier une matrice rectangulaire de taille n/p × n par
un vecteur de taille n. Ceci nécessite n2 /p opérations.
Pour ce qui est des communications, c’est dans le « all-gather » que tout se passe, et on a déjà vu la complexité
de cette opération. Le temps total d’exécution de cette stratégie est donc :
n2 n
T= + α log2 p + (p − 1) β
p p
° ´¹¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¸¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¶
calcul communication
On note qu’avec n processeurs, on trouve T = O (n). Le temps de calcul domine asymptotiquement les commu-
nications lorsque n augmente, donc a minima le weak-scaling devrait être assez bon.
3.4.2 Stratégie n°2 : Pi possède la i-ème tranche des colonnes de M
Essayons de prendre le problème autrement, et d’éviter cette coûteuse opération collective au début.
Si le processus de rang i (qu’on va noter Pi ) possède la i-ème tranche des colonnes de M , alors il n’a besoin
que de la i-ème tranche de x, qu’il possède déjà par hypothèse sur la pré-distribution des données. « Jusque-là,
tout va bien ».
M x yi
Il y a donc malgré tout bien une opération de re-distribution des données, mais en plus une opération de
réduction ! Il faut calculer la somme des yi que possèdent chaque processus, puis répartir le tout par « tranches »
entre tout le monde. C’est précisément de ce que fait « Reduce-Scatter ».
Comme la complexité de « Reduce-Scatter » est sensiblement la même que celle de « all-gather », et que la
quantité de calcul est la même que précédemment, on trouve une complexité comparable à celle de la première
stratégie. On n’a donc rien gagné à ce stade.
... √ √
M0,0 M0,1 0 p 2 p
√
M1,0 M1,1 ... 1 p+1 ...
⋮ ⋮ ⋱ ⋮ ⋮ ⋱
√
p−1 p−1
M M
Le vecteur x est réparti sur tous les processus comme précédemment : le processus de rang r possède la r-ème
√
tranche de x. Cela signifie que les processus de la première colonne de M possèdent collectivement les p
première tranches de x.
Examinons maintenant le calcul que le processus (i, j) doit accomplir. Il possède Mi,j ainsi qu’une portion de
taille n/p de x (en noir sur le dessin de droite ci-dessous). Mais le problème, c’est que pour calculer un produit
√
avec Mi,j , il lui faudrait une tranche de x plus grosse, de taille n/ p (hachurée sur le dessin) :
×
√
taille n/p taille n/ p
M x y x
Imaginons donc que x et y sont découpés à la fois en p « petites tranches » de taille n/p, qu’on note toujours xi
√ √ ̃i et Ỹi .
et yi , mais aussi en p « grosses tranches » de taille n/ p, qu’on notera X
√ √
Chaque « grosse tranche « , de taille n/ p est constituée de p « petites tranches » de taille n/p. La première
grosse tranche X ̃i est composé des petites tranches
̃ est composée de x0 , x1 , . . . , x√p−1 . De manière analogue, X
√ 0 √
d’indices [i ⋅ p; (i + 1) ⋅ p[. Vu la numérotation des processus, ces petites tranches se trouvent réparties sur
les processus de la i-ème colonne.
̃i et produit une contribution à Ỹi . On a en effet :
On voit donc que le processus (i, j) a besoin de X
√
p−1
√
̃i ,
Ỹi = ∑ Mi,j × X (0 ≤ i < p)
j=0
Pour la première étape, où les processus de chaque colonne doivent mutualiser leurs petites tranches pour former
la grosse tranche dont ils ont tous besoin, on fait un « all-gather ». Pour la troisième étape, où les processus
de chaque ligne doivent sommer leur contribution, puis se la dispatcher, on fait un « reduce-scatter ». Ces deux
√ √
opérations collectives concernent des tableaux de taille n/ p, et p processus y participent.
̃i sont répartis sur les colonnes tandis qu’à la fin les Ỹi sont répartis sur
Le petit détail, c’est qu’au début les X
les lignes. Ceci peut se régler avec une dernière étape :
4. Pour tout(i, j), les processus (i, j) et (j, i) effectuent une communication point-à-point pour échanger leur
petite tranche de y.
La quantité de calculs est la même que précédemment : il s’agit de calculer le produit d’une matrice carrée de
√
taille n/ p et d’un vecteur de la bonne taille. Ceci nécessite n2 /p opérations.
√ √
Les deux opérations collectives coûtent chacune la même chose, à savoir environ α log p + ( p − 1) np β. Enfin,
la dernière phase de « transposition », nécessite que chaque processus envoie et reçoive un message de taille n/p,
donc ceci ajoute un temps α + n/p ⋅ β. Au final, on trouve donc :
n2 √ n
T= + (1 + log2 p) α + (2 p − 1) β
p p
° ´¹¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¸¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¹ ¶
calcul communication
n2 n
T≤ + α log2 p + 2 √ β + O (1)
p p
L’intérêt principal de cette méthode est que la quantité de communications est plus faible que dans les deux
premières stratégies (elle décroît avec p).
Il est de plus possible possible d’utiliser plus de n processeurs. La limite naturelle est n2 : on ne peut pas donner
moins qu’un seul coefficient de la matrice à un processeur. Mais on peut répartir la matrice en n2 si on a envie.
Le petit détail, c’est qu’on ne peut pas répartir le vecteur x en plus que n2 . En pratique ce n’est pas un très gros
problème. On peut envisager que seuls certains processus possèdent des tranches de x, ou bien que plusieurs
processus possèdent la même tranche. Ce n’est pas très gênant.
Si on dispose de p = n2 processeurs, le temps de calcul par processus tombe à O (1), de même que le terme de
volume des communications. Tout ce qui reste est le terme de latence, qui est de l’ordre de O (log n). On se
heurte alors à une borne inférieure, car chaque tranche de y dépend de toutes les tranches de x, or il faut log2 n
messages successifs pour que tout ce petit monde puisse contribuer.