C++17 并行算法与 TBB 并行以及 OpenMP 性能比较

19
自从C++17标准库开始支持并行算法,我认为它应该是我们的首选方案,但在与tbb和openmp进行比较后,我改变了想法,我发现标准库要慢得多。 通过这篇文章,我想寻求专业建议,关于是否应该放弃标准库的并行算法,使用tbb或openmp,感谢! 环境: Mac OSX,Catalina 10.15.7 GNU g++-10 基准代码:
#include <algorithm>
#include <cmath>
#include <chrono>
#include <execution>
#include <iostream>
#include <tbb/parallel_for.h>
#include <vector>

const size_t N = 1000000;

double std_for() {
  auto values = std::vector<double>(N);

  size_t n_par = 5lu;
  auto indices = std::vector<size_t>(n_par);
  std::iota(indices.begin(), indices.end(), 0lu);
  size_t stride = static_cast<size_t>(N / n_par) + 1;

  std::for_each(
      std::execution::par,
      indices.begin(),
      indices.end(),
      [&](size_t index) {
        int begin = index * stride;
        int end = (index+1) * stride;
        for (int i = begin; i < end; ++i) {
          values[i] = 1.0 / (1 + std::exp(-std::sin(i * 0.001)));
        }
      });

  double total = 0;

  for (double value : values)
  {
    total += value;
  }
  return total;
}

double tbb_for() {
  auto values = std::vector<double>(N);

  tbb::parallel_for(
      tbb::blocked_range<int>(0, values.size()),
      [&](tbb::blocked_range<int> r) {
        for (int i=r.begin(); i<r.end(); ++i) {
          values[i] = 1.0 / (1 + std::exp(-std::sin(i * 0.001)));
        }
      });

  double total = 0;
  for (double value : values) {
    total += value;
  }
  return total;
}

double omp_for()
{
  auto values = std::vector<double>(N);

#pragma omp parallel for
  for (int i=0; i<values.size(); ++i) {
    values[i] = 1.0 / (1 + std::exp(-std::sin(i * 0.001)));
  }

  double total = 0;

  for (double value : values) {
    total += value;
  }
  return total;
}

double seq_for()
{
  auto values = std::vector<double>(N);

  for (int i=0; i<values.size(); ++i) {
    values[i] = 1.0 / (1 + std::exp(-std::sin(i * 0.001)));
  }

  double total = 0;

  for (double value : values) {
    total += value;
  }
  return total;
}

void time_it(double(*fn_ptr)(), const std::string& fn_name) {
  auto t1 = std::chrono::high_resolution_clock::now();
  auto rez = fn_ptr();
  auto t2 = std::chrono::high_resolution_clock::now();
  auto duration = std::chrono::duration_cast<std::chrono::microseconds>( t2 - t1 ).count();
  std::cout << fn_name << ", rez = " << rez << ", dur = " << duration << std::endl;
}

int main(int argc, char** argv) {
  std::string op(argv[1]);
  if (op == "std_for") {
    time_it(&std_for, op);
  } else if (op == "omp_for") {
    time_it(&omp_for, op);
  } else if (op == "tbb_for") {
    time_it(&tbb_for, op);
  } else if (op == "seq_for") {
    time_it(&seq_for, op);
  }
}

编译选项:

g++ --std=c++17 -O3 b.cpp -ltbb -I /usr/local/include -L /usr/local/lib -fopenmp

结果:

std_for, rez = 500106, dur = 11119
tbb_for, rez = 500106, dur = 7372
omp_for, rez = 500106, dur = 4781
seq_for, rez = 500106, dur = 27910

我们可以看到,std_forseq_for(顺序for循环)更快,但它仍然比tbbopenmp慢得多。

更新

如评论中所建议的,我单独运行每个for以公平对待。上述代码已更新,结果如下:

>>> ./a.out seq_for
seq_for, rez = 500106, dur = 29885

>>> ./a.out tbb_for
tbb_for, rez = 500106, dur = 10619

>>> ./a.out omp_for
omp_for, rez = 500106, dur = 10052

>>> ./a.out std_for
std_for, rez = 500106, dur = 12423

就像其他人所说的那样,连续运行4个版本是不公平的,与以前的结果相比。


5
如果您以不同的顺序调用各种方法,是否会获得类似的结果?可能会出现各个向量重复使用之前函数释放的内存的情况,这可能导致后续函数的缓存未命中次数减少。 - 1201ProgramAlarm
1
@VictorGubin 不,GCC上没有SIMD优化。首先,simd未被指定(尽管GCC通常不关心它)。此外,目前在GCC上应用矢量化需要遗憾地使用--fast-math(因为支持严格的IEEE-754合规性很困难)。实际上,在GCC上,矢量化是独立于OpenMP完成的。您可以在此处检查矢量化 - Jérôme Richard
1
在代码的同一次执行中运行所有这些方法可能会导致过度订阅(因为它们很可能各自创建自己的线程池)。另外,线程创建是昂贵的,因此你应该要么两次运行并计时第二个并行区域,要么运行一个空的并行操作(以启动线程),然后计时你真正的操作。 - Jim Cownie
1
第一个OpenMP并行区域较慢,因为它会启动线程团队。请始终在“热身”并行区域之后测量OpenMP程序的性能。我要求您切换调用,因为这将把启动开销从std_for()部分移动到omp_for()部分。 - Hristo Iliev
你有多少个CPU核心?为什么要使用 size_t n_par = 5lu;?如果你增加这个值会发生什么? - Dmytro Ovdiienko
显示剩余10条评论
3个回答

2

你已经发现了测量什么以及如何进行测量很重要。你最终的任务肯定与这个简单的练习有很大不同,而且并不能完全反映出在这里找到的结果。

除了缓存和预热会受到任务顺序的影响(你在更新的问题中明确研究了这一点)之外,在你的例子中还有另一个问题需要考虑。

真正重要的是实际的并行代码。如果这并不决定你的性能/运行时,那么并行化就不是正确的解决方案。但在你的例子中,你还测量了资源分配、初始化和最终计算。如果这些在你最终应用程序中导致了实际成本,那么再次说明并行化不是万能药。因此,为了进行公平比较并真正衡量实际并行代码的执行性能,建议按照以下方式修改代码(抱歉,我没有安装openmp)并继续你的研究:

#include <algorithm>
#include <cmath>
#include <chrono>
#include <execution>
#include <iostream>
#include <tbb/parallel_for.h>
#include <vector>

const size_t N = 10000000; // #1

void std_for(std::vector<double>& values, 
             std::vector<size_t> const& indices, 
             size_t const stride) {

  std::for_each(
      std::execution::par,
      indices.begin(),
      indices.end(),
      [&](size_t index) {
        int begin = index * stride;
        int end = (index+1) * stride;
        for (int i = begin; i < end; ++i) {
          values[i] = 1.0 / (1 + std::exp(-std::sin(i * 0.001)));
        }
      });
}

void tbb_for(std::vector<double>& values) {

  tbb::parallel_for(
      tbb::blocked_range<int>(0, values.size()),
      [&](tbb::blocked_range<int> r) {
        for (int i=r.begin(); i<r.end(); ++i) {
          values[i] = 1.0 / (1 + std::exp(-std::sin(i * 0.001)));
        }
      });

}

/*
double omp_for()
{
  auto values = std::vector<double>(N);

#pragma omp parallel for
  for (int i=0; i<values.size(); ++i) {
    values[i] = 1.0 / (1 + std::exp(-std::sin(i * 0.001)));
  }

  double total = 0;

  for (double value : values) {
    total += value;
  }
  return total;
}
*/

void seq_for(std::vector<double>& values)
{
  for (int i=0; i<values.size(); ++i) {
    values[i] = 1.0 / (1 + std::exp(-std::sin(i * 0.001)));
  }
}

void time_it(void(*fn_ptr)(std::vector<double>&), const std::string& fn_name) {
  std::vector<double> values = std::vector<double>(N);

  auto t1 = std::chrono::high_resolution_clock::now();
  fn_ptr(values);
  auto t2 = std::chrono::high_resolution_clock::now();
  auto duration = std::chrono::duration_cast<std::chrono::microseconds>( t2 - t1 ).count();

  double total = 0;
  for (double value : values) {
    total += value;
  }
  std::cout << fn_name << ", res = " << total << ", dur = " << duration << std::endl;
}

void time_it_std(void(*fn_ptr)(std::vector<double>&, std::vector<size_t> const&, size_t const), const std::string& fn_name) {
  std::vector<double> values = std::vector<double>(N);

  size_t n_par = 5lu;  // #2
  auto indices = std::vector<size_t>(n_par);
  std::iota(indices.begin(), indices.end(), 0lu);
  size_t stride = static_cast<size_t>(N / n_par) + 1;
  
  auto t1 = std::chrono::high_resolution_clock::now();
  fn_ptr(values, indices, stride);
  auto t2 = std::chrono::high_resolution_clock::now();
  auto duration = std::chrono::duration_cast<std::chrono::microseconds>( t2 - t1 ).count();

  double total = 0;
  for (double value : values) {
    total += value;
  }
  std::cout << fn_name << ", res = " << total << ", dur = " << duration << std::endl;
}



int main(int argc, char** argv) {
  std::string op(argv[1]);
  if (op == "std_for") {
    time_it_std(&std_for, op);
    //  } else if (op == "omp_for") {
    //time_it(&omp_for, op);
  } else if (op == "tbb_for") {
    time_it(&tbb_for, op);
  } else if (op == "seq_for") {
    time_it(&seq_for, op);
  }
}

在我的(较慢的)系统上,结果如下:
  • std_for,res = 5.00046e + 06,dur = 66393
  • tbb_for,res = 5.00046e + 06,dur = 51746
  • seq_for,res = 5.00046e + 06,dur = 196156
我注意到这里从seq_for到tbb_for的差异进一步增加了。现在大约是4倍,而在您的示例中看起来更像3倍。 std_for仍然比tbb_for慢20..30%。 但是,还有其他参数。将N(参见#1)增加了10倍(好吧,这不是很重要),并将n_par(参见#2)从5增加到100(这很重要)后,结果为
  • tbb_for,res = 5.00005e + 07,dur = 486179
  • std_for,res = 5.00005e + 07,dur = 479306
在这里,std_for与tbb_for相当!
因此,回答您的问题:我肯定不会立即放弃c ++ 17标准并行化。

1
也许你已经知道了,但我在这里没有看到提到的一件事是(至少对于gcc和clang),PSTL实际上是使用/后端由TBB、OpenMP(目前只有在clang上,我相信)或其顺序版本实现的。我猜你正在使用libc++,因为你在Mac上;据我所知,至少对于Linux,LLVM发行版不带有启用PSTL的功能,如果从源代码构建PSTL和libcxx/libcxxabi,则默认为顺序后端。

https://github.com/llvm/llvm-project/blob/main/pstl/CMakeLists.txt

https://github.com/gcc-mirror/gcc/blob/master/libstdc%2B%2B-v3/include/pstl/pstl_config.h


0
  1. OpenMp适用于直接的并行编码。
  2. 另一方面,TBB使用工作窃取机制,可以为不平衡和嵌套循环提供更好的性能。
  3. 我更喜欢在复杂和嵌套并行性方面使用TBB而不是OpenMP。(OpenMP对于嵌套并行性有巨大的开销)

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接