为什么先看硬件
很多"应用层怎么调都上不去"的性能问题,根因在硬件布局上:你的线程在节点 A 上跑,数据在节点 B 的内存里——代码逻辑完全正确,但每次访存都要经过片间互联链路,这个延迟在代码里看不见。
第一条命令:看清 NUMA 拓扑
sudo yum install -y numactl <span># openEuler / CentOS</span>
<span># 或 sudo apt install -y numactl</span>
numactl -H
输出形如(这是 openEuler 官方文档给出的真实示例):
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 就从理论概念变成每天要面对的现实。
第二条命令:看跨节点访问是否真的在发生
numastat
输出形如(同样是官方文档的真实示例):
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 与缓存
lscpu
关注这几行(现代 lscpu 会直接给出缓存信息):
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:
<span>/*
* 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
*/</span>
<span>#<span>include</span> <span><stdio.h></span></span>
<span>#<span>include</span> <span><stdlib.h></span></span>
<span>#<span>include</span> <span><stdint.h></span></span>
<span>#<span>include</span> <span>"timing.h"</span></span>
<span>#<span>define</span> COUNT (1 << 20) <span>/* 1048576 个节点,每节点 64 字节,共 64MB,远超 L3 */</span></span>
<span>/* 每个节点占满一条 cache line(64 字节),避免多节点共享同一行造成干扰 */</span>
<span><span>struct</span> <span>node</span> {</span>
<span><span>struct</span> <span>node</span> *<span>next</span>;</span>
<span>char</span> pad[<span>56</span>]; <span>/* 补齐到 64 字节 */</span>
};
<span>int</span> <span>main</span><span>(<span>void</span>)</span>
{
<span><span>struct</span> <span>node</span> *<span>nodes</span>;</span>
<span>if</span> (posix_memalign((<span>void</span> **)&nodes, <span>64</span>,
(<span>size_t</span>)COUNT * <span>sizeof</span>(<span>struct</span> node)) != <span>0</span>) {
<span>fprintf</span>(<span>stderr</span>, <span>"内存分配失败\n"</span>);
<span>return</span> <span>1</span>;
}
<span>/* 初始化:先顺序写一遍,迫使内存真正被分配(避免写时复制带来的假象) */</span>
<span>for</span> (<span>int</span> i = <span>0</span>; i < COUNT; i++) {
nodes[i].next = &nodes[i]; <span>/* 先指向自己,下一步再打乱 */</span>
}
<span>/* 构造随机环:用简单的线性同余打乱顺序,
* 让访问模式不可预测,绕开硬件预取器 */</span>
<span>uint64_t</span> seed = <span>12345</span>;
<span>int</span> *perm = <span>malloc</span>((<span>size_t</span>)COUNT * <span>sizeof</span>(<span>int</span>));
<span>if</span> (!perm) { <span>fprintf</span>(<span>stderr</span>, <span>"内存分配失败\n"</span>); <span>return</span> <span>1</span>; }
<span>for</span> (<span>int</span> i = <span>0</span>; i < COUNT; i++) perm[i] = i;
<span>for</span> (<span>int</span> i = COUNT - <span>1</span>; i > <span>0</span>; i--) {
seed = seed * <span>6364136223846793005ULL</span> + <span>1442695040888963407ULL</span>;
<span>int</span> j = (<span>int</span>)(seed >> <span>33</span>) % (i + <span>1</span>);
<span>int</span> tmp = perm[i]; perm[i] = perm[j]; perm[j] = tmp;
}
<span>/* 连成环:perm[0] → perm[1] → ... → perm[COUNT-1] → perm[0] */</span>
<span>for</span> (<span>int</span> i = <span>0</span>; i < COUNT; i++) {
nodes[perm[i]].next = &nodes[perm[(i + <span>1</span>) % COUNT]];
}
<span>/* 开始追逐:每次只访问一个节点,无法预取 */</span>
<span><span>struct</span> <span>node</span> *<span>p</span> =</span> &nodes[perm[<span>0</span>]];
<span>uint64_t</span> t0 = now_ns();
<span>for</span> (<span>int</span> i = <span>0</span>; i < COUNT; i++) {
p = p->next; <span>/* 这一句就是被测代码:一次依赖性的随机访存 */</span>
}
<span>uint64_t</span> t1 = now_ns();
<span>/* 防止编译器把整个循环优化掉(p 没被使用的话可能被删) */</span>
<span>printf</span>(<span>"最后落点: %p\n"</span>, (<span>void</span> *)p);
<span>double</span> total_ns = (<span>double</span>)(t1 - t0);
<span>printf</span>(<span>"总耗时: %.3f ms\n"</span>, total_ns / <span>1e6</span>);
<span>printf</span>(<span>"单次访存: %.2f ns\n"</span>, total_ns / COUNT);
<span>printf</span>(<span>"访问次数: %d\n"</span>, COUNT);
<span>free</span>(perm);
<span>free</span>(nodes);
<span>return</span> <span>0</span>;
}
编译运行两组对比:
gcc -O2 -o numa_latency numa_latency.c
<span># 场景 A:CPU 和内存都在节点 0(本地访问)</span>
numactl --cpunodebind=0 --membind=0 ./numa_latency
<span># 场景 B:CPU 在节点 0,内存强制从节点 1 分配(跨节点访问)</span>
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的完整语法与短选项
从三条命令到实测判读,形成可复用的 NUMA 排查闭环,适合在鲲鹏多路服务器上做绑核绑内存优化的工程师按图索骥。