【问题标题】:C++ parallel average matrix with OpenMP使用 OpenMP 的 C++ 并行平均矩阵
【发布时间】:2017-07-08 18:30:12
【问题描述】:

我在 C++ 中有这段代码,用于计算矩阵每一列的平均值。我想使用 OpenMP 并行化代码。

#include <vector>
#include <cstdlib>
#include <chrono>
#include <iostream>
using namespace std;

vector<double> average(const vector<vector<unsigned char>>& original){
  vector<vector<double>> result(original.size(), vector<double>(original[0].size()));
  vector<double> average(original[0].size(), 0.0);

  for (int i=0; i<original.size(); i++) {
    const vector<unsigned char>& vector = original[i];
    for (int k = 0; k < vector.size(); ++k) {
      average[k] += vector[k];
    }
  }
  for (double& val : average) {
    val /= original.size();
  }

  return average;
}

在外部 for 循环之前添加 #pragma omp parallel for 会让我得到虚假结果。你有什么指示吗?我以为我会在网上找到很多这样的例子,但找不到太多。这是我第一次使用 OpenMP。

【问题讨论】:

  • 坦率地说,这看起来更像是 SIMD 而非线程的工作。您的优化器的循环矢量化器可能已经做得很好了。用线程击败它会很困难。
  • 在涉及线程之前我要看的另一件事是将值累积为整数,并且只在最后的除法步骤中将它们提升为双倍。
  • 您使用的矩阵尺寸是多少?
  • @Zulan 我一直在用大约 20.000 列和 500 行的矩阵进行实验。

标签: c++ matrix parallel-processing openmp average


【解决方案1】:

Frank 说得对,您的直接问题可能是您使用的是非原子操作:

average[k] += vector[k];

您可以使用以下方法解决此问题:

#pragma omp atomic
average[k] += vector[k];

但一个更大的概念问题是这可能不会加快您的代码速度。您正在执行的操作非常简单,并且您的内存(至少是行)是连续的。

确实,我为您的代码制作了一个最小工作示例(您应该为您的问题这样做):

#include <vector>
#include <cstdlib>
#include <chrono>
#include <iostream>
using namespace std;

vector<float> average(const vector<vector<unsigned char>>& original){
  vector<float> average(original[0].size(), 0.0);

  #pragma omp parallel for
  for (int i=0; i<original.size(); i++) {
    const vector<unsigned char>& vector = original[i];
    for (int k = 0; k < vector.size(); ++k) {
      #pragma omp atomic
      average[k] += vector[k];
    }
  }
  for (float& val : average) {
    val /= original.size();
  }

  return average;
}

int main(){
  vector<vector<unsigned char>> mat(1000);
  for(int y=0;y<mat.size();y++)
  for(int x=0;x<mat.size();x++)
    mat.at(y).emplace_back(rand()%255);

  std::chrono::steady_clock::time_point begin = std::chrono::steady_clock::now();
  double dont_optimize = 0;
  for(int i=0;i<100;i++){
    auto ret = average(mat);
    dont_optimize += ret[0];
  }
  std::chrono::steady_clock::time_point end = std::chrono::steady_clock::now();

  std::cout<<"Time = "<<(std::chrono::duration_cast<std::chrono::microseconds>(end - begin).count()/100)<<std::endl;

  return 0;
}

使用 g++ -O3 temp.cpp -fopenmp 编译它会启用 OpenMP。我的四核机器上的运行时间始终约为 10,247 微秒。当我禁用 OpenMP 时,运行时间约为 2,561 微秒。

启动和管理线程团队的成本很高。

但是有一个真正的方法可以加速你的代码:改善你的内存布局。

使用std::vector&lt; std::vector&lt;T&gt; &gt; 设计意味着每个vector&lt;T&gt; 可以位于内存中的任何位置。相反,我们希望我们所有的记忆都很好且连续。我们可以通过使用平面数组索引来实现这一点,如下所示:

(请注意,使用了以下代码的早期版本,例如,mat.at(y*width+x)。与使用 mat[y*width+x] 相比,检查此范围意味着会导致速度显着下降,就像现在的代码一样。时间已适当更新.)

#include <vector>
#include <cstdlib>
#include <chrono>
#include <iostream>
using namespace std;

class Matrix {
 public:
  vector<unsigned char> mat;
  int width;
  int height;
  Matrix(int width0, int height0){
    width  = width0;
    height = height0;
    for(int i=0;i<width*height;i++)
      mat.emplace_back(rand()%255);
  }
  unsigned char& operator()(int x, int y){
    return mat[y*width+x];
  }
  unsigned char operator()(int x, int y) const {
    return mat[y*width+x];
  }
  unsigned char& operator()(int i){
    return mat[i];
  }
  unsigned char operator()(int i) const {
    return mat[i];
  }
};

vector<float> average(const Matrix& original){
  vector<float> average(original.width, 0.0);

  #pragma omp parallel for
  for(int y=0;y<original.height;y++)
  for(int x=0;x<original.width;x++)
    #pragma omp atomic
    average[x] += original(x,y);

  for (float& val : average) 
    val /= original.height;

  return average;
}

int main(){
  Matrix mat(1000,1000);

  std::cerr<<mat.width<<" "<<mat.height<<std::endl;

  std::chrono::steady_clock::time_point begin = std::chrono::steady_clock::now();
  double dont_optimize = 0;
  for(int i=0;i<100;i++){
    auto ret = average(mat);
    dont_optimize += ret[0];
  }
  std::chrono::steady_clock::time_point end = std::chrono::steady_clock::now();

  std::cout<<"Time = "<<(std::chrono::duration_cast<std::chrono::microseconds>(end - begin).count()/100)<<std::endl;

  return 0;
}

请注意,我还使用float 而不是double:您可以通过这种方式将两倍的数字塞入相同数量的空间,这有利于缓存。

这使得没有 OpenMP 的运行时间为 292 微秒,而使用 OpenMP 的运行时间为 9426 微秒。

总之,使用 OpenMP/并行性会减慢您的代码速度,因为并行完成的工作比设置并行性花费的时间更少,但使用更好的内存布局可以提高大约 90% 的速度。此外,使用我创建的方便的 Matrix 类可以提高代码的可读性和可维护性。

编辑

在 10,000x10,000 而不是 1,000x1,000 的矩阵上运行此程序会得到类似的结果。对于向量的向量:不使用 OpenMP 为 7,449 微秒,使用 OpenMP 为 156,316 微秒。对于平面数组索引:不使用 OpenMP 为 32,668 微秒,使用 OpenMP 为 145,470 微秒。

性能可能与我机器上可用的硬件指令集有关(特别是,如果我的机器缺少原子指令,那么 OpenMP 将不得不使用互斥锁等来模拟它们)。实际上,在使用-march=native 编译的平面阵列示例中,OpenMP 的性能得到了改进,尽管仍然不是很好:没有 OpenMP 时为 33,079 微秒,使用 OpenMP 时为 127,841 微秒。稍后我会尝试更强大的机器。

编辑

虽然上述测试是在 Intel(R) Core(TM) i5 CPU M 480 @ 2.67GHz 上执行的,但我已经在坏蛋 Intel(R) Xeon(R) CPU 上编译了这段代码(使用 -O3 -march=native) E5-2680 v3 @ 2.50GHz。结果相似:

  • 1000x1000,向量的向量,没有 OpenMP:145μs
  • 1000x1000,向量的向量,使用 OpenMP:2,941μs
  • 10000x10000,向量的向量,没有 OpenMP:20,254μs
  • 10000x10000,向量的向量,使用 OpenMP:85,703μs
  • 1000x1000,平面阵列,无 OpenMP:139μs
  • 1000x1000,平面阵列,使用 OpenMP:3,171μs
  • 10000x10000,平面阵列,无 OpenMP:18,712μs
  • 10000x10000,平面阵列,使用 OpenMP:89,097μs

这证实了我们之前的结果:使用 OpenMP 执行此任务往往会减慢速度,即使您的硬件非常出色。事实上,这两个处理器之间的大部分加速可能是由于 Xeon 的大型 L3 缓存大小:30,720K 它比 i5 上的 3,720K 缓存大 10 倍。

编辑

从他们的回答中结合Zulan's reduction strategy 可以让我们有效地利用并行性:

vector<float> average(const Matrix& original){
  vector<float> average(original.width, 0.0);
  auto average_data = average.data();

  #pragma omp parallel for reduction(+ : average_data[ : original.width])
  for(int y=0;y<original.height;y++){
    for(int x=0;x<original.width;x++)
      average_data[x] += original(x,y);
  }

  for (float& val : average) 
    val /= original.height;

  return average;
}

对于 24 个线程,这在 10,000x10,000 阵列上提供了 2629 微秒:比串行版本提高了 7.1 倍。在您的原始代码上使用 Zulan 的策略(没有平面数组索引)可以得到 3529 微秒,因此通过使用更好的布局,我们仍然可以获得 25% 的加速。

【讨论】:

  • 哇,非常感谢你这么详细的解释!我以为我会轻松获得很多性能,但显然不是。我将尝试进行一些实验,看看我是否可以在巨大的矩阵上获得收益。
  • 这个答案的结论严重偏向于小矩阵的假设。
  • @Zulan:结论的部分。无论矩阵大小如何,平面数组索引都会更快。无论如何,我现在对 10,000x10,000 矩阵进行测试并发现相同的结果:我假设这是由于原子的同步效应。欢迎您将尺寸调大,看看是否有不同的发现。
  • 我发布了一个补充答案,解释了如何通过线程实际加快速度。我很确定您的性能问题源于在Mat::operator() 实现中使用运行时检查的.at 而不是operator[]。通常,编译器会为具有这种访问权限的循环生成合理的代码,您不必手动执行连续遍历。
  • @Zulan:使用operator[] 代替.at() 确实提高了速度;我已经相应地修改了时间。我认为编译器仍在生成次优代码,因为它们使用的是vaddss 而不是vaddps
【解决方案2】:

弗兰克和理查德有基本的问题权利。关于内存布局的提示也是正确的。但是,它可能比使用 atomic 做得更好。不仅原子数据访问非常昂贵,而且通过从所有线程写入完全共享的内存空间,缓存性能会大大下降。因此,只有原子增量的并行循环可能无法很好地扩展。

减少

基本思想是首先计算一个局部和向量,然后再安全地将这些向量相加。这样,大部分工作都可以独立高效地完成。最近的 OpenMP 版本让执行此操作非常方便。

这是基于 Richards 示例的示例代码 - 但是我确实交换了索引并修复了 operator() 效率。

#include <chrono>
#include <cstdlib>
#include <iostream>
#include <memory>
#include <vector>

class Matrix {
public:
  std::vector<unsigned char> mat;
  int width;
  int height;
  Matrix(int width0, int height0) {
    srand(0);
    width = width0;
    height = height0;
    for (int i = 0; i < width * height; i++)
      mat.emplace_back(rand() % 255);
  }
  unsigned char &operator()(int row, int col) { return mat[row * width + col]; }
  unsigned char operator()(int row, int col) const {
    // do not use at here, the extra check is too expensive for the tight loop
    return mat[row * width + col];
  }
};

std::vector<float> __attribute__((noinline)) average(const Matrix &original) {
  std::vector<float> average(original.width, 0.0);
  // We can't do array reduction directly on vectors
  auto average_data = average.data();

  #pragma omp parallel reduction(+ : average_data[ : original.width])
  {
    #pragma omp for
    for (int row = 0; row < original.height; row++) {
      for (int col = 0; col < original.width; col++) {
        average_data[col] += original(row, col);
      }
    }
  }
  for (float &val : average) {
    val /= original.height;
  }
  return average;
}

int main() {
  Matrix mat(500, 20000);

  std::cerr << mat.width << " " << mat.height << std::endl;

  std::chrono::steady_clock::time_point begin = chrono::steady_clock::now();
  double dont_optimize = 0;
  for (int i = 0; i < 100; i++) {
    auto ret = average(mat);
    dont_optimize += ret[0];
  }
  std::chrono::steady_clock::time_point end = std::chrono::steady_clock::now();

  std::cout << "Time = "
            << (std::chrono::duration_cast<std::chrono::microseconds>(end-begin).count() / 100.)
            << "\n" << optimize << std::endl;
  return 0;
}

对于您给定的矩阵大小,这将在 2.5 GHz 标称频率的 Intel Xeon E5-2680 v3 上使用 12 个线程将时间从 ~1.8 ms 减少到 ~0.3 ms。

切换循环

或者,您可以并行化内部循环,因为它的迭代彼此独立。但是,由于每个线程的工作量很小,这会更慢。然后您可以交换内部和外部循环,但这会使内存访问不连续,这也对性能不利。所以最好的方法是像这样拆分内部循环:

constexpr size_t chunksize = 128;
#pragma omp parallel for
for (size_t col_chunk = 0; col_chunk < original.width; col_chunk += chunksize) {
  for (size_t row = 0; row < original.height; row++) {
    const auto col_end = std::min(col_chunk + chunksize, original.width);
    for (size_t col = col_chunk; col < col_end; col++) {

这为您提供了合理的连续内存访问,同时避免了线程之间的所有交互。但是,线程的边界仍然可能存在一些错误的共享。我无法轻松获得非常好的性能,但它仍然比具有足够线程数的串行更快。

【讨论】:

  • 这很好 - 谢谢。从这里出发的唯一方法是让 AVX 正常工作。
【解决方案3】:

average[k] += vector[k]; 不是原子操作。

每个线程可能(并且可能会)读取 k 的当前值(可能同时),添加到它,然后将值写回。

这些类型的跨线程数据竞争是未定义的行为。

编辑: 一个简单的解决方法是反转循环的顺序,并在k 循环上并行化。这样,每个线程将只写入一个值。但是随后,您将在顶级向量上的查找次数乘以 K,因此您可能不会获得那么大的性能提升,因为您将开始非常难以处理缓存。

【讨论】:

    猜你喜欢
    • 2013-05-18
    • 1970-01-01
    • 2014-05-03
    • 2012-05-30
    • 2016-07-04
    • 2021-12-07
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多