NUMA架构优化:从原理到实战

NUMA,全称Non-Uniform Memory Access,非统一内存访问架构。说白了,就是CPU访问不同内存位置的速度不一样快。这个概念在单核时代几乎没人关心,但到了多核、多路处理器的今天,不懂NUMA,你的性能优化可能就白做了。

我记得第一次遇到NUMA问题是在一个数据库项目上。当时我们换了台新服务器,64核的机器,结果跑同样的负载,性能反而比32核的老机器还差。查了三天,最后发现是内存跨节点访问导致的。嗯,从那以后,我每次做性能调优都会先看一眼NUMA拓扑。

NUMA架构原理

先说说NUMA是怎么来的。早期的SMP(对称多处理)架构里,所有CPU共享一条内存总线。CPU多了,总线就成了瓶颈。后来工程师们想了个办法:把CPU和内存分成多个组,每个组叫一个NUMA节点。CPU访问自己节点内的内存很快,访问其他节点的内存就慢一些。

核心概念:

  • NUMA节点:一组CPU核心和本地内存的集合
  • 本地访问:CPU访问同节点内存,延迟低(通常几十纳秒)
  • 远程访问:CPU访问其他节点内存,延迟高(通常翻倍甚至更多)
  • 跨节点带宽:节点间通过互联通道(如Intel的QPI/UPI)通信

你想想看,如果一个线程在节点0的CPU上运行,却频繁访问节点1的内存,那性能损失有多大?这就是NUMA优化的核心问题。

NUMA节点0 CPU核心 0-3 CPU核心 4-7 本地内存 (DDR4) 延迟: ~80ns QPI/UPI互联通道 NUMA节点1 CPU核心 8-11 CPU核心 12-15 本地内存 (DDR4) 延迟: ~80ns QPI/UPI互联通道 远程访问 延迟: ~160ns

内存亲和性

内存亲和性,就是让程序尽量使用本地内存。怎么做呢?核心思路是:哪个CPU上的线程分配内存,就从哪个节点的内存池里分配

在Linux上,默认的内存分配策略是"先本地,后远程"。如果本地内存不够了,才会去远程节点借。但有些场景下,这个默认策略并不够用。

我的经验:在高性能计算场景中,我习惯用 mbind()set_mempolicy() 来显式控制内存分配策略。特别是做内存池预分配时,一定要绑定到正确的NUMA节点。

来看一个实际例子。假设你有一个16核的机器,分两个NUMA节点。你开了8个线程,每个线程处理自己的数据分区。如果线程0跑在节点0的CPU上,但它的数据被分配到了节点1的内存里——每次访问都要跨节点,性能直接腰斩。

// 使用libnuma绑定内存到指定节点
#include <numa.h>
#include <numaif.h>

void* allocate_on_node(size_t size, int node) {
    void* ptr = mmap(NULL, size, PROT_READ | PROT_WRITE,
                     MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (ptr == MAP_FAILED) return NULL;
    
    // 绑定内存到指定NUMA节点
    struct bitmask* mask = numa_allocate_nodemask();
    numa_bitmask_setbit(mask, node);
    mbind(ptr, size, MPOL_BIND, mask->maskp, mask->size, 0);
    numa_free_nodemask(mask);
    
    return ptr;
}

// 使用示例
void* local_mem = allocate_on_node(1024 * 1024 * 64, 0); // 在节点0分配64MB

注意:mbind() 的粒度是页级别的。如果你分配了2MB的巨页,mbind会以2MB为单位绑定。我曾经踩过这个坑——分配了1GB内存,以为绑定了节点0,结果因为页表分裂,部分页面跑到了节点1上。

线程亲和性

线程亲和性,就是把线程绑定到特定的CPU核心上运行。光有内存亲和性还不够,你得保证访问内存的线程和内存所在的节点是同一个

我个人习惯用 pthread_setaffinity_np() 来做线程绑定。当然,OpenMP也提供了 OMP_PROC_BIND 环境变量,用起来更方便。

#include <pthread.h>
#include <sched.h>

void bind_thread_to_core(pthread_t thread, int core_id) {
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    CPU_SET(core_id, &cpuset);
    
    int ret = pthread_setaffinity_np(thread, sizeof(cpu_set_t), &cpuset);
    if (ret != 0) {
        fprintf(stderr, "绑定线程到核心%d失败: %d\n", core_id, ret);
    }
}

// 在worker线程中调用
void* worker_thread(void* arg) {
    int core_id = *(int*)arg;
    bind_thread_to_core(pthread_self(), core_id);
    
    // 现在这个线程只会在core_id上运行
    // 配合内存亲和性,确保数据也在同一个NUMA节点
    process_data();
    return NULL;
}

核心原则:线程亲和性 + 内存亲和性 = NUMA感知编程。两者缺一不可。

你可能会问:如果线程数比CPU核心数多怎么办?嗯,这时候就要小心了。我建议的做法是:每个NUMA节点上运行的线程数不要超过该节点的CPU核心数。否则线程切换会导致缓存污染,NUMA优化的效果会大打折扣。

numactl工具使用

numactl是Linux上管理NUMA策略的命令行工具。我个人觉得,它是排查NUMA问题最快的手段。不需要改代码,跑一下就能看到效果。

先看看怎么查看NUMA拓扑:

# 查看NUMA节点信息
$ numactl --hardware

available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7
node 0 size: 32768 MB
node 0 free: 24576 MB
node 1 cpus: 8 9 10 11 12 13 14 15
node 1 size: 32768 MB
node 1 free: 24576 MB
node distances:
node   0   1
  0:  10  20
  1:  20  10

看到那个 node distances 了吗?10表示本地访问,20表示远程访问。这个数值是相对延迟,不是纳秒数,但比例关系很说明问题。

常用的numactl命令:

命令 说明 示例
--cpunodebind 绑定到指定NUMA节点的CPU numactl --cpunodebind=0 ./app
--membind 只从指定节点分配内存 numactl --membind=1 ./app
--interleave 内存交错分配(所有节点轮询) numactl --interleave=all ./app
--preferred 优先从指定节点分配,不够再找其他节点 numactl --preferred=0 ./app

一个小技巧:如果你不确定程序有没有跨节点访问,可以用 numastat 监控。运行 numastat -p <pid>,看 local_nodeother_node 的比值。如果 other_node 占比超过10%,就该优化了。

我曾经帮一个团队排查性能问题。他们的数据库跑在32核机器上,但吞吐量一直上不去。我上去跑了个 numastat -p,发现 other_node 占比高达40%。再一看,他们用的内存分配器默认是interleave模式。改成membind后,性能提升了35%。

实战:NUMA感知的内存池

最后,分享一个我在项目中用过的NUMA感知内存池设计。这个模式特别适合每个线程独立处理数据分区的场景。

class NumaAwarePool {
public:
    NumaAwarePool(size_t per_node_size) {
        int num_nodes = numa_num_configured_nodes();
        pools_.resize(num_nodes);
        
        for (int i = 0; i < num_nodes; ++i) {
            // 在每个NUMA节点上预分配内存
            pools_[i].data = allocate_on_node(per_node_size, i);
            pools_[i].size = per_node_size;
            pools_[i].offset = 0;
        }
    }
    
    void* allocate(size_t size) {
        // 获取当前线程运行的NUMA节点
        int node = numa_node_of_cpu(sched_getcpu());
        if (node < 0) node = 0;
        
        // 从本地节点分配
        auto& pool = pools_[node];
        if (pool.offset + size > pool.size) {
            return nullptr; // 内存不足,需要扩容逻辑
        }
        
        void* ptr = static_cast<char*>(pool.data) + pool.offset;
        pool.offset += size;
        return ptr;
    }
    
private:
    struct NodePool {
        void* data;
        size_t size;
        size_t offset;
    };
    std::vector<NodePool> pools_;
};

注意:这个实现里用了 sched_getcpu() 来获取当前CPU。这个调用本身有开销,高频调用时要注意。我一般会在线程启动时获取一次NUMA节点号,然后缓存起来。

NUMA优化说白了就是一件事:让数据和计算离得近。原理不复杂,但实际做起来有很多细节。我建议你从 numactl --hardware 开始,先摸清你的机器拓扑,然后用 numastat 看看现有程序的跨节点访问情况。发现问题后,再考虑用代码层面的亲和性绑定。

记住,不是所有程序都需要NUMA优化。如果你的程序内存访问模式是随机的,或者数据量很小,那优化了也没太大效果。但如果你在做数据库、高性能计算、或者大规模数据处理——NUMA优化几乎是必修课。


公众号:蓝海资料掘金营,微信deep3321