【问题标题】:OpenMP code far slower than serial - memory or thread overhead bottleneck?OpenMP 代码比串行内存或线程开销瓶颈慢得多?
【发布时间】:2015-11-04 00:16:49
【问题描述】:

我正在尝试并行化 (OpenMP) 一些科学 C++ 代码,其中大部分 (>95%) CPU 时间用于计算 N~ 阶的讨厌(且不可避免)O(N^2) 交互200 种不同的粒子。此计算重复 1e10 个时间步长。我用 OpenMP 尝试了各种不同的配置,每一个都比串行代码慢一些(至少一个数量级),随着添加额外的内核,扩展性很差。

下面是相关代码的草图,具有代表性的虚拟数据层次结构Tree->Branch->Leaf。每个Leaf 对象都存储自己的位置和速度,用于当前和前三个时间步长等。然后每个Branch 存储Leaf 对象的集合,每个Tree 存储Branch 对象的集合。这种数据结构非常适用于复杂但 CPU 密集度较低的计算,这些计算也必须在每个时间步执行(需要数月才能完善)。

#include <omp.h>

#pragma omp parallel num_threads(16) // also tried 2, 4 etc - little difference - hoping that placing this line here spawns the thread pool at the onset rather than at every step
{
while(i < t){
    #pragma omp master
    {
       /* do other calculations on single core, output etc.  */
       Tree.PreProcessing() 
       /* PreProcessing can drastically change data for certain conditions, but only at 3 or 4 of the 1e10 time steps */
       Tree.Output()
    }
    #pragma omp barrier
    #pragma omp for schedule(static) nowait
    for(int k=0; k < size; k++){
         /* do O(N^2) calc that requires position of all other leaves */
         Tree.CalculateInteraction(Branch[k]) 
    }
    /* return to single core to finish time step */
    #pragma omp master
    {
        /* iterate forwards */
        Tree.PropagatePositions()
        i++
    }
    #pragma omp barrier
}

简单地说,CPU-hog 函数是这样做的:

void Tree::CalculateInteraction(Leaf* A){
// for all branches B in tree{
       // for all leaves Q in B{
          if(condition between A and Q){skip}
          else{
                // find displacement D of A and Q 
                // find displacement L of A and "A-1"
                // take cross product of the two displacements
                // add the cross-product to the velocity of leaf A 
                for(int j(0); j!=3; j++){
                    A->Vel[j] += constant * (D_cross_L)[j];
                }

我的问题是,这种性能下降是由于 openMP 线程管理开销占主导地位,还是因为数据层次结构设计时没有考虑并行性?

我应该注意,每个步骤的并行时间都比串行时间长得多,这不是一些初始化开销问题;这两个版本已针对需要 1 小时和 10 小时的计算进行了测试,最终希望应用于可能需要 30 小时的串行计算(即使是 2 倍的加速也是非常有益的)。此外,可能值得知道我正在使用带有-fopenmp -march=native -m64 -mfpmath=sse -Ofast -funroll-loops 的g++ 5.2.0。

我是 OpenMP 的新手,因此非常感谢任何提示,如果有任何需要澄清的地方,请告诉我。

【问题讨论】:

  • 树形结构的组织精确度如何?子节点是连续数组还是链表(即父节点只有指向第一个子节点的指针,每个子节点都指向她的妹妹)?我经常使用并行八叉树(用于 N 体模拟和 SPH),但总能找到出色的缩放比例。我也很好奇你需要 O(N^2) 来做什么——这通常分别通过 O(NlnN) 或 O(N) 中的树或 FMM 来近似。
  • @Walter 叶子存储在一个双向链表中,指向分支的前一个和下一个叶子(可以在内存中的“任何地方”找到)。树是这些特定类型计算的标准选择(超流氦的涡流灯丝模型,参见例如this review),但由于这个项目的时间限制,我正在寻找一个更快的解决方案来运行一组长计算.
  • @Walter 我猜你使用的是连续数组?但是您确切地知道您有多少个子节点。如果您不知道并且必须使用列表,那么内存访问可能真的会扰乱并行扩展。我想知道每个线程分配器是否会有所帮助。
  • @emsr,这仅适用于天真的程序员。看我的回答。

标签: c++ multithreading performance parallel-processing openmp


【解决方案1】:

性能测量工具(如 Linux perf)可能会为您提供有关缓存性能或争用的一些信息;优化的第一步是测量!

也就是说,我的猜测是,这是一个与速度更新的实现相结合的数据布局问题:每个线程在任何给定时间都试图加载与(基本上)随机叶相关联的数据,这是一个配方用于缓存抖动。与叶子关联的数据有多大,它们在内存中是否相邻排列?

如果它确实是一个缓存问题(做测量!)那么它很可能通过平铺 N^2 问题来解决:而不是累积所有其他叶子贡献的速度增量,它们可以分批累积。为了计算的目的,考虑将 N 个叶子分成 K 个批次,其中每批叶子数据适合(比如说)一半的缓存。然后迭代K^2对(A,B)的batch,执行交互步骤,也就是计算batch B中所有叶子对batch A中叶子的贡献,应该可以并行完成遍历 A 中的叶子而不破坏缓存。

确保叶子在内存中按批次连续排列可以获得更多收益。

【讨论】:

  • 谢谢,我实际上是想要求一个合适的分析器,因为 gprof 很短,而且英特尔的 vtune 不支持我的内核。由于预处理过程中发生的变化,叶子在内存中不一定是连续的。我一定会尝试您的第一个建议,然后可能会考虑更改预处理以确保在内存中进行更有用的安排。
  • 在平铺问题以在每个叶子上仅更新一次速度后,在四个内核上运行,perf 报告的缓存未命中占所有缓存引用的 ~0.164%,每个周期挤出 1.49 条指令.串行代码丢失了近 48% 的缓存引用(但总体缓存引用减少了 400 倍),并设法在每个周期产生 1.83 条指令,在四分之一的时间内完成计算。
  • 感谢您的反馈!我对你实现的平铺的描述有点困惑:你如何安排每个叶子只有一个更新,而不迭代所有其他叶子并累积?不过,在整体性能方面,perf 是否发现了串行和并行版本之间的任何其他主要差异?
  • 请原谅,我误读了您的原始答案。我的“平铺”(根本不是平铺)包括仍然迭代所有点并将速度累积到每个核心给定点的临时向量中,仅在函数结束时更新速度;这只是稍微提高了性能。
  • 我自己也很困惑;如果这是一个缓存问题,那么性能(以及缓存研磨)肯定会报告更多(超过
【解决方案2】:

您的问题很可能是虚假共享,因为您对节点使用了链接列表。使用这种内存布局,您不仅几乎每次将树走到另一个节点时都会遇到缓存未命中的问题(如 halfflat 所述)。

一个更严重的问题是,从不同线程访问和修改的树节点实际上可能在内存中很接近。如果它们共享一条缓存线,这意味着 错误共享(或 cache ping-pong)会导致不同线程之间共享的缓存线重复重新同步。

解决这两个问题的方法是避免链接数据结构。它们几乎总是导致效率低下的原因。在您的情况下,解决方案是首先使用最少的数据构建一个链表树(只有那些需要定义树的数据),然后将其映射到另一个不使用链表并且可能包含更多数据的树。这就是我所做的,树的遍历速度相当快(树的遍历永远不会很快,因为即使有连续的姐妹节点,缓存未命中也是不可避免的,因为父子访问不能同时连续)。如果按照旧树的顺序将粒子添加到新树中,则树的构建可以获得显着的加速(因子>2)(这样可以避免缓存未命中)。

【讨论】:

  • 这很有帮助,谢谢。正如我在对 halfflat 答案的评论中所述,我发现在并行情况下与串行代码相比,缓存未命中率相对较低。
  • 澄清一下,您是否建议在每个时间步创建/更新仅包含计算所需位置的树,然后由交互功能使用?仍然存在每个叶子的计算必须跳过它自己和它的单个邻居的位置的问题,因此推测错误共享问题仍然存在。我将阅读更多关于虚假分享的内容,现在我有了它的名称,感谢您的帮助。
  • 所以拓扑树的节点具有 a) 指向父节点 b) 指向子链表的第一个节点 c) 指向数据树中节点的指针? Q1:您是否担心将数据推送到您的数据树中有时可能会导致指针重新分配和失效? Q2:如果你有一个解决方案,为什么数据树需要是一棵树?可以是向量吗?
  • 也许拓扑树存储向量索引而不是指针或迭代器。也许数据的有序序列化将有助于父子连续问题。我会想。 R P0 C00 C01 C02 P1 C10 C11... 会增加 P0 C00 C01 C02 在同一条线上的可能性..
  • @emsr。正如我所解释的,我映射到一棵树,其中所有节点的数据在内存中是连续的:一个 vector 用于索引到父节点,另一个用于索引到第一个子节点,另一个用于子节点的数量(子节点相邻)等。我还区分了 cell 节点(对应于具有子节点的立方体)和 leaf 节点(对应于单个对象/粒子)并具有这些不同的vectors。包含在任何 cell 节点中的所有 leaf 节点在内存中按照空间填充曲线的顺序是连续的,以便有效处理邻居。
【解决方案3】:

感谢您提供原始来源的链接!我已经能够在两个平台上编译并获得一些统计数据:具有 icpc 15.0 和 g++ 4.9.0 的 Xeon E5-2670;在 Core i7-4770 上,使用 g++ 4.8.4。

在 Xeon 上,icpc 和 g++ 生成的代码都随线程数量而变化。我运行了一个从发行版中的 run.in 文件派生的缩短(3e-7 秒)模拟:

Xeon E5-2670 / icpc 15.0
threads   time   ipc
---------------------
1         17.5   2.17
2         13.0   1.53
4          6.81  1.53
8          3.81  1.52

Xeon E5-2670 / g++ 4.9.0
threads   time   ipc
---------------------
1         13.2   1.75
2          9.38  1.28
4          5.09  1.27
8          3.07  1.25

在 Core i7 上,我确实看到了您在 g++ 4.8.4 中观察到的丑陋缩放行为:

Core i7-4770 / g++ 4.8.4
threads   time   ipc
---------------------
1          8.48  2.41
2         11.5   0.97
4         12.6   0.73

第一个观察结果是某些特定于平台的因素会影响缩放。

我查看了point.h 和velnl.cpp 文件,注意到您使用vector&lt;double&gt; 变量来存储3-d 矢量数据,包括许多临时数据。这些都将访问堆,并且是潜在的争用源。 Intel 的 openmp 实现使用线程局部堆来避免堆争用,也许 g++ 4.9 也这样做,而 g++-4.8.4 没有?

我分叉了项目(halfflat/vfmcppar 在 github 上)并修改了这些文件以将 std::array&lt;double,3&gt; 用于这些 3-d 向量;这可以恢复缩放,并提供更快的运行时间:

Core i7-4770 / g++ 4.8.4
std::array implementation
threads   time   ipc
---------------------
1          1.40  1.54
2          0.84  1.35
4          0.60  1.11

我没有在相当长的模拟上运行这些测试,因此由于设置和 i/o 开销,一些缩放很可能会丢失。

要点是任何共享资源都会阻碍可伸缩性,包括堆。

【讨论】:

  • 哇,这太棒了,感谢您付出所有这些努力。我之前的运行是在 4670K 和 4770 上运行,但使用的是 g++ 5.1.0。我想我在某个地方仍然有 ipc 的许可证...我这周不在,但期待测试您的更改。
【解决方案4】:

可能与性能无关,但现在编写的代码具有奇怪的并行化结构。

我怀疑它能否产生正确的结果,因为parallel 内的while 循环没有障碍(omp master 没有障碍,omp for nowait 也没有障碍)。

因此,(1)线程可能在主线程完成Tree.PreProcessing()之前启动omp for循环,一些线程实际上可能在主线程进行单个预处理步骤之前执行omp for任意次数; (2) master 可以在其他线程完成omp for 之前运行Tree.PropagatePositions(); (3) 不同的线程可能运行不同的时间步长; (4) 理论上,主线程可能在某些线程进入并行区域之前就完成了while循环的所有步骤,因此omp for循环的某些迭代可能永远不会执行。

还是我错过了什么?

【讨论】:

  • 你可能是对的,我认为主块的末尾有一个隐含的障碍,但更仔细地查看文档似乎必须添加它。话虽如此,也许它与编译器有关,因为此代码会产生正确的结果(如果您的建议正在发生,它们将是非常严重的错误)。一些值得测试的东西,我将编辑我的问题,以消除任何绊倒这个问题的人的困惑。
  • 我认为,在外部 while 循环中使用 omp parallel 而不是在内部循环中简单地使用 omp parallel for 并没有什么好处。 (这是我在 github 上的 fork 中使用的。)
猜你喜欢
  • 1970-01-01
  • 2021-07-13
  • 1970-01-01
  • 2013-10-09
  • 2021-03-27
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-10-11
相关资源
最近更新 更多