Ce qu’est le NUMA

NUMA veut dire Non-Uniform Memory Access, accès mémoire non uniforme. Sur un système mono-socket, tous les cœurs CPU accèdent à l’ensemble de la RAM du système par le même contrôleur mémoire. Le temps d’accès est le même quel que soit le cœur qui fait la demande et l’endroit où siègent les données en mémoire physique.

Sur un système multi-sockets, chaque socket CPU a son propre contrôleur mémoire et sa propre banque de RAM. Un cœur du socket 0 accède rapidement à la RAM rattachée au socket 0 — c’est la mémoire locale. Il peut aussi accéder à la RAM rattachée au socket 1, mais cette demande doit traverser le lien inter-socket (Intel UPI, AMD Infinity Fabric). C’est de la mémoire distante, et c’est plus lent.

Le noyau appelle nœud NUMA l’ensemble d’un socket et de sa mémoire locale. Un système bi-socket AMD EPYC a au moins deux nœuds NUMA. Certains processeurs EPYC exposent quatre nœuds NUMA par socket (un par CCD), ce qui donne huit nœuds sur une carte bi-socket.

L’écart de performance entre accès local et accès distant n’a rien de subtil. L’accès local tourne autour de 80 à 100 ns. L’accès distant autour de 130 à 200 ns. C’est une pénalité de 50 à 100 % par opération mémoire. Prise seule, c’est un très petit chiffre. Payée sur chaque accès mémoire pendant toute la vie de la VM, elle cesse d’être petite.

Pourquoi ça compte pour la virtualisation

Quand Proxmox crée une VM, il alloue des vCPU et de la RAM. Par défaut, ces vCPU peuvent être ordonnancés sur n’importe quel cœur physique de n’importe quel socket. La RAM de la VM peut être allouée depuis le pool mémoire de n’importe quel nœud NUMA.

Si l’ordonnanceur met un vCPU sur le socket 0 alors que la RAM de la VM est sur le socket 1, chaque accès mémoire de ce vCPU traverse le lien inter-socket. Si les vCPU rebondissent entre les sockets — ce qu’ils feront s’ils ne sont pas épinglés — le motif d’accès mémoire devient un fouillis. Certains accès sont locaux, d’autres distants, et les performances de la VM sautillent en conséquence.

Pour des charges générales — un serveur web, un serveur de fichiers, une VM bureautique — c’est souvent supportable. La surcharge est là mais elle se répartit sur beaucoup d’opérations et ne domine pas.

Pour des charges intensives en E/S — bases de données, serveurs de stockage, tout ce qui fait beaucoup d’E/S disque ou réseau — la pénalité se cumule. Chaque achèvement de DMA, chaque livraison d’interruption, chaque copie de tampon passe par la mémoire. Si ces accès traversent les sockets, la surcharge s’accumule vite.

Pourquoi ça compte pour le passthrough

Les équipements PCIe sont physiquement câblés à un socket CPU donné. Chaque socket a son propre root complex PCIe. Le disque NVMe du connecteur 3 est peut-être sur les lignes PCIe du socket 0. Le GPU du connecteur 5 est peut-être sur celles du socket 1.

Quand un équipement fait du DMA, les données vont dans la mémoire rattachée au nœud NUMA vers lequel l’IOMMU les dirige. Si la RAM de la VM est allouée depuis le nœud local de l’équipement, l’écriture DMA va directement en mémoire locale. Si la RAM est sur l’autre nœud, chaque opération DMA traverse le lien inter-socket.

Pour un disque NVMe qui fait des centaines de milliers d’IOPS, cette pénalité de 50 à 100 ns par opération s’additionne. À iodepth=32 en lectures aléatoires de 4 Kio, l’écart de débit entre NUMA aligné et mal aligné peut atteindre 20 à 30 %. Et ça, c’est avant d’avoir regardé la surcharge IOMMU, l’ASPM, le MPS ou quoi que ce soit d’autre.

Un NUMA mal aligné envoie chaque DMA sur le lien inter-socket ; aligné, il reste localMal aligné — la VM est sur le nœud 0, le disque sur le nœud 1Nœud NUMA 0cœurs 0–15RAM 128 GoVM : vCPU épinglés 0–15, RAM allouée iciNœud NUMA 1cœurs 16–31RAM 128 GoNVMe — root complex 1UPI / IFchaque DMA traverse le lien — 130 à 200 nsAligné — vCPU, RAM et disque tous sur le nœud 1Nœud NUMA 0cœurs 0–15RAM 128 Golibre pour d'autres VMNœud NUMA 1cœurs 16–31RAM 128 GoNVMe — root complex 1VM : affinity 16-31, numa0 hostnodes=1, policy=bindau repos80 à 100 nsÀ iodepth=32 en lectures aléatoires de 4 Kio, l'écart entre les deux vaut 20 à 30 % du débit —avant même que la surcharge IOMMU, l'ASPM ou le MPS n'entrent en jeu.Sur un système mono-socket il n'y a pas de second nœud ni de lien inter-socket, rien de toutceci ne s'applique.
Le même matériel dans les deux cas. La seule différence est le nœud sur lequel la VM a été épinglée — et si le DMA du disque doit traverser le lien pour atteindre la mémoire de la VM.

La même chose vaut pour les cartes réseau, les GPU et tout autre équipement passé en passthrough. Le trafic DMA de l’équipement doit atterrir en mémoire locale, et les vCPU qui traitent ce trafic doivent être sur le même nœud.

Comment vérifier votre topologie

Trouver sur quel nœud NUMA se trouve un équipement

# Replace 0000:XX:00.0 with your device's PCI address from lspci
cat /sys/bus/pci/devices/0000:XX:00.0/numa_node

Ceci renvoie le numéro du nœud NUMA. Si ça renvoie -1, le noyau n’a pas pu déterminer le nœud. Ça arrive parfois avec des équipements derrière certains switches PCIe. Dans ce cas, tracez la topologie PCIe à la main avec lspci -tv et faites correspondre le port racine au socket.

Voir votre disposition NUMA complète

numactl --hardware

Ceci vous montre chaque nœud NUMA, combien de cœurs CPU s’y trouvent, combien de mémoire y est rattachée, et la distance (coût relatif) entre les nœuds.

Exemple de sortie sur un système EPYC bi-socket :

available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
node 0 size: 131072 MB
node 1 cpus: 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
node 1 size: 131072 MB
node distances:
node   0   1
  0:  10  32
  1:  32  10
Lire la table des distances entre nœuds de numactl --hardwareLa table des distances de numactl --hardwareBi-socket, 2 nœuds NUMA par socket. Coût relatif, pas des nanosecondes.nœud 0nœud 1nœud 2nœud 3nœud 0nœud 1nœud 2nœud 310163232161032323232101632321610socket 0socket 110 — le nœud lui-même, mémoire locale16 — même socket, l'autre nœud32 — à travers le lien inter-socketÉpinglez une VM et son équipement dans unmême 10, et jamais sa mémoire sur un 32.Une carte à 2 nœuds ne montre que 10 et 32 ;les 16 apparaissent dès qu'un socket exposeplusieurs nœuds.
Les chiffres sont des coûts relatifs, pas des nanosecondes. Gardez une VM et son équipement à l’intérieur d’un même 10, et ne laissez jamais sa mémoire atterrir sur un 32.

La table des distances vous donne le coût relatif. 10, c’est local. 32, c’est distant. Des nombres plus élevés veulent dire plus de sauts. Sur des configurations EPYC à quatre nœuds par socket, certaines paires de nœuds sont à 32 et d’autres à 16, selon le CCD sur lequel elles se trouvent.

Faire correspondre les équipements aux nœuds

# List all PCI devices and their NUMA nodes
for dev in /sys/bus/pci/devices/*; do
    node=$(cat "$dev/numa_node" 2>/dev/null)
    echo "$(basename $dev) node=$node $(lspci -s $(basename $dev) 2>/dev/null | cut -d' ' -f2-)"
done

Ceci vous donne une image complète des équipements et de leurs nœuds. Cherchez vos contrôleurs NVMe, vos cartes réseau et les GPU que vous passez en passthrough.

Comment aligner une VM sous Proxmox

Épingler les vCPU sur le bon nœud

Dans le fichier de configuration de la VM (/etc/pve/qemu-server/<vmid>.conf) :

numa: 1
affinity: 0-15    # Adjust to match cores on the correct NUMA node

Le paramètre affinity épingle les vCPU de la VM sur des cœurs physiques précis. Réglez-le sur la plage de cœurs du même nœud NUMA que votre équipement en passthrough.

Si votre NVMe est sur le nœud 1 et que le nœud 1 a les cœurs 16 à 31, mettez affinity: 16-31. Si la VM n’a besoin que de 8 vCPU, épinglez sur un sous-ensemble : affinity: 16-23.

Allouer la mémoire depuis le bon nœud

Activer numa: 1 dans la configuration de la VM dit à Proxmox de présenter une topologie NUMA à la VM. QEMU tentera d’allouer la mémoire de la VM depuis le nœud NUMA où les vCPU sont épinglés.

Pour un contrôle explicite, vous pouvez fixer la topologie NUMA dans la configuration de la VM :

numa0: cpus=0-7,hostnodes=0,memory=16384,policy=bind

Ceci dit à QEMU de lier le premier nœud NUMA de la VM (le nœud 0 du point de vue de l’invité) au nœud NUMA hôte 0, en utilisant les cœurs 0 à 7 et 16 Gio de mémoire. Le policy=bind garantit que la mémoire est allouée strictement depuis ce nœud plutôt que de se rabattre sur d’autres nœuds si le pool local est sous pression.

Vérifier l’épinglage

Après avoir démarré la VM, vérifiez que les vCPU tournent bien là où vous le pensez :

# Find the QEMU process
pgrep -a qemu | grep <vmid>

# Check CPU affinity of the process
taskset -cp <pid>

# Or check per-vCPU thread affinity
for tid in $(ls /proc/<pid>/task/); do
    echo "Thread $tid: $(taskset -cp $tid 2>/dev/null)"
done

Erreurs courantes

Ne pas épingler du tout

Si vous ne réglez pas affinity, les vCPU de la VM peuvent être ordonnancés sur n’importe quel cœur. L’ordonnanceur du noyau les déplacera entre les nœuds selon l’équilibrage de charge. Chaque fois qu’un vCPU migre d’un nœud à l’autre, toutes les données sur lesquelles il travaillait dans le cache de l’ancien nœud deviennent distantes.

Pour des VM générales, c’est acceptable. Pour des VM en passthrough qui font beaucoup d’E/S, non.

Épingler sur le mauvais nœud

Vérifiez le nœud NUMA de l’équipement avant d’épingler. Ne supposez rien. Sur certaines cartes mères, la numérotation physique des connecteurs ne correspond pas au nœud NUMA de façon évidente. Vérifiez toujours avec cat /sys/bus/pci/devices/.../numa_node.

Sur-souscrire un nœud

Si vous épinglez trop de VM sur le même nœud NUMA, les cœurs de ce nœud deviennent sur-souscrits et le pool mémoire local s’épuise. Quand la mémoire déborde sur le nœud distant, vous obtenez le pire des deux mondes : des vCPU épinglés avec de la mémoire distante.

Équilibrez le placement de vos VM entre les nœuds. Si vous avez deux nœuds NUMA et quatre VM, répartissez-les équitablement.

Oublier l’allocation mémoire

Épingler les vCPU sans contrôler aussi l’allocation mémoire ne vous donne que la moitié du bénéfice. Les vCPU sont sur le bon nœud mais la mémoire n’y est peut-être pas. Utilisez policy=bind ou, au minimum, activez numa: 1 pour que l’allocation de QEMU suive l’épinglage CPU.

Systèmes mono-socket

Sur un système mono-socket, tout est sur le nœud NUMA 0. Il n’y a qu’un contrôleur mémoire et un seul jeu de lignes PCIe. L’alignement NUMA n’est pas un sujet.

Vous pouvez quand même mettre numa: 1 dans la configuration de la VM — ça ne fera pas de mal — mais ça n’aidera pas non plus. Un socket, un contrôleur mémoire, rien à aligner. Gardez l’effort pour une machine qui en a deux. Ainsi, les gains décrits ici ne valent que pour les systèmes multi-sockets, là où le lien inter-socket existe.

Références