多 CPU 计算机上的多线程软件开发:认识 NUMA 架构的坑点,如何避坑并确保性能
正常情况下我们自己个人使用的计算机都是单 CPU 多核心的架构,电脑上插的内存条都是给这个 CPU 专用的。敲代码的时候闭着眼睛瞎几把使用 OpenMP 的 #pragma omp parallel for 来让一部分程序代码以多线程的形式运行,即可在调试的时候立即体现出性能:所有 CPU 核心都跑满了,然后你的程序确实以多线程的方式运行了,然后总的时间成本确实大幅降低了。
但是你会遇到 NUMA 架构的多 CPU 工作站。而直接把这样的多线程代码实现拿到这种工作站上运行的话,实际的运行效果却会出奇地慢,最直观的表现就是:所有 CPU 核心都跑满了,但是总的时间成本却没啥变化,就像单线程运行一样。很神奇是吧?这是因为 NUMA 的硬件架构不同于一般的单 CPU 计算机。
NUMA 架构:你想的架构和真实的架构
NUMA 计算机安装上操作系统后,比如安装了 Windows 10 或者 CentOS 8 或者 Debian bullseye 之后,你直观上看到的就是:我有非常多的 CPU 核心,我有非常多的内存。任务管理器上可以看到“NUMA 节点”这个东西,但似乎不需要去关心它。
可以看到,虽然逻辑上是所有的 CPU 一起使用所有的内存,但物理上则是每个 CPU 只直连接它自己的内存,而如果要访问别的 CPU 的内存,则需要走一道 桥梁 。 此时的内存访问性能会显著降低。
CPU 的缓存负责协调处理全部物理内存空间的一致性。
因此,对于一个工作在某个 CPU 上的线程而言,它所使用的数据需要被存储在它所在的 CPU 连接的物理内存上,而不是别的 CPU 连接的物理内存上。
有一种上当受骗的感觉 内存确实都能访问到,但不是所有的时候它的速度都是合理的。
数据存储于谁的内存,取决于谁先摸到数据
你希望你的线程在运行的时候,它的数据存储于它所在的 CPU 连接的内存上。因为这样可以确保性能。而要做到这一点,只需要你的线程负责完成你的数据的 初始化 即可。当数据被初始化时,这个数据所需的内存页被分配,而 x86 上的主流操作系统(比如 Windows、Linux)会把最靠近你的线程所在的 CPU 的物理内存分配给你的线程。此时,你读写这个数据的时候,CPU 就可以直接操作内存,而无需进行与其它 CPU 之间的通讯了。
因此需要 避免 以下情况的发生:
1、避免主线程分配数据
会导致数据被分配到方便主线程访问的物理内存上, 导致别的线程不能快速访问这些数据。
比如:我使用 std::vector<xxx*> data; 管理我的对象,而我的对象使用 load_xxx() 来加载时,不合理的设计是在主线程里完成数据的构造或者加载,比如:
for (i = 0; i < n; i++)
{
data.push_back(load_xxx(i));
}
这样做,会使所有的 data[i] 只在主线程 所在的 CPU 里具有正常的读写速度。多线程读写这个东西会变得极慢,总的速度甚至比不过单线程。
2、避免在多线程过程里对全局变量进行写入
原理同上,这个全局变量被初始化的时候,它的物理内存会被分配到初始化它的那个线程上,而别的线程访问它则需要 过桥。但如果只是读取的话,这个变量会被 CPU 缓存,因此不会影响性能。而写入这个变量则会导致其它 CPU 被强制同步这个变量所在的缓存,会造成严重的性能问题。
3、避免在多线程过程里使用自旋锁。
自旋锁从性质上就要求所有的线程都得频繁读写它。这导致不管谁初始化它,它都不能被所有的线程快速访问。由于 NUMA 架构的特殊性,对自旋锁原子量的写入会导致 所有 CPU 同步这个原子量所在的内存缓存页。代码从表面上看似乎只是一个变量的判断和写入,但实际上你几乎阻塞了所有的 CPU。
请勿自己造自旋锁的轮子,而是使用操作系统 API 提供的锁,操作系统提供的锁的内部实现逻辑会根据实际的计算机架构而适配,在 NUMA 架构上会有适合 NUMA 架构的实现(而你自己造自旋锁的时候,你就会发现你不一定知道你的自旋锁在 NUMA 架构上会表现得如何了)。
OpenMP 优化方案:使用 schedule(static) 将任务与线程绑定,并在线程里初始化你的数据
OpenMP 的典型用法就是用特殊的 #pragma omp 来开启多线程。例子代码如下:
#pragma omp parallel for
for (int i = 0; i < n; i++)
{
// 此处是多线程运行的,会根据你的任务总数 n 来创建线程处理你的任务
a[i] = 0; // 初始化a[i]
...
}
// 一些代码。此处是单线程运行的。
xxx;
yyy;
// 现在又需要对 a 进行多线程处理了
#pragma omp parallel for
for (int i = 0; i < n; i++)
{
// 此时并不能保证初始化a[i] 的那个 CPU 线程就是现在对 a[i] 进行读写的线程,因此会导致性能问题。
a[i] = zzz;
}
为了确保在第二个多线程的地方,对 a[i] 处理的那个线程就是之前初始化 a[i] 的线程,可以使用 schedule(static) 让线程随任务绑定:
#pragma omp parallel for schedule(static)
for (int i = 0; i < n; i++)
{
// 此处多线程初始化各自的 a[i]
a[i] = 0;
...
}
// 一些代码。此处是单线程运行的。
xxx;
yyy;
// 现在又需要对 a 进行多线程处理了
#pragma omp parallel for schedule(static)
for (int i = 0; i < n; i++)
{
// 此处对 a[i] 进行操作的线程,正是之前初始化 a[i] 的线程,因此可以确保效率
a[i] = zzz;
}
优化效果如下
OpenMP 优化方案其二:设置线程与 CPU 的相关性
这块需要用到环境变量,在 bash 用 export,在 CMD 用 set,使用环境变量来控制一个进程使用哪几个 CPU 线程。
其中,使用 OMP_PLACES 设置你要用哪些核心来跑;使用 OMP_PROC_BIND 设置你的线程分布策略;使用 OMP_NUM_THREADS 指定总的线程数。
使用 OMP_PLACES
假设我只使用第 0、1、2 个 CPU 核心:
export OMP_PLACES="{0},{1},{2}"
我使用第 0 个开始的总共 32 个 CPU 核心:(冒号隔开的三个东西分别是起始核心、个数、步进值)
export OMP_PLACES="{0}:32:1"
我使用第 0 个开始的总共 32 个 CPU 核心和第 64 开始的 32 个核心:(两组核心可以用逗号隔开)
export OMP_PLACES="{0}:32:1, {64}:32:1"
使用 OMP_PROC_BIND
OMP_PROC_BIND 的值是个 enum,可以设置为:master, close, spread.
master:尽量使所有工作线程和主线程位于同一 CPU 上。
close:尽量使所有工作线程“靠近”。
spread:尽量分散所有的工作线程到不同的 CPU 上。
使用OMP_NUM_THREADS 设置线程数
例:
# 使用 16 个线程
export OMP_NUM_THREADS=16
非多线程方案:多进程方案也不错
几乎可以根治多线程使用内存的问题,每个进程使用自己的内存。Linux/Unix 最常见使用这样的并发模型。Windows 上可以通过启动一堆工作进程的方式来实现并发处理。
Python 的 multiprocessing.Pool 用起来也是很爽的,立即可以看到性能效果,缺点是它的底层实现里面的进程间的通讯似乎靠的是腌黄瓜(不是所有的数据都可以腌),并且当你按下 Ctrl+C,你会发现它并没有都停下来,只好关闭 CMD 窗口,而在 Linux 你就得想办法干掉父进程后再连带干掉所有的子进程。
参考文章
How To Befriend NUMA
SC18-BoothTalks-vanderPas.zip
(450.65 KB, 下载次数: 1)
SC18-BoothTalks-vanderPas.z01
(1.99 MB, 下载次数: 0)
|