用三条命令说清你的机器有几个 NUMA 节点、内存怎么分布;写一个程序实测跨节点访问的代价;理解为什么这件事对鲲鹏服务器尤其重要。
为什么先看硬件
很多"应用层怎么调都上不去"的性能问题,根因在硬件布局上:你的线程在节点 A 上跑,数据在节点 B 的内存里------代码逻辑完全正确,但每次访存都要经过片间互联链路,这个延迟在代码里看不见。
第一条命令:看清 NUMA 拓扑
bash
sudo yum install -y numactl # openEuler / CentOS
# 或 sudo apt install -y numactl
numactl -H
输出形如(这是 openEuler 官方文档给出的真实示例):
text
available: 1 nodes (0)
node 0 cpus: 0 1 2 3
node 0 size: 2633 MB
node 0 free: 322 MB
node distances:
node 0
0: 10
逐字段解读:
available: 1 nodes (0)--- 这台机器划分为 1 个 NUMA 节点,编号 0。单节点意味着不存在跨节点问题,简单。node 0 cpus: 0 1 2 3--- 节点 0 包含 CPU 核 0--3。node 0 size/node 0 free--- 节点 0 的总内存 / 空闲内存。node distances--- 节点间距离矩阵,这是最关键的部分 。距离数值越小,表示访问延迟越低。节点到自身的距离通常是 10。
多路鲲鹏服务器(比如双路 920)会看到两个甚至更多节点,距离矩阵里会出现 10、12、20 这样的数值。距离 20 表示跨节点访问的延迟大约是本地访问的两倍------这个数字直接把"跨 NUMA 有多贵"量化了。
鲲鹏 920 通过 HCCS(Huawei Cache Coherency System)做片间互联,最高 480Gbps,最多支持 4 颗 CPU 互联构成 256 物理核的系统。规模上去了,NUMA 就从理论概念变成每天要面对的现实。
第二条命令:看跨节点访问是否真的在发生
bash
numastat
输出形如(同样是官方文档的真实示例):
text
node0
numa_hit 5386186
numa_miss 0
numa_foreign 0
interleave_hit 17483
local_node 5386186
other_node 0
三个关键字段:
| 字段 | 含义 |
|---|---|
numa_hit |
节点内 CPU 访问本地内存的次数 |
numa_miss |
节点内 CPU 访问其他节点内存的次数 |
other_node |
该节点进程分配在其他节点上的内存访问量 |
判读方法 :numa_miss 和 other_node 非零,就说明存在跨 NUMA 访问。这个例子中两者都是 0,说明这台机器上还没有跨节点访存发生(或者本来就是单节点)。
这是第 7 讲绑定优化的直接证据来源 ------优化前后各跑一次 numastat,看这两个数字有没有降下来。
第三条命令:看 CPU 与缓存
bash
lscpu
关注这几行(现代 lscpu 会直接给出缓存信息):
text
Architecture: aarch64
CPU(s): 96
NUMA node(s): 2
NUMA node0 CPU(s): 0-47
NUMA node1 CPU(s): 48-95
L1d cache: 64K
L1i cache: 64K
L2 cache: 512K
L3 cache: 24M
⚠️ 待实机确认:
lscpu的 NUMA 相关输出行在不同发行版、不同 util-linux 版本上格式不完全一致,官方文档只确认了"可用 lscpu 查看 CPU 与核数",未给出含 NUMA 行的完整输出。以numactl -H为准,lscpu作为辅助。
缓存层次对第 9 讲的优化很关键:L1 通常 64KB、L2 几百 KB、L3 几 MB 到几十 MB。当你的数据规模超过 L3,瓶颈就从计算能力变成内存带宽------这正是第 2 讲那个实验里 N 取 64MB 与 4096 个 float(16KB)结果不同的原因。
实测:跨 NUMA 访问到底慢多少
光看数字不够直观,写个程序实测。核心思路是指针追逐(pointer chasing)------构造一条指针链表,每个元素只存下一个元素的地址,然后顺着链走。
为什么用指针追逐而不是顺序遍历数组?因为顺序遍历会被硬件预取器优化掉------CPU 发现你在顺序访问,会提前把数据拉进缓存。指针追逐的访问模式是随机的,预取器帮不上忙,测出的才是真实的访存延迟。
新建 numa_latency.c:
c
/*
* numa_latency.c ------ 实测内存访问延迟,可用 numactl 绑定到不同 NUMA 节点对比
*
* 编译:gcc -O2 -o numa_latency numa_latency.c
* 运行:
* numactl --cpunodebind=0 --membind=0 ./numa_latency # CPU 和内存都在节点 0
* numactl --cpunodebind=0 --membind=1 ./numa_latency # CPU 在 0,内存强制在 1
*/
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include "timing.h"
#define COUNT (1 << 20) /* 1048576 个节点,每节点 64 字节,共 64MB,远超 L3 */
/* 每个节点占满一条 cache line(64 字节),避免多节点共享同一行造成干扰 */
struct node {
struct node *next;
char pad[56]; /* 补齐到 64 字节 */
};
int main(void)
{
struct node *nodes;
if (posix_memalign((void **)&nodes, 64,
(size_t)COUNT * sizeof(struct node)) != 0) {
fprintf(stderr, "内存分配失败\n");
return 1;
}
/* 初始化:先顺序写一遍,迫使内存真正被分配(避免写时复制带来的假象) */
for (int i = 0; i < COUNT; i++) {
nodes[i].next = &nodes[i]; /* 先指向自己,下一步再打乱 */
}
/* 构造随机环:用简单的线性同余打乱顺序,
* 让访问模式不可预测,绕开硬件预取器 */
uint64_t seed = 12345;
int *perm = malloc((size_t)COUNT * sizeof(int));
if (!perm) { fprintf(stderr, "内存分配失败\n"); return 1; }
for (int i = 0; i < COUNT; i++) perm[i] = i;
for (int i = COUNT - 1; i > 0; i--) {
seed = seed * 6364136223846793005ULL + 1442695040888963407ULL;
int j = (int)(seed >> 33) % (i + 1);
int tmp = perm[i]; perm[i] = perm[j]; perm[j] = tmp;
}
/* 连成环:perm[0] → perm[1] → ... → perm[COUNT-1] → perm[0] */
for (int i = 0; i < COUNT; i++) {
nodes[perm[i]].next = &nodes[perm[(i + 1) % COUNT]];
}
/* 开始追逐:每次只访问一个节点,无法预取 */
struct node *p = &nodes[perm[0]];
uint64_t t0 = now_ns();
for (int i = 0; i < COUNT; i++) {
p = p->next; /* 这一句就是被测代码:一次依赖性的随机访存 */
}
uint64_t t1 = now_ns();
/* 防止编译器把整个循环优化掉(p 没被使用的话可能被删) */
printf("最后落点: %p\n", (void *)p);
double total_ns = (double)(t1 - t0);
printf("总耗时: %.3f ms\n", total_ns / 1e6);
printf("单次访存: %.2f ns\n", total_ns / COUNT);
printf("访问次数: %d\n", COUNT);
free(perm);
free(nodes);
return 0;
}
编译运行两组对比:
bash
gcc -O2 -o numa_latency numa_latency.c
# 场景 A:CPU 和内存都在节点 0(本地访问)
numactl --cpunodebind=0 --membind=0 ./numa_latency
# 场景 B:CPU 在节点 0,内存强制从节点 1 分配(跨节点访问)
numactl --cpunodebind=0 --membind=1 ./numa_latency
判读 :场景 B 的"单次访存"数值应该明显大于场景 A。两者的比值,大致对应 numactl -H 里距离矩阵中 20/10 的关系。单节点机器上两个场景结果相同------因为没有节点 1,--membind=1 会失败或退化为本地分配。
关于 numactl 的两个参数,含义要分清:
| 参数 | 短形式 | 作用 |
|---|---|---|
--cpunodebind=N |
-N N |
只在节点 N 的 CPU 上运行程序 |
--membind=N |
-m N |
只从节点 N 分配内存 |
两者可以不一致------这正是上面用来构造"跨节点访问"的手法:CPU 在 0,内存却在 1。还有一个常用的:--localalloc(-l),表示优先在 CPU 所在节点分配内存。
动手练习
- 把
--membind=1改成--interleave=0,1,看延迟落在本地与跨节点之间的什么位置,想想为什么它适合带宽敏感型负载。 - 把
COUNT改小到能装进 L2(比如 4096),再测一次,看延迟降到什么量级。 - 去掉随机打乱那一段(改成顺序链表),看延迟降到多少------这就是硬件预取器的威力,也是基准测试要刻意防预取的原因。
进阶与拓展
--interleave的适用边界:按页粒度在节点间摊开分配,能把带宽敏感型负载(大数组流式扫描这类)的各节点带宽叠加起来;但每次访问仍可能落到远端节点,单次延迟不会降低,延迟敏感型(指针追逐这类)反而可能变差。- 拓扑工具 hwloc :
lstopo以树状/图形输出 NUMA、缓存、核的完整层级;hwloc-calc能把"节点 0 的前 8 个核"直接算成 cpuset 串喂给taskset,比手工抄numactl -H输出更不易错。 - 内核自动均衡 :
/proc/sys/kernel/numa_balancing置 1 后,内核会把页迁移到频繁访问它的线程所在节点,是不绑 NUMA 时的兜底;一旦显式绑核绑内存,建议关掉以免两套策略互相打架。 - 按进程定位 :
numastat -p <pid>查看单个进程在各节点的内存分布,比全局统计更能锁定"是哪个进程在跨节点访存"。
参考来源
- openEuler · 系统资源与性能 --- NUMA 定义、
numactl -H与numastat的真实输出示例与字段含义 - numactl man page ---
--cpunodebind/--membind/--interleave/--localalloc的完整语法与短选项