NUMA 亲和性与内存带宽优化——双路服务器的就近取数策略

一、当 CPU 离数据 200ns 远:双路服务器的隐性延迟税

在多路服务器架构中,每个 CPU 插槽都有自己本地连接的 DIMM 内存通道。访问本地内存的延迟通常在 80 100ns,而跨 CPU 访问对端内存(remote access)的延迟会增加到 180220ns。这一倍的延迟差距不是理论数字——在生产环境的 MySQL InnoDB 缓冲池命中路径上,频繁的跨 NUMA 节点访问可以直接将 TP99 延迟从 1.2ms 推高到 3.8ms。

问题在于,操作系统默认的内存分配策略(MPOL_DEFAULT)会优先在发起分配的线程所在 NUMA 节点上分配内存,但线程会被调度器迁移到其他 NUMA 节点的 CPU 核心上运行。结果就是线程在 Node 0 上分配了内存,却被调度到 Node 1 的核心上执行——每一次内存访问都在跨节点执行,延迟直接翻倍。

更隐蔽的是,dual-socket 服务器的两个 CPU 之间通过 UPI(Ultra Path Interconnect)或 Infinity Fabric 连接,这个 inter-socket 链路的带宽上限远低于本地内存带宽。在 Intel Xeon Platinum 平台上,单路内存带宽约 200GB/s,而 UPI 链路带宽约 30~40GB/s。当多个进程同时跨节点访问内存时,UPI 链路迅速饱和,形成全局性的延迟尖峰。

二、NUMA 架构的硬件真相与操作系统的错觉

graph TB
    subgraph NUMA_Node_0
        CPU0[CPU Cores 0-15] --- L3_0[L3 Cache 32MB]
        L3_0 --- IMC0[内存控制器 0]
        IMC0 --- DDR0[DDR4 DIMM 0]
        IMC0 --- DDR1[DDR4 DIMM 1]
    end
    
    subgraph NUMA_Node_1
        CPU1[CPU Cores 16-31] --- L3_1[L3 Cache 32MB]
        L3_1 --- IMC1[内存控制器 1]
        IMC1 --- DDR2[DDR4 DIMM 2]
        IMC1 --- DDR3[DDR4 DIMM 3]
    end
    
    CPU0 <==>|UPI 10.4 GT/s<br/>~38GB/s 带宽| CPU1
    
    APP0[进程 A<br/>绑定 Node 0] -.-> CPU0
    APP1[进程 B<br/>绑定 Node 1] -.-> CPU1
    
    style CPU0 fill:#c8e6c9
    style CPU1 fill:#c8e6c9
    style DDR0 fill:#e1f5fe
    style DDR1 fill:#e1f5fe
    style DDR2 fill:#e1f5fe
    style DDR3 fill:#e1f5fe

2.1 为什么操作系统不知道这是性能问题

Linux 内核的调度器(CFS)在做负载均衡时会跨 NUMA 节点迁移任务。调度器的设计目标是公平性——让所有 CPU 都忙碌起来,避免某些核心空闲。但调度器不知道被迁移的线程的本地内存亲和性已经失效。

即使开启了 numa_balancing(内核 3.13+),内核的自动 NUMA 迁移机制也面临一个根本性的数据匮乏问题:内核只能观察到页表的访问位,这个数据粒度太粗。页表的 Dirty/Accessed 位在 TLBs 刷新时才会被更新,频率远低于实际的内存访问。这意味着内核在做"是否迁移内存页"的决策时,依据的是过时且不完整的信息。

2.2 使用 numastat 测量 NUMA 分布的真相

# numastat 输出示例——关注 numa_miss 和 numa_foreign 两个关键指标
# numa_miss: 本节点线程去分配其他节点内存的次数(越高说明分配策略有问题)
# numa_foreign: 其他节点线程来分配本节点内存的次数
$ numastat -p $(pgrep mysqld)

Per-node process memory usage (in MBs) for PID 31142 (mysqld):
                           Node 0          Node 1           Total
                  --------------- --------------- ---------------
Huge                         0.00            0.00            0.00
Heap                       512.34         1536.12         2048.46
Stack                        0.45            0.38            0.83
Private                 30000.12        10000.34        40000.46
----------------  --------------- --------------- ---------------
Total                    30512.91        11536.84        42049.75

# 如果一个服务主要在 Node 0 上运行,但大半内存分配在 Node 1
# 说明存在严重的 NUMA 分布错配——需要通过 numactl 重新绑定

三、实战调优:从内核参数到应用层策略

3.1 使用 numactl 进行进程级绑定

# 将 MySQL 进程绑定到 NUMA Node 0,并使用交错模式分配内存
# --cpunodebind=0: 进程和它创建的所有线程只能在 Node 0 的 CPU 上运行
# --membind=0: 内存分配严格限制在 Node 0——避免跨节点访问
# 警告:如果 Node 0 内存不足,OOM Killer 会被触发
numactl --cpunodebind=0 --membind=0 \
    /usr/sbin/mysqld --defaults-file=/etc/my.cnf

# 对于大型 InnoDB Buffer Pool(如 128GB+),如果单路内存不足以容纳
# 可以使用 --interleave 在各个节点上均匀分配
# 这种策略牺牲了局部性,但避免了单节点 OOM
numactl --interleave=all \
    /usr/sbin/mysqld --defaults-file=/etc/my.cnf

3.2 NUMA 感知的内存池设计(Go 语言实现)

package numa

import (
    "runtime"
    "syscall"
    "unsafe"
)

// NumaPool 是 NUMA 感知的内存分配器
// 设计目标:减少跨节点内存访问,提升缓存局部性
// 适用场景:16+ 核心的服务端程序,如 HTTP Server、RPC Framework
type NumaPool struct {
    nodeID  int
    cpuidxs []int // 该 NUMA 节点的 CPU 核心列表
}

// NewNumaPool 创建一个绑定到指定 NUMA 节点的内存池
// nodeID 从 /sys/devices/system/node/ 目录读取
func NewNumaPool(nodeID int) (*NumaPool, error) {
    cpuidxs, err := getCPUListForNode(nodeID)
    if err != nil {
        return nil, err
    }

    pool := &NumaPool{
        nodeID:  nodeID,
        cpuidxs: cpuidxs,
    }

    return pool, nil
}

// ExecOnNode 在指定 NUMA 节点上执行函数
// 通过 runtime.LockOSThread 将当前 goroutine 锁定到特定 OS 线程
// 配合 syscall.SchedSetaffinity 将该线程绑定到目标 NUMA 节点的 CPU 核心
// 注意:LockOSThread 使该 goroutine 独占一个线程——不适合创建上万个 goroutine 的场景
func (np *NumaPool) ExecOnNode(fn func()) {
    // 将当前 goroutine 锁定到 OS 线程
    // 这确保后续的 CPU affinity 设置不会被子 goroutine 继承
    runtime.LockOSThread()
    defer runtime.UnlockOSThread()

    // 构建 CPU 亲和性掩码——只允许在该 NUMA 节点的核心上运行
    // CPU 集合大小需要与实际的核心数匹配(通过 runtime.NumCPU() 取上限)
    var cpuset syscall.CPUSet
    for _, cpuidx := range np.cpuidxs {
        cpuset.Set(cpuidx)
    }

    // 设置线程的 CPU 亲和性
    // 之后该线程的所有内存分配都会优先在本地 NUMA 节点上
    if err := syscall.SchedSetaffinity(0, &cpuset); err != nil {
        // 在生产代码中应记录错误并决定是降级还是 panic
        // 此处为了演示简洁,直接 panic——实际应与监控系统联动
        panic("failed to set CPU affinity: " + err.Error())
    }

    fn()
}

// AllocLocal 在本地 NUMA 节点上分配固定大小的字节切片
// 警告:这个方法使用了 mmap 的直接调用,生产代码需要完善的错误处理和释放逻辑
func (np *NumaPool) AllocLocal(size int) ([]byte, error) {
    // 使用 mmap 的 MAP_ANONYMOUS | MAP_PRIVATE 标志分配匿名内存
    // 配合 runtime.LockOSThread 锁定线程后,内核会将物理页面分配在本地 NUMA 节点上
    data, err := syscall.Mmap(
        -1, 0, size,
        syscall.PROT_READ|syscall.PROT_WRITE,
        syscall.MAP_ANONYMOUS|syscall.MAP_PRIVATE,
    )
    if err != nil {
        return nil, err
    }
    return data, nil
}

// getCPUListForNode 从 sysfs 读取指定 NUMA 节点的 CPU 列表
// 读取 /sys/devices/system/node/node{N}/cpulist
// 返回格式如 "0-15,32-47" 的解析结果
func getCPUListForNode(nodeID int) ([]int, error) {
    // 实现略——通过 ioutil.ReadFile 读取 cpulist 文件并解析范围
    // 核心路径: /sys/devices/system/node/node{nodeID}/cpulist
    // 示例输出: "0-7,16-23" → [0,1,2,3,4,5,6,7,16,17,18,19,20,21,22,23]
    return nil, nil
}

四、NUMA 优化的边界:不要把问题复杂化

NUMA 优化不是包治百病的方案,它有明确的适用和不适用场景:

明确适用

  • 单进程内存占用量接近或超过单 NUMA 节点内存容量(如 InnoDB Buffer Pool 128GB+)
  • 内存访问模式具有强局部性(如大数组的顺序扫描)
  • 延迟敏感型服务(如广告竞价引擎,p99 延迟要求 < 5ms)

明确不适用

  • 单 NUMA 节点已足够容纳进程的所有热数据——不需要额外的优化
  • 无状态的服务(请求间无数据共享),因为每次请求的数据局部性都很弱
  • 容器化环境中的 Pod:除非配置了 topology manager 的 single-numa-node 策略,否则容器的 NUMA 信息不完整,bind 操作可能失败

反模式:在 64 核主机上为每个 goroutine 单独绑定 NUMA 节点。每个 goroutine 的栈只有 2 8KB,NUMA 内存分配的粒度通常是 4KB 页,单个 goroutine 的热数据远不足以填满一个 cache line。正确的做法是按工作池(worker pool)级别绑定——将 816 个 worker 绑定到同一个 NUMA 节点。

五、总结

NUMA 优化是对硬件拓扑的显式利用。它的核心不是把调度器的事做一遍,而是在操作系统不知道你程序的访问模式时,直接告诉它内存应该分配在哪里、线程应该运行在哪里

推荐的实践路径:(1) 先用 numastat -p <pid> 诊断是否存在严重的跨节点访问(numa_miss 占比 > 20% 即为需要优化);(2) 对数据库、消息队列等有状态服务使用 numactl --membind 做进程级绑定;(3) 对自研 RPC 框架部署 NUMA 感知的工作池,在编译期通过 Rust 的 #[cfg(target_os = "linux")] 条件编译——非 Linux 平台使用 no-op 的降级实现;(4) 在 K8s 环境中利用 topology manager 和 CPU Manager 的 static policy 为 Guaranteed QoS 的 Pod 提供单 NUMA 节点隔离。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐