许多内核上令人尴尬的并行工作扩展性差

Posted

技术标签:

【中文标题】许多内核上令人尴尬的并行工作扩展性差【英文标题】:Poor scaling of embarrassingly parallel work on many cores 【发布时间】:2021-07-14 23:49:57 【问题描述】:

我正在尝试在多核系统上并行化代码。在调查扩展瓶颈时,我最终将所有内容都删除到一个(几乎)空的 for 循环中,并发现在 28 个内核时扩展仍然只有 75%。下面的示例不会导致任何错误共享、堆争用或内存带宽问题。我在许多运行 Linux 或 Mac 的机器上看到了类似或更糟的影响,物理内核数从 8 到 56,所有处理器都处于空闲状态。

该图显示了在专用 HPC Linux 节点上进行的测试。这是一个“弱缩放”测试:工作负载与工作人员的数量成正比,垂直轴显示所有线程组合完成的工作速率,缩放到硬件的理想最大值。每个线程运行 10 亿次空的 for 循环迭代。 1 到 28 之间的每个线程计数有一个试验。每个线程的运行时间约为 2 秒,因此线程创建的开销不是一个因素。

这可能是操作系统妨碍了我们吗?或者可能是功耗?谁能提供一个在高核数机器上显示 100% 扩展的计算示例(无论多么微不足道、弱或强)?

下面是要重现的 C++ 代码:

#include <vector>
#include <thread>

int main()

    auto work = [] ()
    
        auto x = 0.0;

        for (auto i = 0; i < 1000000000; ++i)
        
            // NOTE: behavior is similar whether or not work is
            // performed here (although if no work is done, you
            // cannot use an optimized build).

            x += std::exp(std::sin(x) + std::cos(x));
        
        std::printf("-> %lf\n", x); // make sure the result is used
    ;

    for (auto num_threads = 1; num_threads < 40; ++num_threads)
    
        auto handles = std::vector<std::thread>();

        for (auto i = 0; i < num_threads; ++i)
        
            handles.push_back(std::thread(work));
        
        auto t0 = std::chrono::high_resolution_clock::now();

        for (auto &handle : handles)
        
            handle.join();
        
        auto t1 = std::chrono::high_resolution_clock::now();
        auto delta = std::chrono::duration<double, std::milli>(t1 - t0);

        std::printf("%d %0.2lf\n", num_threads, delta.count());
    
    return 0;

要运行该示例,请确保编译不带 优化:g++ -O3 -std=c++17 weak_scaling.cpp。这是重现绘图的 Python 代码(假设您将程序输出通过管道传输到 perf.dat)。

import numpy as np
import matplotlib.pyplot as plt

threads, time = np.loadtxt("perf.dat").T
a = time[0] / 28
plt.axvline(28, c='k', lw=4, alpha=0.2, label='Physical cores (28)')
plt.plot(threads, a * threads / time, 'o', mfc='none')
plt.plot(threads, a * threads / time[0], label='Ideal scaling')

plt.legend()
plt.ylim(0.0, 1.)
plt.xlabel('Number of threads')
plt.ylabel('Rate of work (relative to ideal)')
plt.grid(alpha=0.5)
plt.title('Trivial weak scaling on Intel Xeon E5-2680v4')
plt.show()

更新 -- 这是 56 核节点上的相同扩展,以及该节点的架构:

更新 -- cmets 担心构建未优化。如果在循环中完成工作,结果非常相似,不丢弃结果,使用-O3

【问题讨论】:

有趣。你如何运行程序?此外,您是否使用“默认”系统配置? (即,您是否更改了调控器的配置、超线程、调度算法、频率限制等)。 不,我已经在大约六台机器上运行了测试,所有机器都使用默认配置。我没有在示例中包含线程固定(为了简单起见),但核心亲和力并没有改变结果。 测试没有优化编译的程序的性能可能没有用,因为当优化被禁用时,程序是故意以这样一种方式构建的,以便调试器(或人类)更容易理解机器代码/汇编级别,而不是快速/高效。因此,它的性能并没有告诉我们太多(如果有的话)关于始终启用优化的“真实世界条件”。 这可能与功耗和热环境有关。在几个核心全部运行而其他核心闲置的情况下,处理器有额外的功率和热容量可用,并且可以运行得比额定速度更快(Turbo Boost)。所有核心都用完后,它会减慢到(可能)额定速度,尽管如果它变得太热,它会减慢更多。 运行 watch -n.5 'grep "^cpu MHz" /proc/cpuinfo' 以查看 CPU 频率如何随着测试的进行而变化。 【参考方案1】:

测试没有意义,因为您没有运行优化的构建并且没有提供真正的工作。

我们怎么知道呢?因为任何最近的 gcc 版本都会删除无用的 for 循环,除非你禁用优化。因此,要么您在禁用优化的情况下进行编译,要么根本不存在 for 循环。

当我向您的 work 函数添加一些实际工作并运行优化构建时,当 work 花费超过大约 10 秒时,缩放与预期完全一样。低于大约 100 毫秒的工作时,操作系统开销会使结果变得嘈杂到毫无意义的地步(在我的特定平台上)。

也许您错过了 for 循环已被优化掉的事实,并且正在对线程创建和销毁进行基准测试,而不是完成任何工作。或者您正在对没有优化的代码进行基准测试。做一些真正的工作。计算类似于级数展开的东西,并在每个线程的末尾打印出结果。您将按预期看到缩放。并查看实际的汇编输出以确保编译器不会将循环静态转换为常量结果。现代编译器很容易识别,例如对基于常量输入的算术或几何级数求和,并用最终结果替换计算。

不要对未优化的构建进行任何基准测试。这几乎没有意义,因为您正在积极禁用编译器优化提供的所有性能优势。并且不要对实际上并没有做某事的代码进行基准测试,因为您可以确定循环在执行计算工作时实际执行的次数与您想象的一样多。

【讨论】:

功函数耗时 2 秒,并随迭代次数线性缩放。我希望您不是建议创建线程需要半秒钟?优化的构建将略微增加每个核心的工作率(如果结果没有被浪费)。但是它不应该影响 缩放 您能否展示一个在循环中完成工作并实现 100% 缩放的示例?随着优化和工作的完成(参见上面的更新),我仍然在 40 核节点上获得 80%,在 56 核节点上获得 70%。我仍然认为这是热环境。

以上是关于许多内核上令人尴尬的并行工作扩展性差的主要内容,如果未能解决你的问题,请参考以下文章

为啥在更多 CPU/内核上的并行化在 Python 中的扩展性如此之差?

使用 Python 多处理解决令人尴尬的并行问题

为啥这种令人尴尬的并行算法的性能没有随着多线程而提高?

python中令人尴尬的并行问题

python 令人尴尬的并行问题的进程/线程池

PostgreSQL9.6新功能