多进程 MPI 与多线程 std::thread 性能
multi-process MPI vs. multithreaded std::thread performance
我编写了一个简单的测试程序来比较使用 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。
TL;DR: 确保您有足够的 RAM 并且基准指标准确无误。也就是说,我无法在我的机器上重现这种差异(即我得到相同的性能结果)。
在大多数平台上,您的代码分配 30 GB(因为 sizeof(int)=4
并且每个 process/thread 执行矢量分配并且项目由矢量初始化)。因此,您应该首先确保您至少有足够的 RAM 来执行此操作。否则,由于内存交换,数据可能会写入(慢得多的)存储设备(例如 SSD/HDD)。在这种极端情况下,基准测试并不是很有用(尤其是因为结果可能不稳定)。
假设您有足够的 RAM,您的应用程序主要受 page-faults 的约束。事实上,在大多数现代主流平台上,操作系统(OS)会非常快速地分配虚拟内存,但不会直接将其映射到物理内存。此映射过程通常在页面 read/written 第一次 (即页面错误)并且已知为 slow[=47] 时完成=].此外,出于安全原因(例如,为了不泄露其他进程的凭据),大多数 OS 将在第一次写入每个页面时将其归零,从而使页面错误更加缓慢。在某些系统上,它可能无法很好地扩展(尽管在 Windows/Linux/Mac 的典型台式机上应该没问题)。这部分时间上报为系统时间.
剩下的时间主要是在RAM中填充向量所需的时间。这部分在许多平台上几乎无法扩展:通常 2-3 个内核显然足以饱和台式机上的 RAM 带宽。
也就是说,在我的机器上,我无法重现相同的结果,分配的内存少了 10 倍(因为我没有 30 GB 的 RAM)。同样适用于少 4 倍的内存。实际上,MPI 版本在我的 Linux i7-9600KF 机器上要慢得多。请注意,结果相对稳定且可重现(无论 运行 的顺序和数量如何):
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 运行time 的缓慢初始化,因为一个不执行任何操作的程序大约需要 350 毫秒来初始化.这实际上表明行为是依赖于平台的。至少,它表明不应该用time
来衡量两个应用程序的性能。应该改用 monotonic C++ clocks.
一旦代码被固定为使用准确的计时方法(使用 C++ 时钟和 MPI 屏障),我在两个实现之间得到 非常接近的性能结果(10 运行s,排序时间):
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。
我编写了一个简单的测试程序来比较使用 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。
TL;DR: 确保您有足够的 RAM 并且基准指标准确无误。也就是说,我无法在我的机器上重现这种差异(即我得到相同的性能结果)。
在大多数平台上,您的代码分配 30 GB(因为 sizeof(int)=4
并且每个 process/thread 执行矢量分配并且项目由矢量初始化)。因此,您应该首先确保您至少有足够的 RAM 来执行此操作。否则,由于内存交换,数据可能会写入(慢得多的)存储设备(例如 SSD/HDD)。在这种极端情况下,基准测试并不是很有用(尤其是因为结果可能不稳定)。
假设您有足够的 RAM,您的应用程序主要受 page-faults 的约束。事实上,在大多数现代主流平台上,操作系统(OS)会非常快速地分配虚拟内存,但不会直接将其映射到物理内存。此映射过程通常在页面 read/written 第一次 (即页面错误)并且已知为 slow[=47] 时完成=].此外,出于安全原因(例如,为了不泄露其他进程的凭据),大多数 OS 将在第一次写入每个页面时将其归零,从而使页面错误更加缓慢。在某些系统上,它可能无法很好地扩展(尽管在 Windows/Linux/Mac 的典型台式机上应该没问题)。这部分时间上报为系统时间.
剩下的时间主要是在RAM中填充向量所需的时间。这部分在许多平台上几乎无法扩展:通常 2-3 个内核显然足以饱和台式机上的 RAM 带宽。
也就是说,在我的机器上,我无法重现相同的结果,分配的内存少了 10 倍(因为我没有 30 GB 的 RAM)。同样适用于少 4 倍的内存。实际上,MPI 版本在我的 Linux i7-9600KF 机器上要慢得多。请注意,结果相对稳定且可重现(无论 运行 的顺序和数量如何):
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 运行time 的缓慢初始化,因为一个不执行任何操作的程序大约需要 350 毫秒来初始化.这实际上表明行为是依赖于平台的。至少,它表明不应该用time
来衡量两个应用程序的性能。应该改用 monotonic C++ clocks.
一旦代码被固定为使用准确的计时方法(使用 C++ 时钟和 MPI 屏障),我在两个实现之间得到 非常接近的性能结果(10 运行s,排序时间):
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。