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优化的核心问题。
内存亲和性
内存亲和性,就是让程序尽量使用本地内存。怎么做呢?核心思路是:哪个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_node 和 other_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