Architectures distribuées et NUMA
Département de Mathématiques et Informatique, Faculté des Sciences et Techniques, Université Cheikh Anta Diop de Dakar
Ce que la séance construit
Un bus espionné par tous les caches convient à quelques cœurs, pas à des dizaines. Les grandes machines répartissent la mémoire entre des nœuds : l’accès dépend alors de l’endroit où se trouve la donnée (NUMA), et la cohérence passe par un répertoire qui sait où sont les copies. La séance montre ces mécanismes, puis la manière d’écrire un programme qui garde ses données près des cœurs qui les utilisent.
D’où l’on vient, où l’on va
Amont. Séance 1 (hiérarchie mémoire, lignes de cache, latences en cycles), Séance 2 (espionnage du bus, MSI et MESI, false sharing), Séance 3 (fils, verrous, patrons fork-join).
Cette séance. Limites de l’espionnage, architectures NUMA, cohérence par répertoire et ses trois cas, comparaison et organisation hiérarchique, placement des données et algorithmes conscients de NUMA, mesure.
Aval. Dernière séance du cours : une synthèse S1 à S4 la conclut. Lab associé : Lab 5 (mémoire distribuée et NUMA).
Les protocoles MSI et MESI de la Séance 2 reposent sur l’espionnage du bus (snooping) : tous les caches surveillent un bus partagé et chaque transaction est diffusée à tous. Avec beaucoup de cœurs, trois problèmes apparaissent :
Le problème de passage à l’échelle
Le trafic sur le bus croît linéairement avec le nombre de cœurs, mais la bande passante du bus reste fixe. Il existe donc un nombre de cœurs au-delà duquel le système ne fonctionne plus efficacement.
Limite pratique : environ \(8\) à \(16\) cœurs avec espionnage sur un bus unique.
Lecture. La solution : plusieurs nœuds, chacun avec sa mémoire, reliés par un réseau, et un répertoire qui sait où sont les copies.
NUMA (Non-Uniform Memory Access)
Système multiprocesseur où la latence d’accès à la mémoire dépend de la localisation physique de la mémoire par rapport au processeur :
Par opposition, une machine UMA (Uniform Memory Access) a une seule mémoire, à la même distance de tous les cœurs : c’est la machine à bus de la Séance 2.
Analogie : les bibliothèques
UMA : une seule bibliothèque centrale. Tout le monde met le même temps pour y aller ; si elle est loin, c’est lent pour tout le monde.
NUMA : une bibliothèque par quartier. On va vite à celle de son quartier (mémoire locale) ; si le livre est dans un autre quartier, c’est plus long (mémoire distante). Avantage : chacun a une bibliothèque proche. Défi : savoir où se trouve chaque livre.
Lecture. Quatre nœuds : chacun a ses cœurs, son cache L3 et sa mémoire locale. Les nœuds sont reliés en anneau : N0 et N3 ne sont pas voisins.
Lecture. Le cœur c0 de N0 lit une adresse de sa mémoire locale : défaut dans le L3 (orange), lecture en mémoire 0 : \(80\) ns, soit \(240\) cycles à \(3\) GHz.
Lecture. Adresse de la mémoire de N1 : la requête et la donnée traversent un lien (1 saut, rouge) : \(140\) ns, \(420\) cycles, \(1{,}75\) fois l’accès local.
Lecture. Mémoire de N3 : deux liens, en passant par N1 (2 sauts) : \(200\) ns, \(600\) cycles, \(2{,}5\) fois l’accès local. Le temps d’accès dépend de l’endroit de la donnée.
Lecture. Ordres de grandeur d’un serveur multi-sockets. Mesure de référence (Intel MLC, bi-socket Cascade Lake) : \(80{,}8\) ns en local, \(140{,}4\) ns vers l’autre socket, soit \(1{,}74\) fois plus (\(1{,}75\) dans le tableau). Un accès distant à deux sauts coûte \(2{,}5\) fois l’accès local, \(200\) fois un succès en L1. À \(3\) GHz, \(80\) ns valent \(240\) cycles d’attente pour le cœur.
Énoncé. Un programme accède à un tableau de \(1\) million d’entiers (\(4\) Mo) sur un système NUMA à \(4\) nœuds ; chaque accès va en mémoire (pas de succès en cache, pas de recouvrement des accès) :
Correction
L’espionnage ne fonctionnant plus avec beaucoup de cœurs, un autre mécanisme est nécessaire : le répertoire (directory).
Protocole de cohérence à répertoire
Un protocole à répertoire (directory-based) tient, pour chaque bloc de mémoire :
Avantage principal : des communications point à point au lieu d’une diffusion.
Analogie : le suivi des colis
Sans répertoire (espionnage) : pour trouver un colis, on crie dans toutes les directions ; tous les livreurs écoutent tous les cris. Inefficace avec beaucoup de livreurs.
Avec répertoire : un bureau central sait où est chaque colis. On le lui demande, il indique exactement où aller : des communications ciblées seulement, qui passent mieux à l’échelle.
1. État du bloc : U (Uncached, aucun cache n’a le bloc), S (Shared, copies en lecture seule), M (Modified, un seul cache, bloc modifié).
2. Vecteur de présence : un bit par nœud ; \([1,0,1,0]\) : les nœuds 0 et 2 ont le bloc. Prix : \(p\) bits par bloc de \(512\) bits, \(3{,}1\) % de la mémoire à \(16\) nœuds, \(12{,}5\) % à \(64\).
3. Bit dirty : le bloc a-t-il été modifié ? Il équivaut à l’état M ; le propriétaire est le seul bit à \(1\).
Trois rôles
Principe de fonctionnement
Le demandeur envoie sa demande au nœud home.
Le home consulte son répertoire.
Le home coordonne les actions nécessaires :
Lecture. N1 lit X : défaut dans son cache (I). N1 envoie un Read Request au home N0 (\(50\) ns sur le réseau). Aucune diffusion : un seul destinataire.
Lecture. N0 consulte le répertoire (cadre orange) : X est en U (aucune copie). Le bloc est propre : la mémoire de N0 est à jour.
Lecture. N0 lit X dans sa mémoire : \(80\) ns. Temps écoulé depuis la demande : \(50+80=130\) ns.
Lecture. N0 envoie un Data Reply : N1 passe de I à S, X \(=3\). Latence \(2\times50+80=180\) ns, 2 messages. Le home met à jour l’entrée en même temps qu’il répond (étape suivante).
Lecture. N0 met à jour le répertoire : état U\(\to\)S, bit de présence de N1 à \(1\). Il saura qui prévenir lors d’une écriture.
Lecture. N1 lit X : défaut (I). Read Request vers le home N0 (\(50\) ns). La mémoire de N0 contient X \(=3\), une valeur périmée : N2 a écrit \(8\).
Lecture. N0 consulte le répertoire : état M, seul le bit de N2 vaut \(1\) : N2 est le propriétaire. La mémoire ne peut pas répondre.
Lecture. N0 envoie un Fetch à N2 : rendre le bloc. Temps écoulé : \(100\) ns.
Lecture. N2 renvoie le bloc (Write-Back, \(150\) ns) et passe de M à S ; N0 écrit X \(=8\) en mémoire (\(80\) ns) : \(230\) ns.
Lecture. N0 envoie le Data Reply : N1 passe de I à S avec X \(=8\). Latence \(4\times50+80=280\) ns, 4 messages. Le home met à jour l’entrée en même temps qu’il répond (étape suivante).
Lecture. N0 met à jour le répertoire : M\(\to\)S, présence \(0110\) (N1 et N2). Le bit de N0 reste à \(0\) : le home n’a pas de copie dans son cache.
Énoncé. Système NUMA à \(4\) nœuds ; latence réseau \(50\) ns par message, accès mémoire \(80\) ns. Le nœud 2 veut lire un bloc dont le home est le nœud 0.
Correction
Conclusion : la localité des données est déterminante en NUMA ; un accès distant coûte ici \(2{,}25\) à \(3{,}5\) fois un accès local. Ce modèle compte \(50\) ns par message : il ne se compare pas terme à terme aux \(140\) ns mesurées d’un accès distant.
Lecture. N1 veut écrire X \(=9\) : défaut (I). Write Request vers le home N0 (\(50\) ns).
Lecture. N0 consulte le répertoire : état S, présence \(0011\) : N2 et N3 ont une copie. Il lance la lecture de X en mémoire (\(80\) ns, orange).
Lecture. N0 envoie une Invalidation à N2 et à N3 seulement, en parallèle (\(100\) ns) : leurs copies passent de S à I.
Lecture. N2 et N3 répondent par un Ack (\(150\) ns). La lecture mémoire, finie à \(130\) ns, s’est faite pendant ce temps.
Lecture. N0 envoie le Data Reply avec la permission d’écrire : N1 passe de I à M, X \(=9\), à \(200\) ns. Le home met à jour l’entrée en même temps qu’il répond (étape suivante).
Lecture. N0 met à jour le répertoire : S\(\to\)M, présence \(0100\), propriétaire N1. Bilan : \(1+2+2+1=6\) messages, \(50+\max(100,80)+50=200\) ns.
| Cas | Messages | Latence | Répertoire après |
|---|---|---|---|
| 1. Lecture, bloc propre (U, S) | \(2\) | \(2t_r+t_m = 180\) ns | S, \(+\) demandeur |
| 2. Lecture, bloc modifié (M) | \(4\) | \(4t_r+t_m = 280\) ns | S, propriétaire \(+\) demandeur |
| 3. Écriture, bloc partagé (\(k\) copies) | \(2+2k\) | \(2t_r+\max(2t_r,t_m) = 200\) ns | M, demandeur seul |
Défauts dans le cache du demandeur ; \(t_r=50\) ns par message, \(t_m=80\) ns par accès mémoire.
Invalidations ciblées
Le cas 3 envoie une invalidation aux seuls nœuds dont le bit vaut \(1\) : avec \(k=2\), \(1+2+2+1 = 6\) messages, quel que soit le nombre de nœuds de la machine.
Optimisation : invalider en parallèle
Envoyées l’une après l’autre, les deux invalidations coûteraient \(50+2\times(50+50)+50 = 300\) ns ; envoyées ensemble, pendant la lecture de la mémoire, \(200\) ns.
| Critère | Espionnage (snooping) | Répertoire (directory) |
|---|---|---|
| Communication | diffusion sur un bus partagé | point à point par un réseau |
| Passage à l’échelle | limité (\(8\) à \(16\) cœurs) | centaines à milliers de cœurs |
| Complexité | mise en œuvre plus directe | plus complexe (répertoire, messages) |
| Trafic d’une écriture | atteint les \(n-1\) autres caches | atteint les seuls nœuds qui ont une copie |
| Latence | faible (bus rapide) | plus élevée (passage par le home) |
| Surcoût mémoire | aucun | répertoire : \(p\) bits par bloc (\(3\) % à \(16\) nœuds) |
| Énergie | tous les caches actifs | seuls les nœuds concernés actifs |
| Usage typique | processeurs de bureau et portables | serveurs multi-sockets, HPC |
| Exemples | multiprocesseurs à bus, cœurs d’un même socket | SGI Origin et UV, liens entre sockets AMD EPYC et Intel Xeon |
Lecture. Une grappe de machines (cluster) n’a pas de cohérence matérielle entre ses nœuds : ils communiquent par messages (section suivante).
Lecture. Socket 0 : c2 lit le bloc A, dont c5 a une copie. La demande passe sur le bus du socket et est espionnée par ses 7 autres caches (orange) ; c5 a une copie en S, qui ne répond pas : la mémoire 0, à jour, fournit A. Le socket 1 n’est pas dérangé.
Lecture. c2 lit le bloc B, dont le home est le socket 1 : la demande traverse le lien vers le répertoire du socket 1 (orange) : B est en U ; la mémoire 1 renvoie la donnée. Le répertoire met à jour l’entrée en même temps qu’il répond (étape suivante).
Lecture. Le répertoire du socket 1 note B en S avec le bit du socket 0 (rouge) : une écriture dans le socket 1 n’enverra une invalidation qu’au socket 0. Aucun cœur du socket 1 n’a espionné.
En NUMA, la performance dépend du placement des données. Objectif : maximiser les accès locaux. Le système place une page (\(4\) Kio) en mémoire physique au moment où elle est touchée pour la première fois, pas au moment de l’allocation.
Politiques de placement (Linux)
Avec numactl
Lecture. --membind est strict : si le nœud est plein, l’allocation échoue ; --preferred se replie sur un autre nœud.
import os
import glob
# coeurs du systeme, coeurs autorises (Linux)
print("coeurs :", os.cpu_count())
print("autorises :", sorted(os.sched_getaffinity(0)))
# un dossier par noeud NUMA : coeurs et distances
motif = "/sys/devices/system/node/node[0-9]*"
for d in sorted(glob.glob(motif)):
coeurs = open(d + "/cpulist").read().strip()
dist = open(d + "/distance").read().strip()
nom = os.path.basename(d)
print(nom, ": coeurs", coeurs, " distances", dist)
# lier ce processus aux coeurs 0 et 1 (sans privilege)
os.sched_setaffinity(0, {0, 1})
print("lie a :", sorted(os.sched_getaffinity(0)))Résultat \(\rightarrow\)
coeurs : 2
autorises : [0, 1]
node0 : coeurs 0-1 distances 10
lie a : [0, 1]
Lecture. La machine des exemples n’a qu’un nœud (2 cœurs) ; sur un serveur bi-socket, deux dossiers node0 et node1, distances 10 21. Lier un processus à ses propres cœurs ne demande aucun privilège. Le module psutil ne donne pas la répartition par nœud : elle se lit dans /sys.
Lecture. np.empty réserve 8 pages virtuelles ; aucune page physique n’est encore choisie (hachures) : rien n’a été écrit.
Lecture. Le fil 0, lié à N0, écrit sa moitié : au premier contact de chaque page, le système la place dans la mémoire du nœud qui la touche : p0 à p3 sur N0.
Lecture. Le fil 1, lié à N1, écrit l’autre moitié : premier contact sur N1, donc p4 à p7 dans la mémoire de N1.
Lecture. Chaque fil lit ses pages dans sa mémoire locale (orange) : aucun accès distant, \(80\) ns par accès. Initialiser avec le fil qui calculera place les données au bon endroit.
Lecture. Le fil principal (sur N0) initialise tout le tableau : les 8 pages vont sur N0. Le fil 1 lit toutes ses pages à distance (\(140\) ns) : moyenne \((80+140)/2=110\) ns.
Lecture. Pages entrelacées (numactl --interleave) : page \(i\) sur le nœud \(i \bmod 2\). Chaque fil a une page sur deux en local : \(110\) ns pour chacun, mais les deux contrôleurs mémoire se partagent la charge.
Lecture. Premier contact : toutes les lectures sont locales (\(80\) ns), latence moyenne \(110/80=1{,}375\) fois plus faible. La phase parallèle, aussi longue que le fil le plus lent, est \(140/80=1{,}75\) fois plus courte qu’avec un seul nœud et \(1{,}375\) fois plus courte qu’avec l’entrelacement.
Algorithme NUMA-aware
Algorithme conçu pour maximiser la localité des données en tenant compte de la topologie NUMA : partitionner les données selon les nœuds ; chaque fil travaille sur les données de son nœud ; minimiser les communications entre nœuds ; utiliser des structures de données distribuées.
Approche qui ignore NUMA
Données allouées et initialisées sur un nœud, fils lancés sur tous les nœuds : les fils des autres nœuds n’ont que des accès lents.
Approche consciente de NUMA
Les processus de multiprocessing ne partagent pas leur mémoire : leur modèle est celui de la mémoire distribuée.
Lecture. Mémoire partagée : P0 exécute x = 5 ; la mémoire commune contient \(5\) (rouge).
Lecture. P1 lit x et obtient \(5\) sans rien demander à P0 : le matériel (bus, protocole de cohérence de la Séance 2) transporte la donnée.
Lecture. Mémoire distribuée : A écrit x = 5 dans sa mémoire. B a son propre x, toujours \(0\) : aucune instruction de B ne lit la mémoire de A.
Lecture. A envoie la valeur dans un message (q.put(x)) : le transfert est écrit dans le programme et passe par le réseau.
Lecture. B reçoit le message (q.get()) et range \(5\) dans sa mémoire. C’est le modèle des processus de multiprocessing et des grappes de machines (MPI).
Lecture. Le parent lance 4 processus et ne leur envoie que (i, m, q) : quelques octets, pas les \(10\) millions de valeurs.
Lecture. Chaque processus se lie à un cœur (sched_setaffinity) puis crée sa partition de \(2{,}5\) millions de valeurs : il en touche les pages, qui sont placées sur son nœud.
Lecture. Chaque processus envoie un message de deux nombres : son indice et sa somme partielle. Quatre messages de quelques octets au lieu de \(80\) Mo de données.
Lecture. Le parent reçoit les 4 messages et fait la réduction finale : 3 additions. Le total est identique à celui du calcul séquentiel des mêmes partitions.
Énoncé. Système NUMA à \(2\) nœuds de \(8\) cœurs ; produit de deux matrices \(8000\times8000\).
Approche 1 (naïve) : A et B sur le nœud 0 ; \(16\) fils répartis sur les deux nœuds.
Approche 2 (NUMA-aware) : A partitionnée (A1 sur le nœud 0, A2 sur le nœud 1), B répliquée sur les deux nœuds ; \(8\) fils du nœud 0 calculent avec A1, \(8\) fils du nœud 1 avec A2.
Correction
Compromis : B en double, \(8000^2\times8=512\) Mo de plus (float64), un prix que le gain compense.
Lecture. Les distances sont relatives : \(10\) pour le local, \(21\) pour l’autre socket, soit un accès distant environ \(2{,}1\) fois plus coûteux selon le micrologiciel ; la mesure (\(140{,}4/80{,}8\approx1{,}74\)) est plus fiable que ce chiffre.
import time
import numpy as np
from numpy.random import default_rng
def mesure(data, indices, n=7):
mini = float("inf")
for _ in range(n):
t0 = time.perf_counter()
np.sum(data[indices] ** 2) # lit data
mini = min(mini, time.perf_counter() - t0)
return mini
print(" taille ordre hasard rapport")
for taille in [100_000, 1_000_000, 10_000_000]:
data = default_rng(0).random(taille)
ordre = np.arange(taille)
hasard = default_rng(1).permutation(taille)
t_o = mesure(data, ordre)
t_h = mesure(data, hasard)
print(f"{taille:>10,} {t_o * 1e3:6.2f}",
f"{t_h * 1e3:6.2f} {t_h / t_o:8.1f}")Résultat \(\rightarrow\)
taille ordre hasard rapport
100,000 0.51 0.58 1.1
1,000,000 2.93 5.66 1.9
10,000,000 59.37 113.43 1.9
Lecture. Durées en ms ; seul l’ordre des lectures change. \(100\,000\) valeurs (\(0{,}8\) Mo) tiennent dans les caches de la machine de mesure (\(2\) Mio de L2 par cœur, \(260\) Mio de L3) ; au-delà, lire au hasard coûte ici environ deux fois plus, et bien davantage sur une machine dont le L3 est plus petit que le tableau. Sous numactl --membind, le même programme compare mémoire locale et distante.
import time
import numpy as np
from numpy.random import default_rng
def chrono(f, x, n=7):
mini = float("inf")
for _ in range(n):
t0 = time.perf_counter()
f(x)
mini = min(mini, time.perf_counter() - t0)
return mini
def travail(data):
return np.sum(data ** 2)
print(" taille rapport")
for taille in [100_000, 1_000_000, 10_000_000]:
data = default_rng(0).random(taille)
t_seq = chrono(travail, data)
indices = default_rng(1).permutation(taille)
data_alea = data[indices] # une seule copie
t_alea = chrono(travail, data_alea) # en ordre
print(f"{taille:>10,} {t_alea / t_seq:8.2f}")Résultat \(\rightarrow\)
taille rapport
100,000 0.98
1,000,000 1.00
10,000,000 0.92
Les deux boucles lisent dans l’ordre
data[indices] est copié une fois, avant la mesure ; la copie est ensuite lue dans l’ordre. Rapport entre \(0{,}92\) et \(1{,}00\) : aucun désordre n’est mesuré. Il faut indexer pendant la mesure.
| Critère | Espionnage | Répertoire |
|---|---|---|
| Nombre de cœurs | moins de \(16\) | plus de \(16\) |
| Type de système | bureau, portable, petits serveurs | serveurs multi-sockets, HPC, centres de données |
| Latence | critique (un seul bus, peu de sauts) | tolère le passage par le home |
| Coût | limité | élevé (répertoire, réseau) |
| Complexité | réduite | acceptable |
Organisation hiérarchique
Les serveurs combinent les deux : espionnage entre les cœurs d’un même socket, répertoire entre les sockets. La faible latence de l’espionnage local s’ajoute au passage à l’échelle du répertoire.
numastat, perf) ; repérer les zones d’accès distants.Initialisation centralisée
2 à 4. Migration, false sharing, topologie
Migration des fils : un fil alloue sur le nœud 0 puis migre vers le nœud 1 : tous ses accès deviennent distants. Fixer l’affinité dès le départ.
False sharing entre nœuds : plus coûteux que sur un même socket (la ligne fait l’aller-retour par le réseau, \(2\) à \(3\) fois plus lent). Remplissage (padding) et alignement.
Ignorer la topologie : certains nœuds sont plus proches que d’autres ; consulter les distances (numactl --hardware) et organiser les communications en conséquence.
Lecture. S1 : la mémoire fixe souvent le rythme. S2 : plusieurs copies imposent la cohérence. S3 : des fils qui partagent une donnée se synchronisent. S4 : au-delà d’un bus, la distance aux données se gère : placement, répertoire, messages.
Lab 5 : mémoire distribuée et NUMA
multiprocessing : messages et réduction ;Références : J. L. Hennessy, D. A. Patterson, Computer Architecture: A Quantitative Approach, sixième édition, 2019, § 5.4 (répertoires) ; D. E. Culler, J. P. Singh, A. Gupta, Parallel Computer Architecture, 1999, chap. 8 ; D. Lenoski et al., « The Stanford DASH Multiprocessor », IEEE Computer 25(3), 1992 ; J. Laudon, D. Lenoski, « The SGI Origin », ISCA 1997 ; U. Drepper, What Every Programmer Should Know About Memory, 2007, § 5 ; G. Hager, G. Wellein, Introduction to High Performance Computing for Scientists and Engineers, 2010, chap. 8 (premier contact) ; mesures VSC (KU Leuven), Intel MLC, nœuds Cascade Lake ; man numactl.
Architecture et Programmation Parallèles — Séance 4 Dr. El Hadji Bassirou TOURÉ • DMI/FST/UCAD
Lecture. 4 cœurs. P0 écrit X : défaut, il diffuse un BusRdX. Les \(3\) autres caches examinent la transaction (orange) ; seul C2 a une copie, qu’il invalide (S\(\to\)I) ; C0 passe de I à M (rouge).
Lecture. 16 cœurs, même écriture : \(15\) caches examinent la transaction pour une seule copie à invalider. Le travail d’espionnage croît comme le nombre de cœurs : \(n-1\) examens par transaction.
Lecture. Charge du bus : chaque cœur émet \(20\) millions de transactions par seconde (valeurs d’illustration) ; le bus en écoule \(200\) millions. 4 cœurs : \(40\) % ; 8 cœurs : \(80\) %.
Lecture. 16 cœurs demandent \(320\) millions par seconde, \(160\) % de la capacité : les requêtes attendent leur tour et chaque cœur ralentit. Saturation dès \(10\) cœurs ; 32 cœurs : \(320\) %.