您可能出于以下三个原因而线程化应用程序。每个对性能评测的要求都不同。
- 能更快的执行同一工作:
如果应用程序负载固定(如,对静态照片应用某种效果),我们可以通过线程化更快地完成工作。评测此代码时,我们将记录执行时间和通过线程化实现的加速比。 - 执行更多的工作:
如果将应用程序扩展到执行更多个负载相同的工作(例如,更新较大的像素缓冲区),或将不同的工作添加到负载中(例如,对游戏管道添加粒子效果),则我们应 该进行线程化以通过应用程序完成更多的工作。通常我们将其作为吞吐量进行评测。要评测此代码,就要评测吞吐量,吞吐量是针对全部执行时间评测的工作量。 - 抵销慢操作所花费的时间:
如果应用程序需要进行长时间的操作(如加载文件),则通过线程化可事先执行这些操作,以便在需要结果时能够立刻提供。有多种方法可用于评测此代码,但主要 评测是用以定性的评测;用户能否从这些操作中察觉到延迟?能否轻松地进行评测,取决于负载情况。很多应用程序(尤其是游戏)已开始使用线程化(以及一些其 他技术,如异步磁盘 I/O)来抵消慢操作所花费的时间。
一 些代码组合使用这些方法。例如,假设您要将游戏的着色管道的物理部分移到一个单独的线程。您可能希望执行此操作,以使代码能够运行更加完整的物理模拟。此 代码在着色管道中,因此它可在对象数据流经管道时对其执行操作。如果降低对一致性和时间的要求,并让物理子系统在数据副本(一个不同步的帧)上工作,则您 便能够将物理部分移动到一个单独的线程。这也要求具有一个额外缓冲层,这样物理操作便具有要使用的单独数据。
进行此更改时出现了一个奇怪的现象:附加缓冲成本通常远远低于原来的物理计算,这样其余着色管道的运行速度大大加快了。此更改可以提高游戏的帧频,但是您也可以使用它向管道添加更丰富的步骤。
此类更改具有双重优势,在本案例中,可以带来更高的帧频和更丰富的物理模拟。
如何决定在何处线程化代码?
Amdahl 法则 Amdahl 法则描述了任意给定代码所能实现的加速比的理论可能性。 对于代码 F 的串行成分,理论上预期可以在 N 个处理器上实现加速比: 如果线程化 20% 的代码(80% 保持串行),则在 4 个处理器上可以实现最大加速比: 我们还可以使用 Amdahl 法则预测加速比的上限(将 N 设置为 ∞)。我们更多将该法则用于预测在拥有两个或四个处理器内核的典型情况下的加速比,在这些情况下我们希望线程化能带来较大的优势。 |
也 就说,在理想情况下,可以将所有串行代码转化为并行代码。要开始转化,必须尽可能地将代码向最外层循环线程化。通常,循环迭代中(以及循环迭代之间)的依 赖关系会使转化变得很困难。查找代码的这些部分,但是如果依赖关系使得线程化无法进行,请不要感到惊讶。有些算法使您可以轻松地进行线程化("密集并行" 问题 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 性能分析器的工具),这很容易实现。这是因为经常在出现资源争用的位置使用锁定,因此等候资源时,代码一定会阻塞。在这些情况下,阻塞是一个更为重要的考 虑因素,因此基本上无需评测锁定管理开销。
在典型的循环代码中(迭代计数大小适中、计算集长度适中),这些开销都没有什么影响。继续阅读有关阻塞问题(通常是更为重要的考虑因素)的讨论。
何为阻塞?
阻塞问题通常是阻碍线程化应用程序实现最佳性能的最大因素。解决阻塞问题需从设计和实现两个方面入手。在进行设计时,我们应使实际阻塞量降至最低。我们还应测试和调整实现,以确保代码仅在预期的时间和地点阻塞。
理想情况下,代码阻塞应不花费任何时间,但实际上很难做到这一点。
可以粗略估计阻塞会多大程度地减慢代码速度:注意关键部分相对于其余代码的比率。关键部分较大或关键部分相对于其余代码的比率较高,都表明可能会有麻烦。
要查看和评测代码阻塞的位置、程度和原因,使用可以隔离阻塞的工具至关重要,例如英特尔线程性能分析器。
有时候,在很少争用的资源周围也会有锁定(例如,参考计数)。如果发现锁定花费的时间较多,且代码并没有阻塞,则请考虑进行优化,例如双重检测锁定优化6。此技术使您在多数时间可以使用锁无关算法,且不用支出大量的锁定管理开销。
代码如何加速?
OpenMP 有一个优异的特性,即:运行时将为当前计算机自动创建最佳线程数。如果在单核处理器上运行代码,则将在一个线程上运行所有代码。如果在双核处理器上运行相 同代码,则将在两个线程上运行代码。这就是说,代码将在其当前计算机上自动调整至最佳状态,以尽可能实现最大加速比。
请再次思考我们在上文中所提及的代码段:
- #pragma omp parallel for
- for(int i = 0; i < middleLoopCount; i++) {
- Work(innerLoopCount);
- }
此循环具有何种加速比 - 此循环以不定线程数运行时速度会加快多少?此处我们的循环具有可能的理想加速比,仅受循环计数的限制。我们可以使用任意数目的线程,但不能超过循环计数 middleLoopCount。如果线程数等于循环数,则每个线程都可以运行 for 循环的一次迭代。假设我们始终调用 middleLoopCount 为 1000 的循环。如果给定可以并行运行的 1,000 个线程,则此代码的运行速度可以快 1,000 倍。
此代码能运行这么快吗,即使在理想情况下?我们必须更深入的了解才能知道。在这种情况下,它取决于 Work()函数中的内容。如果该函数中没有阻塞代码,则仍然可能实现理想加速比,且此循环应该是进行线程化的理想之处。如果 Work() 仅包含一个较大的关键部分,用以更新共享数组,那又如何?那么,我们将无法实现理想的加速比,而此循环也不适合进行线程化处理。
实际上,即使在代码可实现完美加速比的情况下,开发人员有时候也必须人为限制加速比。调用 omp_set_num_threads() 可以完成此操作。此调用将设置 OpenMP 可以创建的线程的上限。您可能希望在已对其进行测试的硬件的限制内设置最大线程计数。如果已在至多四核处理器的计算机上测试了代码,则您可能希望强制它在 四个以内的线程上运行,这是因为您还没有在超过此数目的情况下测试过。如果希望在代码中实现此目的,则可以考虑将其设置为可配置状态。当具有更多内核的较 新的硬件可用时,可以通过开关或新的配置选项快速测试代码和启用更大的加速比。
这意味着什么?就目前的硬件而言,我们可以并行运行几个线 程(在双核处理器上运行两个线程,在四核处理器上运行四个线程)。创建的线程并不是多多益善,够使用就行;否则我们将花费更多的时间来创建线程。如果了解 代码中各个线程化区域可能实现的加速比,则就可以大致了解整个应用程序的可能实现的加速比。一些可实现较大加速比的小循环并不能带来太大优势。具有 1.5 倍加速比的主循环才会给代码带来较大的优势。进行此练习,了解您的代码的加速能力。
此处提供了另一个示例。我们将代码按功能分解为两个功能,每个都在各自的 section 中。(在本例中,我们使用 section 指令将循环人为地拆分为两个子集,因此它们看起来并无太大区别。)
- #pragma omp parallel sections
- {
- #pragma omp section
- for (int i = 0; i < chunkSize; i++) {
- Work(innerLoopCount);
- }
- #pragma omp section
- for (int i = chunkSize; i < middleLoopCount; i++) {
- Work(innerLoopCount);
- }
- }
图 5: 功能不同的并行代码的示例
此代码加速情况如何取决于 Work() 函数加速的能力,这与前面所述一样,但同时也取决于我们是否很好地平衡了两个功能之间的工作。在理想情况下,此代码可以达到 2 倍加速比,这是因为它已被拆分为两个子集。
对 于此代码,应从两种线程化技术中选择哪一种,答案很清楚,使用第一种方法(如图 4 所示)对其线程化,可以获得较好的可能加速比。要了解对更多典型代码产生的效果,需要了解各个部分的相对运行时。在所有情况下,此代码将被限制在(最多) 2 倍加速比之内,这是因为它已被拆分为两部分,并且最多只能在 2 个线程上运行。
了解代码的可能加速比并非总是如此容易。具有可变长度循环体的代码运行起来是怎样的呢?处理游戏某个场景中所有对象的动画时可能要面临此问题。可能每个对象需要处理的动画数目都不同,因此很难预计每个对象要处理多久。
OpenMP 为调度这种可变循环提供了很多选择,可以方便的控制可视变化。有关这些选项的详细信息,请参阅下一部分。
通过在英特尔线程性能分析器下运行代码,可以获得有关此代码加速情况的图示。
调度您自己的循环
持续时间可变的循环体会导致代码不平衡。很难预测此类代码的运行时行为。更糟糕的是,由于运行时的行为不断变化,因此无法使用静态的解决方案。
默认情况下,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);
- }
此处,我们静态地请求将块中的工作分发给各个线程。当线程执行完一个块的时候,会以轮询的方式在所有的线程中获取另一个同样大小的块。如果我们不添加调度关键字,则 OpenMP 的默认调度是一个静态调度,具有在线程之间均分循环迭代的 chunkSize。
我们也可以在运行时制定更灵活的调度决策。
- #pragma omp parallel for schedule (dynamic, chunkSize)
- for(int i = 0; i < middleLoopCount; i++) {
- Work(innerLoopCount);
- }
在动态情况下,只要线程需要更多工作,即可马上获得下一个迭代块。它不遵循静态调度的轮询顺序。
编译器可以对静态部分进行一定的优化,对动态部分没有这个功能。
最后,我们可以使用称为引导调度的混合技术。此处,每个线程在第一次运行时获得一个较大的块,在接下来的运行中将逐步缩减块的大小,直到缩减至 chunkSize 指定的限制值。块的大小按需要动态计算;这与块大小逐渐减少的动态调度类似。
- #pragma omp parallel for schedule (guided, chunkSize)
- for(int i = 0; i < middleLoopCount; i++) {
- Work(innerLoopCount);
- }
现在,如何比较这些方法?
图 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 毫秒。这可能导致极为显著的差别,因此请避免使用非常小的块,除非代码极其不平衡,或循环体的运行时间非常长。对于您的代码,正确的块大小应该在运行较少 的块和达到更佳的线程平衡之间做一个权衡。尝试各个值,并评测哪个值在代码中运行的最快。
引导调度使用较小的块作为限制时运行最佳,这使 得它的灵活性最大。现在还不清楚为什么块更大时,它们反而运行的更慢;但是限定为较大的块大小时,运行的时间将很长。引导调度的上限是循环总大小的二分之 一,对于平衡良好的代码,应该相当于在一次运行中将循环拆分为相等几部分的静态情况。
评测调度策略的有效性的一种方法是使用默认调度和各种手动编码的调度运行不平衡线程,然后比较调度提供的加速比。
在评测调度策略时,确保在尽可能多的计算机上进行评测。将单核处理器上的调度与双核处理器上的同一调度进行比较时需格外注意。在这两种处理器上都运行良好的调度才是理想的调度。
保护共享变量
在所有线程环境中,都必须确保多个线程不会同时访问共享变量。
OpenMP 提供了 critical 指令,可以用于定义关键部分,并保证一次只有一个线程执行该代码。对于一般情况,即多个代码区域访问一个共享变量序列的情况,这是正确的解决方案。
如果要使用关键部分保护单个变量的增量或减量,可以找到更好的办法。在多数情况下,可以使用原子指令获得更佳的运行时。但是,还有更好的方法。
Windows 可以实现一组 Interlocked 函数 7 来达到此目的。通过处理器中的底层原子硬件说明可以实现这些函数,因此不需要应用程序级的锁定。
- InterlockedIncrement()
- InterlockedDecrement()
- InterlockedExchange()
- InterlockedExchangeAdd()
- InterlockedCompareExchange()
尽可能使用这些函数而非 OpenMP atomic 或 critical directives ,从而以最快的速度更新共享变量。
结论
本文还讨论了一些用于在代码中查找可能加速比的技术。最后,本文在结束之前比较了各种 OpenMP 线程调度方法,并提供了用于保护共享变量的提示。
OpenMP 提供了一个可以线程化许多应用程序的简单方法,且开销相对较低。立即行动以实现线程化!
操作系统为 Microsoft Windows XP Professional SP2*,并关闭了各种服务和电源管理功能,以提升评测基准的统一性。
所有代码都是在 Microsoft Visual Studio .NET 2003* 环境下开发,并使用英特尔? C++ 编译器 9.0 Windows 版编译。