Évaluation des technologies Xeon Phi

Transcription

Évaluation des technologies Xeon Phi
Rapport de stage
Évaluation des technologies Xeon Phi
Judicaël Grasset
Master d’informatique, parcours calcul haute performance
Tuteur professionnel : Mme Isabelle D’Ast
Enseignant référent : M. Guillaume Blin
Du 1er avril au 30 septembre 2016
Remerciements
Je remercie Mme Isabelle d’Ast et M. Gabriel Staffelbach pour leur suivi et leur aide tout
au long de mon stage.
M. Guillaume Blin pour la logistique du rapport.
L’ensemble de l’équipe CSG, à savoir Mme Isabelle d’Ast, M. Fred Blain, M. Gérard Déjean,
M.Fabrice Fleury, M. Patrick Laporte, et M. Nicolas Monnier pour leur soutien et leur sympathique accueil.
Je remercie l’ensemble du personnel du CERFACS pour la bonne ambiance qui y règne.
Je remercie l’Association des Élèves de l’École Nationale de la Météorologie pour la mise en place
de cours de sport réguliers et gratuits sur le campus, ainsi qu’Oliver Guillet pour les avoir animés.
Et finalement le campus pour être un lieu agréable où travailler.
1
Table des matières
1 Introduction
1.1 Vocabulaire . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3
3
2 Mise en contexte
2.1 Présentation du CERFACS et de l’environnement
2.2 Objectifs . . . . . . . . . . . . . . . . . . . . . . .
2.3 Présentation des machines utilisées . . . . . . . .
2.4 Présentation d’AVBP . . . . . . . . . . . . . . .
2.5 Flux d’utilisation d’AVBP . . . . . . . . . . . . .
2.6 Problématique . . . . . . . . . . . . . . . . . . .
4
4
4
5
6
6
7
de travail
. . . . . .
. . . . . .
. . . . . .
. . . . . .
. . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
3 Optimisation du pré-traitement d’AVBP
8
3.1 Optimisation de code séquentiel et parallélisation . . . . . . . . . . . . . . . . . . . . 9
3.2 Optimisation de la bibliothèque Metis . . . . . . . . . . . . . . . . . . . . . . . . . . 10
4 Optimisation de la partie calculatoire d’AVBP
4.1 Validation des modifications apportées . . . . . . . . . . . . .
4.2 Parallélisation de la procédure compute_thermo . . . . . . .
4.2.1 Parallélisation de boucle . . . . . . . . . . . . . . . . .
4.2.2 Parallélisation par découpage en tâches indépendantes
4.3 Ajout d’OpenMP dans le point chaud principal . . . . . . . .
4.3.1 Variables de module . . . . . . . . . . . . . . . . . . .
4.3.2 Protection des variables de module . . . . . . . . . . .
4.3.3 Sauvegarde des solutions . . . . . . . . . . . . . . . . .
4.4 Optimisation grâce à l’offloading . . . . . . . . . . . . . . . .
4.4.1 Offload des fonctions . . . . . . . . . . . . . . . . . . .
4.4.2 Offload des variables de module . . . . . . . . . . . . .
4.4.3 Finalisation . . . . . . . . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
12
12
12
13
13
14
15
16
17
19
20
20
21
5 Études de performance
5.1 Comparatif des compilateurs
5.2 Performances originales . . .
5.3 Étude de la parallélisation . .
5.3.1 Xeon . . . . . . . . . .
5.3.2 Xeon Phi . . . . . . .
5.3.3 Cluster Lenovo . . . .
5.4 Étude de l’offloading . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
23
23
24
24
25
25
27
28
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
6 Conclusion
A Annexe
A.1 Diagramme de Gantt . . . . . . .
A.2 Autres activités . . . . . . . . . .
A.3 Code modifié du logiciel partition
A.4 Section dans compute_thermo . .
30
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
2
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
31
31
32
32
34
1
Introduction
Le stage présenté dans ce rapport est un stage de fin de master, spécialité informatique, parcours
calcul haute performance. Celui-ci s’est déroulé au CERFACS, un laboratoire de recherche de
Toulouse. Le but principal du stage est d’ajouter un niveau de parallélisme supplémentaire dans
AVBP, via OpenMP, à l’intérieur de parties parallélisées avec MPI. AVBP, est développé depuis
plus de vingt ans et bénéficie déjà d’une excellente parallélisation via MPI. Dans ce rapport nous
verrons quelles ont été les différentes étapes pour réaliser cette tâche, et les résultats obtenus.
1.1
Vocabulaire
— MPI signifie Message Passing Interface. Il s’agit d’un standard 1 permettant de répartir des
calculs sur plusieurs processus. Ces processus pouvant être exécutés sur le même processeur,
ou bien sur différents processeurs, qui peuvent eux-mêmes se trouver sur des machines différentes. Puisqu’il s’agit de processus différents, il n’y a donc pas de partage de zone mémoire
entre les processus. Si jamais il est nécessaire d’échanger des informations, on utilise les
fonctions adaptées, présentent dans le standard. Il faut ainsi savoir quel processus contient
les informations voulues. MPI a un fonctionnement de type mémoire distribué. Le standard
MPI est implémenté par plusieurs bibliothèques telles que OpenMPI 2 et IntelMPI 3 .
— OpenMP signifie Open Multi-Processing. C’est un standard[1], implémenté par les compilateurs. Il permet de paralléliser des calculs en les répartissant sur plusieurs threads. La
première version (1997) de la norme ne disposait que de peu de directives et de peu de
possibilités quant à la gestion de ces threads. Au fil du temps la norme a pu bénéficier de
nombreux apports, tels que la possibilité de spécifier des dépendances dans l’exécution des
threads créés, de demander au compilateur de générer des instructions vectorielles ou encore
la possibilité de déporter des calculs sur un accélérateur. Hormis pour le déport, OpenMP a
un fonctionnement de type mémoire partagée, c’est-à-dire que des threads différents peuvent
accéder à la même zone mémoire. Il faut donc faire attention aux accès concurrents.
— Le SMT (Simultaneous Multithreading), permet à un cœur d’exécuter plusieurs threads
ou processus simultanément sur le même cœur. Intel vend cette fonctionnalité sous le nom
d’Hyperthreading.
— L’Offloading consiste à déporter des calculs hors du processeur principal d’une machine, vers
une autre entité de calcul, comme un processeur spécialisé, qui permet de les effectuer plus
rapidement.
1. http://www.mpi-forum.org/
2. https://www.open-mpi.org/
3. https://software.intel.com/en-us/intel-mpi-library/
3
2
Mise en contexte
2.1
Présentation du CERFACS et de l’environnement de travail
Le CERFACS 4 est un centre de recherche existant depuis 1987, implanté à Toulouse sur le
campus Météo-France. Au total sept actionnaires se partagent sa gouvernance : Airbus Group,
le Cnes, EDF, Météo France, Onera, Safran et Total. Les principaux secteurs de recherche du
CERFACS sont en lien avec les activités de ses actionnaires, et sont donc : l’aéronautique et
l’automobile, l’espace, l’énergie et l’environnement.
Autour de ces thématiques, quatre équipes de recherche s’articulent :
— Environemental impact of aviation (AE)
— Climate modelling and global change (GLOBC)
— Computational fluid dynamics (CFD)
— Parallel algorithms (ALGO)
En plus de ces quatre équipes, une cinquième le Computer Support Group s’occupe de gérer
l’ensemble des stations de travail et des calculateurs. Cette équipe est composée de :
—
—
—
—
—
—
Mme Isabelle d’Ast, Ingénieure logiciel HPC
M. Fred Blain, Ingénieur en informatique
M. Gérard Déjean, Ingénieur système
M. Fabrice Fleury, Ingénieur système
M. Patrick Laporte, Ingénieur système
M. Nicolas Monnier, Chef de l’équipe informatique
C’est dans cette équipe que je fus intégré.
J’ai de plus travaillé en collaboration avec M. Gabriel Staffelbach, chercheur sénior de
l’équipe CFD, qui m’a beaucoup aidé pour comprendre le fonctionnement d’AVBP et y intégrer
mes modifications.
2.2
Objectifs
AVBP est déjà efficacement et fortement parallélisé via la bibliothèque MPI. Cela permet à
AVBP d’être utilisé sur des clusters de plusieurs milliers de cœurs. Toutefois arrive un moment
où la découpe, la répartition du calcul entre les processus MPI, entraine un surcout de calcul qui
commence à devenir important. Il serait donc intéressant de pouvoir ajouter un second niveau de
parallélisme, à l’intérieur des processus MPI. De plus pour continuer à grimper en performances,
les supercalculateurs tendent de plus en plus à utiliser des architectures basées sur un parallélisme
massif, que cela soit grâce à des accélérateurs tels que les cartes graphiques, ou bien des processeurs
plus standards mais bénéficiant d’un nombre important de cœurs. Dans les deux cas, il est souvent
4. Centre de recherche et de formation avancé en calcul scientifique, http://cerfacs.fr/
4
nécessaire de modifier ou de réécrire des parties du logiciel afin de pouvoir utiliser simultanément
l’ensemble des cœurs en y distribuant les calculs. Sans cela, il n’y aura pas de gain de performance
puisque la montée en fréquence des processeurs semble terminée.
L’objectif de ce stage est donc d’ajouter un second niveau de parallélisme, plus fin que la
répartition par bloc du maillage effectué avec MPI, en intervenant directement dans la partie
calculatoire d’AVBP, au niveau des boucles ou des fonctions en utilisant la bibliothèque OpenMP. Ce
faisant, on espère réduire l’écart de performance qui existe entre l’exécution d’AVBP sur Xeon Phi
et sur Xeon. Si cela est réussi, AVBP sera mieux préparé pour les architectures hautes performances
émergentes.
2.3
Présentation des machines utilisées
Xeon Phi
Les Xeon Phi utilisés pour les tests sont vendus sous forme de carte à brancher à une machine
déjà équipée d’un processeur, il agira donc comme un coprocesseur. Les deux cartes utilisées sont
de génération Knights Corner, datant de 2013. D’ici la fin de cette année, la nouvelle génération
(Knights Landing) sera sortie et proposera d’être utilisé directement comme processeur principal
de la machine ou en carte accélératrice comme c’est actuellement le cas. Le Xeon Phi est en
quelque sorte la réponse d’Intel face à la montée de Nvidia et de ses cartes graphiques pour le
calcul scientifique. La prochaine génération, Knights Landing (KNL) devrait normalement offrir
de meilleures performances et donc, devenir peut-être un concurrent sérieux à Nvidia. Un gros
avantage du Xeon Phi sur les cartes graphiques, est qu’il reste un processeur de type x86[2]. Il est
alors possible d’exécuter ces programmes sans avoir besoin de les réécrire. Mais afin d’obtenir de
bonnes performances, modifier les codes peut être nécessaire.
Les cartes utilisées sont constituées de 57 cœurs 5 cadencés à 1.1Ghz, chacun pouvant exécuter
jusqu’à 4 processus par cœur grâce à l’hyperthreading 6 . Chaque carte dispose de 6Go de mémoire
vive.
Autres machines utilisées
La machine principale sur laquelle j’ai travaillé et testé la parallélisation d’AVBP est une machine d’Intel, comprenant deux processeurs Xeon E5-2697 de 12 cœurs, avec hyperthreading, cadencés à 2.7 Ghz avec 64Go de RAM. C’est sur cette machine qu’étaient branchées les deux cartes
Xeon Phi.
5. Seuls 56 cœurs sont utilisables, le 57ème étant réservé au fonctionnement du système d’exploitation embarqué
dans la carte.
6. Il ne s’agit pas vraiment d’hyperthreading tel qu’il existe sur Xeon. Mais pour simplifier, nous ne considérerons
pas de différence. Se reporter à [2] pour plus de détails.
5
Le cluster Lenovo est le dernier calculateur acquis par le CERFACS (inauguré le 30 septembre
2015). Celui-ci est constitué de 252 nœuds de calculs, chacun composé de deux processeurs Xeon
E5-2680 v3 cadencés à 2.5 Ghz. Ces processeurs disposent eux aussi d’hyperthreading mais celui-ci
est désactivé sur le cluster. Les nœuds dédiés au calcul disposent de 64 Go de mémoire vive.
2.4
Présentation d’AVBP
AVBP 7 est un solveur aux grandes échelles d’écoulements réactifs compressibles diphasiques
développé au CERFACS depuis plus de vingt ans. Il bénéficie aussi d’améliorations apportées par
des contributeurs extérieurs venant par exemple de l’IFPEN 8 , de l’École centrale Paris... AVBP
permet d’effectuer des simulations de dynamique des fluides, telle que la propagation et dispersion
d’une onde, la combustion d’un gaz vaporisé, la convection d’un tourbillon... Il est actuellement
utilisé par divers groupes industriels.
AVBP est principalement écrit en Fortran 95/2003. Il bénéficie d’une très bonne parallélisation
via MPI et peut donc être exécuté sur de grands clusters (AVBP a été utilisé sur un calculateur IBM
Blue Gene Q 9 , en utilisant 296 000 processus MPI). Son code source est divisé en deux parties :
les outils (environ 160 000 lignes) et le solveur (environ 320 000 lignes). La plupart du logiciel n’a
pas été écrit par des informaticiens mais par des physiciens, le code reste ainsi très simplement
structuré et n’utilise par exemple que très peu de types dérivés. Il m’a d’ailleurs été demandé en
début de stage d’éviter de trop compliquer le code, afin qu’il puisse être lu et compris au maximum
par des personnes n’ayant pas un parcours orienté informatique.
2.5
Flux d’utilisation d’AVBP
— Prétraitement : création du maillage, décomposition de domaines du maillage, renseigner les
paramètres initiaux de la simulation, les espèces chimiques utilisées, le comportement de la
simulation aux frontières du maillage (boundary condition)...
— Exécution de la simulation (principale phase calculatoire). On simule le comportement des
espèces sur un temps donné.
— Post-traitement (non traité lors de ce stage). Extraire les données voulues des résultats de
la simulation, les visualiser.
7. http://www.cerfacs.fr/avbp7x/
8. Institut français du pétrole et des énérgies nouvelles
9. Calculateur du Laboratoire national d’Argonne : http://www.alcf.anl.gov/mira
6
2.6
Problématique
Comment accélérer l’exécution d’AVBP sur le Xeon Phi ? Et plus généralement sur les architectures hautement multicœurs ?
L’utilisation du Xeon Phi pour la parallélisation peut se faire de deux manières :
— Soit on utilise le Xeon phi comme un processeur standard en exécutant l’intégralité d’AVBP
dessus. (native mode)
— Soit on utilise le Xeon Phi comme un accélérateur en transférant dessus la partie parallélisée
et exécutant le reste sur le processeur standard. (offload mode)
Ces deux méthodes ont été implémentées et mesurées au cours du stage et seront décrites dans
ce rapport.
7
3
Optimisation du pré-traitement d’AVBP
partition est, comme son nom l’indique, un outil de partitionnement. Il permet d’effectuer la
décomposition de domaines des maillages non-structurés 10 en répartissant équitablement le nombre
de cellules et le nombre de voisins entre les différentes partitions. Ceci afin que le calcul utilisant ce
maillage puisse être exécuté efficacement sur un nombre important de processus MPI (par exemple
plusieurs milliers).
Le point important pour augmenter efficacement le nombre de processus MPI, est la minimisation du nombre de voisins tout en équilibrant la charge de travail. Il existe des bibliothèques
implémentant des algorithmes parallèles permettant de répartir de manière équilibrée les cellules,
mais sans tenir compte du nombre de voisins. C’est ce type d’algorithme qui était utilisé auparavant
pour AVBP.
Le problème est qu’au-delà du millier de processus MPI, le nombre de voisins peut devenir
élevé. Ce fût par exemple le cas, lors d’un test d’AVBP sur un IBM Blue Gene Q 11 , où la majorité
des processus avaient une quarantaine de voisins, alors que certains en avaient plusieurs centaines.
Ces derniers passaient un temps alors supérieur aux autres processus MPI pour communiquer avec
l’ensemble de leurs voisins, ce qui ralentissait donc l’exécution globale du programme (les processus
MPI ayant peu de voisins se retrouvaient à attendre ceux qui en avaient beaucoup). Ce problème
est généralement dû au fait que certains maillages ne sont pas contigus, ce qui augmente alors le
nombre de voisins, cf. figure 2.
1
2
1
3
2
1
3
(a) Maillage non-contigu
(b) Maillage contigu
Figure 2 – Contigüité de maillage. Les nombres représentent l’identifiant du processus MPI gérant
la zone du maillage
On voit sur la figure 2a que le maillage est non contigu, ce qui augmente le nombre de voisins.
La surface 1 est voisine de toutes les surfaces, alors que dans la figure 2b, la surface 1 n’a qu’un
voisin. Si ces surfaces doivent échanger avec leurs voisins les valeurs qui sont stockées aux frontières,
il vaut mieux minimiser le nombre de voisins, ce qui minimise le nombre de communications. (Sans
oublier que l’on cherche aussi à minimiser la taille des surfaces).
10. Un maillage non-structuré est un maillage où tous les nœuds n’ont pas le même nombre d’adjacences.
11. Voir note 9
8
Ainsi, afin d’obtenir de meilleures performances lors de l’augmentation du nombre de processus
MPI, le CERFACS utilise donc un outil développé en interne qui permet de répartir au mieux les
cellules et les voisins ; mais comme dit précédemment, celui-ci n’est pas parallèle et pourra donc
difficilement gagner en performances dans le futur, sur des machines ayant toujours plus de cœurs.
Ma première mission consista donc à essayer d’ajouter du parallélisme via OpenMP, afin de réduire
le temps de partitionnement.
Ce premier travail m’a permis de me familiariser avec le langage Fortran, langage que je n’avais
jamais pratiqué.[3]
3.1
Optimisation de code séquentiel et parallélisation
Le code de partition est divisé en deux parties, une écrite en C, une en Fortran. On ne s’intéressera pour le moment qu’à la partie en Fortran.
Après étude du code de partition, on remarque qu’une boucle, cf. annexe A.3, pourrait bénéficier
d’une parallélisation. Mais avant cela, on s’aperçoit qu’elle pourrait même être améliorée sans avoir
à la paralléliser, simplement en en réécrivant une partie. Il s’agit en fait d’extraire une mise à jour de
tableau de la boucle la plus imbriquée, afin de pouvoir faire générer au compilateur un memset 12 .
Auparavant cette mise à jour du tableau était faite avec une valeur constante à chaque tour de
boucle. En sortant cette mise à jour de la boucle, on peut écrire, via la syntaxe de Fortran, la mise
à jour du tableau en une seule fois. Avec cette modification, cf. code en annexe A.3 le compilateur
génère alors une instruction memset, bien plus rapide à exécuter sur un seul cœur. Après mesure,
il s’avère que cette boucle s’exécute alors environ 50% plus rapidement avec le memset qu’avec la
mise à jour incrémentale. Voir figure 3.
Malheureusement, cette réécriture de la boucle est certes efficace pour un cœur, mais elle ne
l’est pas si l’on parallélise la boucle. En effet après avoir parallélisé le code, il s’est avéré qu’il n’y
avait presque aucun gain en faisant générer au compilateur un memset. Cela est dû au fait qu’un
cœur effectuant la mise à jour de tout le tableau est plus lent que de multiples cœurs effectuant
chacun une mise à jour dans la boucle.
Pour une parallélisation efficace, un autre essai a été effectué à partir du code de base. Une
fois celui-ci parallélisé, avec un parallel do sans aucune dépendance complexe à gérer, un gain de
performance a pu être observé. En utilisant deux threads on obtient le même temps que la version
séquentielle optimisée, c’est-à-dire environ 50% de gain. Il est possible de gagner plus en augmentant
le nombre de threads, on arrive ainsi à une accélération de 5 par rapport à la version originale en
utilisant 8 threads. Voir résultats en figure 3.
En conclusion, on remarque déjà qu’il est possible d’améliorer la vitesse d’exécution en modifiant
12. memset est une fonction bas niveau permettant de remplir une zone mémoire par une constante, cet appel est
généralement bien optimisé sur les compilateurs
9
Figure 3 – Comparaison des temps de la boucle optimisée de partition. Le pourcentage le plus
faible est le mieux. Mesures prises sur bi-socket Xeon E5520 à 2.27Ghz et 24Go de mémoire vive
très légèrement le code et sans parallélisme. Mais qu’il est possible de l’augmenter encore plus, en
parallélisant une partie du code avec OpenMP.
3.2
Optimisation de la bibliothèque Metis
Pour fonctionner, le logiciel partition s’appuie sur la bibliothèque Metis 13 (écrite en C). Étant
donné qu’un temps non négligeable de cet outil de partitionnement était passé dans cette bibliothèque, il semblait intéressant d’essayer de paralléliser celle-ci pour réduire encore un peu le temps
de calcul. À noter qu’une version en partie parallélisée avec OpenMP existe déjà. Mais celle-ci ne
semble pas avoir encore implémenté les fonctions que partition utilise.
Après avoir parcouru les sources de la bibliothèque et recherché de quelle façon il serait possible
d’ajouter un peu de parallélisme qui bénéficierait à partition, une fonction fût trouvée dans le
fichier mesh.c. En effet la fonction CreateGraphDual comprenait une boucle travaillant sur un
tableau qui était un des points de consommation processeur du programme. Pour résoudre ce
problème, la solution utilisée fût de répartir les modifications apportées au tableau sur plusieurs
threads. Malheureusement cette solution nécessite un surcout mémoire (proportionnel au nombre
de threads), cf. description en formule 1 (surcout en octets).
2 ∗ N U M _ELT S ∗ (N U M _T HREADS − 1)
(1)
Sur la figure 4, on peut voir les résultats obtenus. Que cela soit pour des maillages de quelques
13. http://glaros.dtc.umn.edu/gkhome/metis/metis/overview
10
millions d’éléments ou ceux de plus d’une centaine de millions d’éléments, partition bénéficie d’une
accélération d’environ 1,2 pour le passage de 1 à 4 threads et de 1,3 pour le passage de 1 à 8 threads.
(a) Maillage d’une expérience avec flamme dipha- (b) Maillage d’une explosion en domaine confiné, ensique, quelques millions d’éléments
viron 156 millions d’éléments
Figure 4 – Temps d’exécution de partition, sur Xeon
Il n’a pas été possible de tester avec plus de 8 threads pour le plus grand cas (4b). En cause
la gestion des types 32 et 64 bits. La gestion de la mémoire dans Metis est laissée à un allocateur
spécifique à la bibliothèque. Celui-ci prend en paramètre un nombre, du type idx_t, pour spécifier
la quantité de mémoire à allouer. Ce type est un renommage du type int32_t du langage C. La
plus grande valeur qu’il peut représenter est de l’ordre de 2 milliards. Or cela est trop peu pour le
cas explosion avec 16 threads, puisque selon la formule 1 il faudrait environ 4,6 milliards d’octets.
Pour pouvoir allouer une telle quantité de mémoire, il faudrait donc utiliser des entiers sur 64
bits. Cela est facilement faisable avec Metis, il suffit de le spécifier dans un fichier d’en-tête et de
recompiler. Cela semble en revanche nécessiter plus de changements pour partition et les appels à
la bibliothèque HDF qu’il effectue.
11
4
Optimisation de la partie calculatoire d’AVBP
4.1
Validation des modifications apportées
AVBP ne dispose pas d’outils permettant de tester une partie seulement du code, tel que
le permettraient des tests unitaires. En revanche, il dispose d’outils permettant de comparer les
sorties produites, ainsi qu’un ensemble de jeux de données d’entrée.
Pour savoir si les modifications apportées au code n’entrainent pas de régression, il faut alors
produire la sortie de la version originale, celle de la version modifiée et les comparer avec les
outils d’AVBP. Ceux-ci comparent alors les valeurs numériques et indiquent l’ordre de grandeur
des différences pour chaque variable contenue dans la sortie, ainsi que le nombre de différences.
Les différents jeux de données d’entrées représentent différents cas d’utilisation du logiciel et
permettent ainsi de tester les différents embranchements et conditions de la simulation. Ci-dessous
sont listés les principaux cas de test utilisés et ce qu’ils représentent.
— Simple, représente une expérience de laboratoire de flamme diphasique avec du fioul gazeux.
— Bench_tpf_62_14m : représente une expérience de laboratoire de flamme diphasique avec
du fioul liquide.
— Karman, représente une simulation d’allée de tourbillons de Karman.
— Config5_uniform_true_huge représente une explosion dans un domaine confiné.
Pour tester la validité des modifications apportées au code, des scripts bash ont été écrits.
Ceux-ci peuvent lancer AVBP de manière à générer automatiquement des jeux de résultats depuis
le code original et le comparer aux jeux de résultats créés par le code modifié.
4.2
Parallélisation de la procédure compute_thermo
Cette partie concerne une première tentative d’ajouter du parallélisme dans la partie solveur
d’AVBP, et non plus dans ces outils. La fonction étudiée ici n’est pas le principal point chaud
d’AVBP, néanmoins il est tout de même utile de savoir si celle-ci est parallélisable.
Cette fonction est constituée d’une grande boucle effectuant de nombreuses opérations sur des
tableaux, parfois dans des boucles imbriquées. Cette fonction peut toutefois faire appel à d’autres
fonctions. Mais ces dernières ne sont appelées que lors de cas spéciaux, ces appels sont d’ailleurs
gardés par un ifdef. Le code source des fonctions appelées dans le ifdef n’est pas disponible (il
appartient à une version modifiée d’AVBP), il est impossible de savoir si elles modifient les variables qu’elles prennent en paramètre, ni si elles modifient des variables globales, elles sont donc à
considérer comme des boîtes noires.
Afin de pouvoir tout de même modifier le code, il est nécessaire d’écarter ces fonctions du champ
12
d’action. Pour cela, la solution utilisée fût de créer un double de la fonction qui ne contient pas les
fonctions gardées par le ifdef. Puis d’ajouter un nouveau ifdef à un niveau supérieur afin de choisir
quelle fonction sera appelée. En faisant ainsi, il est alors possible de modifier la fonction sans briser
un code existant dont nous n’aurions pas connaissance. Pour terminer cette fonction importe des
variables venant d’autres modules, mais ne fait que les lire. Tout le code que l’on doit modifier est
donc contenu dans cette fonction grâce aux modifications apportées.
4.2.1
Parallélisation de boucle
Paralléliser la boucle sans la modifier est possible. Pour ce faire, on utilise la directive OpenMP
parallel do sur la boucle externe. En plus de cela, il est nécessaire de préciser pour chaque variable
utilisée dans cette boucle comment doit être gérée sa mémoire. Cela se fait au moyen de mots-clés
fournis par OpenMP, tel que private, qui signifie que chaque thread aura une copie de la variable,
ou encore shared qui signifie que l’ensemble des threads exécutant la boucle devra se partager
l’accès à la variable et donc placer, implicitement ou non, des zones critiques où seul un thread à la
fois pourra modifier la variable. Cela peut poser problème si les threads ont régulièrement besoin
d’écrire dans cette variable. Puisqu’ils ne peuvent la modifier tous en même temps, ils risquent de
passer leurs temps à attendre le droit de le faire. On perd alors le bénéfice de la parallélisation.
Cette manière de paralléliser la boucle n’était pas efficace, en cause surement, la vingtaine de
variables marquées shared, ce qui entraine alors le problème que l’on vient de décrire.
4.2.2
Parallélisation par découpage en tâches indépendantes
Pour éviter ces accès non parallèles, la boucle a été découpée en plusieurs parties indépendantes.
L’idée étant de séparer les calculs effectués sur les variables à l’intérieur de la boucle en un maximum
de boucles indépendantes pouvant être exécutées en parallèles. Néanmoins certains calculs sur une
variable A peuvent nécessiter un résultat stocké dans la variable B. Toutes les boucles, même
correctement découpées, ne peuvent être exécutées en parallèle, il est donc nécessaire de spécifier
un ordre d’exécution, en respectant les dépendances de chaque variable.
Pour ce faire un graphe de dépendance a donc été créé, celui-ci permettant de savoir quelles
sont les dépendances entre les variables, et donc dans quel ordre exécuter les calculs, cf. figure 14
en annexe.
Pour implémenter cette solution, les directives sections d’OpenMP ont été utilisées. Celles-ci
permettent d’indiquer des blocs de code contenant des calculs pouvant être effectués indépendamment et donc en parallèle, cf. listing 9. À noter qu’il existe dans les normes récentes d’OpenMP, de
nouvelles directives permettant de spécifier des dépendances entre des tâches, cette solution a été
envisagée, mais n’a pas réussi à être mise en place.
Après avoir implémenté cette solution, les mesures montrèrent qu’il n’y avait encore une fois pas
13
de gain significatif de performance sur la durée totale de l’application. On en conclut donc, qu’il est
possible de paralléliser cette fonction, mais que cela n’est pas utile. Cette fonction est exécutée très
rapidement à chaque appel (de l’ordre 10−2 ou 10−3 secondes pour les cas qui ont pu être observés),
le problème venant de la répétition de ces appels. De plus, cette fonction est la seule parallélisée
avec OpenMP, les threads ne sont donc crées que pour être utilisés par celle-ci, ce qui ajoute alors
un cout.
4.3
Ajout d’OpenMP dans le point chaud principal
Afin de paralléliser efficacement le logiciel, il est nécessaire de paralléliser une partie prenant un
temps conséquent du programme. Pour pouvoir savoir quelle partie prend le plus de temps, il faut
prendre des mesures. Ces mesures seront prises principalement grâce à gprof 14 .
On peut voir sur la figure 5 la répartition du temps de calcul. La fonction prenant le plus de
temps est gather_o_cpy avec 14%, mais elle-ci est appelée plus de 35 millions de fois. Chaque
appel de cette fonction a un cout complètement négligeable (environ 10−6 s) et ne peut donc être
parallélisé. La plupart des fonctions appelées dans la fonction scheme ont des temps d’exécution
similaires.
La fonction scheme est appelée à l’intérieur d’une boucle contenue dans la fonction compute_residuals. Si chaque appel à scheme ne prend qu’un temps négligeable (environ 10−4 s), la
durée de la boucle les contenant l’est moins, et peut avoisiner des temps de l’ordre de la seconde
(dépendant du cas et de la taille du maillage). Il est donc intéressant de tenter de paralléliser la
boucle lançant les multiples appels à scheme.
À noter qu’il est surement possible d’améliorer les fonctions appelées à l’intérieur de la boucle
sur laquelle j’ai choisi de travailler. Cela se ferait non pas au niveau du parallélisme de tâche mais au
niveau des instructions. Il faudrait en effet essayer de vectoriser au maximum les étapes de calculs,
en utilisant les instructions SIMD 15 disponibles sur les processeurs Xeon via les jeux d’instructions
dédiés, tels que SSE4, AVX2 et sur Xeon Phi avec AVX-512. Cela peut se faire via les fonctions
intrinsics, petite surcouche à l’assembleur, ou en laissant le compilateur en générer lorsqu’il y arrive,
ou bien encore en indiquant au compilateur qu’il peut et qu’il doit en générer via des directives
spéciales, par exemple celles d’OpenMP 4. Ce dernier cas aurait donc très bien pu correspondre à
mes objectifs de stage. Mais un ingénieur expérimenté du CERFACS travaille déjà sur ce point et
modifie l’organisation en mémoire de nombreuses structures de données. De plus le logiciel Advisor
fourni par Intel, qui sert à indiquer quelles sont les parties du code qui ont été vectorisées et celles
qui ne le sont pas, indique qu’AVBP est déjà plutôt bien vectorisé.
Les gains potentiels que je puisse avoir semblant donc assez limités avec la vectorisation, il était
14. gprof est un outil libre de profilage de code : https://sourceware.org/binutils/docs-2.27/gprof/index.
html
15. Single Instruction Multiple Data : une même instruction est appliquée en même temps sur plusieurs données.
Par exemple deux tableaux contenant 4 doubles peuvent être additionnés deux à deux en une instruction.
14
compute_
72.20%
(0.00%)
1000×
9.16%
1000×
7.84%
1000×
42.35%
1000×
compute_residuals_
42.35%
(0.73%)
1000×
compute_gradients_
9.16%
(0.06%)
1000×
mod_compute_fe_implicit_residual_mp_compute_fe_implicit_residual_
7.84%
(0.75%)
1000×
8.79%
3687000×
mod_ngrad_mp_ngrad_
8.79%
(0.07%)
3687000×
41.34%
1229000×
mod_scheme_mp_scheme_
41.34%
(0.41%)
1229000×
6.62%
2458000×
mod_impl_scheme_mp_impl_scheme_
6.62%
(0.02%)
2458000×
2.15%
5112000×
2.06%
4916000×
7.86%
614500×
8.26%
19664000×
mod_artificial_viscosity_mp_artificial_viscosity_
7.86%
(0.03%)
614500×
0.77%
1843500×
gather_o_cpy_
14.79%
(14.79%)
35226265×
0.52%
6.44%
1229000× 614500×
9.40%
614500×
6.24%
1229000×
mod_compute_les_flux_mp_compute_les_flux_
9.40%
(0.08%)
614500×
0.49%
3687000×
scheme_expl_ttgc_
6.24%
(0.05%)
1229000×
0.49%
3687000×
colin_av_
6.44%
(0.01%)
614500×
fill_
7.37%
(7.37%)
55532013×
Figure 5 – Extrait du graphe d’appel d’AVBP sur le cas Simple
plus intéressant de travailler sur la partie parallélisation d’OpenMP, les gains potentiels y étant
plus importants.
4.3.1
Variables de module
En premier lieu, une simple directive de parallélisation de la boucle fût ajoutée. Si le programme
s’exécuta sans problème, les résultats produits s’avérèrent complètement faux. C’est-à-dire que les
valeurs de sortie du programme n’étaient pas du tout celles attendues. La procédure appelée dans la
boucle ne semble modifier aucun de ses arguments et ne renvoie pas de résultat. C’est donc qu’elle
modifie des variables globales pour stocker ses résultats.
1
2
3
4
5
MODULE mod_exemple
!Ces variables sont accessibles pour quiconque importe le module
INTEGER, DIMENSION(n) :: VAR1
...
END MODULE mod_exemple
Listing 1 – Exemple d’une variable pouvant être considérée comme globale
Afin de savoir où étaient modifiées ces variables globales, une étude du code était nécessaire.
15
Cette étude permit de découvrir que de nombreuses fonctions appelées lors des calculs agissent
selon des principes communs : elles sont encapsulées dans un module et travaillent à la fois sur des
variables locales ainsi que sur des variables déclarées au niveau du module. Ces dernières peuvent
donc être utilisées n’importe où dans le code, il suffit de les importer. Elles sont donc à considérer
comme des variables globales.
Les variables de ces modules sont généralement des tableaux à plusieurs dimensions. Ces tableaux engendreraient un surcout s’ils étaient locaux et devaient être alloués et désalloués à chaque
fois que la fonction est appelée. Ils sont ainsi déclarés au niveau du module et initialisés une seule
fois au début du programme. Si cela fonctionne très bien en temps normal, ce n’est plus le cas
lorsque plusieurs threads sont susceptibles d’appeler la fonction en même temps. En effet, un premier thread appelle la fonction, celui-ci se met à modifier les variables du module, au même moment
un second thread exécute la même fonction et se met donc lui aussi à modifier les variables globales
du module. Les deux threads vont ainsi écraser les modifications que l’autre aurait pu apporter,
faussant ainsi complètement le bon déroulement du programme.
4.3.2
Protection des variables de module
Afin de résoudre ce problème, deux solutions ont d’abord été envisagées. Soit transformer ces
variables de modules en variables locales à la fonction. Mais dans ce cas on ajouterait un surcout à chaque appel de fonction à cause des allocations et désallocations. Cela nécessiterait de
réécrire toutes les lignes de code gérant la mémoire de ces tableaux, or l’on souhaite minimiser les
changements.
Soit ajouter une dimension à chacune de ces variables pour indiquer le thread à laquelle la
variable correspond. Ce changement pourrait permettre de garder l’allocation de la mémoire au
début du programme et n’impactera donc pas les performances. En revanche cela nécessite de
modifier de nombreuses lignes de code (à chaque déclaration ainsi qu’à chaque utilisation) et réduira
la lisibilité à cause de la dimension supplémentaire.
Une autre solution serait d’utiliser l’attribut private fourni par OpenMP, celui-ci permet de
spécifier qu’une variable doit être locale à un thread. Chaque thread aurait donc sa propre copie
pour travailler. L’attribut private est précisé à l’endroit où se trouve le début de la parallélisation.
Cf. listing 2.
Nous avons donc une solution qui semble pouvoir fonctionner sans nécessiter de réécrire tout le
système d’allocation. La seule modification à apporter sera d’importer toutes les variables de module
et les déclarer private à l’endroit de la boucle de calcul principal. Toutefois la norme OpenMP définit
ce cas particulier d’utilisation de private comme étant un comportement indéfini. 16
La norme OpenMP fournit une solution pour résoudre ce cas particulier via la directive : threadprivate. Cette directive est similaire à la directive private, ainsi chaque thread possèdera sa propre
16. Cf. norme OpenMP 4.0, Exemple,
openmp-examples-4.0.2.pdf#chapter.36
Chapitre
16
36,
private.2f
http://openmp.org/mp-documents/
1
2
3
4
5
6
7
8
MODULE mod_exemple
REAL(pr), DIMENSION(:,:,:), ALLOCATABLE :: time
CONTAINS
!La subroutine f modifiera le tableau time
SUBROUTINE f()
...
END SUBROUTINE f
END MODULE mod_exemple
9
10
11
12
13
14
15
16
17
SUBROUTINE foo()
USE mod_exemple, ONLY: time
!L'attribut devrait protéger la variable time
!en créant une copie privée pour chaque thread
!$OMP PARALLEL DO PRIVATE(time)
DO i=1,n
compute() !Va appeler la fonction f
END DO
Listing 2 – Tentative de protection de la variable time
copie de la variable, mais cette fois-ci, l’attribut semble avoir été créé spécifiquement pour gérer
ces cas de variables de module.
Le point fort de l’utilisation de cette directive est qu’elle ne nécessite que très peu de modifications dans le code. Il suffit de déclarer les variables threadprivate, juste après les avoir déclarées dans
le module. Chaque thread aura donc sa propre copie (ce qui occasionnera donc un petit surcout de
mémoire), qui sera comme auparavant, alloué au début du programme. Seul bémol, AVBP utilise
son propre allocateur mémoire afin d’ajouter des informations de débogage, or cette fonction ne
peut être appelée de façon concurrente. Tous les threads ne pourront donc pas allouer leur mémoire au même moment. Cela devra se faire de façon séquentielle, cf. listing 3. Heureusement cela
n’impactera pas les performances puisque cela n’arrive qu’une unique fois, au début du programme.
Cette solution à base de threadprivate a donc été implémentée. Ce qui a permis de réduire les
différences avec les solutions générées par le code original. Il restait toutefois encore des différences
à traiter.
4.3.3
Sauvegarde des solutions
Afin de pouvoir travailler plus efficacement, on décida de réduire le nombre de cas tests à
modifier, cela réduit par conséquent le nombre de fonctions appelées lors de l’exécution et donc
le nombre de fonctions dont il faut vérifier le bon comportement lors d’une exécution avec la
parallélisation d’OpenMP. Les modifications se feront de manières plus progressives, en ajoutant
les cas de tests au fur et à mesure, après les avoir faits fonctionner l’un après l’autre.
17
1
2
3
4
5
MODULE COMPUTE_X
INTEGER, DIMENSION(:,:,:), ALLOCATABLE :: tmp_array_3d
!$OMP THREADPRIVATE(tmp_array_3d)
...
END MODULE COMPUTE_X
6
7
8
9
10
11
MODULE ALLOC_MEM
...
!$OMP PARALLEL !Zone parallèle exécutée par plusieurs threads
!$OMP CRITICAL !Zone critique à l'intérieur de la zone parallèle
!Exécuté par plusieurs threads, mais seulement un à la fois.
12
13
14
15
16
17
18
19
!Init() alloue la mémoire des variables marquées threadprivate
!Ces variables auront une zone mémoire différentes sur chaque thread
CALL INIT()
!$OMP END CRITICAL
!$OMP END PARALLEL
...
END MODULE ALLOC_MEM
Listing 3 – Initialisation des zones mémoires marquées threadprivate pour chaque thread
De plus, il était nécessaire de vérifier dans quel ordre sont effectués les opérations, celui-ci
influençant le résultat lors d’opérations sur des flottants. J’ai donc ajouté la directive omp ordered
qui force un bloc de code d’une zone parallèle à être exécuté dans le même ordre que s’il était
exécuté par un seul thread. J’ai donc d’abord englobé toute ma partie parallèle dans ce bloc, ce qui
revient à exécuter le code séquentiellement. Puis réduit petit à petit la portée de ce bloc, jusqu’à
ce que les résultats redeviennent faux. Cela m’a permis de localiser les portions de code où l’ordre
des opérations avait de l’importance.
Ces portions ayant le besoin d’être ordonnées correspondaient aux moments où les résultats
locaux étaient accumulés dans les variables globales servant à stocker les résultats finaux. Ces
opérations étaient effectuées une fois par variable et par tour de boucle, réparties en divers endroits
du code. La seule solution trouvée pour garder l’ordre des opérations a été d’extraire le code de
mise à jour hors de la boucle, de sauvegarder toutes les solutions intermédiaires et de les appliquer
après l’exécution de la boucle. Cette solution permet de résoudre le problème simplement, sans
apporter de grosse modification au code, les solutions générées sont ainsi exactement les mêmes
entre la version originale du code et ma version parallélisée avec OpenMP. En revanche, le fait de
sauvegarder les solutions intermédiaires entraine un surcout mémoire important.
Le surcout mémoire provient de deux sources, le premier est d’environ 5Mo par thread et
provient de la duplication des tableaux de travail internes aux fonctions appelées dans scheme, que
chaque thread duplique en début de programme.
Le second provient de la sauvegarde ordonnée des solutions afin de les utiliser de manière
18
déterministe et non pas dans l’ordre (aléatoire) dans lequel les threads les calculent. Cela engendre
un second cout plus important, de l’ordre de la centaine de Mo pour le cas Simple, mais non
dépendant du nombre de threads.
Les méthodes tout justes décrites, threadprivate et la sauvegarde des solutions, ont pu être
réutilisées avec succès par M. Gabriel Staffelbach pour paralléliser une autre partie d’AVBP.
4.4
Optimisation grâce à l’offloading
L’offloading consiste à déporter des calculs depuis le processeur principal de la machine vers
une autre entité (coprocesseur, carte graphique) capable de les exécuter plus rapidement. Ce déport
est généralement effectué en réécrivant le code en langage spécifique à l’accélérateur (par exemple
CUDA, OpenCL), ou via des directives que l’on ajoute au code originel et qui permettront au
compilateur de générer automatiquement le code pour l’accélérateur (par exemple avec les directives
OpenMP, OpenACC). Il est aussi possible d’utiliser une bibliothèque conçue pour être exécutée sur
l’accélérateur et de l’appeler depuis son propre code (MKL, OpenCV...).
Pour mon stage, j’ai travaillé avec une carte Xeon Phi d’Intel et implémenté la méthode consistant à utiliser des directives pour spécifier au compilateur ce qu’il doit déporter. J’ai commencé
par utiliser les directives spécifiques au compilateur à Intel, car la plupart des exemples et documentations que j’ai pu lire les utilisaient, il était donc plus facile pour moi d’utiliser celles-ci pour
débuter. Une fois les bases comprises et bon nombre d’expérimentations effectuées, j’ai utilisé les
directives OpenMP (apparues dans la norme 4 de 2013) afin de ne plus dépendre du compilateur
d’Intel. Le passage de l’un à l’autre se fait sans problème puisque la logique est la même, il suffit
de les réécrire avec la syntaxe et le vocabulaire spécifique à OpenMP.
Pour pouvoir déporter une partie de l’exécution du programme sur l’accélérateur il est nécessaire
de spécifier dans le code source quelles sont les parties que l’on veut déporter. Ainsi demander
l’exécution sur l’accélérateur se fait ainsi :
1
2
3
4
5
!$OMP TARGET DEVICE(num_device)
DO i=1,n
CALL compute()
END DO
!$OMP END TARGET
Listing 4 – Offloading d’une boucle via OpenMP
19
4.4.1 Offload des fonctions
Toutes les fonctions appelées, dans la boucle que l’on souhaite déporter, doivent être marquées
comme étant déportables. C’est-à-dire qu’il faut ajouter une directive pour indiquer au compilateur
qu’il doit compiler cette fonction pour l’architecture de l’accélérateur.
De plus si une fonction que l’on a marquée comme déportable fait appel à d’autres fonctions,
alors il faut aussi marquer ces dernières comme déportable. Ainsi, moins la routine à déporter est
imbriquée, c’est-à-dire qu’elle n’est pas une routine de calcul indépendante, plus il sera nécessaire
d’apporter des modifications dans les sources.
1
2
3
4
5
6
SUBROUTINE f(...)
!$OMP DECLARE TARGET(f,g,h,z)
CALL g(...)
CALL h(...)
CALL z(...)
[...]
Listing 5 – Déclaration de déport d’une fonction
Afin d’accélérer la mise ne place de l’offload, il a été choisi de ne pas porter des parties du
code qui n’étaient dédiées qu’au débogage. Ces fonctions de débogage contenaient des appels à
des fonctions de la bibliothèque MPI en C ce qui semblait compliquer le déport, d’après les tests
effectués.
4.4.2 Offload des variables de module
En plus de marquer les fonctions comme étant déportables, il faut aussi marquer toutes les
variables de module, à la déclaration (dans le module donc) ainsi que dans chaque fonction déportée
qui utilise une de ces variables. Cela nécessite d’ajouter de petites quantités de code un peu partout
dans les sources. Cela n’a rien de difficile mais est long et fastidieux.
Toutefois certaines variables ont posé problème. En effet toutes celles qui avaient été marquées
threadprivate (cf. listing 3) ne pouvaient pas être déportées, le compilateur informant que cela
n’était pas possible. Il a donc fallu se résoudre à utiliser une des autres solutions qui avaient été
auparavant écartés. De nombreuses modifications au code furent alors apportées afin de changer la
portée de ces variables et de les rendre locales à la fonction qui les utilisait.
Un autre problème important, est l’impossibilité de déporter des variables de type dérivé contenant des tableaux alloués dynamiquement. Ce problème a été difficile à comprendre. Il n’y avait
aucun problème à la compilation, seulement une erreur de segmentation à l’exécution, lorsque le
programme tentait d’accéder aux tableaux à l’intérieur de la variable. Le problème a d’abord été
pris pour simple erreur d’allocation comme les autres, mais ajouter la variable dans la liste des
20
variables à allouer ne changeait rien, de plus le problème n’apparaissait que lorsque l’on accédait
aux tableaux qu’elle contenait. Après quelques tests, le problème a été attribué à l’utilisation de ce
cas spécifique : type dérivé contenant des tableaux alloués dynamiquement. Cela a été corroboré
par une discussion sur le forum officiel d’Intel 17 . Celle-ci nous apprend que des personnes ont déjà
été confrontées à ce problème, avec la version 2013 du compilateur. Des développeurs ont répondu
qu’un rapport de bug avait été remonté. Malheureusement, aujourd’hui avec les compilateurs ifort16
et ifort17 le problème existe toujours.
Un palliatif a donc été mis en place, afin de passer outre ce problème. Celui-ci extrait les tableaux
de la structure afin de les passer un à un en paramètre à la fonction.
Marquer les variables est la première chose à faire, mais ce n’est pas la seule. Il faut aussi indiquer
le moment où elles doivent être allouées sur l’accélérateur et celui où elles doivent être synchronisées
entre l’accélérateur et le processeur standard. C’est-à-dire qu’il faut placer des directives pour
indiquer le moment où la valeur contenue dans une variable doit être envoyée sur l’accélérateur,
et le moment où l’on récupère une valeur depuis l’accélérateur. Dans mon cas, cela correspondait
à retrouver toutes les variables globales qui étaient utilisées dans les sous-fonctions déportées, les
importer, leur allouer une zone mémoire sur l’accélérateur, et si besoin les initialiser en transférant
une valeur depuis la mémoire locale vers celle de l’accélérateur. Cf. le listing 6 qui résume les
différentes étapes du déport des variables et de l’exécution sur l’accélérateur. Je n’avais au début
pas compris qu’il fallait demander l’allocation via une directive pour les variables importées depuis
des modules, je pensais qu’il suffisait de les avoir marquées comme déportables au moment de
leur déclaration et dans les fonctions où elles étaient utilisées pour qu’une zone mémoire leur soit
réservée sur le Xeon Phi.
4.4.3
Finalisation
Une fois toutes ces modifications effectuées, le programme a pu être exécuté sans erreur de segmentation. Toutefois cela n’est pas encore parfait. Les résultats numériques obtenus avec l’offloading
sont quelque peu différents des résultats obtenus avec le Xeon seul. Néanmoins les différences sont
suffisamment minimes pour ne pas fausser les résultats. Il a toutefois été essayé de les minimiser
en utilisant les différentes options du compilateur d’Intel, tel que changer le niveau d’optimisation
globale, la précision globale, forcer l’utilisation de fonction mathématique plus précise, interdire
certaines opérations vectorielles non disponibles sur Xeon. Mais cela n’a jamais réussi à les faire
disparaitre toutes, et bien souvent ces changements d’options avaient un impact négatif sur les
performances. Il semble donc qu’il faille accepter une légère différence dans les résultats obtenus
sur ces deux architectures x86.
17. https://software.intel.com/en-us/forums/intel-many-integrated-core/topic/393561
21
1
2
3
4
SUBROUTINE f(...)
USE mod_foo, ONLY: var1, var2, var3, var4...
USE mod_bar, ONLY: bvar1, bvar2, bvar3, bvar4...
...
5
6
INTEGER, DIMENSION(:,:,:), ALLOCATABLE :: local_array
7
8
9
!Allocation de la mémoire sur l'accélérateur
!$OMP TARGET DATA(ALLOC: var1, var2, var3, bvar1, bvar2...)
10
11
12
!Envoi de la valeur de la variable du processeur vers l'accélérateur
!$OMP TARGET UPDATE(TO: var1, var2, var3, bvar1, bvar2...)
13
14
15
16
17
!Début du déport de code. A partir de ce point le code est exécuté sur l'accélérateur
!(plus récupération dans une variable locale
!de certains résultats de calculs effectués en offload)
!$OMP TARGET DEVICE(num_device) MAP(FROM: local_array)
18
19
20
21
22
23
24
25
26
!On exécute une zone parallèle sur l'accélérateur
!$OMP PARALLEL DO
DO i=1,n
CALL compute(local_array)
END DO
!$OMP END TARGET
!$OMP END TARGET DATA
...
Listing 6 – Exemple de déport de code avec déport de variables (locales et importées depuis des
modules)
22
5
Études de performance
Au cours du stage, j’ai été amené à mesurer les performances du code modifié par rapport aux
performances du code existant. Pour ce faire, un ensemble de fonctions, en bash, a été écrit, et a
évolué au cours du stage suivant les besoins. Ces fonctions visaient à lancer aisément les prises de
mesures, récolter les résultats, et calculer les temps médians. Ceci pour les différents jeux de tests,
en jouant avec le nombre de processus MPI et le nombre de threads OpenMP, le tout pouvant
prendre les mesures sur le Xeon et sur le Xeon Phi.
5.1
Comparatif des compilateurs
Des prises de mesures ont été effectuées afin de connaitre les performances d’AVBP suivant
les versions du compilateur d’Intel utilisé. Cela permet de vérifier qu’il n’y a pas de régression du
compilateur, dû à un bug, ou à des changements dans les options qui lui sont passées. Des mesures
de performances ont donc été effectuées avec les trois dernières versions des compilateurs Intel,
ifort15, ifort16 et ifort17 bêta sur le code original. Comme on peut le voir sur la figure 6 il ne
semble pas y avoir de régression, au contraire on observe globalement une légère augmentation des
performances à chaque nouvelle version.
D’après Intel Advisor, le code généré par le compilateur ifort17 est moins vectorisé que celui
généré par ifort15. On pourrait alors penser qu’il devrait être moins performant. Or ce n’est pas le
cas. On peut donc penser qu’ifort15 générait des instructions vectorielles là où elles n’étaient pas
avantageuses.
Le compilateur ifort17 a donc été utilisé pour prendre la plupart des mesures (sauf sur le cluster
Lenovo, car non installé) puisqu’il offre les meilleures performances.
(a) Bi-socket Xeon
(b) Xeon Phi
Figure 6 – Évolution des performances du compilateur Fortran d’Intel au fil des versions, sur
bi-socket Xeon E5-2697 et sur Xeon Phi
23
5.2
Performances originales
(a) Temps d’exécution du cas Simple sur Xeon, en (b) Temps d’exécution du cas Simple sur Xeon Phi
fonction du nombre de processus MPI
en fonction du nombre de processus MPI
Sur la figure 7a, on peut voir le cas de test Simple mesuré avec différents nombres de processus
MPI. Ces temps correspondent au temps du logiciel originel, tel qu’il m’a été fourni. 18 On peut
y voir qu’AVBP est donc déjà plutôt bien parallélisé, chaque ajout de processus MPI diminuant
le temps d’exécution, en tout cas sur les cœurs physiques, car cette mesure est exécutée sur une
machine équipée de deux processeurs de 12 cœurs hyperthreadé. Au-delà de 24 processus, il y a
donc utilisation des cœurs logiques (hyperthreading), c’est-à-dire que plusieurs processus MPI sont
exécutés sur le même cœur physique du processeur.
Sur la figure 7b, on peut voir le même cas de test, avec un nombre moindre d’itérations, mais
s’exécutant cette fois sur le Xeon Phi. Ce Xeon Phi ne disposant que de 57 cœurs physiques, les
mesures avec plus de 57 processus MPI utilisent alors l’hyperthreading. On peut remarquer que les
temps d’exécution sont toutefois bien plus élevés, dans le meilleur cas, le Xeon Phi met environ
180s pour exécuter 25 itérations alors que le Xeon met environ 140s pour en exécuter 200.
Comme l’on pouvait s’y attendre, le Xeon Phi est plus lent que le Xeon, car le code n’a pas
été écrit pour ce type de processeur. La prochaine génération (Knights Landing) devant réduire
l’écart entre un Xeon Phi et un Xeon. On remarque aussi que dans les deux cas, l’hyperthreading
n’apporte pas de gain.
5.3
Étude de la parallélisation
Dans cette partie sont montrées quelques-unes des prises de mesures effectuées durant le stage.
Ces prises de mesures permettent d’observer les résultats obtenus avec l’ajout d’OpenMP dans
AVBP, et de les comparer à la version originale purement MPI.
18. Git, numéro de commit : b9dad7faf3abfa9ef61bee8404f3d37be03d3cae
24
5.3.1
Xeon
Sur la figure 8, on peut voir les temps écoulés pour l’exécution du cas Simple sur 200 itérations.
On voit que l’ajout de parallélisation via OpenMP est bénéfique, puisque la partie passée dans le
calcul de la fonction scheme profite d’une accélération de 1,65 dans le cas de 6 processus MPI et 2
threads, et d’une accélération de 2.5 en passant à 4 threads par processus, cf. figure 8a.
(a) 6 processus MPI
(b) 12 processus MPI
Figure 8 – Temps d’exécution du cas Simple sur Xeon, en fonction du nombre de threads OpenMP
et de processus MPI
On remarque sur la figure 8b que lorsque l’on utilise l’hyperthreading du Xeon (passage à 4
threads et 12 processus MPI, utilisation des 48 cœurs logiques de la machine), cela ne bénéficie pas
à la partie parallélisée avec OpenMP, le temps passé dans scheme reste le même que lorsque seul 2
threads étaient utilisés avec 12 processus MPI (utilisation des 24 cœurs physiques de la machine).
En revanche, le reste du programme est ralenti.
5.3.2
Xeon Phi
Le Xeon Phi sur lequel les tests ont été effectués ne dispose que de 6Go de mémoire vive.
Ainsi peu de cas de tests pouvaient y être exécutés. De plus, la consommation mémoire ayant été
augmentée afin de pouvoir paralléliser avec OpenMP, encore moins de cas de tests peuvent être
exécutés sur le Xeon Phi.
Sur la figure 9, on peut ainsi remarquer que le cas Simple ne permet plus d’utiliser un grand
nombre de processus MPI, contrairement à ce qui était possible auparavant, cf. figure 7b. Au
niveau des performances, on s’aperçoit que l’ajout de parallélisme via OpenMP est positif, avec
une accélération de l’ordre de 3.6 avec 4 threads, 5.3 pour 8 threads pour 14 processus MPI. En
revanche, suite à l’augmentation de la consommation mémoire, il n’a pas été possible de tester
au-delà de 28 processus MPI sur le cas Simple. Difficile donc de savoir si les gains se maintiendront,
ou bien s’il serait simplement plus utile de lancer un grand nombre de processus MPI.
25
(a) 14 processus MPI
(b) 28 processus MPI
Figure 9 – Temps d’exécution du cas Simple sur 25 itérations en fonction du nombre de processus
MPI et du nombre de threads OpenMP sur Xeon Phi
Pour tenter de répondre à ce problème, un autre cas a été mesuré, figure 10. Celui-ci est bien
moins couteux en mémoire et peut donc être exécuté avec la version d’AVBP modifiée sur bien plus
de processus MPI sur Xeon Phi, mais il est aussi bien moins couteux en calcul par itération. La
partie OpenMP est donc moins utile, puisque le programme passe son temps à y entrer pour faire
très peu de calcul et en ressortir aussitôt.
On peut toutefois y voir que le Xeon Phi profite bien de l’ajout de parallélisme puisque à chaque
fois que l’on augmente le nombre de threads, les performances s’améliorent. À noter que dans la
configuration à 28 processus MPI et 8 threads par processus, nous sommes dans une situation où
l’on utilise le maximum de la carte, c’est-à-dire que l’ensemble des cœurs physiques sont utilisés et
que chacun exécute 4 threads, on utilise donc tous les cœurs logiques de la carte. Et au contraire
du Xeon, l’utilisation de ces cœurs logiques apporte un bénéfice.
Cette tendance continue si un processus est placé sur chaque cœur physique du Xeon Phi (donc
56 processus MPI), et que l’on exécute 4 threads sur chaque, utilisant ainsi tous les cœurs logiques
de la carte. Les résultats montrent que le temps passé dans la fonction scheme est ainsi presque
réduit de moitié, passant de 22% à 13% du temps total.
26
(a) 28 processus MPI
(b) 56 processus MPI
Figure 10 – Temps d’exécution du cas Karman sur Xeon Phi, en fonction du nombre de threads
OpenMP et de processus MPI
5.3.3
Cluster Lenovo
Utiliser ce cluster permet de se rapprocher des cas d’utilisations réels d’AVBP, c’est-à-dire de
cas utilisant plusieurs nœuds de calculs. Le nombre maximum de cœurs que je peux obtenir de ce
cluster est de 360 (15 nœuds de 24 cœurs).
Les mesures sont prises sur 15 nœuds, chacun lançant un processus MPI. On fait ensuite varier
le nombre de threads afin de savoir si la partie parallélisée avec OpenMP bénéficie effectivement de
cet ajout. Notons qu’il n’y a cette fois-ci aucune utilisation de l’hyperthreading dans les prises de
mesures car celui-ci est désactivé sur le cluster.
Figure 11 – Temps d’exécution du cas Simple sur 200 itérations en fonction du nombre de threads
OpenMP sur 15 nœuds du cluster Lenovo (15 processus MPI, un sur chaque nœud)
Les résultats se trouvent sur la figure 11. On y voit que le temps attribué à la fonction scheme
27
diminue effectivement lorsque l’on augmente le nombre de threads. Lors de l’utilisation du nombre
maximum de threads (24, donc tous les cœurs physiques du nœud), le temps de calcul passé dans la
fonction scheme est devenu minimal (environ 45s, soit environ 4% du temps total) alors que celui-ci
occupait presque la moitié du temps de calcul lorsqu’il était effectué sur un unique thread (965s
soit environ 45% du temps total).
5.4
Étude de l’offloading
Sur la figure 12, on voit les résultats des mesures de l’offload sur le cas Simple, avec 6 processus
MPI lancés sur le Xeon et la boucle exécutant scheme déportée sur le Xeon Phi et parallélisée avec
OpenMP.
Figure 12 – Offload du cas Simple, sur Xeon avec déport sur Xeon Phi. 6 processus MPI exécutés
sur le Xeon
On peut remarquer tout d’abord, que quel que soit le nombre de threads, le temps de transfert
des données entre le Xeon et le Xeon Phi est constant. Néanmoins ce temps prend une part importante de l’exécution, environ 12.5% dans le cas à 28 threads. De plus, puisque le temps de transfert
est constant mais que le temps d’exécution total diminue grâce à l’augmentation du nombre de
threads, le cout du transfert est de plus en plus important. Il passe ainsi à 18.5% pour le cas à 56
threads et 22% pour le cas à 112 threads. Il n’a pas été possible de mesurer l’utilisation de la carte
avec le nombre maximal de threads (224 threads, un thread par cœur logique), car le programme
utilise trop de mémoire vive. Idem pour les tests à plus de 6 processus MPI.
Comparée à la version 6 processus MPI sur Xeon seulement, la version offload est plus lente.
Ainsi en comparant les temps passés dans la fonction scheme, on s’aperçoit que le meilleur temps
de la version avec offload (853s) est plus élevé que le plus mauvais temps d’une exécution sur Xeon
seul (environ 200s, 6 processus MPI, 1 thread par processus).
On en conclut que dans ce cas, utiliser le Xeon Phi pour déporter la boucle d’exécution de
28
scheme n’est pas bénéfique. D’autant plus que de nombreuses modifications ont été apportées au
code afin de permettre ce déport. D’après ces résultats et ceux obtenus précédemment, cf. 5.3.2,
il vaut mieux utiliser le Xeon Phi pour exécuter l’ensemble du programme plutôt que de l’utiliser
comme un accélérateur en y déportant quelques routines.
29
6
Conclusion
Au cours de ce stage ont été implémentées deux méthodes différentes pour essayer d’augmenter
les performances d’AVBP sur le Xeon Phi.
La méthode du déport de code afin d’utiliser le Xeon Phi comme accélérateur ne semble pas
concluante, car elle nécessite de modifier beaucoup de code pour des performances dégradées comparé à l’usage du Xeon seul. Le cas Simple, avec 6 processus MPI est exécuté en environ 400s sur
le Xeon seul alors qu’il est exécuté en environ 1080s sur le Xeon plus déport sur Xeon Phi (112
threads).
L’utilisation du Xeon Phi seul, c’est-à-dire exécuter l’intégralité d’AVBP dessus, est toujours
moins efficace que l’exécution sur Xeon. Toutefois l’écart de performance a pu être réduit. Ainsi le
cas Simple (25 itérations) sur Xeon Phi avec 14 processus MPI prenait auparavant environ 590s et
ne prend qu’environ 350s avec la version modifiée sur 4 threads. De plus, ce gain a pu être obtenu
sans avoir à apporter de lourdes modifications au code. La méthode employée a de plus pu être
réutilisée par un chercheur du Cerfacs afin de paralléliser une autre partie d’AVBP.
On regrette toutefois de ne pas pouvoir exécuter plus de tests faute de mémoire. La prochaine
génération de Xeon Phi (Knights Landing) pourra accéder directement à la mémoire vive de la
machine et résoudra donc ce problème.
Finalement, la parallélisation via OpenMP semble aussi pouvoir bénéficier à l’exécution d’AVBP
sur Xeon. On a ainsi vu que la méthode scheme pouvait bénéficier d’un ajout de parallélisme lors
d’une comparaison à nombre de processus MPI égal, sur le cluster Lenovo, à 15 processus MPI dans
les deux cas, la version parallélisée avec OpenMP utilisant 24 threads est deux fois plus rapide que
celle utilisant MPI seul.
30
A
Annexe
A.1
Diagramme de Gantt
Le digramme de Gantt de la figure 13 permet de montrer approximativement la répartition du
travail au cours du stage.
Apr
May
Jun
Jul
Aug
Formation
Partition
Thermo
OpenMP &
AVBP
Offloading
Mesures des
bindings
Prises de
mesures
Rapport
Figure 13 – Utilisation du temps imparti pour le stage
— «Formation», correspond à ma semaine d’arrivée au CERFACS, j’ai pu bénéficier d’une
semaine de formation à l’utilisation du logiciel que je devais modifier.
— «Partition» correspond aux modifications apportées à l’outil d’AVBP servant à partitionner
les maillages. Cf. 3
— «Thermo» correspond aux premières modifications apportées au code d’AVBP, et non plus
de ses outils. Cf. 4.2
— «OpenMP & AVBP», il s’agit là de l’objectif principal de ce stage. Ajouter du parallélisme
avec OpenMP à l’intérieur de parties déjà parallélisées grâce à MPI. Section 4 de ce rapport.
— «Offloading» correspond à l’implémentation de l’utilisation du Xeon Phi comme un accélérateur en exécutant qu’une partie des calculs dessus. Cf. la partie 4.4.
— «Mesures des bindings», recherche du meilleur placement des processus et threads sur les
cœurs du processeur afin d’obtenir la meilleur performance possible.
— «Prises de mesures», correspond aux principaux moments passés à prendre des mesures, que
cela soit pour prendre le temps du code original ou de celui modifié. Principalement décrit
dans la section 5.
31
A.2
Autres activités
— Formation AVBP
Lors de mon arrivée au CERFACS, j’ai pu bénéficier d’une semaine de formation, sous
forme de présentation et de travaux pratiques, à l’utilisation du logiciel AVBP. Cela m’a
permis d’apercevoir l’utilisation qui en est faite par les chercheurs. Je n’ai pas pu réellement
comprendre car le niveau requis en physique était bien au-delà de ce que j’étais capable de
fournir. J’ai donc pu faire les TP, grâce au sujet, mais je n’ai pu comprendre ce à quoi il
servait ni les méthodes que l’on employait pour résoudre les systèmes.
— Présentation Intel Advisor
Un ingénieur d’Intel est venu faire une présentation 19 du logiciel Intel Advisor. Celui-ci
permet d’étudier le niveau de vectorisation du code. Cette présentation fût assez utile car
cela m’a permis de comprendre plus vite comment fonctionne ce logiciel, et de l’utiliser pour
mesurer le niveau de vectorisation d’AVBP.
— «HPC Code Modernization» 20 par Bayncore Ltd., Micro Sigma, Intel
J’ai pu participer à une journée de conférences sur les techniques de modernisation de code,
visant à augmenter la vectorisation et le parallélisme. Ces conférences étaient animées par
des personnes de la société Bayncore Ltd. qui vend des conseils dans le domaine du HPC, et
par Micro Sigma qui vend des licences de logiciels. Les conférences semblaient en fait avant
tout destinées à des gens étant plus physiciens qu’informaticiens mais qui souhaiteraient
améliorer leurs codes.
— Présentation de produits Nvidia
J’ai pu assister à une réunion commerciale, où deux personnes de chez Nvidia sont venus présenter au CERFACS des machines composées essentiellement de cartes graphiques. N’ayant
jamais eu l’occasion d’assister à des réunions de présentation de machines, j’ai trouvé cela
tout à fait intéressant. Cela m’a permis d’entrevoir comment sont effectuées les négociations
lors de l’achat de nouvelle machine.
A.3
Code modifié du logiciel partition
On peut voir aux lignes 7 et 9 du listing 7 que les tableaux element_to_type et element_weight
vont être parcourus de 1 à lcl_cnt(np,i) par l’indice k. Pendant ce parcours on leur attribuera
une nouvelle valeur dépendant de i. Or i est l’index de la boucle supérieure, il ne changera donc
pas pendant la boucle d’indice k. On peut donc sortir ces deux mises à jour de tableau de la
boucle la plus imbriquée et utiliser la syntaxe de Fortran pour les mettre à jour, cf. listing 8. Cela
permet d’annoncer plus clairement nos intentions au compilateur, et donc lui permet d’optimiser
plus facilement le code. Dans ce cas le compilateur a généré un memset, ce qui permet à cette
boucle de s’exécuter deux fois plus vite.
19. http://cerfacs.fr/en/event/unlock-next-gen-hardware-performance-secrets-for-your-hpc-codes/
20. http://www.inteldevconference.fr/
32
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ofs=0
k=0
element_csr(k)=0
DO np=1,nparts
DO i=1,ELEMENT_TYPE_CNT
DO j=1,lcl_cnt(np,i)
element_to_type(k) = i
! Use number of vertices in element as element wait for multi-element meshes
element_weight(constraints*k) = elmnt_vtx_cnt(i)
k=k+1
ofs=ofs+elmnt_vtx_cnt(i)
element_csr(k)=ofs
END DO
END DO
END DO
Listing 7 – Extrait du code original de partition
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ofs=0
k=0
element_csr(k)=0
DO i=1,ELEMENT_TYPE_CNT
DO np=1,nparts
DO j=1,lcl_cnt(np,i)
element_csr(k+j)=elmnt_vtx_cnt(i)*j+ofs
END DO
IF (lcl_cnt(np,i) > 1) THEN
element_to_type(k:k+lcl_cnt(np,i)-1) = i
!Use number of vertices in element as element wait for multi-element meshes
element_weight(constraints*k:constraints*(k+lcl_cnt(np,i)-1)) = elmnt_vtx_cnt(i)
END IF
k=k+lcl_cnt(np,i)
ofs=ofs+lcl_cnt(np,i)*elmnt_vtx_cnt(i)
END DO
END DO
Listing 8 – Optimisation du code de partition sans ajout de parallélisme.
33
A.4
Section dans compute_thermo
ssbar
hsk
Xi_k
dP_drhok
esk
beta
gamma
bbeta
Cp_bar
alpha
pressure
rbar
Cv_bar
ind
W_bar
rhoinv
Figure 14 – Graphe de dépendances des mises à jour des variables
!$OMP PARALLEL
!Les calculs de ce bloc sont effectués en parallèle
!$OMP SECTIONS
!$OMP SECTION
!calcul de la variable alpha2
!$OMP SECTION
!calcul de la variable ind2
!$OMP END SECTIONS
!Une fois les précédents calculs terminés, ceux de ce bloc seront exécutés en parallèle
!$OMP SECTIONS
!$OMP SECTION
!calcul de la variable W_bar
!$OMP SECTION
!calcul de la variable esk
!$OMP END SECTIONS
!$OMP END PARALLEL
Listing 9 – Parallélisation par tâche en utilisant les SECTIONS
34
Références
[1] OpenMP Architecture Review Board. OpenMP Application Programming Interface, 4.5 edition,
november 2015.
[2] Jim Jeffers and James Reinders. Intel® Xeon Phi™ Coprocessor High-Performance Programming. Elsevier, 2013.
[3] Michael Metcalf, Malcolm Cohen, and John Reid. Fortran 95/2003 Explained. Oxford University
Press, 2004.
35