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/s | 89 ns | 基准(1.0×) |
| 同 Node 不同 CPU | 200 GB/s | 89 ns | 1.0× |
| 跨 Node 访问 | 110 GB/s | 140 ns | 0.55× 带宽,1.57× 延迟 |
| 跨 2 个 Node | 80 GB/s | 180 ns | 0.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 拓扑
命令 1:numactl --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 nodes | NUMA 节点数量 | 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 归属
方法 1:nvidia-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 Affinity | GPU 属于哪个 NUMA 节点 | GPU 0-3 → Node 0 |
| NV12 | NVLink 连接(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 总线,性能降低
方法 2:lspci 查看 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.0 | 600 GB/s(单向) | 1-2 μs | 同 Node 内 GPU 通信 |
| PCIe 4.0 x16 | 32 GB/s(单向) | 5-10 μs | GPU-CPU 通信 |
| QPI/UPI | 40 GB/s | 100-200 ns | CPU-CPU 通信 |
| 跨 Node GPU 通信 | ~25 GB/s | 15-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.py | 2350 samples/sec | 基准 (1.0×) |
| 只绑定 CPU | numactl --cpunodebind=0 python train.py | 2680 samples/sec | 1.14× |
| 只绑定内存 | numactl --membind=0 python train.py | 2590 samples/sec | 1.10× |
| CPU+内存绑定(最佳) | numactl --cpunodebind=0 --membind=0 python train.py | 3180 samples/sec | 1.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/sec | 2275 samples/sec |
| 绑定 NUMA | 25100 samples/sec | 3138 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% | 低 | ⭐⭐⭐⭐⭐ |
| 使用本地 GPU | 15-30% | 低 | ⭐⭐⭐⭐⭐ |
| DataLoader worker 绑定 | 10-20% | 中 | ⭐⭐⭐⭐ |
| 禁用透明大页 | 5-10% | 低 | ⭐⭐⭐ |
| pin_memory=True | 5-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 核心要点
-
NUMA 架构的本质
- 每个 CPU 有独立的本地内存
- 访问本地内存比远程快 1.8-2 倍
- Node 间通过 QPI/UPI 互连
-
GPU 的 NUMA 归属
- GPU 通过 PCIe 连接到特定 CPU
- 查询命令:
nvidia-smi topo -m - GPU 0-3 通常在 Node 0,GPU 4-7 在 Node 1
-
亲和性的三个维度
- CPU 亲和性:进程运行在哪些核心
- 内存亲和性:内存分配在哪个 Node
- 设备亲和性:使用本地 GPU
-
性能优化的黄金法则
- 同时绑定 CPU 和内存到同一 Node
- 使用与 GPU 相同 Node 的 CPU
- 避免跨 Node 访问
-
工具链
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 所在 Node | numactl --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%,而代价只是一行命令。这是最简单但最有效的优化之一。