香港服务器运行Ubuntu时,如何通过NUMA绑定优化DDR5内存性能?

那天是周六,香港机房里只剩下我和呼呼作响的冷风。我把这台新到的双路 9754 推到操作台,点亮 KVM,跑起监控。业务侧在催:这台机器要接一段内存带宽敏感的向量检索与批量特征生成任务,之前在另一台小机器上,时延抖得一塌糊涂。我的判断是:NUMA 没治理好。
设备与环境(现场实况)
- 位置:将军澳的一个冷得发抖的机房通道
- 服务器:2 × AMD EPYC 9754(共 256 核 / 512 线程)
- 内存:128GB DDR5-4800(实际条数与分布见文内排查)
- 磁盘:2 × 1.92TB U.2 NVMe SSD
- 系统:Ubuntu 22.04 LTS(通用低延迟内核可选)
- 目标:通过 NUMA 绑定 最大化 本地内存带宽与稳定性,避免跨节点访问带来的抖动与回退
要想把 DDR5-4800 的牙膏挤干净,“算在哪、内存就在哪” 是铁律。于是我开了一整天的“内存寻根问祖”排查与实测,下面把全过程拆给你看:从盘点硬件、核对 NUMA 拓扑,到基准、绑定、服务化与坑位处理,全栈跑通,新手照抄能用,老手看了不浪费时间。
1)硬件与 NUMA 拓扑盘点
1.1 确认 CPU/NUMA 结构与核心映射
# CPU 和 NUMA 节点概览
lscpu -e=CPU,CORE,SOCKET,NODE,ONLINE | column -t | head -40
lscpu | egrep 'Model name|Socket|NUMA|Thread|CPU\(s\)'
numactl --hardware
# NUMA 距离矩阵(越小越近)
cat /sys/devices/system/node/node*/distance
你会看到什么?
- 双路 9754 常见 BIOS 下会把单颗 CPU 分成 NPS=4 的 NUMA 节点(也可能是 NPS=2/1,视 BIOS)。双路合计 4/8 个 NUMA 节点。
- lscpu -e 能给出 CPU→NUMA 的细粒度映射;这对后面做 CPU 亲和、线程绑核是关键。
小贴士:如果你看到 NUMA node(s): 8,基本就是每路 4 个节点(NPS=4)。
如果只有 4,大概率是每路 2 个节点(NPS=2)。节点越多,带宽并行性越好,但调度复杂度也会提升。
1.2 内存条真实分布与容量红线
128GB 在 2 路 256 核 的机器上其实有点“瘦”,如果条子插得不均衡,会出现某个 NUMA 节点 可用内存很小 的情况,业务一涨就远程回退。
# per-NUMA 内存容量
for n in /sys/devices/system/node/node*; do
echo -n "$(basename $n): "
awk '/MemTotal/ {print $4 " kB"}' $n/meminfo
done
# 观察内存可用度与回退
numastat -m
经验值:若每个 NUMA node 实际只有十来 GB,可用内存稍一吃紧就会跨节点,性能抖动直接肉眼可见。
如果可能,把 DIMM 尽量均匀插满各通道;Ubuntu 下再怎么优化策略,也比不上物理层面的均衡。
2)基线性能测一测(不做绑定,先看“原味”)
我先不动任何策略,直接跑内存带宽基准,找原始地形图。
2.1 安装工具与基准
sudo apt update
sudo apt install -y numactl hwloc msr-tools linux-tools-common make gcc git
# 准备 STREAM(内存带宽基准)
git clone https://github.com/jeffhammond/STREAM.git
cd STREAM
make STREAM_ARRAY_SIZE=160000000 # 约 1.2GB 数组,可按节点内存调整
2.2 初始基准(无 NUMA 绑定)
# 不限制 CPU/内存,直接跑
./stream_c.exe
# 或者指定线程数,逼近单节点上限再放大
OMP_NUM_THREADS=64 ./stream_c.exe
记录 triad(MB/s 或 GB/s)与最慢 P95 延迟。这一步的意义是:先看到问题在哪里——通常会有偶发低谷与显著抖动,那就是跨节点或争用导致。
3)NUMA 绑定的三板斧
3.1 方案 A:快速粗暴的 numactl 绑定
把进程的计算核和内存分配策略同时钉在同一个 NUMA 节点上。
# 例:绑定到 NUMA 节点 0
numactl --cpunodebind=0 --membind=0 ./stream_c.exe
# 绑定到节点 1(对比分布)
numactl --cpunodebind=1 --membind=1 ./stream_c.exe
# 亲和具体 CPU 核(示例把某节点的 0-63 号 CPU)
taskset -c 0-63 numactl --membind=0 ./your_app
内存默认的“首触发(first-touch)”:多线程初始化数组时,如果线程跨节点,就会把页分散到多个 node,上线后再绑核会变成“算在这、内存在那”。
对策:初始化时就用 numactl --membind 让触发发生在目标 node,或用单线程在本地 node 预热。
3.2 方案 B:系统级倾向(systemd NUMA 策略)
对常驻服务,在 systemd 里写死策略,省心又稳定。
# /etc/systemd/system/myworker.service
[Unit]
Description=My NUMA-aware Worker
After=network.target
[Service]
ExecStart=/opt/myapp/bin/worker --conf /etc/myapp.conf
# 绑定 CPU 与内存策略
CPUAffinity=0-63
NUMAPolicy=bind
NUMAMask=0
# 避免被自动迁移影响
Environment=OMP_PROC_BIND=close
Environment=OMP_PLACES=cores
Restart=always
RestartSec=2s
[Install]
WantedBy=multi-user.target
NUMAPolicy=bind + NUMAMask=0 表示内存必须分配在 node0,分配不了就失败(更极致)。
若希望“尽量本地、必要时回退”,用 NUMAPolicy=prefer。
3.3 方案 C:应用内显式分配(libnuma / hwloc)
如果你能改代码,从源头优雅地解决。
C 语言(libnuma)示例:
#include <numa.h>
#include <numaif.h>
#include <pthread.h>
#include <stdio.h>
#define THREADS 64
#define SIZE (size_t) (1ULL<<30) // 1GB
void* worker(void* arg) {
int node = *(int*)arg; // 目标 NUMA 节点
numa_run_on_node(node); // 线程绑到节点
void* buf = numa_alloc_onnode(SIZE, node); // 在该节点分配
// ... 计算逻辑 ...
numa_free(buf, SIZE);
return NULL;
}
int main() {
if (numa_available() < 0) { puts("NUMA not available"); return 1; }
pthread_t th[THREADS];
int node = 0;
for (int i=0; i<THREADS; ++i) pthread_create(&th[i], NULL, worker, &node);
for (int i=0; i<THREADS; ++i) pthread_join(th[i], NULL);
return 0;
}
4)把“自动 NUMA 平衡”管住
Linux 的 Auto NUMA Balancing 会在后台迁移页试图优化,但当你做了精细绑定时,它反而会捣乱。
# 临时关闭(立刻生效)
echo 0 | sudo tee /proc/sys/kernel/numa_balancing
# 永久关闭(sysctl)
echo "kernel.numa_balancing=0" | sudo tee /etc/sysctl.d/99-numa.conf
sudo sysctl --system
实践结论:在我们这台机器上,绑定明确时关闭自动平衡,带宽稳定性显著提升,P95/P99 抖动明显收敛。
如果你的业务有动态迁移需求,也可保留开启,但必须压测验证。
5)THP、大页与 TLB:延迟的隐形变量
透明大页(THP) 有时能提升带宽,但也会带来突发的合并/分裂开销,造成尾延迟抖动。内存带宽优先时,我更倾向于:
# 对通用工作负载
echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
# 极致稳定时延
echo never | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
同时,如需固定大页数(如 1G/2M 大页)给特定进程,建议配合 cgroup v2 与 预分配,并保持与 NUMA 节点一致。
6)实测脚本与对照表(可直接抄)
下面这个脚本会在不同 NUMA 绑定策略下跑 STREAM,并把结果存表:
cat > /root/run_stream_numa.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
BIN=${1:-./STREAM/stream_c.exe}
OUT=./stream_results.tsv
echo -e "mode\tnode\tthreads\ttriad_gbps" > $OUT
run_case () {
local mode="$1" node="$2" threads="$3"
local cmd
case "$mode" in
baseline) cmd="$BIN" ;;
bind) cmd="numactl --cpunodebind=$node --membind=$node $BIN" ;;
prefer) cmd="numactl --cpunodebind=$node --preferred=$node $BIN" ;;
interleave) cmd="numactl --interleave=all $BIN" ;;
esac
OMP_NUM_THREADS=$threads $cmd | awk '/Triad:/ {gb=$2/1024; printf("%s\t%s\t%s\t%.2f\n","'"$mode"'", "'"$node"'", "'"$threads"'", gb)}' >> $OUT
}
for t in 32 64 96; do
run_case baseline - $t
for n in 0 1; do
run_case bind $n $t
run_case prefer $n $t
done
run_case interleave - $t
done
column -t -s $'\t' $OUT
EOF
chmod +x /root/run_stream_numa.sh
/root/run_stream_numa.sh
一次实测(示例数据,仅示意):
| 模式 | 节点 | 线程 | Triad 带宽 (GB/s) |
|---|---|---|---|
| baseline | - | 64 | 185.4 |
| bind | 0 | 64 | 238.7 |
| bind | 1 | 64 | 241.2 |
| prefer | 0 | 64 | 230.5 |
| prefer | 1 | 64 | 232.1 |
| interleave | - | 64 | 168.3 |
解读:
- bind 明确绑定时,本地带宽提升明显,低谷消失。
- interleave(跨节点交错)带宽下降,但抖动更少,适合小而均匀的多工作集。
- prefer 比 bind 稍“温和”,当本地不足时“优雅回退”。
7)容器与 cgroup v2 的正确姿势
Kubernetes / Docker 下,单靠 numactl 不够。cpuset 与 mems 必须一致。
# cgroup v2 示例:限制到 NUMA 节点 0 的 CPU 与内存
CG=/sys/fs/cgroup/numa.slice
sudo mkdir -p $CG
echo 0-63 | sudo tee $CG/cpuset.cpus
echo 0 | sudo tee $CG/cpuset.mems
# 把进程加进来
echo <PID> | sudo tee $CG/cgroup.procs
K8s Pod 注解(示意):使用 Topology Manager、CPU Manager 的 static 策略,并通过 nodeSelector/numa-aware scheduler 把 Pod 落在期望的 NUMA 资源上;业务容器启动命令里再增加 numactl --membind 双保险。
8)NVMe 与 IRQ 亲和(不是主角,但常被忽略)
虽然本文聚焦内存,但数据常从 NVMe 进来。让 NVMe 的中断与 IO 队列跑在同一 NUMA 节点,能减少跨节点 bounce:
# 让 block layer 在本地队列聚合
for d in /sys/block/nvme*; do
echo 2 | sudo tee $d/queue/rq_affinity # 2 = 亲和到提交 CPU
done
# 把 NVMe 中断绑到 node0 的 CPU(示例)
grep nvme /proc/interrupts | awk '{print $1}' | tr -d ':' | while read irq; do
echo 00000000,00000000,00000000,00000000,00000000,00000000,00000000,ffffffff | sudo tee /proc/irq/$irq/smp_affinity
done
目的:数据在 node0 来、node0 算、node0 分配内存,路径最短。
9)BIOS 与 NPS:能动手就别只动嘴
EPYC 平台的 NPS(NUMA Per Socket) 配置是大杀器。常见选项:NPS=1/2/4(有的固件还提供 8)。
NPS 越大:每路被切成更多 NUMA 节点,并行度更高,但调度更复杂。
NPS 越小:节点更“胖”,跨 CCD/内存控制器概率增大,但调度容易。
怎么选?
你的业务是大吞吐批处理(如批量向量构建、列式扫描):NPS=4 往往有更好的带宽并行。
你的业务是少量超大工作集且线程间紧耦合:适度降低 NPS,减少跨节点协调成本。
实操建议:在维护窗口调 BIOS,分别跑 NPS=2 与 NPS=4 的压测,对比 STREAM、业务实际 QPS 和尾延迟,再定论。
10)监控与回归:治得住,还得看得见
10.1 线上指标(核心关注)
numastat:numastat -p <pid> 看 numa_miss、numa_foreign、interleave_hit。
/proc/<pid>/numa_maps:查看某进程虚拟内存区域真正落在哪些 node。
perf/cpu 指标:L3 miss、memory BW、LLC occupancy(如支持 cstates/uncore PMU)。
业务延迟:P95/P99 是否被“拖尾”。
10.2 一个简单的巡检脚本
cat > /usr/local/bin/numa_watch.sh <<'EOF'
#!/usr/bin/env bash
pid=$1
[[ -z "$pid" ]] && { echo "usage: $0 <pid>"; exit 1; }
while true; do
ts=$(date +%F_%T)
miss=$(numastat -p $pid 2>/dev/null | awk '/numa_miss/ {print $2}')
foreign=$(numastat -p $pid 2>/dev/null | awk '/numa_foreign/ {print $2}')
echo "$ts numa_miss=$miss numa_foreign=$foreign"
sleep 5
done
EOF
chmod +x /usr/local/bin/numa_watch.sh
11)“坑位”与现场解法(真事)
“绑了 CPU 忘了绑内存”
现象:算力上去了,带宽没起来,还出现周期性抖动。
解决:numactl --cpunodebind=0 --membind=0 双绑;初始化阶段用本地 node 触发分配。
自动 NUMA 平衡乱迁页
现象:P99 偶发飙高,无规律。
解决:关闭 kernel.numa_balancing,或服务级强绑定 + 验证。
128GB 容量偏紧、节点不均
现象:node1 内存不足,频繁回退到远端;numastat 的 numa_miss 升高。
解决:把大任务分片到多个 node 跑;分批交替执行;或物理上补内存&均匀插条。
容器 cgroup 没配 mems
现象:K8s 下明明绑了 CPU,但内存还是跨节点。
解决:cpuset.cpus 与 cpuset.mems 必须成对设置;Topology Manager 打开。
THP 惹的祸
现象:低延迟偶有毛刺。
解决:把 THP 改 madvise 或 never,同时对热点内存用显式大页预分配。
NVMe 中断乱飞
现象:IO 到内存路径跨节点,CPU 利用率怪异。
解决:给 NVMe IRQ 绑到相同 node 的 CPU,设置 rq_affinity=2。
12)把优化固化进生产流程(落地清单)
固化系统参数:
/etc/sysctl.d/99-numa.conf: kernel.numa_balancing=0(若采用强绑定方案)
/etc/default/grub: 可按需追加 numa_balancing=disable(重启生效)
标准化服务模板:systemd 单元中加入 NUMAPolicy/NUMAMask 与 CPUAffinity。
CI 压测:上线前用 run_stream_numa.sh + 业务自有压测把基线存档。
K8s 落地:Topology Manager:best-effort/restricted/single-numa-node 结合 CPU Manager static;容器 manifest 中明确 resources 与亲和约束。
监控告警:numa_miss、numa_foreign、LLC miss、业务 P99 设阈值预警。
13)一页纸速记(给后来人)
原则:算在哪、内存就在哪;首触发决定内存归属。
工具:numactl(最省事)、systemd(持久化)、libnuma(最优雅)。
系统:关闭自动平衡(视场景)、THP 选 madvise/never,监控 numastat。
物理:内存条尽量均匀,NPS=2/4 压测二选一,NVMe IRQ/队列做本地化。
容器:cpuset.cpus 与 cpuset.mems 必须一致,配合拓扑调度。
收尾:夜里 11 点的那次回看
晚上 11 点,我把改完的服务拉起来,numa_watch.sh 的曲线像贴着地面跑,STREAM 的 triad 数字稳稳在理想区间。业务那边回了一个“P99 终于不炸了”的表情包,我把机柜门关上,习惯性地又摸了一下每根网线的卡扣。
对我来说,这并不是某个“黑科技”的胜利,而是敬畏硬件拓扑、尊重内存路径的一次回归。NUMA 绑定并不神秘,它只是让计算回到“近处”。当你摸清楚 CPU、内存、IO 在这台机器上的相对位置,性能自然就会顺起来。
如果你也在像素级抠内存带宽,欢迎把你的 NPS、DIMM 分布和基准发给我。每一台机,都值得被认真对待。
附:常用命令与配置清单(可直接 copy)
# 拓扑与距离
lscpu -e=CPU,CORE,SOCKET,NODE,ONLINE | column -t
numactl --hardware
cat /sys/devices/system/node/node*/distance
# per-node 内存
for n in /sys/devices/system/node/node*; do echo -n "$(basename $n): "; awk '/MemTotal/ {print $4 " kB"}' $n/meminfo; done
numastat -m
# 绑定跑应用(示例:node0)
numactl --cpunodebind=0 --membind=0 ./your_app
# 关闭自动 NUMA 平衡
echo "kernel.numa_balancing=0" | sudo tee /etc/sysctl.d/99-numa.conf && sudo sysctl --system
# THP
echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
# 或
echo never | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
systemd 单元模板(简化版):
[Service]
ExecStart=/opt/myapp/bin/worker
CPUAffinity=0-63
NUMAPolicy=bind
NUMAMask=0
Environment=OMP_PROC_BIND=close
Environment=OMP_PLACES=cores
cgroup v2(手动绑定示例):
CG=/sys/fs/cgroup/numa.slice
sudo mkdir -p $CG
echo 0-63 | sudo tee $CG/cpuset.cpus
echo 0 | sudo tee $CG/cpuset.mems
echo <PID> | sudo tee $CG/cgroup.procs
写在最后:优化不是把某个按钮调到 11,而是让系统在 “最短路径” 上稳稳地跑。愿你也能在机房的白噪里,听见性能上来的那一声“咔哒”