虚拟化 NUMA 深度调优:CPU Pin、大页内存、内存绑定实战
深入讲解虚拟化平台的 NUMA(非统一内存访问)调优技术:NUMA 拓扑感知、vCPU 绑定到物理 CPU(CPU Pinning)、大页内存(Huge Pages)消除 TLB 压力、NUMA 内存绑定防止跨 NUMA 访问,通过低延迟数据库和高性能计算场景的完整配置,掌握虚拟机极致性能调优。
虚拟化环境的性能损耗通常来自两个地方:内存访问延迟(NUMA 跨节点访问)和 CPU 调度开销(vCPU 频繁在物理核间迁移)。CPU Pinning 把 vCPU 锁定到特定物理核,大页内存减少 TLB Miss,NUMA 绑定让 VM 的内存访问都在同一 NUMA 节点内。三者组合可以把虚拟化开销从 15-30% 降到 2-5%。
NUMA 基础概念
多路服务器 NUMA 拓扑(2 路 Intel Xeon 示例):
NUMA Node 0: NUMA Node 1:
CPU 0-15 (物理核 0-7, HT) CPU 16-31 (物理核 8-15, HT)
内存 0-127GB 内存 128-255GB
PCIe 槽 x16 (GPU 0) PCIe 槽 x16 (GPU 1)
Node 0 内存访问延迟:~70ns
Node 0 访问 Node 1 内存:~140ns(慢 2x!)
虚拟机如果跨 NUMA 运行:
vCPU 0 在 Node 0 的 CPU 上运行
VM 内存被分配到 Node 1
→ 每次内存访问都要跨 NUMA = 延迟翻倍
一、查看系统 NUMA 拓扑
# 查看 NUMA 节点数量和 CPU 布局
numactl --hardware
# 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 0 free: 98304 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 1 free: 102400 MB
# node distances:
# node 0 1
# 0: 10 21 ← 节点间延迟比(10=本地,21=跨节点)
# 查看 CPU 和核心的对应关系(超线程)
lscpu | grep -E "Socket|Core|Thread|NUMA"
# Thread(s) per core: 2
# Core(s) per socket: 8
# Socket(s): 2
# NUMA node(s): 2
# NUMA node0 CPU(s): 0-7,16-23 ← Socket 0 的物理核和超线程核
# NUMA node1 CPU(s): 8-15,24-31
# 查看 PCIe 设备属于哪个 NUMA 节点(GPU/NIC 绑定)
cat /sys/class/pci_bus/0000:01/device/numa_node
# 0 ← GPU 在 NUMA 0
cat /sys/class/net/ens3f0/device/numa_node
# 0 ← 网卡在 NUMA 0
二、CPU Pinning(vCPU 绑定到物理核)
确定 CPU 拓扑(绑定前必做)
# 用 lstopo 可视化 CPU 拓扑(需要 hwloc 包)
apt install hwloc
lstopo --of txt
# 文字版输出示例:
# Machine (256GB total)
# NUMANode L#0 (128GB)
# Package L#0
# Core L#0 + PU L#0 (P#0) + PU L#1 (P#16) ← 物理核0,超线程是CPU0和CPU16
# Core L#1 + PU L#2 (P#1) + PU L#3 (P#17)
# Core L#2 + PU L#4 (P#2) + PU L#5 (P#18)
# Core L#3 + PU L#6 (P#3) + PU L#7 (P#19)
# NUMANode L#1 (128GB)
# Package L#1
# Core L#8 + PU L#16 (P#8) + PU L#17 (P#24)
# ...
# 关键原则:
# 超线程的两个线程共享同一个物理核的资源(L1/L2 Cache)
# 把同一物理核的两个线程都给同一 VM(避免 cache 竞争)
virsh/QEMU 中配置 CPU Pinning
<!-- virsh edit:CPU Pinning 配置 -->
<domain>
<!-- 定义 vCPU 数量 -->
<vcpu placement='static'>8</vcpu>
<!-- CPU 拓扑(可选,让 VM 内部看到正确拓扑) -->
<cpu mode='host-passthrough'>
<topology sockets='1' dies='1' cores='4' threads='2'/>
<!-- 4 物理核,2 超线程 = 8 vCPU -->
<!-- NUMA 拓扑(让 VM 内部感知 NUMA,仅当 VM 有多个 NUMA 节点时) -->
<numa>
<cell id='0' cpus='0-7' memory='16384' unit='MiB'/>
</numa>
</cpu>
<!-- CPU Pinning:vCPU → 物理 CPU 映射 -->
<cputune>
<!-- 把 vCPU 0/1 绑定到物理核 0 的两个超线程(CPU 0 和 CPU 16)-->
<vcpupin vcpu='0' cpuset='0'/>
<vcpupin vcpu='1' cpuset='16'/>
<!-- 物理核 1 的两个超线程 -->
<vcpupin vcpu='2' cpuset='1'/>
<vcpupin vcpu='3' cpuset='17'/>
<!-- 物理核 2 -->
<vcpupin vcpu='4' cpuset='2'/>
<vcpupin vcpu='5' cpuset='18'/>
<!-- 物理核 3 -->
<vcpupin vcpu='6' cpuset='3'/>
<vcpupin vcpu='7' cpuset='19'/>
<!-- emulator 线程(QEMU 主线程)绑定到 CPU 8 -->
<emulatorpin cpuset='8'/>
<!-- IO 线程绑定 -->
<iothreadpin iothread='1' cpuset='9'/>
</cputune>
<!-- NUMA 内存绑定:VM 内存只从 NUMA 0 分配 -->
<numatune>
<memory mode='strict' nodeset='0'/>
</numatune>
</domain>
验证 CPU Pinning
# 启动 VM 后验证
virsh vcpuinfo <vm-name>
# VCPU: 0
# CPU: 0 ← vCPU0 绑定到物理 CPU 0
# State: running
# CPU time: 87.4s
# CPU Affinity: y------- ← 只在 CPU 0 运行
# 在宿主机上查看 VM 进程的 CPU 亲和性
PID=$(virsh dumpxml <vm-name> | grep pid | head -1 | grep -oP '\d+')
taskset -cp $PID
# 运行时也可以手动调整(临时)
virsh vcpupin <vm-name> 0 0 # vCPU0 绑定到物理 CPU 0
三、大页内存(Huge Pages)
默认内存页大小 4KB,TLB(页表缓存)条目有限。大页(2MB 或 1GB)减少 TLB 缺失,降低内存访问延迟。
配置 Huge Pages(宿主机)
# 查看当前大页状态
cat /proc/meminfo | grep -i huge
# HugePages_Total: 1024 ← 已分配 1024 个 2MB 大页(= 2GB)
# HugePages_Free: 512 ← 空闲 512 个
# Hugepagesize: 2048 kB ← 大页大小 2MB
# 方法1:运行时分配(不需重启)
echo 2048 > /proc/sys/vm/nr_hugepages # 分配 2048 个 2MB 大页(= 4GB)
# 方法2:永久配置
echo "vm.nr_hugepages = 2048" >> /etc/sysctl.conf
sysctl -p
# 1GB 超大页(需要重启,在 GRUB 参数中配置)
# 在 /etc/default/grub 中:
GRUB_CMDLINE_LINUX_DEFAULT="quiet hugepagesz=1G hugepages=16 default_hugepagesz=1G"
update-grub && reboot
# 挂载 hugetlbfs
mkdir /dev/hugepages2M
mount -t hugetlbfs -o pagesize=2M hugetlbfs /dev/hugepages2M
# 查看大页使用情况
grep -E "HugePages|Hugepagesize" /proc/meminfo
# 按 NUMA 节点分配大页(配合 CPU Pinning 使用)
echo 1024 > /sys/devices/system/node/node0/hugepages/hugepages-2048kB/nr_hugepages
echo 1024 > /sys/devices/system/node/node1/hugepages/hugepages-2048kB/nr_hugepages
让 VM 使用大页内存
<!-- virsh edit:启用大页内存 -->
<domain>
<memoryBacking>
<!-- 使用 2MB 大页(默认) -->
<hugepages>
<page size='2' unit='M'/>
</hugepages>
<!-- 锁定内存(防止 swap,大页内存必须) -->
<locked/>
<!-- 不与其他 VM 共享内存页 -->
<nosharepages/>
</memoryBacking>
</domain>
# 确认 VM 使用了大页
virsh dumpxml <vm-name> | grep hugepages
# 在宿主机上查看大页使用
grep -i "HugePages_Free" /proc/meminfo
# 启动 VM 前 Free: 2048,启动后 Free: 2048 - VM内存/2MB = 减少了对应数量
四、NUMA 绑定
# 用 numactl 启动 QEMU 进程(替代 virsh,更精细的控制)
numactl --cpunodebind=0 --membind=0 \
qemu-system-x86_64 \
-m 16G \
-smp 8,sockets=1,cores=4,threads=2 \
...
# 通过 virsh 设置 NUMA 内存绑定(在 VM 配置中)
virsh numatune <vm-name> --nodeset 0 --mode strict
# mode=strict:只从 node 0 分配内存(node 0 不够则失败,不跨节点)
# mode=interleave:在多个 node 间交错分配
# mode=preferred:优先从 node 0,不够再用其他
# 查看 VM 的 NUMA 绑定
virsh numatune <vm-name>
大 VM 跨 NUMA 配置(VM 内存 > 单个 NUMA 节点)
<!-- VM 内存超过单个 NUMA 节点时,配置 VM 内的 NUMA 拓扑 -->
<domain>
<memory unit='GiB'>256</memory> <!-- 256GB VM,跨两个 NUMA 节点 -->
<cpu>
<numa>
<!-- VM 内 NUMA 0:映射到宿主机 NUMA 0 -->
<cell id='0' cpus='0-15' memory='131072' unit='MiB'/>
<!-- VM 内 NUMA 1:映射到宿主机 NUMA 1 -->
<cell id='1' cpus='16-31' memory='131072' unit='MiB'/>
</numa>
</cpu>
<cputune>
<!-- VM 内 NUMA 0 的 vCPU 绑定到宿主机 NUMA 0 的物理核 -->
<vcpupin vcpu='0' cpuset='0'/>
<!-- ... vCPU 0-15 绑定到宿主机 CPU 0-7,16-23 -->
<!-- VM 内 NUMA 1 的 vCPU 绑定到宿主机 NUMA 1 的物理核 -->
<vcpupin vcpu='16' cpuset='8'/>
<!-- ... vCPU 16-31 绑定到宿主机 CPU 8-15,24-31 -->
</cputune>
<numatune>
<!-- VM 内 NUMA 0 的内存从宿主机 NUMA 0 分配 -->
<memnode cellid='0' mode='strict' nodeset='0'/>
<!-- VM 内 NUMA 1 的内存从宿主机 NUMA 1 分配 -->
<memnode cellid='1' mode='strict' nodeset='1'/>
</numatune>
</domain>
五、完整高性能 VM 配置示例
<!-- 适合低延迟数据库(MySQL/PostgreSQL)或 HPC 的 VM 配置 -->
<domain type='kvm'>
<name>db-vm-01</name>
<memory unit='GiB'>64</memory>
<currentMemory unit='GiB'>64</currentMemory>
<memoryBacking>
<hugepages><page size='2' unit='M'/></hugepages>
<locked/>
<nosharepages/>
</memoryBacking>
<vcpu placement='static'>16</vcpu>
<cpu mode='host-passthrough' check='none'>
<topology sockets='1' dies='1' cores='8' threads='2'/>
</cpu>
<cputune>
<!-- 绑定到 NUMA 0 的物理核(物理核 0-7,每核2个超线程)-->
<vcpupin vcpu='0' cpuset='0'/>
<vcpupin vcpu='1' cpuset='16'/>
<vcpupin vcpu='2' cpuset='1'/>
<vcpupin vcpu='3' cpuset='17'/>
<vcpupin vcpu='4' cpuset='2'/>
<vcpupin vcpu='5' cpuset='18'/>
<vcpupin vcpu='6' cpuset='3'/>
<vcpupin vcpu='7' cpuset='19'/>
<vcpupin vcpu='8' cpuset='4'/>
<vcpupin vcpu='9' cpuset='20'/>
<vcpupin vcpu='10' cpuset='5'/>
<vcpupin vcpu='11' cpuset='21'/>
<vcpupin vcpu='12' cpuset='6'/>
<vcpupin vcpu='13' cpuset='22'/>
<vcpupin vcpu='14' cpuset='7'/>
<vcpupin vcpu='15' cpuset='23'/>
<!-- emulator 和 IO 线程用预留的 CPU -->
<emulatorpin cpuset='8'/>
<iothreadpin iothread='1' cpuset='9'/>
<iothreadpin iothread='2' cpuset='10'/>
</cputune>
<numatune>
<memory mode='strict' nodeset='0'/>
</numatune>
<iothreads>2</iothreads>
<devices>
<disk type='file' device='disk'>
<driver name='qemu' type='raw' cache='none' io='native' iothread='1'/>
<source file='/var/lib/libvirt/images/db-data.raw'/>
<target dev='vda' bus='virtio'/>
</disk>
</devices>
</domain>
性能验证
# 在 VM 内验证 NUMA 效果
numactl --hardware
# 应该看到 VM 内部有 1 个 NUMA 节点(对应宿主机 NUMA 0)
# 测试内存访问延迟(mlc 工具,Intel Memory Latency Checker)
./mlc --latency_matrix
# 测量内存延迟:
# 配置优化前:约 120-150ns(跨 NUMA 访问)
# 配置优化后:约 70-80ns(本地 NUMA 访问)
# 测试 TLB 效果(大页 vs 小页)
# 运行内存密集型应用,对比 perf 统计
perf stat -e dTLB-loads,dTLB-load-misses -a sleep 60
# 优化前:dTLB-load-misses ~5%
# 优化后(大页):dTLB-load-misses ~0.1%
小结
NUMA 调优的核心原则是**“局部性”**:让 VM 的 vCPU 和内存都在同一个 NUMA 节点内。实施步骤:先用 numactl --hardware 和 lstopo 了解服务器 NUMA 拓扑 → 用 CPU Pinning 把 vCPU 锁定到特定物理核 → 用 numatune 绑定内存到同一 NUMA 节点 → 配置大页内存减少 TLB 压力。对于延迟敏感的数据库(PostgreSQL、Redis),这套配置能把 P99 延迟降低 30-50%。注意:CPU Pinning 后宿主机的 CPU 调度灵活性降低,需要预留足够的 CPU 核给宿主机和 QEMU emulator 线程使用。
