调度您自己的循环
在前一部分中,我们介绍了持续时间可变的循环体如何阻碍您的循环实现较好的加速比。如果碰到此问题,您将如何解释?您可能已经知道代码中何处可能出现此问题,但也可使用英特尔线程性能分析器观察到此位置。该分析器将提供一张图片,显示您的代码使用最大线程数运行的频率。我们希望线程尽可能的保持平衡,从而使每个线程运行的工作量大致相同。这使我们可以接近代码的理想加速比。
持续时间可变的循环体会导致代码不平衡。很难预测此类代码的运行时行为。更糟糕的是,由于运行时的行为不断变化,因此无法使用静态的解决方案。
默认情况下,OpenMP 通过将此代码拆分为几个相等的块,然后在各自的线程上运行它们来调度循环。默认的块足够大,以致当所有的线程在它们当中运行一遍的时候该循环体运行结束。
幸运的是,OpenMP 为我们提供了几个调度选项。通过将 schedule (static[, chunk_size])、schedule(dynamic[, chunk_size)) 或 schedule(guided[, chunk_size)) 添加到 OpenMP 指令,可以控制调度各个线程的方式。使用正确的调度方法对代码进行试验。
对于具有可变循环的代码,例如上文中动画变化的示例,可能使用动态调度代码才能实现最佳性能。进行试验直至为代码找到正确的 chunk_size。在某些情况下,如果将运行时间最长的部分移到循环的开始,则可以获得最佳性能。
让我们再看看并行循环示例,我们可以使用一些不同的方式来调度此示例。
| #pragma omp parallel for schedule (static, chunkSize)for(int i = 0; i < middleLoopCount; i++) { Work(innerLoopCount);} |
图 6:静态调度
此处,我们静态地请求将块中的工作分发给各个线程。当线程执行完一个块的时候,会以轮询的方式在所有的线程中获取另一个同样大小的块。如果我们不添加调度关键字,则 OpenMP 的默认调度是一个静态调度,具有在线程之间均分循环迭代的 chunkSize。
我们也可以在运行时制定更灵活的调度决策。
| #pragma omp parallel for schedule (dynamic, chunkSize)for(int i = 0; i < middleLoopCount; i++) { Work(innerLoopCount);} |
图 7:动态调度
在动态情况下,只要线程需要更多工作,即可马上获得下一个迭代块。它不遵循静态调度的轮询顺序。
编译器可以对静态部分进行一定的优化,对动态部分没有这个功能。
最后,我们可以使用称为引导调度的混合技术。此处,每个线程在第一次运行时获得一个较大的块,在接下来的运行中将逐步缩减块的大小,直到缩减至 chunkSize 指定的限制值。块的大小按需要动态计算;这与块大小逐渐减少的动态调度类似。
| #pragma omp parallel for schedule (guided, chunkSize)for(int i = 0; i < middleLoopCount; i++) { Work(innerLoopCount);} |
图 8: 引导调度
现在,如何比较这些方法?
![]()
图 9:相同平衡代码上的各种调度方法(内层循环 1000 次,中间循环 1000 次)
哪种调度技术最出色?这得视情况而定。可以在平衡良好的代码和不平衡代码上运行同一方法进行比较,从而找出适合代码的调度方法。
让我们了解一下这些技术有何区别。首先,chunkSize 参数意味着各个调度之间有所区别。对于静态调度,将块按照相同的顺序分发给各个线程,即按照线程数轮询。对于动态调度,按照需要将块分发给线程。此方法更适用于不平衡代码。对于引导调度,各个线程在第一次运行时获得较大的块,随后块越来越小,直至达到 chunkSize 的下限。在所有情况下,返回到线程调度代码要支出一些额外开销,以此换来较好的灵活性。我们仅在发现会减慢代码运行速度的一些其他问题(如线程不平衡)时,才更改默认调度方法。较小的额外开销可以弥补所有的线程不平衡。在所有情况下,该循环在两个线程间拆分了 1000 次迭代。
记住,我们是针对 2 个线程分析其行为。现在,这些数字说明了什么?
各次静态运行存在些许差异。较小的块运行的时间略微有点长,这在意料之中,因为我们运行调度代码的次数更多。图中的最后一个静态评测是 OpenMP 的默认调度方法。它总是按照迭代计数除以您正在运行的线程数来计算;在本例中,chunk_size 等于 500。比较代码中的各种调度时,应始终以评测此种情况为基础。
事实上,大小为 1 的块和大小为 10 的块之间的差数(大约 890 毫秒)即为静态调度开销,或者是设置和启动各个迭代的开销。如果调度块的大小为 1,则必须设置和启动循环 500 次(它在每个线程上运行一个块)。对于大小为 10 的块,我们必须设置和启动 50 次。通过这两个块的比较我们发现,在 890 微秒的时间里额外的开销有 450 次,因此使用此调度方法的开销比运行每个循环的开销仅少了 2 毫秒。这并不是很糟糕,但是您仍然需要考虑在静态调度中使用略微大一些的块。
动态调度的灵活性最大,但是在调度出错时,造成的性能损失也最大。对于平衡良好的代码,调度大小为 1 的块明显是个错误的选择,但是调度大小为 10 的块效果还不错。我们还可以计算动态调度的开销,大小为 1 的块和大小为 10 的块之间的差数为 8,700 毫秒。每个块额外调度灵活性所花费的时间大约为 19 毫秒。这可能导致极为显著的差别,因此请避免使用非常小的块,除非代码极其不平衡,或循环体的运行时间非常长。对于您的代码,正确的块大小应该在运行较少的块和达到更佳的线程平衡之间做一个权衡。尝试各个值,并评测哪个值在代码中运行的最快。
引导调度使用较小的块作为限制时运行最佳,这使得它的灵活性最大。现在还不清楚为什么块更大时,它们反而运行的更慢;但是限定为较大的块大小时,运行的时间将很长。引导调度的上限是循环总大小的二分之一,对于平衡良好的代码,应该相当于在一次运行中将循环拆分为相等几部分的静态情况。
评测调度策略的有效性的一种方法是使用默认调度和各种手动编码的调度运行不平衡线程,然后比较调度提供的加速比。
在评测调度策略时,确保在尽可能多的计算机上进行评测。将单核处理器上的调度与双核处理器上的同一调度进行比较时需格外注意。在这两种处理器上都运行良好的调度才是理想的调度。 |