Pour réaliser une tâche de calcul de grande envergure, comme la simulation complète de la dynamique des fluides autour d'une voiture de course, un seul ordinateur peut être insuffisant. Pour pallier ce problème, il est possible de regrouper plusieurs serveurs et de former un cluster de calcul.
Dans ce contexte, un facteur clé de succès est de disposer d'un bon réseau d'interconnexion entre les différents serveurs (que nous appellerons les nœuds de calcul). Si ce n'est pas le cas, le temps gagné en puissance de calcul supplémentaire sera perdu en temps de communication accru.
Dans cet article, nous verrons comment mettre en place un cluster haute performance à partir de zéro, en utilisant une technologie d'interconnexion appelée RoCE. L'objectif est d'exécuter des applications utilisant MPI.
L'utilisation de RoCE nécessite l'emploi d'une carte réseau (NIC) compatible. Dans notre cas, il s'agit de cartes Mellanox ConnectX-4. L'ensemble de la procédure décrite dans cet article devrait être similaire pour les autres cartes fabriquées par Mellanox. Les serveurs utilisés sont des serveurs OCP Leopard, équipés de 2 processeurs Intel Xeon. Dans cet exemple, nous en avons 12.
Le commutateur (switch) doit également être compatible RoCE. Dans notre cas, le matériel provient également de la sphère OCP et est un wedge 100 GbE. Son interface en ligne de commande est similaire à celle des commutateurs Arista. Tous les serveurs sont connectés à ce commutateur. Leurs adresses IP vont de 10.1.4.1
à 10.1.4.12
.
Si vous n'avez pas encore acheté vos serveurs, voici quelques lignes directrices. Les tâches HPC comme la CFD sont souvent limitées par la mémoire (memory bound), ce qui signifie que les opérations les plus longues sont l'attente des données provenant de la RAM. Par conséquent, vous devriez privilégier un processeur avec un nombre élevé de canaux de mémoire et une fréquence de RAM élevée. Le nombre de cœurs n'est pas très important, car les canaux de mémoire sont souvent saturés avec 3 cœurs par canal.
Tous les serveurs fonctionnent sous Linux et ont Ubuntu 20.04 installé.
Pour comprendre l'objectif de RoCE et pourquoi il offre d'excellentes performances, nous devons nous plonger un peu dans les technologies en jeu.
Lors d'une communication "normale", un message envoyé, qui occupe initialement un espace mémoire, est transféré à travers différentes couches qui vont chacune copier le message dans leur mémoire et le traiter avant de le transmettre à la couche suivante. Cette architecture en couches présente de nombreux avantages du point de vue de la programmation, mais elle implique beaucoup de copies et une forte utilisation des ressources. Même avec un débit très élevé, un réseau utilisant cette architecture standard aura une latence élevée et ne pourra pas atteindre de bonnes performances en clustering.
Pour contourner cela, il est nécessaire de mettre en œuvre une technique appelée RDMA (Remote Direct Memory Access). Le RDMA est une technique où les données sont transférées directement de la mémoire principale d'un serveur à celle d'un autre, sans aucune autre copie ni tampon intermédiaire. Cela réduit considérablement la latence et signifie que le noyau n'a pas à se soucier de copier les données.
Pour réaliser cette technique, de nouvelles technologies et protocoles ont dû être développés. L'un d'eux est l'Infiniband, qui est à la fois une spécification physique de bus et de connecteurs, ainsi qu'une spécification de protocole. L'Infiniband est très efficace et très répandu dans le TOP500, mais il est également assez coûteux et complexe à mettre en place.
RoCE (RDMA over Converged Ethernet) est la tentative de tirer le meilleur des deux mondes : la performance d'Infiniband et la popularité d'Ethernet. Son fonctionnement consiste à encapsuler le protocole Infiniband à l'intérieur de paquets Ethernet (RoCE v1) ou UDP/IP (RoCE v2). C'est cette technologie que nous allons découvrir aujourd'hui.
Avant de voir comment créer un cluster RoCE, nous allons configurer un cluster fonctionnant sur des connexions TCP standard. Voici les différentes étapes.
La première chose à faire est de configurer la connexion SSH sans mot de passe entre tous les nœuds. SSH est un protocole et un programme qui permet de se connecter en toute sécurité à un serveur distant et d'y exécuter des commandes. Il sera utilisé par le nœud hôte pour démarrer des processus sur tous les autres nœuds. Cependant, nous devons le faire fonctionner sans demander de mot de passe.
Pour permettre une connexion sans mot de passe, le serveur de réception doit avoir la clé publique de l'expéditeur (qui est généralement ~/.ssh/id_rsa.pub
) dans son fichier authorized_keys
(l'emplacement standard est ~/.ssh/authorized_keys
).
Deux options sont fondamentalement disponibles. La première est de copier toutes les clés dans les différents fichiers authorized_keys
. La seconde est de générer une nouvelle paire de clés qui sera utilisée par tous les serveurs (il suffit de copier les fichiers id_rsa
et id_rsa.pub
dans chaque dossier ~/.ssh/
). Ainsi, l'authorized_key
de tous les nœuds sera le même et ne contiendra que la clé publique unique.
Une fois cela fait, vous devriez pouvoir vous connecter aux autres serveurs via SSH, sans que le terminal ne vous demande de mot de passe.
Cette étape est facultative, mais elle simplifie grandement la tâche de mise en place d'un cas d'utilisation MPI. Pour fonctionner, le cluster MPI a besoin que les données du fichier exécutable se trouvent sur chaque serveur et au même emplacement. Par conséquent, il est possible soit de copier ces fichiers sur chaque serveur, soit de créer un répertoire partagé. La première solution offre de meilleures performances, mais la seconde est plus facile à mettre en place. Consultez ce tutoriel pour configurer un répertoire NFS.
Il est maintenant temps de mettre tout en pratique. Tout d'abord, nous allons installer une implémentation de MPI. J'ai choisi OpenMPI, qui peut être installé avec apt-get install openmpi-bin
.
Pour exécuter OpenMPI sur plusieurs nœuds, nous devons indiquer à l'hôte où trouver les autres nœuds. Cela se fait dans un fichier d'hôtes qui contient une ligne par serveur, avec son IP et le nombre de slots sur la machine. Le nombre de slots serait généralement le nombre de cœurs sur la machine, mais ici nous pouvons commencer avec un seul slot par nœud, afin d'essayer le clustering.
Voici un petit script pour générer le fichier d'hôtes pour un nombre donné de slots par nœud :
rm -f hostfile
for ((i=1; i <= $NB_NODES; i++))
do
echo “10.1.4.$i slots=$NB_SLOT_PER_NODE” >> hostfile
done
Le résultat sera :
$ NB_NODES=12 NB_SLOT_PER_NODE=1 ./generate-hostfile.sh
$ cat hostfile
10.1.4.1 slots=1
10.1.4.2 slots=1
10.1.4.3 slots=1
10.1.4.4 slots=1
10.1.4.5 slots=1
10.1.4.6 slots=1
10.1.4.7 slots=1
10.1.4.8 slots=1
10.1.4.9 slots=1
10.1.4.10 slots=1
10.1.4.11 slots=1
10.1.4.12 slots=1
Maintenant, essayons mpirun :
mpirun --np 12 --hostfile /path/to/hostfile \
--mca plm_rsh_agent "ssh -q -o StrictHostKeyChecking=no" \
hostname
oici la signification de chaque argument :
--np
indique le nombre de processus à démarrer ; ici, j'ai 12 machines et je veux démarrer 1 processus par machine, soit 12 processus au total.--hostfile
indique le chemin vers le fichier d'hôtes mentionné ci-dessus.--mca plm_rsh_agent "ssh -q -o StrictHostKeyChecking=no"
est une petite astuce pour que SSH ne vérifie pas si l'hôte est connu.hostname
est enfin le nom du programme qui sera exécuté. Ici, c'est un programme spécial intégré à OpenMPI, où tous les nœuds affichent leur nom d'hôte.Mon résultat ressemble à :
1c34da7f9a3a
1c34da7f9a42
1c34da7f9ac2
0c42a1198eaa
1c34da7f9bb2
1c34da5c5cc4
1c34da5c5cac
1c34da7f99e2
1c34da5c5e54
1c34da7f9bba
1c34da7f9bc2
1c34da7f99ea
À ce stade, vous pouvez essayer d'exécuter votre programme HPC préféré. Vous devriez obtenir des résultats plutôt médiocres lors de l'utilisation de plusieurs nœuds, car notre interconnexion est lente. Il est temps de mettre RoCE en action !
Maintenant que notre cluster de base fonctionne, ajoutons le support RoCE.
La première chose à faire est d'installer le pilote Mellanox pour la carte d'interface réseau. Vous pouvez l'obtenir ici. Ensuite, vous devrez dézipper l'archive et exécuter mlnxofedinstall
sur chaque nœud du cluster.
Le code exact et les options à envoyer à mlnxofedinstall
peuvent différer selon la distribution et la version du pilote, mais voici ce que j'ai exécuté :
VERSION=MLNX_OFED-5.5-1.0.3.2
DISTRO=ubuntu20.04-x86_64
wget “https://content.mellanox.com/ofed/$VERSION/$VERSION-$DISTRO.tgz” &&
tar xvf “$VERSION-$DISTRO.tgz” &&
“./$VERSION-$DISTRO/mlnxofedinstall” --add-kernel-support
Une fois cela fait, vous pouvez vérifier la version installée du pilote avec :
root@b8-ce-f6-fc-40-12:~$ ofed_info -s
MLNX_OFED_LINUX-5.6-2.0.9.0:
Nous devons maintenant activer deux modules de noyau nécessaires aux échanges RDMA et RoCE. Pour ce faire, exécutez : modprobe rdma_cm ib_umad
.
Ensuite, vous pouvez vérifier que les périphériques RoCE sont reconnus avec ibv_devinfo
. Voici ce que j'obtiens :
root@b8-ce-f6-fc-40-12:~$ ibv_devinfo
hca_id: mlx5_0
transport: InfiniBand (0)
fw_ver: 14.32.1010
node_guid: b8ce:f603:00fc:4012
sys_image_guid: b8ce:f603:00fc:4012
vendor_id: 0x02c9
vendor_part_id: 4117
hw_ver: 0x0
board_id: MT_2470112034
phys_port_cnt: 1
port: 1
state: PORT_ACTIVE (4)
max_mtu: 4096 (5)
active_mtu: 1024 (3)
sm_lid: 0
port_lid: 0
port_lmc: 0x00
link_layer: Ethernet
hca_id: mlx5_1
transport: InfiniBand (0)
fw_ver: 14.32.1010
node_guid: b8ce:f603:00fc:4013
sys_image_guid: b8ce:f603:00fc:4012
vendor_id: 0x02c9
vendor_part_id: 4117
hw_ver: 0x0
board_id: MT_2470112034
phys_port_cnt: 1
port: 1
state: PORT_DOWN (1)
max_mtu: 4096 (5)
active_mtu: 1024 (3)
sm_lid: 0
port_lid: 0
port_lmc: 0x00
link_layer: Ethernet
La sortie montre qu'un seul des ports est connecté : la carte réseau a deux ports physiques, le premier, mlx5_0
, est PORT_ACTIVE
, ce qui signifie qu'il est connecté, tandis que le second, mlx5_1
, est marqué PORT_DOWN
. La couche de transport est reconnue comme Infiniband, ce qui est le signe que RoCE est activé. En effet, rappelez-vous que RoCE est réalisé en encapsulant Infiniband sur UDP/IP.
Définissez la version du protocole RoCE sur v2 (-d
est le périphérique, -p
le port et -m
la version) :
root@b8-ce-f6-fc-40-12:~$ cma_roce_mode -d mlx5_0 -p 1 -m 2
RoCE v2
Enfin, vous pouvez voir comment les périphériques sont associés aux interfaces Internet avec :
root@b8-ce-f6-fc-40-12:~$ ibdev2netdev
mlx5_0 port 1 ==> eth0 (Up)
mlx5_1 port 1 ==> ens1f1np1 (Down)
Pour tester la connexion, exécutez ib_send_lat --disable_pcie_relaxed -F
sur un nœud (le serveur) et ib_send_lat --disable_pcie_relaxed -F <ip-of-the-server>
sur un autre. Sur mon cluster, j'ai des latences d'environ 2 microsecondes. Si les vôtres sont beaucoup plus élevées, cela pourrait signifier que RoCE ne fonctionne pas correctement.
Si vous voulez vous assurer que RoCE est utilisé (ce qui est souvent utile), vous pouvez utiliser un sniffer pour suivre tous les messages envoyés et reçus par un nœud. Cependant, capturer le trafic RoCE n'est pas si facile, car il ne passe pas par le noyau. La procédure à suivre est la suivante :
tcpdump
.docker run -it --net=host --privileged
-v /dev/infiniband:/dev/infiniband -v /tmp/traces:/tmp/traces
mellanox/tcpdump-rdma bash
tcpdump -i mlx5_0 -s 0 -w /tmp/traces/capture1.pcap
. Vous pouvez ensuite l'arrêter avec Ctrl+C
et lire le fichier /tmp/traces/capture1.pcap
avec wireshark
.-c <count>
.Le protocole RRoCE signifie Routable RoCE, qui est un autre nom pour RoCE v2.
Maintenant que RoCE est supporté par les nœuds, il est temps de l'utiliser pour accélérer certaines applications. Pour ce faire, nous devrons dire à MPI d'utiliser RoCE. La bonne nouvelle est que le pilote de Mellanox est livré avec une version optimisée d'OpenMPI, appelée HPC-X, conçue pour utiliser au mieux les cartes réseau Mellanox.
Mellanox installe HPC-X dans un emplacement non standard, donc pour l'utiliser, nous devons taper :
$ export PATH=/usr/mpi/gcc/openmpi-4.1.2rc2/bin:$PATH
$ export LD_LIBRARY_PATH=/usr/mpi/gcc/openmpi-4.1.2rc2/lib:$LD_LIBRARY_PATH
mpirun
devrait maintenant faire référence à HPC-X tel que fourni avec le pilote Mellanox :
$ which mpirun
/usr/mpi/gcc/openmpi-4.0.2rc3/bin/mpirun
Il ne nous reste plus qu'à ajouter un petit flag à la commande MPI (UCX est la bibliothèque qui prend en charge l'interconnexion RoCE dans OpenMPI) :
mpirun --np 12 --hostfile /path/to/hostfile
--mca plm_rsh_agent "ssh -q -o StrictHostKeyChecking=no"
-x UCX_NET_DEVICES=mlx5_0:1
hostname
À ce stade, rien d'extraordinaire ne devrait se produire, car hostname
n'implique pas l'envoi et la réception de messages. Pour tester l'interconnexion, nous allons utiliser osu-micro-benchmark. Essayez maintenant :
$ export OSU="/usr/mpi/gcc/openmpi-4.1.2rc2/tests/osu-micro-benchmarks-5.6.2”
$ mpirun --np 12 --hostfile /path/to/hostfile
--mca plm_rsh_agent "ssh -q -o StrictHostKeyChecking=no"
-x UCX_NET_DEVICES=mlx5_0:1
$OSU/osu_alltoall
Vous pouvez utiliser la méthode précédente pour vous assurer que les paquets RoCE sont envoyés. Vous pouvez également essayer de voir la différence avec et sans RoCE. Cependant, le cluster n'est pas vraiment prêt, et le résultat pourrait être médiocre pour le moment.
C'est parce que le réseau a un comportement avec perte (lossy behavior) pour l'instant. Cela signifie que certains paquets peuvent être perdus en transit, et bien que cela puisse être acceptable dans certains cas, ce n'est pas du tout le cas dans un environnement HPC. Par conséquent, nous devons le configurer un peu plus.
L'élément clé pour obtenir un comportement sans perte est d'activer un mécanisme appelé PFC (Priority Flow Control). L'idée de ce protocole est de différencier les niveaux de priorité et d'adapter le comportement en fonction de la priorité. L'option qui nous intéresse est de rendre cette priorité en mode sans perte (lossless). Ce qui se passe, c'est que lorsqu'un périphérique (le commutateur ou un nœud) est submergé, il demande au réseau de s'arrêter avant que son tampon ne soit entièrement plein et que certains paquets ne soient perdus.
Une solution possible serait d'activer ce mode pour chaque priorité, mais ce serait un peu sale. Ici, nous allons faire les choses correctement et activer le mode sans perte pour une seule priorité et indiquer à MPI de l'utiliser. Il y a deux étapes pour obtenir un comportement sans perte :
Ces deux étapes doivent être effectuées sur le commutateur et sur les serveurs. Dans cet exemple, nous utiliserons la priorité 3.
La syntaxe pour configurer le commutateur peut être très différente d'un modèle à l'autre. Dans mon cas, le commutateur wedge a une syntaxe similaire aux commutateurs Arista.
Une fois connecté au commutateur, regardez d'abord l'état des interfaces pour connaître la plage à configurer :
wedge>show interfaces status
Port Name Status Vlan Duplex Speed Type Flags Encapsulation
Et1/1 connected trunk full 25G 100GBASE-CR4
Et1/2 connected trunk full 25G 100GBASE-CR4
Et1/3 connected trunk full 25G 100GBASE-CR4
Et1/4 connected trunk full 25G 100GBASE-CR4
Et2/1 connected trunk full 25G 100GBASE-CR4
Et2/2 connected trunk full 25G 100GBASE-CR4
Et2/3 connected trunk full 25G 100GBASE-CR4
Et2/4 connected trunk full 25G 100GBASE-CR4
Et3/1 connected trunk full 25G 100GBASE-CR4
Et3/2 connected trunk full 25G 100GBASE-CR4
Et3/3 connected trunk full 25G 100GBASE-CR4
Et3/4 connected trunk full 25G 100GBASE-CR4
Et4/1 notconnect trunk full 25G Not Present
Et4/2 notconnect trunk full 25G Not Present
Et4/3 notconnect trunk full 25G Not Present
Et4/4 notconnect trunk full 25G Not Present
[...]
Ici, ma plage sera 1/1-3/4
.
Maintenant, entrez en mode de configuration :
wedge>enable
wedge#configure
wedge(config)#interface ethernet 1/1-3/4
wedge(config-if-Et1/1-3/4)#
Nous allons d'abord définir le mécanisme de marquage de priorité sur L3, ce qui signifie que le champ DSCP dans l'en-tête IP sera utilisé. Ce mode est légèrement plus facile à configurer que le mécanisme L2, qui nécessite l'utilisation d'un VLAN.
wedge(config-if-Et1/1-3/4)#qos trust dscp
Nous activons maintenant le contrôle de flux prioritaire et définissons la priorité 3 en mode sans perte :
wedge(config-if-Et1/1-3/4)#priority-flow-control on
wedge(config-if-Et1/1-3/4)#priority-flow-control priority 3 no-drop
L'état du PFC peut être affiché pour s'assurer que tout est correctement configuré :
wedge(config-if-Et1/1-3/4)#show priority-flow-control
The hardware supports PFC on priorities 0 1 2 3 4 5 6 7
PFC receive processing is enabled on priorities 0 1 2 3 4 5 6 7
The PFC watchdog timeout is 0.0 second(s) (default)
The PFC watchdog recovery-time is 0.0 second(s) (auto) (default)
The PFC watchdog polling-interval is 0.0 second(s) (default)
The PFC watchdog action is errdisable
The PFC watchdog override action drop is false
The PFC watchdog non-disruptive priorities are not configured
The PFC watchdog non-disruptive action is not configured
The PFC watchdog port non-disruptive-only is false
The PFC watchdog hardware monitored priorities are not configured
Global PFC : Enabled
E: PFC Enabled, D: PFC Disabled, A: PFC Active, W: PFC Watchdog Enabled
Port Status Priorities Action Timeout Recovery Polling Note
Interval/Mode Config/Oper
---------------------------------------------------------------------------------------
Et1/1 E A W 3 - - - / - - / - DCBX disabled
Et1/2 E A W 3 - - - / - - / - DCBX disabled
Et1/3 E A W 3 - - - / - - / - DCBX disabled
Et1/4 E A W 3 - - - / - - / - DCBX disabled
Et2/1 E A W 3 - - - / - - / - DCBX disabled
Et2/2 E A W 3 - - - / - - / - DCBX disabled
Et2/3 E A W 3 - - - / - - / - DCBX disabled
Et2/4 E A W 3 - - - / - - / - DCBX disabled
Et3/1 E A W 3 - - - / - - / - DCBX disabled
Et3/2 E A W 3 - - - / - - / - DCBX disabled
Et3/3 E A W 3 - - - / - - / - DCBX disabled
Et3/4 E A W 3 - - - / - - / - DCBX disabled
Tout d'abord, nous configurons la carte réseau en définissant le mode de confiance (trust mode) (c'est-à-dire le mécanisme de marquage de priorité utilisé) sur L3 et en activant le mode sans chute de paquets sur la priorité 3 avec :
$ sudo mlnx_qos -i eth0 --trust=dscp --pfc 0,0,0,1,0,0,0,0
Les différentes options ont l'explication suivante :
-i eth0
est l'interface Ethernet liée au périphérique RoCE à configurer. Vous pouvez obtenir l'association Internet-périphérique avec ibdev2netdev
.--trust=dscp
est l'option pour définir le marquage sur L3 (DSCP).--pfc
est suivi d'une série de 8 nombres qui indiquent pour chaque priorité, de 0 à 7, si elle doit être activée. 1
active le mode sans perte pour la priorité 3.Il ne nous reste plus qu'à dire à MPI d'utiliser la priorité 3. Pour ce faire, nous définissons le flag : UCX_IB_SL
(pour Infiniband Service Level) sur 3 et le flag UCX_IB_TRAFFIC_CLASS
sur 124. Ce deuxième flag sera utilisé pour remplir le champ DSCP. Par défaut, la priorité 3 va de 30 à 39, nous ciblons donc ici 31. Comme le champ DSCP est rempli avec UCX_IB_TRAFFIC_CLASS/4
, nous le définissons sur 124 (car 124/4=31).
Voici un exemple pour le benchmark OSU :
$ mpirun --np 12 --hostfile /path/to/hostfile \
--mca plm_rsh_agent "ssh -q -o StrictHostKeyChecking=no" \
-x UCX_NET_DEVICES=mlx5_0:1 \
-x UCX_IB_SL=3 \
-x UCX_IB_TRAFFIC_CLASS=124 \
$OSU/osu_alltoall
Pour conclure, examinons une comparaison des 3 configurations de cluster que nous avons démontrées dans cet article : cluster TCP, RoCE avec perte et RoCE sans perte. Ci-dessous se trouvent les sorties du benchmark osu_alltoall
. Chaque fois, 4 nœuds utilisant chacun 28 cœurs ont été utilisés.
Quelques points sont notables dans cette comparaison :