在开始前提出一个警告:错误的绑定会带来严重的性能下降!进程绑定只能减低亲和性差的系统原因带来的性能影响,而不解决任何程序本身存在的问题 ,尤其是负载不均衡导致的性能波动(甚至可能在绑核后变得更明显)。
一、背景介绍
NUMA架构
现代处理器均采用 NUMA 架构,每个 socket 通过内存控制器连接本地内存(local memory),通过 socket 间的高速总线访问属于其他 socket 的远端内存(remote memory)。
我们将直接连接的 CPU core 和内存和其他外设(如网卡、GPU)称为一个 NUMA domain(或 NUMA node),在同一个 domain 中(intra-domain)的访存性能(包括带宽和延迟)通常显著高于跨 NUMA(inter-domain)的性能,这种现象被称为 NUMA 效应。
用numactl -H命令显示NUMA信息(后文的举例,都会以这个NUMA拓扑结构作为例子,称为conv集群):
available: 2 nodes (0-1)
node 0 cpus: 0 2 4 6 8 10 12 14 16 18 20 22 24 26
node 0 size: 128831 MB
node 0 free: 95549 MB
node 1 cpus: 1 3 5 7 9 11 13 15 17 19 21 23 25 27
node 1 size: 129019 MB
node 1 free: 101947 MB
node distances:
node 0 1
0: 10 21
1: 21 10
上述信息显示,每个 socket 的 14 个 core 属于一个 NUMA domain,每个 domain 都安装了 128GB 内存。
此系统中的 CPU 核心编号划分是交错(interleaving)的,即编号为偶数的核心属于 NUMA node 0 (socket 0),奇数属于 NUMA node 1 (socket 1),依此类推。还有一类编号方式称为连续编号,即连续的若干 core 均属于同一个 NUMA domain。Intel CPU 通常使用交错编号,而 AMD CPU 通常使用连续编号。
进程绑定的意义
当处理器中存在多个 CPU 核心时,操作系统(OS)的调度器会将进程在可用的核心间迁移,以试图维持负载均衡。那么可能产生以下影响:
内存访问延迟:在一个多 NUMA 系统中,如果进程被迁移到了与创建时不同的 NUMA domain,就可能影响性能(在 NUMA(Non-Uniform Memory Access)架构中,每个 CPU 核心或核心组(node)有自己的本地内存。如果进程被绑定到某个核心,且该核心靠近其访问的内存,那么内存访问延迟会显著降低。)。
缓存失效(cache miss):当进程在不同 CPU 核心间迁移时,由于每个核心有自己的缓存(L1、L2、L3),进程在新核心上运行时,缓存通常会是冷的(即没有存储进程需要的数据)。这会导致大量的缓存失效(cache miss)。当绑定进程至某个CPU后,程序就会一直在指定的CPU跑,不会由操作系统调度到其他CPU上。这样能够大大提高CPU cache的命中率,提高性能。(如果 CPU 所要操作的数据在缓存中,则直接读取,这称为缓存命中。)
分支预测冷启动:进程迁移时,除了缓存失效外,还会遇到分支预测器等硬件组件的冷启动开销。分支预测器是现代 CPU 中用于提高指令执行效率的重要组件,它能够预测程序中分支指令的执行方向。当进程迁移到新的核心时,分支预测器需要重新学习程序的分支模式,这会导致指令执行效率下降。
因此,在运行计算密集的程序时,通常需要将进程、线程与 CPU 核心进行绑定(binding / pinning),即控制进程与 CPU 核心的亲和性(affinity),消除上述的各类影响。
二、绑定方法
(1)MPI程序
mpirun
mpirun
在执行程序时可以使用一系列参数指导进程绑定,如 OpenMPI 支持:
bind-to(进程绑定)
--bind-to-x
:自动绑定每个进程到 core 或者 socket(并可通过 --map-by
控制进程映射),使用 --report-bindings
可以打印出绑定情况;
--bind-to-
core:将MPI进程绑定到CPU核上,避免进程在CPU核之间切换带来的开销,可以减轻cache争抢现象,提高MPI 程序的性能。
map-by(进程映射)
--map-by ppr:n:node:PE=m:为每个节点分配n个MPI进程,每个MPI进程分配m个CPU核
参数格式 | 含义 | 说明 |
---|---|---|
--map-by | 指定进程映射策略 | 控制MPI进程在节点上的分布方式 |
ppr:n:node | "Process Per Resource" 模式 | n 是每个资源(如节点、NUMA域、Socket等)上分配的进程数 |
PE=m | "Processing Element" 数量 | 每个进程绑定的CPU核心数(通常用于混合MPI+OpenMP并行) |
--map-by node:PE=m:为每个节点分配1个MPI进程,每个MPI进程分配m个CPU核
下面是MPI参数的详细介绍:
For process binding:
--bind-to <foo>
Bind processes to the specified object, defaults to core. Supported options include slot, hwthread, core, l1cache, l2cache, l3cache, socket, numa, board, cpu-list, and none.
-cpus-per-proc, --cpus-per-proc <#perproc>
Bind each process to the specified number of cpus. (deprecated in favor of --map-by <obj>:PE=n)
-cpus-per-rank, --cpus-per-rank <#perrank>
Alias for -cpus-per-proc. (deprecated in favor of --map-by <obj>:PE=n)
-bind-to-core, --bind-to-core
Bind processes to cores (deprecated in favor of --bind-to core)
-bind-to-socket, --bind-to-socket
Bind processes to processor sockets (deprecated in favor of --bind-to socket)
-report-bindings, --report-bindings
Report any bindings for launched processes.
To map processes:
--map-by <foo>
Map to the specified object, defaults to socket. Supported options include slot, hwthread, core, L1cache, L2cache, L3cache, socket, numa, board, node, sequential, distance, and ppr. Any object can include modifiers by adding a : and any combination of PE=n (bind n processing elements to each proc), SPAN (load balance the processes across the allocation), OVERSUBSCRIBE (allow more processes on a node than processing elements), and NOOVERSUBSCRIBE. This includes PPR, where the pattern would be terminated by another colon to separate it from the modifiers.
-bycore, --bycore
Map processes by core (deprecated in favor of --map-by core)
-byslot, --byslot
Map and rank processes round-robin by slot.
-nolocal, --nolocal
Do not run any copies of the launched application on the same node as orterun is running. This option will override listing the localhost with --host or any other host-specifying mechanism.
-nooversubscribe, --nooversubscribe
Do not oversubscribe any nodes; error (without starting any processes) if the requested number of processes would cause oversubscription. This option implicitly sets "max_slots" equal to the "slots" value for each node. (Enabled by default).
-oversubscribe, --oversubscribe
Nodes are allowed to be oversubscribed, even on a managed system, and overloading of processing elements.
-bynode, --bynode
Launch processes one per node, cycling by node in a round-robin fashion. This spreads processes evenly among nodes and assigns MPI_COMM_WORLD ranks in a round-robin, "by node" manner.
-cpu-list, --cpu-list <cpus>
Comma-delimited list of processor IDs to which to bind processes [default=NULL]. Processor IDs are interpreted as hwloc logical core IDs. Run the hwloc lstopo(1) command to see a list of available cores and their logical IDs.
Slurm
SLURM 的进程绑定分为三级,具体可以查阅 此文档。使用 low-level 的 --cpu-bind
参数可以用于精确地控制绑定,SLURM 也可以根据参数组合进行自动的绑定。在 conv
集群上使用 -n 28 -N 1
时(占满 CPU 核心),绑定参数效果举例如下:
[empty]
:自动绑定一个进程到每个核心,等价于--cpu-bind=cores
--ntasks-per-socket=14
:每个 socket 绑定 14 个进程(conv集群每个socket只有14个核),等价于--cpu-bind=sockets
或--cpu-bind=ldom
--cpu-bind=none
:不进行任何绑定
(2)OpenMP程序
同一进程内的所有 OpenMP 线程共享地址空间,因此容易受到 NUMA 效应的影响。
OpenMP 进程使用以下的方式控制线程的绑定:
- OMP_PROC_BIND 环境变量 / proc_bind 指令:控制线程绑定与否,以及线程对于绑定单元(称为 place)分布
- OMP_PLACES 环境变量:控制每个 place 的对应,常用
threads/cores/sockets
在示例conv集群上,操作如下:
OMP_NUM_THREADS=28 OMP_PROC_BIND=true OMP_PLACES=cores
:每个线程绑定到一个 core,使用默认的分布(线程n
绑定到 coren
);OMP_NUM_THREADS=2 OMP_PROC_BIND=true OMP_PLACES=sockets
:每个线程绑定到一个 socket;OMP_NUM_THREADS=4 OMP_PROC_BIND=close OMP_PLACES=cores
:每个线程绑定到一个 core,线程在 socket 上连续分布(分别绑定到 core0,1,2,3
;OMP_NUM_THREADS=4 OMP_PROC_BIND=spread OMP_PLACES=cores
:每个线程绑定到一个 core,线程在 socket 上尽量散开分布(分别绑定到 core0,7,14,21
;
(3)MPI+OpenMP混合程序
a) 普通绑定
在使用 MPI + OpenMP 混合编程时,进程绑定对性能的影响尤为关键。每个 MPI 进程需要绑定在一组核心上(通常属于同一个 NUMA domain),并把它的 OpenMP 线程绑定在其中的每个核心上。如在 conv
集群上,在1个计算节点,使用 2 进程 × 14 线程的绑定方式为:
OMP_NUM_THREADS=14 OMP_PROC_BIND=true OMP_PLACES=cores srun -n 2 -N 1 --cpu-bind=sockets ./exe
OpenMP 线程只能绑定于其“可见”的核心上,也就是父进程被绑定的核心。上面使用 SLURM 的 --cpu-bind=sockets
实现了每进程分配 14 个核心,与 14 个 OpenMP 线程恰好一一对应。
b) 手动(脚本)绑定
如果需要更复杂的绑定关系(如 4 进程 × 7 线程),则需要借助更复杂的进程绑定选项或者 wrapper script 来精细控制每个进程可见的核心。
如使用下面提供的 wrapper script,可直接运行
OMP_NUM_THREADS=7 OMP_PROC_BIND=true OMP_PLACES=cores srun -n 4 -N 1 --cpu-bind=none ./wrapper.sh ./exe
则 4 个进程分别会被绑定在编号为 0,2,4,6,8,10,12
,1,3,5,7,9,11,13
,14,16,18,20,22,24,26
,15,17,19,21,23,25,27
的核心上。
#!/bin/bash
# LOCAL_RANK=$OMPI_COMM_WORLD_LOCAL_RANK # for OpenMPI
# LOCAL_RANK=$MPI_LOCALRANKID # for Intel MPI
LOCAL_RANK=$SLURM_LOCALID # for SLURM
# LOCAL_SIZE=$OMPI_COMM_WORLD_LOCAL_SIZE # for OpenMPI
# LOCAL_SIZE=$MPI_LOCALNRANKS # for Intel MPI
LOCAL_SIZE=$SLURM_TASKS_PER_NODE # for SLURM
NCPUS=$(nproc --all) # eg: 28
NUM_NUMA=2
# calculate binding parameters
# bind to sequential cores in a NUMA domain
CORES_PER_PROCESS=$(($NCPUS / $LOCAL_SIZE)) # eg: 7 when LOCAL_SIZE=4
NUMA_ID=$(($LOCAL_RANK / $NUM_NUMA)) # eg: 0, 0, 1, 1
NUMA_OFFSET=$(($LOCAL_RANK % $NUM_NUMA)) # 0, 1, 0, 1
CORE_START=$(($NUMA_ID * $CORES_PER_PROCESS * $NUM_NUMA + $NUMA_OFFSET)) # eg: 0, 1, 14, 15
CORE_END=$((($NUMA_ID + 1) * $CORES_PER_PROCESS * $NUM_NUMA - $NUM_NUMA + $NUMA_OFFSET)) # eg: 12, 13, 26, 27
CORES=$(seq -s, $CORE_START $NUM_NUMA $CORE_END) # eg: 0,2,4,6,8,10,12 for rank 0
# execute command with specific cores
echo "Process $LOCAL_RANK on $(hostname) bound to core $CORES"
exec numactl -C "$CORES" $@
使用 wrapper script 运行时,需要禁用 MPI 或者 SLURM 的进程绑定功能(如 mpirun --bind-to-none
或者 srun --cpu-bind=none
),避免互相干扰。
(4)程序主动绑定
除了上述几种在运行时指定的绑定方式外,程序可以主动调用系统接口或第三方库控制自己的 CPU 绑定,例如:
- POSIX sched_setaffinity(2) / pthread_getaffinity_np(3) API;
- libnuma:
numactl
底层使用的库; - libhwloc:OpenMPI 项目中衍生的可移植库。
在程序中直接控制绑定可以实现更丰富灵活的功能,也是某些情况下的唯一选择(如基于 pthread
的多线程程序)。但这些方法更容易与外部的绑定配置产生冲突,因此需要谨慎使用。
三、调试工具
- launcher 打印:
srun --cpu-bind=verbose
mpirun --report-binding
- 进程内获取:
- sched_getaffinity(2) / pthread_getaffinity_np(3)
libnuma
/libhwloc
中的相应函数
- 工具程序:
numactl --show
参考链接:
MPI参数:mpirun(1) man page (version 4.1.8)
Slurm参数:Slurm Workload Manager - Support for Multi-core/Multi-thread Architectures