Por defecto, el planificador de Linux mueve los hilos de las vCPUs de una máquina virtual entre todos los núcleos del host. En cargas sensibles a la latencia (bases de datos, trading, juegos, audio o vídeo en tiempo real) eso provoca pérdidas de caché y accesos a memoria de otro socket. En este tutorial analizarás la topología de CPU y NUMA de un host KVM con Ubuntu 24.04, fijarás las vCPUs de una VM a núcleos físicos concretos (CPU pinning), atarás su memoria al mismo nodo NUMA y reservarás núcleos para el propio host.
Requisitos previos
- Un servidor físico con Ubuntu 24.04 LTS, KVM y libvirt instalados (
qemu-kvm,libvirt-daemon-system,libvirt-clients). - Un usuario no root con privilegios
sudoy miembro del grupolibvirt. - Una VM ya creada con libvirt. En los ejemplos se llama
db01y tiene 4 vCPUs y 8 GB de RAM. - Idealmente un servidor con dos o más sockets, que es donde NUMA tiene más impacto. En un servidor de un solo socket el pinning sigue siendo útil, pero la parte de NUMA no aplica.
Notael pinning tiene sentido cuando reservas núcleos para VMs concretas. Si fijas muchas VMs a los mismos núcleos, el rendimiento será peor que dejando trabajar al planificador.
Paso 1: Analizar la topología de CPU del host
Antes de fijar nada tienes que saber qué CPUs lógicas comparten núcleo físico (hilos hermanos de Hyper-Threading o SMT) y a qué nodo NUMA pertenece cada una. lscpu da el resumen:
lscpu | grep -E '^(CPU\(s\)|Thread|Core|Socket|NUMA)'
CPU(s): 16
Thread(s) per core: 2
Core(s) per socket: 4
Socket(s): 2
NUMA node(s): 2
NUMA node0 CPU(s): 0-3,8-11
NUMA node1 CPU(s): 4-7,12-15
Con lscpu -e ves el detalle por CPU lógica. La columna CORE indica el núcleo físico: dos CPUs lógicas con el mismo número de CORE son hilos hermanos del mismo núcleo:
lscpu -e=CPU,NODE,SOCKET,CORE
CPU NODE SOCKET CORE
0 0 0 0
1 0 0 1
2 0 0 2
3 0 0 3
4 1 1 4
5 1 1 5
6 1 1 6
7 1 1 7
8 0 0 0
9 0 0 1
10 0 0 2
11 0 0 3
12 1 1 4
13 1 1 5
14 1 1 6
15 1 1 7
En este ejemplo, las CPUs 0 y 8 son el mismo núcleo físico, 1 y 9 otro, etc. Tu numeración puede ser distinta: toma siempre los valores de tu propio servidor.
Paso 2: Revisar la memoria por nodo NUMA
Instala numactl, que incluye las utilidades numactl y numastat:
sudo apt install numactl
numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 8 9 10 11
node 0 size: 64215 MB
node 0 free: 41022 MB
node 1 cpus: 4 5 6 7 12 13 14 15
node 1 size: 64508 MB
node 1 free: 52870 MB
node distances:
node 0 1
0: 10 21
1: 21 10
La tabla de distancias indica el coste relativo de acceder a la memoria: 10 es memoria local y 21 la del otro socket, aproximadamente el doble de lenta. El objetivo es que cada VM use CPUs y memoria del mismo nodo, y que ese nodo tenga memoria libre suficiente para toda la RAM de la VM.
Paso 3: Planificar la asignación
Con la topología del ejemplo, un reparto razonable para una VM de 4 vCPUs en el nodo 0 es:
| Uso | CPUs del host |
|---|---|
| Sistema operativo del host | 0, 8 (núcleo físico 0) |
Hilos del emulador QEMU (E/S) de db01 | 1, 9 (núcleo físico 1) |
vCPU 0 y vCPU 1 de db01 | 2, 10 (núcleo físico 2) |
vCPU 2 y vCPU 3 de db01 | 3, 11 (núcleo físico 3) |
| Resto de VMs | 4-7, 12-15 (nodo 1) |
Las vCPUs se asignan por pares de hilos hermanos y la VM verá una topología de 2 núcleos con 2 hilos cada uno, igual que el hardware real. Así el sistema operativo invitado sabe qué vCPUs comparten núcleo y puede planificar mejor.
Paso 4: Aplicar el pinning a la VM
Apaga la VM y edita su definición XML:
virsh shutdown db01
virsh edit db01
Localiza la línea <vcpu> y añade después los bloques <cputune> y <numatune>. Sustituye también el bloque <cpu> existente:
<vcpu placement='static'>4</vcpu>
<cputune>
<vcpupin vcpu='0' cpuset='2'/>
<vcpupin vcpu='1' cpuset='10'/>
<vcpupin vcpu='2' cpuset='3'/>
<vcpupin vcpu='3' cpuset='11'/>
<emulatorpin cpuset='1,9'/>
</cputune>
<numatune>
<memory mode='strict' nodeset='0'/>
</numatune>
<cpu mode='host-passthrough' check='none'>
<topology sockets='1' dies='1' cores='2' threads='2'/>
</cpu>
Qué hace cada elemento:
vcpupin: fija cada vCPU a una CPU lógica del host. El orden (0 y 1 en el núcleo 2, 2 y 3 en el núcleo 3) coincide con la topología declarada en<topology>, en la que las vCPUs consecutivas son hilos hermanos.emulatorpin: fija los hilos auxiliares de QEMU (E/S de disco y red, gestión) a otros núcleos, para que no interrumpan a las vCPUs.numatuneconmode='strict': toda la memoria de la VM se reserva en el nodo 0. Si el nodo no tiene memoria suficiente, la VM no arranca en lugar de usar memoria remota en silencio.host-passthrough: expone la CPU física tal cual, con todas sus instrucciones y cachés. Es la opción más rápida, pero dificulta la migración en vivo a hosts con otra CPU.
El producto sockets x dies x cores x threads debe ser igual al número de vCPUs (aquí 1 x 1 x 2 x 2 = 4). Al guardar, virsh edit valida el XML y rechaza los cambios si hay errores.
Arranca la VM:
virsh start db01
Si prefieres no editar el XML, puedes fijar vCPUs con virsh vcpupin. La opción --config guarda el cambio y --live lo aplica también a la VM en marcha:
virsh vcpupin db01 0 2 --config --live
virsh emulatorpin db01 1,9 --config --live
Paso 5: Verificar el pinning y la memoria
Comprueba la afinidad de cada vCPU:
virsh vcpupin db01
VCPU CPU Affinity
----------------------
0 2
1 10
2 3
3 11
virsh vcpuinfo muestra además en qué CPU física se está ejecutando cada vCPU en este momento; siempre debe coincidir con su afinidad:
virsh vcpuinfo db01 | grep -E '^(VCPU|CPU):'
VCPU: 0
CPU: 2
VCPU: 1
CPU: 10
VCPU: 2
CPU: 3
VCPU: 3
CPU: 11
Comprueba que la memoria del proceso QEMU está en el nodo 0:
sudo numastat -c qemu-system-x86
Per-node process memory usage (in MBs)
PID Node 0 Node 1 Total
--------------- ------ ------ -----
48213 (qemu-syst 8231 2 8233
Prácticamente toda la memoria debe aparecer en Node 0. Dentro de la VM, lscpu debe mostrar la topología que definiste:
Thread(s) per core: 2
Core(s) per socket: 2
Socket(s): 1
Paso 6: Reservar CPUs para el host y para las VMs
El pinning evita que las vCPUs se muevan, pero no impide que procesos del host (o de otras VMs) usen los núcleos 2, 3, 10 y 11. En Ubuntu 24.04, que usa cgroups v2, puedes limitar con systemd qué CPUs usa cada grupo de procesos, sin tocar parámetros del kernel.
Restringe los servicios del sistema y las sesiones de usuario a las CPUs 0 y 8:
sudo systemctl set-property system.slice AllowedCPUs=0,8
sudo systemctl set-property user.slice AllowedCPUs=0,8
sudo systemctl set-property init.scope AllowedCPUs=0,8
libvirt coloca todas las VMs en machine.slice. Asígnale el resto de CPUs:
sudo systemctl set-property machine.slice AllowedCPUs=1-7,9-15
systemctl set-property guarda los cambios de forma persistente en /etc/systemd/system.control/. Verifica el valor efectivo:
cat /sys/fs/cgroup/system.slice/cpuset.cpus.effective
cat /sys/fs/cgroup/machine.slice/cpuset.cpus.effective
0,8
1-7,9-15
Importantereservar solo un núcleo físico para el host es adecuado para un servidor dedicado a virtualización. Si el host ejecuta otros servicios (almacenamiento Ceph, backups, monitorización), reserva más núcleos o notarás lentitud en el host.
Para deshacer la restricción, elimina las propiedades con sudo systemctl revert system.slice (y lo mismo para el resto de slices).
Paso 7 (opcional): Usar hugepages para la memoria de la VM
Las páginas grandes (hugepages) reducen los fallos de TLB en VMs con mucha memoria. Reserva 4096 páginas de 2 MB (8 GB) en el nodo 0, el mismo en el que está la VM:
echo 4096 | sudo tee /sys/devices/system/node/node0/hugepages/hugepages-2048kB/nr_hugepages
grep -i hugepages_ /proc/meminfo
HugePages_Total: 4096
HugePages_Free: 4096
HugePages_Rsvd: 0
HugePages_Surp: 0
Si HugePages_Total es menor que el valor pedido, el nodo no tenía suficiente memoria contigua libre; reserva las páginas justo después de arrancar el host. Para que la reserva sobreviva a los reinicios, puedes repetir el comando en una unidad systemd de tipo oneshot que se ejecute antes de libvirtd.service.
Apaga la VM, añade este bloque en su XML (virsh edit db01), justo después de <currentMemory>, y arráncala de nuevo:
<memoryBacking>
<hugepages/>
</memoryBacking>
Tras arrancarla, HugePages_Free debe haber bajado en unas 4096 páginas.
Solución de problemas
La VM no arranca con numatune en modo strict: el nodo NUMA no tiene memoria libre suficiente. Revisa numactl --hardware, reduce la RAM de la VM o usa mode='preferred', que prefiere el nodo indicado pero permite usar otro si no hay memoria.
Error sobre cpuset.cpus al arrancar la VM: alguna CPU de vcpupin o emulatorpin no está incluida en AllowedCPUs de machine.slice. Las CPUs fijadas deben ser un subconjunto de las permitidas al slice.
El rendimiento empeora tras el pinning: comprueba que dos VMs no comparten las mismas CPUs con virsh vcpupin en cada una, y que las vCPUs no están repartidas entre dos nodos NUMA.
Conclusión
Has analizado la topología de tu host, fijado las vCPUs y los hilos del emulador de una VM a núcleos físicos del mismo nodo NUMA, atado su memoria a ese nodo y separado las CPUs del host de las de las VMs. Como siguientes pasos, mide el efecto con una prueba de carga real (por ejemplo sysbench o pgbench dentro de la VM) antes y después del cambio, aplica el mismo esquema al resto de VMs críticas repartiéndolas por nodos NUMA, y combina el pinning con virtio-blk o virtio-scsi con iothread dedicado para cargas de disco intensivas.
