技术博客

虚拟化 NUMA 深度调优:CPU Pin、大页内存、内存绑定实战

深入讲解虚拟化平台的 NUMA(非统一内存访问)调优技术:NUMA 拓扑感知、vCPU 绑定到物理 CPU(CPU Pinning)、大页内存(Huge Pages)消除 TLB 压力、NUMA 内存绑定防止跨 NUMA 访问,通过低延迟数据库和高性能计算场景的完整配置,掌握虚拟机极致性能调优。

KVMNUMACPU-Pinning大页内存性能调优虚拟化HugePages

虚拟化环境的性能损耗通常来自两个地方:内存访问延迟(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 --hardwarelstopo 了解服务器 NUMA 拓扑 → 用 CPU Pinning 把 vCPU 锁定到特定物理核 → 用 numatune 绑定内存到同一 NUMA 节点 → 配置大页内存减少 TLB 压力。对于延迟敏感的数据库(PostgreSQL、Redis),这套配置能把 P99 延迟降低 30-50%。注意:CPU Pinning 后宿主机的 CPU 调度灵活性降低,需要预留足够的 CPU 核给宿主机和 QEMU emulator 线程使用。