如何提高OpenMP代码的性能?

How can I improve the perfomance of my OpenMP code?

本文关键字:性能 代码 何提高 OpenMP      更新时间:2023-10-16

我目前正在努力提高我的代码并行性能,我仍然是OpenMP的新手。我必须在一个大容器上迭代,在每次迭代中从多个条目读取并将结果写入单个条目。下面是我试图做的一个非常简单的代码示例。

data是一个指向数组的指针,其中存储了大量数据点。在并行区域之前,我创建了一个数组newData,因此可以使用data作为只读,newData作为只写,之后我扔掉了旧的data,并使用newData进行进一步的计算。根据我的理解,datanewData在线程之间共享,并且在并行区域内声明的所有内容都是私有的。从data读取多个线程会导致性能问题吗?

我使用#criticalnewData的元素分配一个新值,以避免竞争条件。这是必要的,因为我访问newData的每个元素只有一次,从来没有通过多个线程?

我也不确定日程安排。我是否需要指定staticdynamic时间表?我可以使用nowait,因为所有的线程是相互独立的?

array *newData = new array;
omp_set_num_threads (threads);
#pragma omp parallel
{
    #pragma omp for
    for (int i = 0;  i < range; i++)
    {
        double middle = (*data)[i];
        double previous = (*data)[i-1];
        double next = (*data)[i+1];
        double new_value = (previous + middle + next) / 3.0;
        #pragma omp critical(assignment)
        (*newData)[i] = new_value;
    }
}
delete data;
data = newData;

我知道在第一次和最后一次迭代previousnext不能从data中读取,在实际代码中这是考虑到的,但对于这个最小的例子,你得到了从data读取多次的想法。

首先,摆脱所有不必要的依赖关系。#pragma omp critical(assignment)不是必需的,因为(*newData)的每个索引每个循环只写一次,所以没有竞争条件。

你的代码现在看起来像这样:

#pragma omp parallel for
for (int i = 0; i < range; i++)
   (*newData)[i] = ((*data)[i-1] + (*data)[i] + (*data)[i+1]) / 3.0;

现在我们要寻找瓶颈。我想到的潜在候选人名单是这样的:

    <
  • 缓慢部门/gh>
  • 缓存抖动
  • ILP(指令级并行)
  • 内存带宽限制
  • <
  • 隐藏依赖/gh>

让我们进一步分析一下。

缓慢部门:计算double/double需要一些cpu。要了解CPU的运行时间和吞吐量,必须查看其规格。也许用*0.3333..替换/3.0可能会有所帮助,但也许您的编译器已经这样做了。使用扩展指令集(如SSE/AVX),您可以一次安排多个除法/乘法。

缓存不足:因为你的CPU必须一次加载/存储一条缓存线,所以可能会有冲突。想象一下,如果线程1试图写(*newdata)[1],线程2试图写(*newdata)[2],它们在同一缓存行上。现在其中一个要等另一个。您可以使用#pragma omp parallel for schedule(static, 64)来解决这个问题。

独立

: 如果多个操作是独立的,cpu可以将这些操作调度到一个管道中。要做到这一点,你必须展开你的循环。可以像这样:

assert(range % 4 == 0);
#pragma omp parallel for
for (int i = 0; i < range/4; i++) {
   (*newData)[i*4+0] = ((*data)[i*4-1] + (*data)[i*4+0] + (*data)[i*4+1]) / 3.0;
   (*newData)[i*4+1] = ((*data)[i*4+0] + (*data)[i*4+1] + (*data)[i*4+2]) / 3.0;
   (*newData)[i*4+2] = ((*data)[i*4+1] + (*data)[i*4+2] + (*data)[i*4+3]) / 3.0;
   (*newData)[i*4+3] = ((*data)[i*4+2] + (*data)[i*4+3] + (*data)[i*4+4]) / 3.0;
}

内存带宽限制:对于你的简单循环,想想这个。你需要加载多少内存,你的CPU要花多长时间来处理它。你加载了大约1条缓存线,计算了一些解引用,一些指针加法,两次加法和一次除法。您达到的限制取决于您的CPU规格。现在考虑缓存局部性。您可以修改代码以更好地利用缓存吗?如果一个线程在一次循环迭代中获得i=3,而在下一次循环迭代中获得i=7,则必须重新加载3 (*data)。但是如果要从i=3到i=4,则可能不需要加载任何内容,因为(*data)[i+1]在先前加载的缓存中。你节省了一些RAM带宽。要利用这一点,请展开循环。使用float代替double也会增加这个几率。

隐藏的依赖关系:这部分我个人觉得很棘手。有时你的编译器不关闭,它可以重用一些数据,因为它不知道它没有改变。使用const有助于编译器。但有时你需要一个restrict来给编译器正确的提示。但我不太了解这一点,无法解释它。

下面是我要做的:

const double ONETHIRD = 1.0 / 3.0;
assert(range % 4 == 0);
#pragma omp parallel for schedule(static, 1024)
for (int i = 0; i < range/4; i++) {
   (*newData)[i*4+0] = ((*data)[i*4-1] + (*data)[i*4+0] + (*data)[i*4+1]) * ONETHIRD;
   (*newData)[i*4+1] = ((*data)[i*4+0] + (*data)[i*4+1] + (*data)[i*4+2]) * ONETHIRD;
   (*newData)[i*4+2] = ((*data)[i*4+1] + (*data)[i*4+2] + (*data)[i*4+3]) * ONETHIRD;
   (*newData)[i*4+3] = ((*data)[i*4+2] + (*data)[i*4+3] + (*data)[i*4+4]) * ONETHIRD;
}

然后是基准测试。更多的基准,更多的基准。只有基准测试才能告诉你哪些技巧有帮助。

PS:还有一件事要考虑。如果您发现您的程序严重占用内存带宽。你可以考虑改变算法。也许可以把两个步骤合二为一。比如从b[i] := (a[i-1] + a[i] + a[i+1]) / 3.0d[i] := (n[i-1] + n[i] + n[i+1]) / 3.0 = (a[i-2] + 2.0 * a[i-1] + 3.0 * a[i] + 2.0 * a[i+1] + a[i+1]) / 3.0。我想原因你自己会发现的。

祝你优化得愉快;-)

    在多个线程中读取一个数组通常是无害的。
  1. 你只需要一个临界区,如果多个线程工作在完全相同的数据块,这里每个线程访问数组的不同部分,所以你不需要它。临界区对性能非常不利,所以只有在绝对必要时才使用它们。通常它们可以被原子动作取代:openMP,原子还是临界?像临界区一样,如果每个线程访问不同的数据,它们就没有意义了。
  2. 对于调度器,最好对它们分别进行测试并测量性能,因为对性能的预测通常是错误的。也可以尝试不同的块大小。
  3. 其他一些可能有帮助的事情:
    • 测量性能经常受到pc上其他任务的干扰,因此进行多次测量并取最小值(除非每次输入不同,然后取平均值并进行更多测量)。
    • 你真的需要双精度吗?浮动更快。
  4. 编辑:nowait是为多个独立的for循环:https://msdn.microsoft.com/en-us/library/ek5st0e3.aspx

我假设你正在尝试用1D数组做某种卷积或中值模糊。简短的回答是:坚持默认的时间表策略,并摆脱关键。

正如我所知,你是并行的新手,处理OpenMP指令有点混乱,比如nowait/private/reduction/critical/atomic/single等。我认为你需要的是一本写得好的教科书来阐明各种概念。如果您有一定的基础知识,那么学习OpenMP一个小时就足以处理大多数日常编程。