CUDA-aware MPI : le cocktail détonnant !
By   |  July 11, 2013

Faire cohabiter CUDA et MPI, c’est bien. Tirer avantage des spécificités du premier pour nettement accélérer le second, c’est mieux. Voici comment réunir efficacement les deux API star du calcul intensif…

Si le HPC est ce qu’il est aujourd’hui, c’est en grande partie grâce à MPI (Message Passing Interface), l’API standardisée pour l’échange de données par passage de messages entre processus distribués. MPI est très largement utilisé, notamment sur clusters et gros systèmes, pour coder des applications capables d’évoluer en même temps que l’infrastructure matérielle sur laquelle on les exécute. 

D’un autre côté, il y a CUDA, qui lui-même est conçu pour le développement et l’accélération de codes parallèles exécutables sur un seul ordinateur ou un seul nœud. Logiquement, les raisons de vouloir combiner les deux approches sont donc nombreuses. L’une des principales est de permettre la résolution de problèmes manipulant des ensembles de données trop volumineux pour tenir dans l’espace mémoire d’un seul accélérateur GPU, ou qui exigeraient des temps de calcul démesurément longs s’ils n’étaient pas distribués. On peut également souhaiter accélérer une application MPI existante avec des GPU, ou upgrader un code mono-nœud multi-GPU pour qu’il puisse tourner sur plusieurs nœuds. Grâce à l’approche dite CUDA-aware MPI, tous ces objectifs peuvent être atteints facilement et efficacement. Le but de cet article est de vous en présenter le fonctionnement, les grands principes de mise en œuvre et les bénéfices en termes de performances.

Rappels essentiels sur MPI

Avant d’expliquer ce qu’est précisément CUDA-aware MPI, rappelons quelques éléments essentiels concernant MPI. Cette petite introduction servira de base au lecteur peu familier de l’API et permettra d’apprécier les enjeux du mariage. Les processus à l’œuvre dans un code MPI bénéficient d’espaces d’adressage privés. C’est grâce à cela qu’un applicatif MPI peut être exécuté sur un système à mémoire distribuée. Le standard MPI définit un protocole et un jeu d’instructions de passage de messages qui couvrent les messages point-à-point ainsi que les opérations collectives telles que les réductions. L’exemple du listing 1 en montre une implémentation basique : dans le  programme, le processus 0 envoie le message “Salut !” au processus 1 qui affiche ensuite un accusé de réception. Notez que, en terminologie MPI classique, un processus est appelé “rang” (rank, dans la littérature anglophone), comme indiqué par l’appel à MPI_Comm_rank(). Ce programme peut être compilé et linké avec les wrappers fournis par les différentes implémentation MPI disponibles, de la façon suivante :

Source mpicc . c – o monappli

Pour démarrer monappli, on utilise typiquement le lanceur MPI mpirun. C’est lui qui se charge de démarrer de multiples instances du programme et de les distribuer sur les nœuds du cluster d’exécution,  à partir d’une commande de type

mpirun -np 4 ./appli


MPI en mode CUDA

Avant d’entrer dans les détails techniques, signalons que plusieurs implémentations de MPI compatibles CUDA sont disponibles. Outre les versions “brandées” (IBM Platform MPI, Cray MPI, HP-MPI…), vous disposez aussi de versions Open Source telles que MVAPICH2 1.0 ou Open MPI 1.7.1. Comment CUDA intervient-il dans le contexte MPI ? Reprenons l’exemple du listing 1 et remarquons que ce sont des pointeurs vers la mémoire système qui sont passés aux appels MPI. En effet, MPI commande que seuls les pointeurs vers la mémoire hôte peuvent être passés en messages. Or, quand on utilise CUDA, on a souvent besoin d’envoyer des buffers GPU plutôt que des buffers système. Classiquement, il faut donc passer par la mémoire hôte pour rendre l’opération possible, comme au listing 2. Avec une bibliothèque compatible CUDA, ce n’est plus nécessaire : les buffers GPU peuvent être passés directement aux appels MPI, comme le montre le listing 3.

Fig. 1 – A gauche, pas d’UVA = adresses mémoire multiples.A droite, UVA = espace d’adressage unifié.

Voilà, en très résumé, la raison d’être de CUDA-aware MPI. Nous allons voir maintenant comment l’utiliser et en quoi cette approche est plus efficace que le staging au travers de la mémoire système.

Selon que les buffers se trouvent en mémoire hôte ou en mémoire déportée, une implémentation MPI compatible CUDA doit les gérer de façon différente. Elle pourrait logiquement exposer des instructions distinctes, selon le cas, ou un argument spécifique permettant d’indiquer où réside le buffer concerné. En réalité, aucune de ces acrobaties n’est nécessaire. Depuis CUDA 4.0, l’Unified Virtual Addressing (UVA) combine la mémoire hôte et la mémoire de tous les GPU présents dans un nœud donné en un seul espace d’adressage virtuel. Comme le montrent la figure 1, l’UVA détermine la localisation d’un buffer par les octets de poids forts de son adresse, de sorte qu’il n’y a pas besoin de modifier l’API de MPI.

Avantages annexes

Le bénéfice en termes de simplicité de codage est donc patent, mais il n’est pas le seul. Le gain en efficacité est lui aussi bien réel, et cela pour au moins deux raisons. D’une part, l’ensemble des opérations nécessaires pour transférer les messages peut être “pipeliné”. D’autre part, certaines technologies d’accélération matérielles comme GPUDirect peuvent être utilisées par MPI de façon totalement transparente pour le développeur et l’utilisateur.

Fig. 2 – A gauche, pas de GPUDirect pour le passage de messages en peer-to-peer à l’intérieur d’un même nœud. A droite, grâce à GPUDirect P2P, les buffers se copient directement entre les espaces mémoires des GPU.

L’avantage, avec GPUDirect, c’est que les communications avec les GPU bénéficient d’une large bande passante et d’une latence très réduite. Techniquement, l’appellation GPUDirect recouvre en effet plusieurs technologies distinctes. Et dans un contexte MPI, ces technologies embrassent l’ensemble des communications entre rangs : à l’intérieur d’un nœud, entre nœuds en mode classique et entre nœuds en mode RDMA (Remote Direct Memory Access). A l’intérieur d’un nœud, on profite des fonctions de communication peer-to-peer de GPUDirect, disponibles depuis CUDA 4.0. Les buffers peuvent ainsi être copiés directement entre les espaces mémoire de deux GPU, comme le montre la figure 2. Entre les nœuds, le support des accès RDMA  arrivé avec CUDA 5.0 permet d’envoyer les buffers directement de la mémoire des GPU vers les interfaces réseau sans passer par la mémoire hôte. Le gain en performance est donc lui aussi immédiat (cf. figure 3), comme d’ailleurs à chaque fois que deux périphériques court-circuitent le CPU.

Fig. 3 – A gauche, pas de GPUDirect pour la copie de buffers entre les nœuds. A droite, grâce au mode RDMA de GPUDirect, les buffers passent directement de la mémoire du GPU à celle de l’interconnexion.

Il faut également mentionner un troisième bénéfice concret découlant de GPUDirect. Pour bien l’appréhender, revenons un instant sur le concept de mémoire paginée (pageable). La mémoire système allouée via malloc est généralement paginée. Autrement dit, les pages mémoire peuvent être déplacées par le kernel, par exemple pour décharger des données sur le disque dur. Mais la pagination mémoire n’est pas sans impact sur la copie de données en DMA ou en RDMA. Ces transferts travaillent en effet indépendamment du CPU, et donc indépendamment du noyau de l’OS, de sorte qu’il faut éviter de déplacer les pages mémoire pendant leur copie. L’opération qui consiste à désactiver le déplacement de pages mémoire est appelée “pinning “. D’où le nom pinned memory appliqué à la mémoire qui, ne pouvant être déplacée, devient utilisable pour les transferts DMA et RDMA. Cette configuration a pour avantage d’accélérer les transferts entre l’hôte et les accélérateurs, comme le montre la figure 4. D’où son introduction dès CUDA 3.1, pour permettre au pilote réseau et au pilote CUDA de partager un buffer commun, afin d’éviter des memcpy en mémoire hôte.   

Fig. 4 – A gauche, l’absence de GPUDirect nécessite un mécanisme coûteux pour le transfert de pages mémoires vers l’interconnexion. A droite, GPUDirect permet aux pilotes réseau et CUDA de partager un buffer commun.

Pour bien voir comment ces techniques et les buffers intermédiaires affectent les communications MPI, reprenons l’exemple du listing 3. On y lit que le rang MPI 0 envoie un buffer GPU au rang MPI 1, qui le reçoit dans un autre buffer GPU. L’opération reste dans le cadre d’instructions MPI classiques, en l’occurrence MPI_Send et MPI_Recv. La figure 5 illustre ce qui se passe concrètement. En fonction de l’implémentation MPI, de la taille du message ou du protocole, quelques détails peuvent varier mais le principe et les conclusions restent les mêmes : en utilisant RDMA, les opérations mises en transparence sont évitées.

Fig. 5 – Economie des opérations (mises en transparence) obtenue grâce la combinaison des technologies GPUDirect. Légende des symboles : A = buffer GPU. B = buffer hôte pour mémoire paginée. C = buffer CUDA “pinned” en mémoire hôte. D = buffer réseau “pinned” en mémoire hôte. E = transfert DMA par bus PCIe. F = opération memcpy en mémoire hôte. G = message réseau RDMA.

Si GPUDirect RDMA est disponible, le buffer peut être déplacé directement sans qu’à aucun moment on n’ait à faire intervenir la mémoire hôte. Les données passent de la mémoire de l’accélérateur du rang MPI 0 à la mémoire de l’accélérateur du rang MPI 1 via une séquence PCIe DMA > RDMA > PCIe DMA. Selon la taille du buffer, l’opération nécessitera peut-être plusieurs itérations, mais le principe fonctionnel et la séquence (R)DMA sont identiques.

Fig. 6 – Par rapport au schéma précédent, les opérations se compliquent lorsque GPUDirect n’est pas disponible sur un des périphériques de la chaîne de communication (carte réseau incompatible, par exemple). Il faut en effet d’abord déplacer le buffer “pinned” du pilote CUDA vers le buffer “pinned” de l’interface réseau en mémoire hôte du rang MPI concerné – puis vice-versa.

Si GPUDirect n’est pas disponible, par exemple si la carte réseau ne le supporte pas, la situation est un brin plus complexe. Le buffer doit d’abord être déplacé du buffer pinned du pilote CUDA vers le buffer pinned de l’interface réseau en mémoire hôte du rang MPI 0. Après cela, il peut être envoyé par le réseau. Dès réception, le rang MPI 1 devra effectuer les opérations précédentes en sens inverse (figure 6).

Bien que cela implique de multiples transferts en mémoire, le temps d’exécution pour certains d’entre eux peut être masqué par l’exécution de transferts DMA via PCIe, de copies en mémoire hôte et de transferts réseau en mode pipeline, comme l’illustre la figure 7.

Fig. 7 – Schéma de principe du pipeline de transmission de messages si GPUDirect n’est pas disponible.

Par contraste, avec une implémentation MPI non compatible CUDA, l’obligation de faire passer les données via la mémoire hôte comme au listing 2 a des conséquences lourdes. Car non seulement une copie de données est nécessaire dans la mémoire hôte de chaque nœud, mais en plus le pipeline sera ralenti après le premier appel cudaMemcpy et après le MPI_Recv du rang MPI 1, ce qui va fortement ralentir l’exécution (figure 8).

Fig. 8 – Schéma de principe du passage de message dans un contexte MPI non compatible CUDA.

On pourrait éventuellement implémenter un pipeline plus efficace par l’intermédiaire de flux CUDA et de copies mémoire asynchrones, mais les résultats obtenus au final ne tiendraient quand même pas la comparaison avec ce que donne un CUDA-aware MPI mobilisant GPUDirect.

La preuve par 9

Regardons (figure 9) ce que donnent les différentes implémentations évoquées à l’épreuve de benchmarks MPI dédiés bande passante et latence. Les tests mesurent les temps d’exécution pour l’envoi de messages de tailles croissantes d’un buffer associé à un rang MPI 0 vers un buffer associé à un rang MPI 1. Ils sont réalisés en utilisant MVAPICH2 1.9b et deux cartes Tesla K20 installées dans deux nœuds connectés en InfiniBand FDR. La latence pour le passage d’un message d’un seul petit octet est de 19 microsecondes en MPI classique, 18 microsecondes en MPI compatible CUDA avec accélération GPUDirect entre réseau et stockage, et 1 microseconde en communication hôte à hôte. Quant à la bande passante crête pour les trois configurations, elle atteint respectivement 1,89 Go/s, 4,18 Go/s et 6, 19 Go/s. Voilà qui est parlant, non ?

Fig. 9 – La preuve par neuf avec MVAPICH2 v1.9b. En rouge, l’implémentation Device-to-Device. En vert, l’implémentation Device-to-Device avec GPUDirect. En bleu, l’implémentation Host-to-Host.

© HPC Today 2024 - All rights reserved.

Thank you for reading HPC Today.

Express poll

Do you use multi-screen
visualization technologies?

Industry news

Brands / Products index