多进程 MPI 与多线程 std::thread 性能

Posted

技术标签:

【中文标题】多进程 MPI 与多线程 std::thread 性能【英文标题】:multi-process MPI vs. multithreaded std::thread performance 【发布时间】:2021-09-08 07:45:48 【问题描述】:

我编写了一个简单的测试程序来比较使用 MPI 在多个进程上并行化的性能,或者使用std::thread 在多个线程上进行并行化的性能。并行化的工作只是写入一个大数组。我看到的是,多进程 MPI 的性能远远优于多线程。

测试代码为:

#ifdef USE_MPI
#include <mpi.h>
#else
#include <thread>
#endif
#include <iostream>
#include <vector>

void dowork(int i)
    int n = 1000000000;
    std::vector<int> foo(n, -1);


int main(int argc, char *argv[])
    int npar = 1;
#ifdef USE_MPI
    MPI_Init(&argc, &argv);
    MPI_Comm_size(MPI_COMM_WORLD, &npar);
#else
    npar = 8;
    if(argc > 1)
        npar = atoi(argv[1]);
    
#endif
    std::cout << "npar = " << npar << std::endl;

    int i;

#ifdef USE_MPI
    MPI_Comm_rank(MPI_COMM_WORLD, &i);
    dowork(i);
    MPI_Finalize();
#else
    std::vector<std::thread> threads;
    for(i = 0; i < npar; ++i)
        threads.emplace_back([i]()
            dowork(i);
        );
    
    for(i = 0; i < npar; ++i)
        threads[i].join();
    
#endif
    return 0;

Makefile 是:

partest_mpi:
    mpic++ -O2 -DUSE_MPI  partest.cpp -o partest_mpi -lmpi
partest_threads:
    c++ -O2 partest.cpp -o partest_threads -lpthread

而执行的结果是:

$ time ./partest_threads 8
npar = 8

real    0m2.524s
user    0m4.691s
sys 0m9.330s

$ time mpirun -np 8 ./partest_mpi
npar = 8
npar = 8
npar = 8
npar = 8
npar = 8
npar = 8
npar = 8npar = 8


real    0m1.811s
user    0m4.817s
sys 0m9.011s

所以问题是,为什么会发生这种情况,我可以对线程代码做些什么来使其性能更好?我猜这与内存带宽和缓存利用率有关。我在 Intel i9-9820X 10 核 CPU 上运行它。

【问题讨论】:

确保在运行基准测试之前禁用频率缩放。 ***.com/a/9006802/412080 操作系统是否将您的线程全部映射到同一个内核?使用 hwloc 或类似工具打印出您正在运行的内核。或者,使用固定工具来防止操作系统迁移您的线程/进程。 【参考方案1】:

TL;DR:确保您有足够的 RAM 并且基准指标准确无误。话虽如此,我无法在我的机器上重现这种差异(即我得到相同的性能结果)。

在大多数平台上,您的代码分配 30 GB(因为 sizeof(int)=4 和每个进程/线程执行向量的分配并且项目由向量初始化)。因此,您应该首先确保至少有足够的 RAM 来执行此操作。否则,由于内存交换,数据可能会被写入(慢得多的)存储设备(例如 SSD/HDD)。在这种极端情况下,基准测试并没有真正的用处(尤其是因为结果可能不稳定)。

假设您有足够的 RAM,您的应用程序主要受 page-faults 的约束。确实,在大多数现代主流平台上,操作系统(OS)会很快分配虚拟内存,但不会直接映射到物理内存。此映射过程通常在页面被第一次读取/写入(即页面错误)并且已知缓慢时完成。此外,出于安全原因(例如,不泄露其他进程的凭据),大多数操作系统会在第一次写入每个页面时将其归零,从而使页面错误更慢。在某些系统上,它可能无法很好地扩展(尽管在具有 Windows/Linux/Mac 的典型台式机上应该没问题)。这部分时间报告为系统时间

剩下的时间主要是在 RAM 中填充向量所需的时间。这部分在许多平台上几乎无法扩展:通常 2-3 个内核显然足以使台式机上的 RAM 带宽饱和。

话虽如此,在我的机器上,我无法重现相同的结果,但分配的内存减少了 10 倍(因为我没有 30 GB 的 RAM)。这同样适用于减少 4 倍的内存。实际上,在我使用 i7-9600KF 的 Linux 机器上,MPI 版本要慢得多。请注意,结果相对稳定且可重复(无论运行的顺序和次数如何):

time ./partest_threads 6 > /dev/null
real    0m0,188s
user    0m0,204s
sys 0m0,859s

time mpirun -np 6 ./partest_mpi > /dev/null
real    0m0,567s
user    0m0,365s
sys 0m0,991s

MPI 版本的不良结果来自我机器上的MPI 运行时初始化缓慢,因为一个不执行任何操作的程序大约需要 350 毫秒才能初始化。这实际上表明行为是平台相关的。至少,它表明不应使用time 来衡量两个应用程序的性能。应该改用monotonic C++ clocks。

一旦代码被修复为使用准确的计时方法(使用 C++ 时钟和 MPI 屏障),我会在两个实现之间获得非常接近的性能结果(10 次运行,使用排序的计时):

pthreads:
Time: 0.182812 s
Time: 0.186766 s
Time: 0.187641 s
Time: 0.18785 s
Time: 0.18797 s
Time: 0.188256 s
Time: 0.18879 s
Time: 0.189314 s
Time: 0.189438 s
Time: 0.189501 s
Median time: 0.188 s

mpirun:
Time: 0.185664 s
Time: 0.185946 s
Time: 0.187384 s
Time: 0.187696 s
Time: 0.188034 s
Time: 0.188178 s
Time: 0.188201 s
Time: 0.188396 s
Time: 0.188607 s
Time: 0.189208 s
Median time: 0.188 s

要对 Linux 进行更深入的分析,您可以使用perf 工具。内核端分析表明,大部分时间(60-80%)都花在内核函数 clear_page_erms 上,它在页面错误期间将页面归零(如前所述),然后是填充向量值的 __memset_avx2_erms。其他功能只占用总运行时间的一小部分。这是一个使用 pthread 的示例:

  64,24%  partest_threads  [kernel.kallsyms]              [k] clear_page_erms
  18,80%  partest_threads  libc-2.31.so                   [.] __memset_avx2_erms
   2,07%  partest_threads  [kernel.kallsyms]              [k] prep_compound_page
   0,86%  :8444            [kernel.kallsyms]              [k] clear_page_erms
   0,82%  :8443            [kernel.kallsyms]              [k] clear_page_erms
   0,74%  :8445            [kernel.kallsyms]              [k] clear_page_erms
   0,73%  :8446            [kernel.kallsyms]              [k] clear_page_erms
   0,70%  :8442            [kernel.kallsyms]              [k] clear_page_erms
   0,69%  :8441            [kernel.kallsyms]              [k] clear_page_erms
   0,68%  partest_threads  [kernel.kallsyms]              [k] kernel_init_free_pages
   0,66%  partest_threads  [kernel.kallsyms]              [k] clear_subpage
   0,62%  partest_threads  [kernel.kallsyms]              [k] get_page_from_freelist
   0,41%  partest_threads  [kernel.kallsyms]              [k] __free_pages_ok
   0,37%  partest_threads  [kernel.kallsyms]              [k] _cond_resched
[...]  

如果这两种实现之一有任何内在的性能开销,perf 应该能够报告它。如果您在 Windows 上运行,则可以使用其他分析工具,例如 VTune。

【讨论】:

加 1 用于调查。 malloc 调用mmap 时分配大向量不是最佳的,因为std::vector 构造函数初始化所有元素,这会导致每个页面出现一个页面错误。最好使用MAP_POPULATE 标志调用mmap 以避免这些不必要的页面错误,但是,std::vector 没有接口将此类提示传播到操作系统。 mmap 将返回的页面归零以避免信息泄漏,这可以用作优化以避免 std::vector 必须再次将元素归零,除非提供了非零位模式默认值。 我同意。然而,上次我使用MAP_POPULATE 来加速页面错误,并没有快多少。我认为主要问题之一是大多数系统上的小页面大小。在这种情况下,大页面可能会有很大帮助(尽管 Linux/Max 现在应该使用透明大页面,IDK for Windows)。对于std::vector,我认为可以使用特定的用户分配器在内部使用带有特定标志的mmap。看看它是否会更快会很有趣。

以上是关于多进程 MPI 与多线程 std::thread 性能的主要内容,如果未能解决你的问题,请参考以下文章

C++并发与多线程 3_线程传参数详解,detach 注意事项

c++ thread创建与多线程同步详解

C++并发与多线程 4_创建多个线程数据共享问题分析

多线程与多进程介绍

python多进程与多线程使用场景

Python多线程与多进程