HOOOS

多卡多NUMA服务器性能调优:MPI进程、GPU与MPS守护进程的最优绑定实践

0 6 HPC系统专家 GPUMPINUMA
Apple

在多卡多NUMA(Non-Uniform Memory Access)架构的服务器上运行MPI(Message Passing Interface)大规模并行程序时,默认的调度策略往往会导致灾难性的性能抖动。

如果一个MPI进程运行在 Socket 0 的 CPU 核心上,却调用了挂载在 Socket 1 上的 GPU,或者其对应的 MPS(Multi-Process Service)服务运行在错误的 NUMA 节点上,数据拷贝(cudaMemcpy)就必须跨越带宽极低、延迟极高的系统总线(如 Intel UPI 或 AMD Infinity Fabric)。

要榨干硬件的极限性能,必须实现 MPI进程 - CPU核心 - 本地NUMA内存 - 物理GPU - MPS守护进程 的「五位一体」就近物理绑定。


核心痛点与绑定原则

在不进行显式绑定的默认情况下,主要存在三大性能杀手:

  1. 跨Socket通信延迟:CPU与非本地(Non-local)GPU通信,PCIE控制器需要跨Socket转发,带宽减半,延迟翻倍。
  2. MPS上下文混杂:若多个GPU共用一个运行在随机NUMA节点上的MPS Server,会导致严重的锁竞争和内存通道冲突。
  3. OS线程漂移:MPI进程在同一Socket的不同Core之间无规则漂移,导致CPU L1/L2 缓存频繁失效。

最优绑定原则:

  • 1:1 亲和对应:确保每个MPI Rank绑定的CPU Core,与所分配的GPU处于同一个NUMA节点。
  • 隔离的MPS实例:为每个GPU启动一个独立的、绑定到对应NUMA节点的MPS Control Daemon。
  • 严格锁定:使用 numactl 和 MPI 自身的 Affinity 参数限制进程活动范围。

第一步:精准抓取硬件拓扑

在动手写绑定脚本前,必须摸清服务器的物理拓扑。

1. 自动分析 CPU 与 GPU 的亲和关系

运行以下命令,输出 GPU 与 CPU 核心的对应矩阵:

nvidia-smi topo -m

你会得到类似如下的矩阵输出:

        GPU0    GPU1    GPU2    GPU3    CPU Affinity    NUMA Affinity
GPU0     X      PIX     SYS     SYS     0-23,48-71      0
GPU1    PIX      X      SYS     SYS     0-23,48-71      0
GPU2    SYS     SYS      X      PIX     24-47,72-95     1
GPU3    SYS     SYS     PIX      X      24-47,72-95     1

从上图可知:GPU 0/1 属于 NUMA 0(CPU核心 0-23, 48-71);GPU 2/3 属于 NUMA 1(CPU核心 24-47, 72-95)。

2. 抓取PCIE拓扑

使用 lstopo(需安装 hwloc 工具包)可视化导出拓扑图,确认 GPU 挂载在哪个 PCIe Switch 下,尽量避免跨 PCIe Switch 的通信。


第二步:多NUMA环境下的MPS守护进程部署

通常,大家习惯只启动一个全局 nvidia-cuda-mps-control。在多NUMA环境下,这是错误的

最优的做法是:为每个GPU单独启动一个绑在对应NUMA节点上的MPS控制进程。通过设置独立的管道目录(Pipe Directory)和日志目录来实现物理隔离。

以下是初始化脚本 start_numa_mps.sh

#!/bin/bash
# 假设服务器有4张卡,0-1在NUMA 0,2-3在NUMA 1

# 定义各GPU的NUMA归属
GPU_NUMA=(0 0 1 1)

for gpu_id in {0..3}; do
    numa_node=${GPU_NUMA[$gpu_id]}
    
    # 创建独立的MPS目录,防止冲突
    export CUDA_MPS_PIPE_DIRECTORY=/tmp/nvidia-mps_gpu${gpu_id}
    export CUDA_MPS_LOG_DIRECTORY=/tmp/nvidia-mps_log_gpu${gpu_id}
    mkdir -p $CUDA_MPS_PIPE_DIRECTORY $CUDA_MPS_LOG_DIRECTORY

    # 限制该MPS守护进程可见的GPU
    export CUDA_VISIBLE_DEVICES=$gpu_id

    # 使用 numactl 绑定 MPS 服务本身到对应的 NUMA 节点
    echo "Starting MPS for GPU $gpu_id on NUMA node $numa_node..."
    numactl --cpunodebind=$numa_node --membind=$numa_node nvidia-cuda-mps-control -d

    # 设置每个MPS实例的最大活跃线程占比(例如4个MPI进程共享1张卡,则设为25)
    # echo "set_default_active_thread_percentage 25" | nvidia-cuda-mps-control
done

第三步:编写 MPI Rank 智能分发包装脚本

MPI 启动器(如 mpirunsrun)在拉起进程时,会向环境变量中注入本地 Rank 编号(如 OpenMPI 的 OMPI_COMM_WORLD_LOCAL_RANK 或 Slurm 的 SLURM_LOCALID)。

我们编写一个包装脚本 wrap_rank.sh,利用这个 Local Rank 计算出进程应该绑定的 CPU 核心、GPU 以及对应的 MPS 管道。

#!/bin/bash

# 获取当前节点的本地 Rank 编号(兼容 OpenMPI 和 Slurm)
LOCAL_RANK=${OMPI_COMM_WORLD_LOCAL_RANK:-${SLURM_LOCALID:-0}}

# 物理硬件参数配置(以单机4卡、双路CPU、每路24核为例,开启超线程共96核)
# Socket 0 (NUMA 0): Cores 0-23, 48-71 -> 绑定 GPU 0, 1
# Socket 1 (NUMA 1): Cores 24-47, 72-95 -> 绑定 GPU 2, 3

# 1. 映射 Local Rank 到物理 GPU
# 假设每个GPU分配4个MPI进程(共16个本地Rank)
GPU_ID=$(( LOCAL_RANK / 4 ))
NUMA_NODE=$(( GPU_ID < 2 ? 0 : 1 ))

# 2. 计算并分配该Rank独占的 CPU 核心
# 避开超线程,只用物理核。每个GPU分配 24 / 4 = 6 个物理核
CORES_PER_RANK=6
if [ $NUMA_NODE -eq 0 ]; then
    # NUMA 0 的物理核范围是 0-23
    START_CORE=$(( LOCAL_RANK * CORES_PER_RANK ))
    END_CORE=$(( START_CORE + CORES_PER_RANK - 1 ))
else
    # NUMA 1 的物理核范围是 24-47,需要减去前面NUMA 0占用的Rank偏移
    RELATIVE_RANK=$(( LOCAL_RANK - 8 ))
    START_CORE=$(( 24 + RELATIVE_RANK * CORES_PER_RANK ))
    END_CORE=$(( START_CORE + CORES_PER_RANK - 1 ))
fi

# 3. 配置当前进程的 GPU 环境与 MPS 专属管道
export CUDA_VISIBLE_DEVICES=$GPU_ID
export CUDA_MPS_PIPE_DIRECTORY=/tmp/nvidia-mps_gpu${GPU_ID}
export CUDA_MPS_LOG_DIRECTORY=/tmp/nvidia-mps_log_gpu${GPU_ID}

# 4. 打印调试信息(生产环境稳定后可注释掉)
echo "[Node $(hostname) Rank $LOCAL_RANK] Bind to Cores ${START_CORE}-${END_CORE}, Memory NUMA ${NUMA_NODE}, GPU ${GPU_ID} via MPS"

# 5. 使用 numactl 强制锁定 CPU 核心与内存节点,并接管后续的MPI二进制执行
exec numactl --physcpubind=${START_CORE}-${END_CORE} --membind=${NUMA_NODE} "$@"

第四步:联合启动与实战验证

1. 启动命令

在运行你的 MPI 任务时,将上述包装脚本插入到你的可执行文件之前。

使用 OpenMPI 启动:

mpirun -np 16 \
  -bind-to none \
  -map-by node \
  ./wrap_rank.sh ./your_mpi_application [args]

注意:-bind-to none 非常关键。它指示 MPI 放弃自带的粗暴绑定策略,将绑核主导权完全交给我们在包装脚本中指定的 numactl

使用 Slurm 启动:

srun --nodes=2 \
  --ntasks-per-node=16 \
  --cpu-bind=none \
  ./wrap_rank.sh ./your_mpi_application [args]

2. 线上验证绑定结果

在程序运行期间,登录对应节点执行以下命令,验证 CPU 与 GPU 绑定是否生效:

  • 验证 CPU 亲和性

    ps -eo pid,psr,comm | grep your_mpi_app
    

    确认对应 PID 的 PSR(处理器编号)是否处于你指定的 START_CORE - END_CORE 范围内,且绝无跨节点漂移。

  • 验证 GPU 及 MPS 绑定

    nvidia-smi
    

    在进程列表中,你应该能看到 your_mpi_application 的 Type 标志为 M(代表 MPS 模式),并且均布在 4 张 GPU 上,没有出现单卡拥堵或空挂现象。

  • 验证内存本地化率
    使用 numastat -c your_mpi_application 观察 Numa_Hit(本地节点内存命中次数)与 Numa_Miss(跨节点访问次数)。优化后,Numa_Miss 的增长应该趋近于 0。

通过这套方案,在典型的 GPU 密集型 MPI 科学计算或大模型分布式推理场景下,跨节点数据传输的延迟通常能降低 30% - 50%,整体吞吐量可获得 15% - 25% 的实质性提升。

点评评价

captcha
健康