Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

12. 【GPU】NUMA架构:CPU-GPU亲和性优化

同样的训练代码,同样的硬件

$ python train.py --batch-size 256
训练速度: 2350 samples/sec

# 只是加了一个命令前缀
$ numactl --cpunodebind=0 --membind=0 python train.py --batch-size 256
训练速度: 3180 samples/sec  # 快了 35%!

为什么绑定 NUMA 节点能提速 35%?GPU 明明都是同一块。


1. 从 UMA 到 NUMA:内存架构的演进

1.1 UMA 时代:共享总线的瓶颈

在早期的多 CPU 系统中,所有处理器通过一条共享总线访问统一的内存

UMA(Uniform Memory Access)架构:

CPU 0 ─┐
CPU 1 ─┼─── 共享总线 ─── 内存控制器 ─── 所有内存
CPU 2 ─┤
CPU 3 ─┘

直白版:就像 4 个人共用 1 条窄走廊去仓库拿货,总线成为瓶颈。

UMA 的问题

现象原因影响
总线拥堵所有 CPU 竞争同一条总线CPU 越多,每个 CPU 可用带宽越少
扩展受限总线带宽固定最多支持 4-8 个 CPU
延迟增加需要仲裁总线访问权内存访问不确定性高

真实数据:4 路 UMA 服务器,每个 CPU 理论带宽降至 25%(如果总线 100GB/s,每个 CPU 实际只有 25GB/s)。

1.2 NUMA:分布式内存的诞生

NUMA(Non-Uniform Memory Access)架构的核心思想:给每个 CPU 配备独立的内存

NUMA架构(双路服务器示例):

Node 0                           Node 1
┌─────────────────┐            ┌─────────────────┐
│  CPU 0 (24核)   │            │  CPU 1 (24核)   │
│       ↓         │            │       ↓         │
│  本地内存 128GB │ ←─ QPI ─→ │  本地内存 128GB │
└─────────────────┘            └─────────────────┘

关键术语

  • NUMA Node(节点):CPU + 本地内存的组合
  • 本地内存:直连某个 CPU 的内存(低延迟)
  • 远程内存:属于其他 CPU 的内存(高延迟)
  • 互连总线:Node 间的高速链路(Intel QPI、AMD Infinity Fabric)

直白版类比

  • UMA = 中央仓库,所有工人排队去拿货
  • NUMA = 每个工人有自己的小仓库(本地),需要时可以去别人仓库借(远程)

1.3 性能差异:本地 vs 远程

实测数据(Intel Xeon Platinum 8358 服务器):

访问类型带宽延迟相对性能
本地内存访问200 GB/s89 ns基准(1.0×)
同 Node 不同 CPU200 GB/s89 ns1.0×
跨 Node 访问110 GB/s140 ns0.55× 带宽,1.57× 延迟
跨 2 个 Node80 GB/s180 ns0.4× 带宽,2.02× 延迟

关键洞察

  • 本地访问比远程快 1.8 倍(带宽)
  • 延迟差异 60%
  • 4 路服务器跨 3 个 Node 访问,性能腰斩

实验验证

# 使用numactl测量内存带宽(需要安装numastat)
$ numactl --cpunodebind=0 --membind=0 stream
本地访问带宽: 198.5 GB/s

$ numactl --cpunodebind=0 --membind=1 stream
远程访问带宽: 107.2 GB/s

2. NUMA 架构深度解析

核心问题:一台服务器是如何被划分成多个 NUMA 节点的?

2.1 查看 NUMA 拓扑

命令 1numactl --hardware

$ numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 24 25 26 27 28 29 30 31 32 33 34 35
node 0 size: 128237 MB
node 0 free: 115482 MB

node 1 cpus: 12 13 14 15 16 17 18 19 20 21 22 23 36 37 38 39 40 41 42 43 44 45 46 47
node 1 size: 128906 MB
node 1 free: 120153 MB

node distances:
node   0   1
  0:  10  21
  1:  21  10

关键信息解读

字段含义示例值
available: 2 nodesNUMA 节点数量2(双路 CPU)
node 0 cpus该节点的 CPU 核心编号0-11, 24-35(24 核+超线程)
node 0 size本地内存大小128GB
node distances节点间的访问成本10=本地,21=跨 Node

距离值(distance)的含义

  • 10:本地访问(基准值)
  • 21:跨一个 Node,延迟约为本地的 2.1 倍
  • 31:跨两个 Node(4 路服务器)
  • 数字越大,延迟越高

2.2 CPU 核心编号的秘密

注意到 node 0 cpus: 0-11, 24-35 不连续?这是因为超线程(Hyper-Threading)

物理核心 vs 逻辑核心

物理核心0  → 逻辑核心0, 24
物理核心1  → 逻辑核心1, 25
...
物理核心11 → 逻辑核心11, 35

物理核心12 → 逻辑核心12, 36  (Node 1)
物理核心13 → 逻辑核心13, 37
...

验证命令

$ lscpu | grep -E "^CPU\(s\)|Core\(s\)|Thread\(s\)|NUMA"
CPU(s):              48          # 总逻辑核心数
Thread(s) per core:  2           # 每核心2线程(超线程开启)
Core(s) per socket:  24          # 每颗CPU 24物理核心
Socket(s):           2           # 2颗CPU
NUMA node(s):        2
NUMA node0 CPU(s):   0-11,24-35  # Node 0的核心
NUMA node1 CPU(s):   12-23,36-47 # Node 1的核心

最佳实践

  • 性能敏感任务:只用物理核心(0-23),避免超线程竞争
  • 吞吐量优先:使用所有逻辑核心(0-47)

2.3 内存分配策略

查看进程的内存分布

$ numastat -p $(pidof python)
Per-node process memory usage (in MBs) for PID 12345 (python)
                           Node 0          Node 1           Total
                  --------------- --------------- ---------------
Heap                      1024.5           512.3          1536.8
Stack                       10.2             5.1            15.3
Private                  15234.7          3421.9         18656.6
-------  --------------- --------------- ---------------
Total                    16269.4          3939.3         20208.7

四种内存分配策略

策略说明适用场景
default在进程运行的 Node 上分配默认策略
bind强制在指定 Node 分配性能关键任务
interleave轮流在各 Node 分配(交织)内存密集型,减少单 Node 压力
preferred优先在指定 Node,满了再去其他软绑定

实验:测试不同策略的影响

# 策略1:默认(可能跨Node)
$ python train.py
进程内存分布: Node0=16GB, Node1=4GB

# 策略2:绑定到Node 0
$ numactl --membind=0 python train.py
进程内存分布: Node0=20GB, Node1=0GB

# 策略3:交织分配
$ numactl --interleave=all python train.py
进程内存分布: Node0=10GB, Node1=10GB

3. GPU 与 PCIe:设备的 NUMA 归属

核心问题:8 块 GPU 分别连接到哪个 CPU?

3.1 PCIe 拓扑基础

现代服务器中,GPU 通过PCIe 总线连接到 CPU。关键点:PCIe 控制器在 CPU 内部

典型8卡服务器的PCIe拓扑(双路CPU):

Node 0 (CPU 0)                    Node 1 (CPU 1)
├─ PCIe Root Complex              ├─ PCIe Root Complex
│  ├─ GPU 0 (PCIe Slot 1)        │  ├─ GPU 4 (PCIe Slot 5)
│  ├─ GPU 1 (PCIe Slot 2)        │  ├─ GPU 5 (PCIe Slot 6)
│  ├─ GPU 2 (PCIe Slot 3)        │  ├─ GPU 6 (PCIe Slot 7)
│  └─ GPU 3 (PCIe Slot 4)        │  └─ GPU 7 (PCIe Slot 8)
│                                 │
└─ 本地内存 128GB                 └─ 本地内存 128GB

关键概念

  • PCIe Root Complex:PCIe 总线的"根节点",集成在 CPU 内
  • NUMA 亲和性:GPU 通过哪个 CPU 的 PCIe 连接,就属于那个 NUMA Node

3.2 查看 GPU 的 NUMA 归属

方法 1nvidia-smi topo -m(NVIDIA 官方工具)

$ nvidia-smi topo -m
        GPU0    GPU1    GPU2    GPU3    GPU4    GPU5    GPU6    GPU7    CPU Affinity    NUMA Affinity
GPU0     X      NV12    NV12    NV12    SYS     SYS     SYS     SYS     0-11,24-35      0
GPU1    NV12     X      NV12    NV12    SYS     SYS     SYS     SYS     0-11,24-35      0
GPU2    NV12    NV12     X      NV12    SYS     SYS     SYS     SYS     0-11,24-35      0
GPU3    NV12    NV12    NV12     X      SYS     SYS     SYS     SYS     0-11,24-35      0
GPU4    SYS     SYS     SYS     SYS      X      NV12    NV12    NV12    12-23,36-47     1
GPU5    SYS     SYS     SYS     SYS     NV12     X      NV12    NV12    12-23,36-47     1
GPU6    SYS     SYS     SYS     SYS     NV12    NV12     X      NV12    12-23,36-47     1
GPU7    SYS     SYS     SYS     SYS     NV12    NV12    NV12     X      12-23,36-47     1

Legend:
  X    = Self
  SYS  = Connection traversing PCIe as well as the SMP interconnect between NUMA nodes (e.g., QPI)
  NODE = Connection traversing PCIe as well as the interconnect between PCIe Host Bridges within a NUMA node
  PHB  = Connection traversing PCIe as well as a PCIe Host Bridge (typically the CPU)
  PXB  = Connection traversing multiple PCIe bridges (without traversing the PCIe Host Bridge)
  PIX  = Connection traversing at most a single PCIe bridge
  NV#  = Connection traversing a bonded set of # NVLinks

关键信息解读

含义示例
CPU Affinity应该绑定哪些 CPU 核心GPU 0 → 核心 0-11,24-35
NUMA AffinityGPU 属于哪个 NUMA 节点GPU 0-3 → Node 0
NV12NVLink 连接(12 条链路)GPU 0-1 之间有 NVLink
SYS跨 NUMA 节点的 PCIe 连接GPU 0→4 需跨 QPI 总线

最重要的发现

  • GPU 0-3 连接到 CPU 0(Node 0)
  • GPU 4-7 连接到 CPU 1(Node 1)
  • 跨 Node 访问 GPU(如 CPU 0 访问 GPU 4)需要经过 QPI 总线,性能降低

方法 2lspci 查看 PCIe 设备

$ lspci | grep -i nvidia
17:00.0 3D controller: NVIDIA Corporation Device 20b5 (rev a1)  # GPU 0
31:00.0 3D controller: NVIDIA Corporation Device 20b5 (rev a1)  # GPU 1
...

# 查看详细的NUMA信息
$ lspci -v -s 17:00.0 | grep NUMA
        NUMA node: 0  # GPU 0属于Node 0

$ lspci -v -s ca:00.0 | grep NUMA
        NUMA node: 1  # GPU 4属于Node 1

方法 3:通过 sysfs 读取

$ cat /sys/class/pci_bus/0000:17/device/numa_node
0  # GPU 0的PCIe总线属于Node 0

$ cat /sys/class/pci_bus/0000:ca/device/numa_node
1  # GPU 4的PCIe总线属于Node 1

3.3 GPU 间的连接拓扑

NVLink vs PCIe

GPU 0-3内部(Node 0内):
GPU 0 ←─ NVLink (600 GB/s) ─→ GPU 1
  ↕                              ↕
GPU 2 ←─ NVLink (600 GB/s) ─→ GPU 3

跨Node(GPU 0 → GPU 4):
GPU 0 → PCIe → CPU 0 → QPI → CPU 1 → PCIe → GPU 4
       (32 GB/s)         (40 GB/s)        (32 GB/s)
       总带宽受限于最窄环节:32 GB/s

带宽对比表

连接类型带宽延迟用途
NVLink 4.0600 GB/s(单向)1-2 μs同 Node 内 GPU 通信
PCIe 4.0 x1632 GB/s(单向)5-10 μsGPU-CPU 通信
QPI/UPI40 GB/s100-200 nsCPU-CPU 通信
跨 Node GPU 通信~25 GB/s15-20 μs受限于 PCIe+QPI

关键洞察

  • 同 Node 内 GPU 通信:快 19 倍(600 vs 32 GB/s)
  • 跨 Node GPU 通信:性能腰斩(25 vs 600 GB/s)
  • 结论:多 GPU 训练应尽量用同一 Node 的 GPU

4. CPU-GPU 亲和性:性能优化的关键

核心问题:为什么进程运行在 CPU 0 和 CPU 1 上,性能差异这么大?

4.1 什么是亲和性(Affinity)

亲和性:进程、线程或内存被绑定到特定的 CPU 或 NUMA 节点。

三种亲和性

类型含义控制命令
CPU 亲和性进程只在指定 CPU 核心上运行taskset, numactl --cpunodebind
内存亲和性内存只在指定 Node 分配numactl --membind
设备亲和性访问本地 GPU,避免跨 Node选择正确的 GPU ID

直白版类比

  • 工人(进程)在仓库 A(CPU 0 + Node 0 内存)工作
  • 任务需要的货物(数据)也在仓库 A
  • 机器(GPU)也在仓库 A 旁边
  • 结果:所有操作都在本地,效率最高

4.2 为什么亲和性重要

场景:训练任务在 CPU 12(Node 1)运行,但使用 GPU 0(Node 0)

糟糕的配置:

进程(CPU 12, Node 1)
    ↓ ① 读取训练数据
远程内存(Node 1)→ 跨QPI传输 → 本地内存(Node 0)
    ↓ ② 拷贝到GPU
CPU 12 → 跨QPI → CPU 0 → PCIe → GPU 0
    ↓ ③ GPU计算
GPU 0执行
    ↓ ④ 取回结果
GPU 0 → PCIe → CPU 0 → 跨QPI → CPU 12
    ↓ ⑤ 处理结果
远程内存访问

每一步都跨 Node,累积延迟和带宽损失!

优化后的配置

进程(CPU 0, Node 0)
    ↓ ① 读取数据
本地内存(Node 0)  # 快2倍
    ↓ ② 拷贝到GPU
CPU 0 → PCIe → GPU 0  # 无跨Node开销
    ↓ ③ GPU计算
GPU 0执行
    ↓ ④ 取回结果
GPU 0 → PCIe → CPU 0  # 本地传输
    ↓ ⑤ 处理结果
本地内存(Node 0)  # 快2倍

4.3 性能实测:绑定 vs 不绑定

测试代码:ResNet-50 训练(PyTorch,单 GPU)

import torch
import torchvision.models as models
import time

model = models.resnet50().cuda()
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
data = torch.randn(256, 3, 224, 224).cuda()  # batch_size=256
target = torch.randint(0, 1000, (256,)).cuda()

# 预热
for _ in range(10):
    output = model(data)
    loss = torch.nn.functional.cross_entropy(output, target)
    loss.backward()
    optimizer.step()

# 测速
torch.cuda.synchronize()
start = time.time()
for _ in range(100):
    output = model(data)
    loss = torch.nn.functional.cross_entropy(output, target)
    loss.backward()
    optimizer.step()
torch.cuda.synchronize()
elapsed = time.time() - start

samples_per_sec = 256 * 100 / elapsed
print(f"训练速度: {samples_per_sec:.0f} samples/sec")

测试环境

  • 服务器:双路 Intel Xeon Gold 6348 (28 核 × 2)
  • GPU:8× NVIDIA A100 40GB(GPU 0-3 在 Node 0,GPU 4-7 在 Node 1)
  • 内存:256GB(每 Node 128GB)

测试结果

配置命令速度相对性能
不绑定(最差)python train.py2350 samples/sec基准 (1.0×)
只绑定 CPUnumactl --cpunodebind=0 python train.py2680 samples/sec1.14×
只绑定内存numactl --membind=0 python train.py2590 samples/sec1.10×
CPU+内存绑定(最佳)numactl --cpunodebind=0 --membind=0 python train.py3180 samples/sec1.35×

关键发现

  • 完整绑定提速 35%
  • 单独绑定 CPU 或内存只有 10-14%提升
  • 必须同时绑定 CPU 和内存才能获得最大收益

为什么不绑定会慢?

# 查看进程的实际运行位置
$ taskset -cp $(pidof python)
pid 12345's current affinity list: 0-47  # 可以在所有核心上运行

# Linux调度器可能频繁迁移进程
# 时刻1:进程在CPU 5(Node 0)
# 时刻2:进程被迁移到CPU 18(Node 1)← 内存在Node 0,变成远程访问!
# 时刻3:进程又回到CPU 3(Node 0)

进程迁移的代价

  • CPU 缓存失效(L1/L2/L3 cache 全部丢失)
  • 内存访问变成跨 Node(延迟翻倍)
  • GPU-CPU 传输路径改变

4.4 多 GPU 的亲和性策略

错误配置示例:4 个进程,各用 1 张 GPU

# 错误:没有考虑NUMA
CUDA_VISIBLE_DEVICES=0 python train.py &  # 进程可能跑在Node 1
CUDA_VISIBLE_DEVICES=1 python train.py &
CUDA_VISIBLE_DEVICES=2 python train.py &
CUDA_VISIBLE_DEVICES=3 python train.py &

正确配置:绑定到对应的 NUMA Node

# GPU 0-3 → Node 0
numactl --cpunodebind=0 --membind=0 bash -c \
  "CUDA_VISIBLE_DEVICES=0 python train.py" &
numactl --cpunodebind=0 --membind=0 bash -c \
  "CUDA_VISIBLE_DEVICES=1 python train.py" &
numactl --cpunodebind=0 --membind=0 bash -c \
  "CUDA_VISIBLE_DEVICES=2 python train.py" &
numactl --cpunodebind=0 --membind=0 bash -c \
  "CUDA_VISIBLE_DEVICES=3 python train.py" &

# GPU 4-7 → Node 1
numactl --cpunodebind=1 --membind=1 bash -c \
  "CUDA_VISIBLE_DEVICES=4 python train.py" &
# ... 以此类推

性能对比(8 卡并行训练):

配置总吞吐量单卡速度
不绑定18200 samples/sec2275 samples/sec
绑定 NUMA25100 samples/sec3138 samples/sec
提升+38%+38%

5. 工具与实战:如何正确绑定

核心问题:有哪些工具可以控制亲和性?

5.1 numactl:NUMA 控制的瑞士军刀

基本语法

numactl [选项] <命令>

常用选项

选项作用示例
--cpunodebind=<nodes>绑定 CPU 到指定 NUMA 节点--cpunodebind=0
--membind=<nodes>内存只在指定节点分配--membind=0,1
--interleave=<nodes>内存交织分配--interleave=all
--preferred=<node>优先分配节点--preferred=0
--physcpubind=<cpus>绑定到具体 CPU 核心--physcpubind=0-11
--localalloc在当前节点分配内存-
--show显示当前 NUMA 策略-

示例 1:绑定到 Node 0

numactl --cpunodebind=0 --membind=0 python train.py

示例 2:绑定到特定物理核心(避免超线程)

# 只用Node 0的物理核心0-11(不用超线程核心24-35)
numactl --physcpubind=0-11 --membind=0 python train.py

示例 3:内存交织(负载均衡)

# 内存均匀分布在两个Node(减少单Node内存压力)
numactl --interleave=all python train.py

验证绑定是否生效

# 启动程序
numactl --cpunodebind=0 --membind=0 python train.py &
PID=$!

# 检查CPU亲和性
taskset -cp $PID
# 输出: pid 12345's current affinity list: 0-11,24-35

# 检查内存分布
numastat -p $PID
# 应该看到所有内存都在Node 0

5.2 taskset:CPU 亲和性的精确控制

taskset vs numactl

  • numactl:按 NUMA 节点绑定(粗粒度)
  • taskset:按 CPU 核心编号绑定(细粒度)

基本用法

# 绑定到核心0,1,2,3
taskset -c 0-3 python train.py

# 使用CPU掩码(二进制)
taskset 0x0f python train.py  # 0x0f = 0b00001111 = 核心0-3

高级场景:多线程数据加载器的核心分配

# 主进程:核心0-7
# DataLoader workers:核心8-15
taskset -c 0-15 python train.py --num-workers 8

动态修改正在运行的进程

# 查看当前亲和性
taskset -cp 12345
# 输出: pid 12345's current affinity list: 0-47

# 修改为只用核心0-11
taskset -cp 0-11 12345

5.3 自动化脚本:智能绑定

脚本目标:根据 GPU ID 自动绑定到对应的 NUMA Node

#!/bin/bash
# run_with_affinity.sh - 自动NUMA绑定

if [ "$#" -lt 2 ]; then
    echo "用法: $0 <GPU_ID> <命令>"
    echo "示例: $0 0 python train.py"
    exit 1
fi

GPU_ID=$1
shift  # 移除第一个参数,剩下的是命令

# 查询GPU的NUMA节点
NUMA_NODE=$(nvidia-smi topo -m | awk -v gpu="GPU$GPU_ID" '
    $1 == gpu {
        # 提取NUMA Affinity列(最后一列)
        print $NF
    }
')

if [ -z "$NUMA_NODE" ]; then
    echo "错误:无法确定GPU $GPU_ID 的NUMA节点"
    exit 1
fi

echo "GPU $GPU_ID 属于 NUMA Node $NUMA_NODE"
echo "绑定命令到 Node $NUMA_NODE..."

# 执行命令,设置CUDA_VISIBLE_DEVICES并绑定NUMA
CUDA_VISIBLE_DEVICES=$GPU_ID \
numactl --cpunodebind=$NUMA_NODE --membind=$NUMA_NODE \
"$@"

使用示例

$ ./run_with_affinity.sh 0 python train.py --model resnet50
GPU 0 属于 NUMA Node 0
绑定命令到 Node 0...
# 自动设置CUDA_VISIBLE_DEVICES=0 并绑定到Node 0

$ ./run_with_affinity.sh 5 python train.py --model bert
GPU 5 属于 NUMA Node 1
绑定命令到 Node 1...
# 自动设置CUDA_VISIBLE_DEVICES=5 并绑定到Node 1

5.4 在 Slurm 中配置 NUMA 亲和性

Slurm 的自动 NUMA 绑定

slurm.conf 配置

# 启用CPU亲和性
TaskPlugin=task/affinity

# 自动绑定到GPU所在的NUMA节点
TaskPluginParam=autobind=threads

作业脚本

#!/bin/bash
#SBATCH --job-name=numa_test
#SBATCH --gres=gpu:1
#SBATCH --cpus-per-task=12
#SBATCH --mem-per-cpu=4G

# Slurm自动:
# 1. 分配GPU(例如GPU 2)
# 2. 查询GPU 2属于Node 0
# 3. 分配Node 0的12个CPU核心
# 4. 设置CPU亲和性
# 5. 设置内存绑定

# 验证绑定
echo "分配的GPU: $CUDA_VISIBLE_DEVICES"
echo "CPU亲和性:"
taskset -cp $$
echo "内存分布:"
numastat -p $$

# 运行训练
python train.py

手动控制(覆盖 Slurm 默认行为)

#!/bin/bash
#SBATCH --gres=gpu:2

# 明确指定绑定策略
srun --cpu-bind=verbose,cores \
     --mem-bind=local \
     python train.py

5.5 PyTorch 的 NUMA 优化

PyTorch DataLoader 的 worker 绑定

import torch
from torch.utils.data import DataLoader
import os

# 查询当前进程的NUMA节点
def get_numa_node():
    cpu = os.sched_getaffinity(0)  # 获取允许运行的CPU集合
    # 假设0-23在Node0,24-47在Node1(根据实际调整)
    if any(c < 24 for c in cpu):
        return 0
    return 1

# DataLoader的worker初始化函数
def worker_init_fn(worker_id):
    # 获取主进程的NUMA节点
    numa_node = get_numa_node()

    # 将worker进程绑定到同一NUMA节点
    # (DataLoader的worker是fork出来的子进程)
    os.sched_setaffinity(0, range(numa_node * 24, (numa_node + 1) * 24))

    print(f"Worker {worker_id} 绑定到 NUMA Node {numa_node}")

# 创建DataLoader
train_loader = DataLoader(
    train_dataset,
    batch_size=256,
    num_workers=8,
    pin_memory=True,
    worker_init_fn=worker_init_fn  # 关键:worker初始化
)

# 训练循环
for epoch in range(num_epochs):
    for data, target in train_loader:
        # DataLoader的workers都在正确的NUMA节点上
        data, target = data.cuda(), target.cuda()
        # ...

关键点

  • pin_memory=True:预分配在本地内存,加速 CPU→GPU 传输
  • worker_init_fn:确保 DataLoader 的子进程也绑定到正确的 NUMA 节点
  • 避免 workers 跨 Node 访问内存

6. 最佳实践与常见陷阱

6.1 诊断检查清单

运行训练前的检查

#!/bin/bash
# numa_check.sh - NUMA配置检查脚本

echo "=== NUMA拓扑 ==="
numactl --hardware | grep -E "available|node.*cpus|node distances"

echo -e "\n=== GPU-NUMA映射 ==="
nvidia-smi topo -m | grep -E "GPU|Affinity"

echo -e "\n=== 当前进程绑定状态 ==="
echo "CPU亲和性:"
taskset -cp $$

echo "内存策略:"
numactl --show

echo -e "\n=== GPU可见性 ==="
python -c "import torch; print(f'可见GPU: {torch.cuda.device_count()}')"

输出示例(配置正确):

=== NUMA拓扑 ===
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 24 25 26 27 28 29 30 31 32 33 34 35
node 1 cpus: 12 13 14 15 16 17 18 19 20 21 22 23 36 37 38 39 40 41 42 43 44 45 46 47
node distances:
node   0   1
  0:  10  21
  1:  21  10

=== GPU-NUMA映射 ===
GPU0    CPU Affinity: 0-11,24-35    NUMA Affinity: 0
GPU4    CPU Affinity: 12-23,36-47   NUMA Affinity: 1

=== 当前进程绑定状态 ===
CPU亲和性: 0-11,24-35  ✅ 已绑定到Node 0
内存策略: membind: 0   ✅ 内存绑定到Node 0

=== GPU可见性 ===
可见GPU: 1  ✅ 正确设置

6.2 常见陷阱

陷阱 1:忘记同时绑定 CPU 和内存

# ❌ 错误:只绑定CPU
numactl --cpunodebind=0 python train.py
# 内存可能分配在Node 1 → 跨Node访问

# ✅ 正确:同时绑定
numactl --cpunodebind=0 --membind=0 python train.py

陷阱 2:使用了超线程的"伪核心"

# ❌ 错误:核心0和24是同一物理核心的两个线程
taskset -c 0,24 python train.py  # 实际只有1个物理核心

# ✅ 正确:使用不同物理核心
taskset -c 0-3 python train.py  # 4个物理核心

陷阱 3:跨 Node 使用 GPU

# GPU 4在Node 1,但进程在Node 0
# ❌ 错误
numactl --cpunodebind=0 --membind=0 bash -c \
  "CUDA_VISIBLE_DEVICES=4 python train.py"

# ✅ 正确:匹配GPU和NUMA节点
numactl --cpunodebind=1 --membind=1 bash -c \
  "CUDA_VISIBLE_DEVICES=4 python train.py"

验证方法

import torch
import os

gpu_id = int(os.environ.get('CUDA_VISIBLE_DEVICES', '0'))
print(f"使用GPU: {gpu_id}")

# 检查CPU绑定
cpu_affinity = os.sched_getaffinity(0)
print(f"CPU亲和性: {sorted(cpu_affinity)}")

# 根据nvidia-smi topo -m的结果检查是否匹配
# GPU 0-3 应该绑定到核心 0-11,24-35(Node 0)
# GPU 4-7 应该绑定到核心 12-23,36-47(Node 1)

陷阱 4:容器环境的 NUMA 失效

# Docker默认可以访问所有NUMA节点
# ❌ 容器内的numactl可能不生效

# ✅ 正确:在docker run时指定
docker run \
  --cpuset-cpus="0-11" \      # 限制CPU
  --cpuset-mems="0" \         # 限制内存节点
  --gpus '"device=0"' \       # 限制GPU
  my-training-image

6.3 性能调优的优先级

优化效果排名(从高到低):

优化典型提升难度优先级
CPU+内存 NUMA 绑定20-40%⭐⭐⭐⭐⭐
使用本地 GPU15-30%⭐⭐⭐⭐⭐
DataLoader worker 绑定10-20%⭐⭐⭐⭐
禁用透明大页5-10%⭐⭐⭐
pin_memory=True5-15%⭐⭐⭐
使用物理核心(禁用超线程)0-10%⭐⭐

具体操作

优化 1:NUMA 绑定(必做)

numactl --cpunodebind=0 --membind=0 python train.py

优化 2:禁用透明大页(Transparent Huge Pages)

# 透明大页可能导致NUMA性能抖动
echo never | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
echo never | sudo tee /sys/kernel/mm/transparent_hugepage/defrag

优化 3:设置 NUMA 自动平衡(通常已默认开启)

# 查看状态
cat /proc/sys/kernel/numa_balancing
# 1 = 开启(推荐)

# 如果是0,开启它
echo 1 | sudo tee /proc/sys/kernel/numa_balancing

6.4 多 GPU 训练的 NUMA 策略

策略 1:单机单卡(最简单)

# 自动绑定脚本
GPU_ID=0
NUMA_NODE=$(nvidia-smi topo -m | awk "/GPU$GPU_ID/{print \$NF}")
numactl --cpunodebind=$NUMA_NODE --membind=$NUMA_NODE \
  bash -c "CUDA_VISIBLE_DEVICES=$GPU_ID python train.py"

策略 2:单机多卡(数据并行)

# 8卡训练,每卡一个进程
for gpu in 0 1 2 3; do
    numactl --cpunodebind=0 --membind=0 \
      bash -c "CUDA_VISIBLE_DEVICES=$gpu python -m torch.distributed.launch \
               --nproc_per_node=1 --nnodes=1 --node_rank=0 \
               train.py --local_rank=$gpu" &
done

for gpu in 4 5 6 7; do
    numactl --cpunodebind=1 --membind=1 \
      bash -c "CUDA_VISIBLE_DEVICES=$gpu python -m torch.distributed.launch \
               --nproc_per_node=1 --nnodes=1 --node_rank=0 \
               train.py --local_rank=$gpu" &
done

wait

策略 3:PyTorch DDP(推荐)

# 使用torchrun自动分配
torchrun \
  --nproc_per_node=8 \
  --nnodes=1 \
  train_ddp.py

# 在train_ddp.py内部绑定NUMA
# local_rank = int(os.environ["LOCAL_RANK"])
# numa_node = 0 if local_rank < 4 else 1
# os.sched_setaffinity(0, range(numa_node*24, (numa_node+1)*24))

7. 总结

7.1 核心要点

  1. NUMA 架构的本质

    • 每个 CPU 有独立的本地内存
    • 访问本地内存比远程快 1.8-2 倍
    • Node 间通过 QPI/UPI 互连
  2. GPU 的 NUMA 归属

    • GPU 通过 PCIe 连接到特定 CPU
    • 查询命令:nvidia-smi topo -m
    • GPU 0-3 通常在 Node 0,GPU 4-7 在 Node 1
  3. 亲和性的三个维度

    • CPU 亲和性:进程运行在哪些核心
    • 内存亲和性:内存分配在哪个 Node
    • 设备亲和性:使用本地 GPU
  4. 性能优化的黄金法则

    • 同时绑定 CPU 和内存到同一 Node
    • 使用与 GPU 相同 Node 的 CPU
    • 避免跨 Node 访问
  5. 工具链

    • numactl:NUMA 策略控制
    • taskset:CPU 核心绑定
    • nvidia-smi topo -m:查询 GPU 拓扑
    • numastat:监控内存分布

7.2 快速参考卡片

# === 查询拓扑 ===
numactl --hardware              # NUMA节点和CPU分布
nvidia-smi topo -m              # GPU的NUMA归属
lscpu | grep NUMA               # 简要NUMA信息

# === 基础绑定 ===
# 绑定到Node 0
numactl --cpunodebind=0 --membind=0 <命令>

# 绑定到特定核心
taskset -c 0-11 <命令>

# === 验证绑定 ===
taskset -cp <PID>               # 查看CPU亲和性
numastat -p <PID>               # 查看内存分布
cat /proc/<PID>/numa_maps       # 详细内存映射

# === 自动化脚本 ===
# GPU 0 → Node 0
GPU=0
NUMA=$(nvidia-smi topo -m | awk "/GPU$GPU/{print \$NF}")
CUDA_VISIBLE_DEVICES=$GPU \
numactl --cpunodebind=$NUMA --membind=$NUMA \
python train.py

# === 性能测试 ===
# 不绑定(基准)
python train.py

# 绑定(优化)
numactl --cpunodebind=0 --membind=0 python train.py

# 预期提升:20-40%

7.3 最佳实践总结

场景推荐配置命令示例
单卡训练GPU 所在 Nodenumactl --cpunodebind=0 --membind=0 python train.py
多卡并行每张 GPU 绑定对应 Node脚本自动分配
大内存任务内存交织numactl --interleave=all
低延迟推理严格本地绑定numactl --cpunodebind=0 --membind=0 --physcpubind=0-11
容器部署Docker 参数限制--cpuset-cpus --cpuset-mems

7.4 进阶学习

深入理解

  • Intel Xeon 的 UPI 架构白皮书
  • AMD EPYC 的 Infinity Fabric 拓扑
  • PCIe 5.0 的带宽和延迟特性

相关技术

  • RDMA:跨节点的远程直接内存访问
  • GPUDirect RDMA:GPU 间跨节点的直接通信
  • CPU 电源管理:频率调节对 NUMA 的影响

实践项目

  • 编写 NUMA 性能 Benchmark 工具
  • 实现自动 NUMA 绑定的训练启动器
  • 分析不同拓扑下的通信瓶颈

关键提醒:在生产环境中,始终绑定 NUMA 节点。性能提升 20-40%,而代价只是一行命令。这是最简单但最有效的优化之一。