NUMA 亲和性与内存带宽优化——双路服务器的就近取数策略
NUMA 亲和性与内存带宽优化——双路服务器的就近取数策略一、当 CPU 离数据 200ns 远双路服务器的隐性延迟税在多路服务器架构中每个 CPU 插槽都有自己本地连接的 DIMM 内存通道。访问本地内存的延迟通常在 80100ns而跨 CPU 访问对端内存remote access的延迟会增加到 180220ns。这一倍的延迟差距不是理论数字——在生产环境的 MySQL InnoDB 缓冲池命中路径上频繁的跨 NUMA 节点访问可以直接将 TP99 延迟从 1.2ms 推高到 3.8ms。问题在于操作系统默认的内存分配策略MPOL_DEFAULT会优先在发起分配的线程所在 NUMA 节点上分配内存但线程会被调度器迁移到其他 NUMA 节点的 CPU 核心上运行。结果就是线程在 Node 0 上分配了内存却被调度到 Node 1 的核心上执行——每一次内存访问都在跨节点执行延迟直接翻倍。更隐蔽的是dual-socket 服务器的两个 CPU 之间通过 UPIUltra 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/sbr/~38GB/s 带宽| CPU1 APP0[进程 Abr/绑定 Node 0] -.- CPU0 APP1[进程 Bbr/绑定 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:#e1f5fe2.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并使用交错模式分配内存 # --cpunodebind0: 进程和它创建的所有线程只能在 Node 0 的 CPU 上运行 # --membind0: 内存分配严格限制在 Node 0——避免跨节点访问 # 警告如果 Node 0 内存不足OOM Killer 会被触发 numactl --cpunodebind0 --membind0 \ /usr/sbin/mysqld --defaults-file/etc/my.cnf # 对于大型 InnoDB Buffer Pool如 128GB如果单路内存不足以容纳 # 可以使用 --interleave 在各个节点上均匀分配 # 这种策略牺牲了局部性但避免了单节点 OOM numactl --interleaveall \ /usr/sbin/mysqld --defaults-file/etc/my.cnf3.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 的栈只有 28KBNUMA 内存分配的粒度通常是 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 节点隔离。