找回密码
 立即注册→加入我们

QQ登录

只需一步,快速开始

搜索
热搜: 下载 VB C 实现 编写
查看: 463|回复: 7

【多线程】多 CPU 计算机上的多线程软件开发:认识 NUMA 架构的坑点,如何避坑并确保性能

[复制链接]

1109

主题

1649

回帖

7万

积分

用户组: 管理员

一只技术宅

UID
1
精华
244
威望
743 点
宅币
24180 个
贡献
46222 次
宅之契约
0 份
在线时间
2294 小时
注册时间
2014-1-26
发表于 2023-6-9 21:57:36 | 显示全部楼层 |阅读模式

欢迎访问技术宅的结界,请注册或者登录吧。

您需要 登录 才可以下载或查看,没有账号?立即注册→加入我们

×

多 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 节点”这个东西,但似乎不需要去关心它。



photo_2023-05-19_17-41-10.jpg

node.png

就像下图一样:



magic.png

但是实际上的 NUMA 架构是这样的:



numa.png

可以看到,虽然逻辑上是所有的 CPU 一起使用所有的内存,但物理上则是每个 CPU 只直连接它自己的内存,而如果要访问别的 CPU 的内存,则需要走一道 桥梁此时的内存访问性能会显著降低
CPU 的缓存负责协调处理全部物理内存空间的一致性。



numa_real.png

因此,对于一个工作在某个 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;
}

优化效果如下



perf.png

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)


回复

使用道具 举报

3

主题

5

回帖

11万

积分

用户组: 技术宅的结界VIP成员

UID
8229
精华
2
威望
84 点
宅币
114704 个
贡献
80 次
宅之契约
0 份
在线时间
10 小时
注册时间
2023-2-9
发表于 2023-6-9 22:33:49 | 显示全部楼层
突然想起小时候看《我爱我家》,里面虚构过一种“可以把两台计算机配置合起来的软件”。

大概实际用起来就是这个感觉....?
回复 赞! 1 靠! 0

使用道具 举报

1109

主题

1649

回帖

7万

积分

用户组: 管理员

一只技术宅

UID
1
精华
244
威望
743 点
宅币
24180 个
贡献
46222 次
宅之契约
0 份
在线时间
2294 小时
注册时间
2014-1-26
 楼主| 发表于 2023-6-10 17:42:06 | 显示全部楼层
Kagamia 发表于 2023-6-9 22:33
突然想起小时候看《我爱我家》,里面虚构过一种“可以把两台计算机配置合起来的软件”。

大概实际用起来就 ...

对,差不多,其中一个计算机的自旋锁在另一个计算机上存储的时候,那就完蛋了
回复 赞! 靠!

使用道具 举报

0

主题

7

回帖

39

积分

用户组: 初·技术宅

UID
2280
精华
0
威望
0 点
宅币
32 个
贡献
0 次
宅之契约
0 份
在线时间
2 小时
注册时间
2017-2-25
发表于 2023-6-26 20:56:16 | 显示全部楼层
所以还是架构底层交换效率不足,极大影响了整体性能。
回复 赞! 靠!

使用道具 举报

55

主题

271

回帖

9330

积分

用户组: 管理员

UID
77
精华
16
威望
237 点
宅币
8199 个
贡献
251 次
宅之契约
0 份
在线时间
253 小时
注册时间
2014-2-22
发表于 2023-6-27 07:53:56 | 显示全部楼层
看了你的文章,忽然感觉自己又应该组装一台多CPU工作站了,不然无法测试程序在多CPU情况下的兼容性。
回复 赞! 靠!

使用道具 举报

1109

主题

1649

回帖

7万

积分

用户组: 管理员

一只技术宅

UID
1
精华
244
威望
743 点
宅币
24180 个
贡献
46222 次
宅之契约
0 份
在线时间
2294 小时
注册时间
2014-1-26
 楼主| 发表于 2023-6-28 10:44:42 | 显示全部楼层
嗷嗷叫的老马 发表于 2023-6-26 20:56
所以还是架构底层交换效率不足,极大影响了整体性能。


软件开发上,如果要搞多线程开发,则需要留意尽可能让你的线程能直接从 CPU 直通的那个内存里掏数据。或者说,不如直接用多进程,每个进程都是单线程的,就不需要担心远程的数据获取导致的性能问题了。
回复 赞! 靠!

使用道具 举报

1109

主题

1649

回帖

7万

积分

用户组: 管理员

一只技术宅

UID
1
精华
244
威望
743 点
宅币
24180 个
贡献
46222 次
宅之契约
0 份
在线时间
2294 小时
注册时间
2014-1-26
 楼主| 发表于 2023-6-28 10:47:37 | 显示全部楼层
美俪女神 发表于 2023-6-27 07:53
看了你的文章,忽然感觉自己又应该组装一台多CPU工作站了,不然无法测试程序在多CPU情况下的兼容性。 ...

这种机器贵死了,性能又拉,还不如家用机 CPU 的 i9-13900KF 配一个大小合理的内存(比如 64GB)。

我这边用过两款这样的工作站,都是品牌机,其中一款是曙光宁畅,另一款是联想ThinkStation P920。都是双 CPU 的。
回复 赞! 靠!

使用道具 举报

QQ|Archiver|小黑屋|技术宅的结界 ( 滇ICP备16008837号 )|网站地图

GMT+8, 2024-3-29 23:00 , Processed in 0.048748 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表