【多线程】多 CPU 计算机上的多线程软件开发:认识 NUMA 架构的坑点,如何避坑并确保性能
# 多 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 节点”这个东西,但似乎不需要去关心它。
就像下图一样:
**但是实际上的 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` **只在主线程** 所在的 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 = 0; // 初始化a
...
}
// 一些代码。此处是单线程运行的。
xxx;
yyy;
// 现在又需要对 a 进行多线程处理了
#pragma omp parallel for
for (int i = 0; i < n; i++)
{
// 此时并不能保证初始化a 的那个 CPU 线程就是现在对 a 进行读写的线程,因此会导致性能问题。
a = zzz;
}
```
为了确保在第二个多线程的地方,对 `a` 处理的那个线程就是之前初始化 `a` 的线程,可以使用 `schedule(static)` 让线程随任务绑定:
```
#pragma omp parallel for schedule(static)
for (int i = 0; i < n; i++)
{
// 此处多线程初始化各自的 a
a = 0;
...
}
// 一些代码。此处是单线程运行的。
xxx;
yyy;
// 现在又需要对 a 进行多线程处理了
#pragma omp parallel for schedule(static)
for (int i = 0; i < n; i++)
{
// 此处对 a 进行操作的线程,正是之前初始化 a 的线程,因此可以确保效率
a = 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 你就得想办法干掉父进程后再连带干掉所有的子进程。
## 参考文章
(https://www.openmp.org/wp-content/uploads/SC18-BoothTalks-vanderPas.pdf)
突然想起小时候看《我爱我家》,里面虚构过一种“可以把两台计算机配置合起来的软件”。
大概实际用起来就是这个感觉....? Kagamia 发表于 2023-6-9 22:33
突然想起小时候看《我爱我家》,里面虚构过一种“可以把两台计算机配置合起来的软件”。
大概实际用起来就 ...
对,差不多,其中一个计算机的自旋锁在另一个计算机上存储的时候,那就完蛋了 所以还是架构底层交换效率不足,极大影响了整体性能。 看了你的文章,忽然感觉自己又应该组装一台多CPU工作站了,不然无法测试程序在多CPU情况下的兼容性。 嗷嗷叫的老马 发表于 2023-6-26 20:56
所以还是架构底层交换效率不足,极大影响了整体性能。
软件开发上,如果要搞多线程开发,则需要留意尽可能让你的线程能直接从 CPU 直通的那个内存里掏数据。或者说,不如直接用多进程,每个进程都是单线程的,就不需要担心远程的数据获取导致的性能问题了。 美俪女神 发表于 2023-6-27 07:53
看了你的文章,忽然感觉自己又应该组装一台多CPU工作站了,不然无法测试程序在多CPU情况下的兼容性。 ...
这种机器贵死了,性能又拉,还不如家用机 CPU 的 i9-13900KF 配一个大小合理的内存(比如 64GB)。
我这边用过两款这样的工作站,都是品牌机,其中一款是曙光宁畅,另一款是联想ThinkStation P920。都是双 CPU 的。
页:
[1]