软件调优(一):NUMA 绑定

环境配置

安装工具

apt update

# 安装控制 NUMA 内存和 CPU 亲和性的命令行工具
apt install numactl

# 安装 NVIDIA Management Library (NVML) 的 Python 库
pip install nvidia-ml-py

# 安装 hwloc 查看 NUMA 拓扑
apt install hwloc

显示系统 NUMA 硬件拓扑信息

# 显示系统 NUMA 硬件拓扑信息
numactl --hardware

打印的信息含义如下:

字段 含义
available: N nodes NUMA 节点总数
node X cpus 该节点包含的 CPU 核心编号
node X size 该节点管理的物理内存总量
node X free 该节点当前空闲内存
node distances 跨节点内存访问延迟相对值,数值越大越慢
  • 如果当前的 NUMA 节点为 1,则不需要 NUMA 绑定。只有多节点才需要进行 NUMA 绑定。

NUMA

什么是 NUMA

在多 CPU 服务器上,内存不是所有 CPU 都“等距离”访问的。这种“访问距离不一样”的架构,就叫 NUMA。

NUMA node 可以简单理解成:一组 CPU 核心 + 和它们更近的内存 + 和它们更近的 PCIe/GPU 设备

使用 hwloc 包内的 lstopo --whole-io topology.svg 可以导出当前设备的 NUMA 节点拓扑信息。例如:

能明显观察到,单个 NUMA 节点只包含 4 个 GPU 节点,这 4 个 GPU 与该 NUMA 节点的 CPU 核心和内存访问会更快,这就是 NUMA 亲和(NUMA Affinity)。

这些拓扑信息的含义是:

项目 信息
CPU Package 数量 2 个
NUMA Node 数量 2 个
NUMA Node 0 内存 669 GB
NUMA Node 1 内存 669 GB
总内存 约 1338 GB
每个 Package 核心数 24 cores
总 CPU core 数 48 cores
每个 core 对应 PU 数 1 个 PU / core
每个 core 对应 LU 数 1 个 LU / core (如果>1,说明开启了超线程)
GPU / Accelerator 数量 8 个
GPU 名称显示 CoProc opencl0 到 CoProc opencl7
每个 GPU 计算单元 108 compute units
每个 GPU 显存 79 GB
NVMe 盘数量 8 个 NVMe,每个 375 GB
系统盘 sda,100 GB
网卡 ens9

常用命令:

# 导出完整拓扑的 svg 图
lstopo --whole-io topology.svg

# 导出 XML 供程序分析
lstopo topology.xml

# 终端查看完整文本
lstopo-no-graphics --whole-io

# 终端查看简略文本
lstopy

如果使用超线程,CPU 逻辑核的数量会比物理核多,使用 lstopo -l 查看。

为什么要绑定 NUMA

深度学习训练虽然主要算力在 GPU 上,但 CPU 仍然负责很多事情, 比如:

  • DataLoader 读取数据
  • 分布式通信的一部分调度
  • batch 拼接
  • CPU 到 GPU 的数据拷贝
  • Python 主进程调度
  • 等等

如果没有绑定 NUMA,GPU 进程可以使用任意的 CPU 核心和任意 NUMA 节点的内存。如果 GPU 进程访问非 NUMA 亲和性的 NUMA 节点,数据传输延迟开销会很大。

所以绑定 NUMA 的核心作用:让 GPU 进程使用与它物理上最近的 CPU 和内存,减少数据传输延迟,提升训练吞吐量。

对于 GPU,可使用 nvidia-smi topo -m 快速查询当前系统推荐的 NUMA 绑定策略:

$ nvidia-smi topo -m
     GPU0 GPU1 GPU2 GPU3 GPU4 GPU5 GPU6 GPU7 CPU Affinity  NUMA Affinity
GPU0 X    NV18 NV18 NV18 NV18 NV18 NV18 NV18 0-51,         104-155 0
GPU1 NV18 X    NV18 NV18 NV18 NV18 NV18 NV18 0-51,         104-155 0
GPU2 NV18 NV18 X    NV18 NV18 NV18 NV18 NV18 0-51,         104-155 0
GPU3 NV18 NV18 NV18 X    NV18 NV18 NV18 NV18 0-51,         104-155 0
GPU4 NV18 NV18 NV18 NV18 X    NV18 NV18 NV18 52-103,       156-207 1
GPU5 NV18 NV18 NV18 NV18 NV18 X    NV18 NV18 52-103,       156-207 1
GPU6 NV18 NV18 NV18 NV18 NV18 NV18 X    NV18 52-103,       156-207 1
GPU7 NV18 NV18 NV18 NV18 NV18 NV18 NV18 X    52-103,       156-207 1

实施 NUMA 绑定

有多种方法可实现进程与相应 NUMA 节点的 CPU 核心之间的绑定。

脚本 + 启动器配置使用

numa-set.sh 脚本

#!/usr/bin/bash

# 这个辅助工具执行 NUMA 节点绑定,可以与 torchrun 及其他启动器配合使用
# 由 https://github.com/yifuwang 贡献

# 1. 首先赋予执行权限:
#
# chmod a+x ./numa-set.sh
#
# 2. 启动 torchrun 并测试是否正确分配了核心
#
# torchrun --nproc_per_node=8 --no-python ./numa-set.sh \
# python -c 'import os; cs=os.sched_getaffinity(0); print(f"{len(cs)} visible cpu cores: {cs}")'
#
# 所以如果你的原始 torchrun 启动命令是:
#
# torchrun --nproc_per_node=8 --nnodes 2 ... train.py
#
# 现在它变成:
#
# torchrun --nproc_per_node=8 --nnodes 2 ... --no-python ./numa-set.sh python train.py
#
# 命令中的 ... 是指省略了一些参数设置
# 为什么需要 --no-python? 因为 torchrun 默认行为是自动在命令前加上 python,把后面的参数当作 Python 脚本的参数
# 但此处我们执行的是 shell 脚本

# 查询设备 LOCAL_RANK 的 PCIe 总线 ID
BUS_ID=$(nvidia-smi --query-gpu=pci.bus_id -i $LOCAL_RANK --format=csv,noheader)        # LOCAL_RANK 是 torchrun 分配的进程编号(0,1,2,3...)
BUS_ID=${BUS_ID,,}                                                                      # 把变量内容全部小写

# 查找设备 LOCAL_RANK 所在的 NUMA 节点
NODE=$(cat /sys/bus/pci/devices/${BUS_ID:4}/numa_node)                                  # ${BUS_ID:4}: 从索引 4 开始截取

# 用 numactl 包裹后面的命令,在启动前先设置进程的 NUMA 绑定,然后再执行该命令
echo "Starting local rank $RANK on NUMA node $NODE"
numactl --cpunodebind=$NODE --membind=$NODE "$@"                                        # "$@":用户传入的实际命令(如 python train.py)及所有参数
  • 按照 numa-set.sh 脚本的注释说明,使用脚本 + torchrun 等启动器执行 NUMA 节点绑定。
  • 在运行脚本中的步骤 2 “启动 torchrun 并测试是否正确分配了核心” 时,如果出现了报错,那么使用命令 numactl --hardware 检查当前是否为单节点,或者当前环境没有配置成功 NUMA。

pynvml + python 代码

numa-set-pynvml.py

# 这个辅助工具会将当前进程绑定到与 GPU 处于同一 NUMA 节点的 CPU 核心上

# 衍生自
# https://github.com/NVIDIA/DeepLearningExamples/blob/9dd9fcb98f56187e49c5ee280cf8dbd530dde57b/TensorFlow2/LanguageModeling/BERT/gpu_affinity.py

import os
import math
import pynvml as nvml

nvml.nvmlInit()

def set_numa_affinity(gpu_index, verbose=False):
    """该工具会将当前进程绑定到与 GPU 位于同一 NUMA 节点的 CPU 核心集合上。
    通常情况下,如果你有 8 个 GPU,那么前 4 个位于第一个 NUMA 节点上,
    剩余的 4 个位于第二个 NUMA 节点上。

    `gpu_index` 通常与分布式训练中的 `LOCAL_RANK` 相同,但需要注意
    `CUDA_VISIBLE_DEVICES` 可能会影响它。例如 `CUDA_VISIBLE_DEVICES=0,7` 就不会正确工作
    —— 这时你可能需要用类似下面的方式重新把“逻辑 GPU 编号”映射回“物理 GPU 编号”:

    ```
    if "CUDA_VISIBLE_DEVICES" in os.environ:
        ids = list(map(int, os.environ.get("CUDA_VISIBLE_DEVICES", "").split(",")))
        gpu_index = ids[gpu_index] # 重新映射
    ```

    """

    num_elements = math.ceil(os.cpu_count() / 64)
    handle = nvml.nvmlDeviceGetHandleByIndex(gpu_index)
    affinity_string = ""
    for j in nvml.nvmlDeviceGetCpuAffinity(handle, num_elements):
        # 假设 nvml 返回的是 64 位整数列表
        affinity_string = f"{j:064b}{affinity_string}"
    affinity_list = [int(x) for x in affinity_string]
    affinity_list.reverse()  # 这样核心 0 就是第 0 个元素
    affinity_to_set = [i for i, e in enumerate(affinity_list) if e != 0]

    if verbose:
        cores = os.sched_getaffinity(0)
        print(f"before: {len(cores)} visible cpu cores: {cores}")
    os.sched_setaffinity(0, affinity_to_set)
    if verbose:
        cores = os.sched_getaffinity(0)
        print(f"after: {len(cores)} visible cpu cores: {cores}")

if __name__ == "__main__":

    # 模拟驱动 GPU 0 的进程
    set_numa_affinity(0, verbose=True)

在训练代码中,我们导入 numa-set-pynvml.py, 并使用 set_numa_affinity() 函数来进行 NUMA 绑定,示例的训练代码如下:

import os
import torch
import torch.distributed as dist

from numa_affinity import set_numa_affinity

def get_physical_gpu_index(local_rank: int) -> int:
    """
    将 torchrun 的 LOCAL_RANK 映射成 NVML 需要的物理 GPU index。
    """
    visible = os.environ.get("CUDA_VISIBLE_DEVICES")

    if visible is None or visible.strip() == "":
        return local_rank

    ids = [int(x.strip()) for x in visible.split(",") if x.strip() != ""]
    return ids[local_rank]

def main():
    dist.init_process_group(backend="nccl")

    local_rank = int(os.environ["LOCAL_RANK"])
    rank = int(os.environ["RANK"])

    physical_gpu_index = get_physical_gpu_index(local_rank)

    # 给当前进程设置 CPU affinity
    # verbose=True 会打印很多次,8 个进程都会打印,可能有点乱
    # 所以这里只让 rank 0 打印,或者你调试时全部打开
    set_numa_affinity(
        physical_gpu_index,
        verbose=(rank == 0)
    )

    # PyTorch 仍然使用 local_rank
    torch.cuda.set_device(local_rank)
    device = torch.device(f"cuda:{local_rank}")

    # 一定尽量在这里之后创建 DataLoader
    train_loader = ...

    model = ...
    model.to(device)

    model = torch.nn.parallel.DistributedDataParallel(
        model,
        device_ids=[local_rank],
        output_device=local_rank,
    )

    for batch in train_loader:
        ...

if __name__ == "__main__":
    main()

srun

若使用 SLURM 且使用 srun 作为启动器(而非 torchrun 等工具),该工具将自动完成所有绑定操作。要使其支持 NUMA 亲和性,只需添加以下两个头文件:

#SBATCH --gres-flags=enforce-binding
#SBATCH --ntasks-per-socket=4

--ntasks-per-socket=4 这个参数假设你的系统有 2 个 CPU 插槽(物理 CPU)和 8 个加速器(例如 GPU),因此每个 CPU 插槽对应 4 个加速器(8 ÷ 2 = 4)。

这是一个更精确的解决方案,因为它会为每个进程分配专属的 CPU 核心组,而不仅仅是把所有 NUMA 节点 0 的 CPU 核心分配给驱动加速器 0-3 的进程,把所有 NUMA 节点 1 的 CPU 核心分配给驱动加速器 4-7 的进程。

完整的启动代码如下: srun-launcher.slurm

#!/bin/bash

# 这是一个用于启动基于 srun 的程序的双节点 SLURM 脚本
# 重要提示:你需要根据注释中标注 EDIT 的地方进行相应设置

#SBATCH --job-name=srun-launcher
#SBATCH --nodes=2
#SBATCH --ntasks-per-node=8          # EDIT 必须与每节点的 GPU 数量一致
#SBATCH --cpus-per-task=10           # EDIT 每个任务分配的 CPU 核心数(总核心数/每节点任务数)
#SBATCH --gres=gpu:8                 # EDIT 如果每节点不是 8 块 GPU,请修改
#SBATCH --time=0:10:00               # EDIT 期望的运行时长
#SBATCH --exclusive
#SBATCH --partition=xyz-cluster      # EDIT 修改为所需的分区名称
#SBATCH --output=%x-%j.out


echo "开始时间: $(date)"

# 脚本中任何错误时自动失败
set -eo pipefail

# 记录脚本的变量和命令,以便将来调试需要
set -x

# EDIT 设置 conda 环境及任何启动脚本
# source /path/to/start-xxx-user # 如果需要在作业前预加载某些内容
# conda activate stas-xxx        # 如果需要激活 conda 环境

LOG_PATH="main_log.txt"

# 我们正在准备 torch.distributed 程序,因此需要:
# - MASTER_ADDR, MASTER_PORT, WORLD_SIZE - 在 `srun` 之前就已确定
# - RANK, LOCAL_RANK - 将在 `srun` 命令中设置
export MASTER_ADDR=$(scontrol show hostnames $SLURM_JOB_NODELIST | head -n 1)
export MASTER_PORT=6000
export WORLD_SIZE=$SLURM_NPROCS

# 在本例中 srun 充当启动器,因此只需 `python` 即可
LAUNCHER="python -u"

# EDIT Python 脚本的路径和名称,以及它所需的任何参数
PROGRAM="torch-distributed-gpu-test.py"

export CMD="$LAUNCHER $PROGRAM"

echo $CMD

# EDIT 如果你想将 /tmp 重定向到 /scratch(某个本地 SSD 路径),因为计算节点上的 /tmp 很小
# export TMPDIR=/scratch

# EDIT: 如需调试可启用以下选项
#
# 用于调试 NCCL 问题
# export NCCL_DEBUG=INFO
#
# 用于在没有正确回溯的情况下解开异步错误 - 可能会使所有操作变得非常慢
# export CUDA_LAUNCH_BLOCKING=1
#
# 用于强制在 NCCL 问题(如挂起的广播)时崩溃
# export NCCL_ASYNC_ERROR_HANDLING=1

# srun 错误处理:
# --wait=60: 第一个任务终止后等待 60 秒,再终止所有剩余任务
# --kill-on-bad-exit=1: 如果任何任务以非零退出码退出,则终止整个作业步
SRUN_ARGS=" \
    --wait=60 \
    --kill-on-bad-exit=1 \
    --jobid $SLURM_JOB_ID \
    "

# 需要使用 bash -c 来实现环境变量的延迟插值
# 我们希望 $SLURM_PROCID 和 $SLURM_LOCALID 的值在实际进程启动时被设置
srun $SRUN_ARGS bash -c "RANK=\$SLURM_PROCID LOCAL_RANK=\$SLURM_LOCALID $CMD" 2>&1 | tee -a $LOG_PATH

echo "结束时间: $(date)"

评论