线程化的性能障碍:它们将给 OpenMP 代码带来什么影响?

 原文地址:http://www.intel.com/cd/ids/developer/apac/zho/recent/323448.htm

 

当今,多核处理器正成为主流,开发人员必须使其代码实现线程化,才能并行运行代码。OpenMP1 可以提供实现应用程序线程化的有效方法。但是,关于线程化代码的性能,您都应该了解什么,使用 OpenMP 时该性能又意味着什么?在之前的文章2中,我们发现所有线程化方法的启动成本都相同,但是 OpenMP 与常见的 Windows* 线程化相比具有一定的性能优势,原因在于它使用了线程池。

在决定如何以及在何处线程化代码时,了解 OpenMP 性能是至关重要的。为进行线程化而更改架构平衡和算法时尤为如此。在应用程序中对 OpenMP 代码进行原型设计和评测前,应当了解 OpenMP 线程化可能对代码带来怎样的性能影响。

在本文中,我们将对使用 OpenMP 实现线程化进行全方位的解读,并了解其对代码性能带来的影响。我们将介绍一些极为常见的 OpenMP 指令。同时,我们还将探讨手动调度代码时的运行时成本,如果循环体在运行时变化较大,则该成本是必不可少的。如果您要线程化现有的序列化代码(或有这方面的打算)、修改和评测现有的并行代码或从头新建并行设计,本文将非常有用。线程化现有代码可能最难,因此我们将重点关注这一方面。

您可能出于以下三个原因而线程化应用程序。每个对性能评测的要求都不同。

  1. 能更快的执行同一工作:
    如果应用程序负载固定(如,对静态照片应用某种效果),我们可以通过线程化更快地完成工作。评测此代码时,我们将记录执行时间和通过线程化实现的加速比。
  2. 执行更多的工作:
    如果将应用程序扩展到执行更多个负载相同的工作(例如,更新较大的像素缓冲区),或将不同的工作添加到负载中(例如,对游戏管道添加粒子效果),则我们应该进行线程化以通过应用程序完成更多的工作。通常我们将其作为吞吐量进行评测。要评测此代码,就要评测吞吐量,吞吐量是针对全部执行时间评测的工作量。
  3. 抵销慢操作所花费的时间:
    如果应用程序需要进行长时间的操作(如加载文件),则通过线程化可事先执行这些操作,以便在需要结果时能够立刻提供。有多种方法可用于评测此代码,但主要评测是用以定性的评测;用户能否从这些操作中察觉到延迟?能否轻松地进行评测,取决于负载情况。很多应用程序(尤其是游戏)已开始使用线程化(以及一些其他技术,如异步磁盘 I/O)来抵消慢操作所花费的时间。


任意组合这些因素,可能会在代码中获得更高的性能。确定哪些因素与您相关。实现线程化的方式和使用的评测指标将取决于您所排定的优先级。

一些代码组合使用这些方法。例如,假设您要将游戏的着色管道的物理部分移到一个单独的线程。您可能希望执行此操作,以使代码能够运行更加完整的物理模拟。此代码在着色管道中,因此它可在对象数据流经管道时对其执行操作。如果降低对一致性和时间的要求,并让物理子系统在数据副本(一个不同步的帧)上工作,则您便能够将物理部分移动到一个单独的线程。这也要求具有一个额外缓冲层,这样物理操作便具有要使用的单独数据。

进行此更改时出现了一个奇怪的现象:附加缓冲成本通常远远低于原来的物理计算,这样其余着色管道的运行速度大大加快了。此更改可以提高游戏的帧频,但是您也可以使用它向管道添加更丰富的步骤。

此类更改具有双重优势,在本案例中,可以带来更高的帧频和更丰富的物理模拟。

线程化代码时,寻找一种分解代码的方法,以便各个部分的代码可以独立运行,并将这些部分分配到不同的线程。将代码分成独立的功能(功能分解)或能够在单独数据块上独立执行同一工作的代码(数据分解或域分解)。如何找到在代码中进行线程化的机会?
 
Amdahl 法则

Amdahl 法则描述了任意给定代码所能实现的加速比的理论可能性。

对于代码 F 的串行成分,理论上预期可以在 N 个处理器上实现加速比:

       

如果线程化 20% 的代码(80% 保持串行),则在 4 个处理器上可以实现最大加速比:



我们还可以使用 Amdahl 法则预测加速比的上限(将 N 设置为 ∞)。我们更多将该法则用于预测在拥有两个或四个处理器内核的典型情况下的加速比,在这些情况下我们希望线程化能带来较大的优势。
 
基本规则是确定花费最多时间的串行代码所在的区域,然后对其进行线程化。我们可以使用 Amdahl 法则3(请见边栏)预测可从拆分代码中实现的最大加速比。

也就说,在理想情况下,可以将所有串行代码转化为并行代码。要开始转化,必须尽可能地将代码向最外层循环线程化。通常,循环迭代中(以及循环迭代之间)的依赖关系会使转化变得很困难。查找代码的这些部分,但是如果依赖关系使得线程化无法进行,请不要感到惊讶。有些算法使您可以轻松地进行线程化("密集并行"问题4),但是多数代码具有依赖关系,这将阻止您线程化整个应用程序。

从另一方面来看,如果代码中长运行循环在最低级别没有依赖关系,则线程化就变得很容易。我们在后文将讨论一些有关每线程开销的问题,但是仅当使用线程池(例如 OpenMP 就是使用线程池)时此方法才可行。如果每次运行低级循环时都要新建线程,则创建线程所耗费的成本将超过所有收益。

应该从代码的最常执行部分(热点)开始线程化,但是使用 OpenMP 可以向代码中所有最低级别循环添加线程。各个线程化所提升的性能都很小,但是如果都加起来,则总共提升的性能将相当可观。
我应该如何评测线程化的代码?
您应该评测所有线程化的代码。有几种不同的方法可以执行此操作,具体取决于您想评测什么。在某些情况下,最佳做法是安装本地计时代码或配置代码;您的代码可能已经具有内置配置机制,或者您可能希望使用 Windows PerformanceCounter API 调用。如果您希望查看应用程序的整体性能和代码中所有热点的详细评测,应该考虑使用类似于 VTune™ 性能分析器的工具。您应该制定如下策略:评测原始代码并以此作为基准,然后评测代码中的后续更改。

在评测线程化应用程序的性能时,应该注意以下几点。首先,需要了解代码的串行部分和代码的并行部分的执行时间。

串行时间:在单个处理器内核上的单个线程中运行代码 通过将串行代码封装入计时代码对其进行评测
并行时间:在多个线程上运行代码,因此可以同时在多个处理器内核上运行此代码 围绕线程化部分进行评测,并记录线程计数,请参阅下文

评测串行代码非常简单,因此我们主要介绍并行代码。

图 1:并行循环示例

在图 1 的代码中,评测所示块花费的(包括 OpenMP #pragma)的并行时间。同时记录线程计数,这样可以看出代码从单核计算机到双核计算机甚至多核计算机的加速情况。记录线程计数的简单方法是使用 omp_get_max_threads() 调用。也请参阅下文对加速比的讨论。

很少有不经过一些大转换而向应用程序添加重要线程的情况。有时候,整个算法都会更改。循环通常包含数据依赖关系。要处理好依赖关系从而可以线程化,可能需要为每个线程缓冲额外的数据副本,然后添加同步代码以解决所有差异。

此类大幅度更改可能会使代码运行速度加快或减慢,具体取决于如何执行更改。使用此方法时请务必小心。

转换代码时可以尝试进行其他更改,例如重构代码。通常情况下,这是一个好方法,但是它将使评测性能更改的难度加大。一般的做法是尽量将这些活动彼此分开。在一次运行过程中进行重构,然后在另一次运行过程中进行线程化(反之亦然)。

评测此类转换的难度比较大。很难评测各个代码块,因为您在不断地对其进行更改。评测经转换的代码的最佳方法是具有一个可以在更改前、更改过程中和更改后都可以评测的应用程序级标准。
线程化将给代码带来何种开销?
在真正了解代码的性能之前,您要考虑其他几个因素。

首先,考虑开销。在进行线程化过程中,总是会增加一些开销;我们增加的是固定的启动开销和每线程开销。在某些情况下(通常是在小循环和低迭代计数中),这些开销数额太高,以至于使线程化代码变得没有意义。在这些情况下,我们最好将代码保留为其原始串行形式。

下面列出了线程化代码的一些开销因素。多数情况下,在 OpenMP 代码中我们可以忽略这些因素,但是在此表的后面,我们将探讨不能忽略这些因素的情况。

因素 说明 如何检测/评测它是否是个问题? 如何处理?
线程库启动开销 代码启动时的一次性开销。对于多数代码都不明显。与应用程序启动的其他部分捆绑在一起 除典型代码内评测外,将串行与线程化应用程序运行相比较

不能由开发人员进行调整
线程启动开销 创建线程的时间。OpenMP 使用线程池的一次性成本。 对线程化代码的多次运行过程进行评测,请参阅下文 线程化更高和/或更大的循环(在可行之处)以抵销此开销
每线程(循环调度)开销 在各个线程上线程化工作的库调度块所花费的时间 仅在与性能紧密相关的代码或不常用的调度代码中评测,请参阅下文 调整代码中的调度,请参阅下文
锁定管理开销 管理关键部分上的锁定所花费的时间。当对 OpenMP 实现相互进行性能评测时,有时会使用5 使用类似 VTune 性能分析器的工具,监视将被频繁调用的锁定调用。多数情况下,大量锁定的代码会出现较大的阻塞问题,请参阅下文。 减少阻塞现象以减少锁定争用和管理,当争用较低时,请参阅下文了解其他可选办法

图 2:OpenMP 中的开销因素

这些是线程化代码中的开销因素。多数情况下,在 OpenMP 代码中我们可以忽略这些因素,我们将进一步探讨不能忽略这些因素的情况。

让我们看看这些不同的开销都是在何处发生的。



图 3:线程开销位置

评测图 3 中显示的这些开销。评测每线程和库启动开销很容易,但是评测线程启动开销则需要进行几种不同的测试。首先,评测恰好运行一次 OpenMP parallel for 指令所需的时间。然后,评测运行该指令两次所需的时间。由于 OpenMP 使用线程池,因此第一个循环应包含所有线程启动开销。最终,第二次迭代所花费的时间应远远少于第一次迭代所花费时间的 2 倍。第二次迭代所增加的时间是运行一次循环迭代的"本地"时间;将第一次循环迭代所花费的时间减去此时间,即可计算出线程启动开销。

早期的研究2发现线程启动开销平均为 170-190 毫秒(在双核计算机上创建两个线程时)。值得高兴的是多数 OpenMP 实现(包括最新的英特尔和 Microsoft 编译器的 OpenMP 实现)都使用了线程池。您的代码只需支出线程开销一次,即在第一次使用线程时。这样,对于多数线程和调用模式来说,线程启动开销非常低。

这是 OpenMP 与典型的 Windows 线程化相比所具有的独特优势之一;只要有可能,OpenMP 运行时即会自动使用线程池。由于典型的 Windows 线程化不使用线程池,因此每个线程都有线程启动开销。可能很多应用程序都禁止这种行为。如果选择 Windows 线程化而非 OpenMP,可以将 Windows 线程与线程池结合使用以避免此开销,但是操作更为复杂。

OpenMP 库可以检测代码在任意给定计算机上的最佳线程数。这是 OpenMP 与典型的 Windows 线程化相比的另一个优势。

有很多迹象表明在管理锁定方面与 Windows 本地线程相比,在英特尔 C++ 编译器中实现的 OpenMP 更有效。这是一个好消息,但可能不会对您的代码产生大的影响。但是最好确保锁定代码不在代码的热点列表中(使用类似 VTune 性能分析器的工具),这很容易实现。这是因为经常在出现资源争用的位置使用锁定,因此等候资源时,代码一定会阻塞。在这些情况下,阻塞是一个更为重要的考虑因素,因此基本上无需评测锁定管理开销。

在典型的循环代码中(迭代计数大小适中、计算集长度适中),这些开销都没有什么影响。继续阅读有关阻塞问题(通常是更为重要的考虑因素)的讨论。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章