上一篇 下一篇 分享链接 返回 返回顶部

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

发布人:Minchunlin 发布时间:2025-09-03 11:01 阅读量:961


那天是周六,香港机房里只剩下我和呼呼作响的冷风。我把这台新到的双路 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,而是让系统在 “最短路径” 上稳稳地跑。愿你也能在机房的白噪里,听见性能上来的那一声“咔哒”

目录结构
全文