É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