C---TBB-并行编程教程-三-

344 阅读1小时+

C++ TBB 并行编程教程(三)

原文:C++ Parallel Programming With Threading Building Blocks

协议:CC BY-NC-SA 4.0

十二、使用工作隔离来保证正确性和性能

任何一个和孩子在一起过的人(或者行为举止像孩子的人)都知道,有时候阻止孩子互相打扰的唯一方法就是把他们分开。TBB 任务和算法也是如此。当任务或算法无法相处时,我们可以使用工作隔离将它们分开。

例如,当使用嵌套并行时,我们需要——在某些有限的情况下——创建工作隔离以确保正确性。在这一章中,我们将浏览出现这种需求的场景,然后提供一组规则来确定何时需要隔离以保证正确性。我们还描述了如何使用isolate函数来创建工作隔离。

在其他情况下,我们可能希望创建工作隔离,这样我们就可以通过使用显式任务舞台来约束任务在哪里执行,从而提高性能。在这些情况下制造孤立是一把双刃剑。一方面,我们将能够控制将参与不同任务领域的线程数量,以此来支持一些任务,或者使用 TBB 库中的钩子将线程固定到特定的内核,以优化局部性。另一方面,显式任务竞技场使得线程更难参与当前分配给它们的竞技场之外的工作。当我们出于性能原因想要创建隔离时,我们将讨论如何使用class task_arena。我们还要提醒的是,虽然class task_arena也可以用来创建隔离以解决正确性问题,但是它的较高开销使得它不太适合这个目的。

当需要并正确使用时,工作隔离是一个有价值的特性,但是,正如我们将在本章看到的,它需要谨慎使用。

正确性的工作隔离

TBB 调度器旨在使工作线程及其底层内核尽可能忙碌。如果当一个工作线程空闲时,它会从另一个线程那里窃取工作,以便有事情做。当线程窃取时,它不知道最初是什么并行算法、循环或函数创建了它所窃取的任务。通常,任务来自哪里是无关紧要的,因此 TBB 图书馆最好的做法是平等对待所有可用的任务,并尽快处理它们。

然而,如果我们的应用程序使用嵌套并行,TBB 库可能会以某种方式窃取任务,导致开发人员可能不期望的执行顺序。这个执行命令本身并不危险;事实上,在大多数情况下,这正是我们希望发生的。但是,如果我们对任务可能如何执行做出不正确的假设,我们可能会创建导致意外甚至灾难性结果的模式。

图 12-1 显示了一个说明这个问题的小例子。在代码中,有两个parallel_for循环。在外部循环的主体中,获得了互斥锁m。获得这个锁的线程在持有锁时调用第二个嵌套的parallel_for循环。如果在m上获得锁的线程在其内部循环完成之前变得空闲,就会出现问题;如果工作线程窃取了迭代,但在主线程耗尽工作时尚未完成迭代,就会发生这种情况。主线程不能简单地退出parallel_for,因为它还没有完成。为了提高效率,这个线程不只是空转,等待其他线程完成它们的工作;谁知道这要花多长时间?相反,它将当前任务保留在堆栈中,并寻找额外的工作让自己忙碌起来,直到可以从中断的地方继续工作。如果这种情况出现在图 12-1 中,在线程寻找工作窃取点时,系统中有两种任务——内部循环任务和外部循环任务。如果线程碰巧从外部parallel_for窃取并执行了一个任务,它将再次尝试获取m上的锁。因为它已经在m上持有一个锁,而tbb::spin_mutex不是递归锁,所以存在死锁。线程被捕获,等待自己释放锁!

../img/466505_1_En_12_Fig1_HTML.png

图 12-1

在执行嵌套的parallel_for时持有锁

看到这个例子后,两个问题普遍出现:(1)真的有人这样写代码吗?(2)一个线程真的能从外部循环窃取任务吗?不幸的是,这两个问题的答案都是肯定的。

事实上,人们确实会这样写代码——尽管几乎都是无意的。出现这种模式的一种常见方式是在调用库函数时持有锁。开发人员可能认为他们知道一个函数是做什么的,但是如果他们不熟悉它的实现,他们可能就错了。如果库调用包含嵌套并行,结果可能是图 12-1 所示的情况。

是的,偷工减料会导致这个例子死锁。图 12-2 显示了我们的例子是如何陷入这种糟糕的状态的。

../img/466505_1_En_12_Fig2_HTML.png

图 12-2

图 12-1 中代码生成的任务树的一个潜在执行

在图 12-2(a) 中,线程 t 0 启动外循环并获取m上的锁。线程 t 0 然后开始嵌套的parallel_for并执行其迭代空间的左半部分。在线程t 0 忙碌的同时,另外三个线程t1t2t3参与竞技场中任务的执行。线程 t 1 和 t 2 偷取外循环迭代,并被阻塞等待获取m上的锁,该锁当前由t 0 持有。同时,线程t 3 随机选择 t 0 进行窃取,并开始执行其内循环的右半部分。这就是事情开始变得有趣的地方。线程 t 0 完成了内循环迭代的左半部分,因此将窃取工作以防止自身空闲。此时,它有两个选择:(1)如果它随机选择线程 t 3 进行窃取,它将执行更多自己的内部循环;或者(2)如果它随机选择线程 t 1 进行窃取,它将执行一个外部循环迭代。请记住,默认情况下,调度程序平等地对待所有任务,因此它不会厚此薄彼。图 12-2(b) 显示了一个不幸的选择,它从线程t 1 中偷取,并在试图获取它已经持有的锁时陷入死锁,因为它的外部任务仍在其堆栈中。

另一个显示正确性问题的例子如图 12-3 所示。同样,我们看到一组嵌套的parallel_for循环,但是由于使用了线程本地存储,我们得到的不是死锁,而是意外的结果。在每个任务中,将一个值写入线程本地存储位置local_i,执行内部parallel_for循环,然后读取线程本地存储位置。由于内部循环,线程可能会在空闲时窃取工作,将另一个值写入线程本地存储位置,然后返回到外部任务。

../img/466505_1_En_12_Fig3_HTML.png

图 12-3

由于使用线程本地存储,嵌套并行可能会导致意外结果

TBB 开发团队使用术语兼职 1 来描述线程在运行中有未完成的子任务,并窃取不相关的任务来保持忙碌的情况。兼职通常是件好事!这意味着我们的线程没有闲置。只有在有限的情况下事情会出错。在我们的两个例子中,都有一个不好的假设。他们都认为——不足为奇——因为 TBB 有一个非抢占式调度器,所以同一个线程永远不会执行内部任务,然后在完成内部任务之前开始执行外部任务。正如我们所见,由于线程在嵌套并行中等待时会窃取工作,这种情况实际上是可能发生的。只有当我们错误地依赖线程以互斥的方式执行任务时,这种典型的良性行为才是危险的。在第一种情况下,在执行嵌套并行时会持有一个锁,从而允许线程暂停内部任务并获取外部任务。在第二种情况下,线程在嵌套并行之前和之后访问线程本地存储,并假设线程不会在两者之间兼职。

正如我们所看到的,这些例子是不同的,但有一个共同的误解。在本章末尾的“更多信息”部分列出的博客“英特尔线程构建模块中的工作隔离功能”中,Alexei Katranov 提供了一个三步清单,用于确定何时需要工作隔离来确保正确性:

  1. 是否使用了嵌套并行(即使是间接的,通过第三方库调用)?如果没有,就不需要隔离;否则,转到下一步。

  2. 对于一个线程来说,重新进入外层并行任务是否安全(就像存在递归一样)?存储到一个线程本地值,重新获取这个线程已经获取的互斥体,或者其他不应该被同一个线程再次使用的资源都可能导致问题。如果重入是安全的,就不需要隔离;否则,转到下一步。

  3. 需要隔离。嵌套并行必须在隔离区域内调用。

this_task_arena::isolate创建一个隔离区域

当我们需要隔离以保证正确性时,我们可以使用this_task_arena名称空间中的isolate函数之一:

../img/466505_1_En_12_Figa_HTML.png

图 12-4 显示了如何使用该功能在图 12-1 的嵌套parallel_for周围添加一个隔离区域。在一个隔离区域内,如果一个线程因为必须等待而变得空闲——例如在嵌套的parallel_for结束时——它将只被允许窃取从它自己的隔离区域内产生的任务。这修复了我们的死锁问题,因为如果一个线程在等待图 12-4 中的内部parallel_for时偷取,它将不被允许偷取外部任务。

../img/466505_1_En_12_Fig4_HTML.png

图 12-4

在嵌套并行的情况下,使用隔离功能来防止兼职

当一个线程在一个隔离区域内被阻塞时,它仍然会从它的任务区域中随机选择一个线程进行窃取,但是现在必须检查该受害线程的队列中的任务,以确保它只窃取源自其隔离区域内的任务。

在阿列克谢的博客中,this_task_arena::isolate的主要特性被很好地总结如下:

  • 隔离仅约束进入或加入隔离区域的线程。隔离区域之外的工作线程可以接受任何任务,包括在隔离区域中产生的任务。

  • 当一个没有隔离的线程执行一个在隔离区域中产生的任务时,它加入这个任务的区域并且变得隔离,直到任务完成。

  • 在隔离区域内等待的线程不能处理在其他隔离区域中产生的任务(即,所有区域都是相互隔离的)。此外,如果隔离区域内的线程进入嵌套的隔离区域,则它不能处理来自外部隔离区域的任务。

哦不!工作隔离会导致其自身的正确性问题!

不幸的是,我们不能只是不加区别地应用工作隔离。这对于性能有影响,我们稍后会谈到,但更重要的是,如果使用不当,工作隔离本身会导致死锁!又来了…

特别是,当我们将工作隔离与 TBB 接口混合时,我们必须格外小心,这些接口将生成任务与等待任务分开——例如task_group和流程图。在一个隔离区域中调用等待接口的任务在等待时不能参与另一个隔离区域中产生的任务。如果有足够多的线程卡在这样的位置,应用程序可能会耗尽线程,向前的进程将会停止。

让我们考虑图 12-5 所示的示例函数。在函数splitRunAndWait, M中,任务在task_group tg中产生。但是每次产卵都发生在不同的隔离区域。

../img/466505_1_En_12_Fig5_HTML.png

图 12-5

task_group tg上调用runwait的函数。对run的调用是从一个隔离区域内发出的。

如果我们直接调用函数fig_12_5,如图 12-5 所示,就没有问题。对splitRunAndWait中的tg.wait的调用本身并不在一个隔离区域内,所以主线程和工作线程可以帮助处理不同的隔离区域,然后在它们完成时转移到其他区域。

但是如果我们把我们的主函数改成图 12-6 中的那个会怎么样呢?

../img/466505_1_En_12_Fig6_HTML.png

图 12-6

task_group tg上调用runwait的函数。对run的调用是从一个隔离区域内发出的。

现在,对splitRunAndWait的调用分别在不同的隔离区域内进行,随后对tg.wait的调用在这些隔离区域内进行。每个调用tg.wait的线程必须等到它的tg结束,但不能窃取任何属于它的tg或任何其他task_group的任务,因为那些任务是从不同的隔离区域产生的!如果M足够大,我们可能会让所有的线程都等待调用tg.wait,而没有线程执行任何相关的任务。所以我们的应用程序死锁了。

如果我们使用一个将产生和等待分开的接口,我们可以通过确保我们总是在产生任务的同一个隔离区域中等待来避免这个问题。例如,我们可以重写图 12-6 中的代码,将对run的调用移到外部区域,如图 12-7 所示。

../img/466505_1_En_12_Fig7_HTML.png

图 12-7

task_group tg上调用runwait的函数。对runwait的呼叫现在都是在隔离区域之外进行的。

现在,即使我们的主函数使用了并行循环和隔离,我们也不再有问题,因为每个调用tg.wait的线程将能够执行来自其tg的任务:

即使是安全的,隔离工作也不是免费的

除了潜在的死锁问题,从性能的角度来看,工作隔离也不是免费的,所以即使它可以安全使用,我们也需要明智地使用它。不在隔离区域中的线程可以在窃取时选择任何任务,这意味着它可以从受害线程的 deque 中快速弹出最旧的任务。如果受害者完全没有任务,它也可以立即挑选另一个受害者。然而,在隔离区域中产生的任务及其子任务被标记以识别它们所属的隔离区域。在隔离区域中执行的线程必须扫描所选择的牺牲者的队列,以找到属于其隔离区域的最老的任务——不是任何老的任务都可以。并且该线程仅在扫描了所有可用任务并且没有从其区域中找到任务之后,才知道牺牲线程是否没有来自其隔离区域的任务。只有到那时,它才会选择另一个受害者来偷东西。从隔离区域内部窃取的线程有更多的开销,因为它们需要更加小心!

使用任务竞技场进行隔离:一把双刃剑

工作隔离限制了线程在寻找工作时的选择。我们可以使用前面部分描述的isolate函数来隔离工作,或者我们可以使用class task_arena。与本章相关的class task_arena接口子集如图 12-8 所示。

../img/466505_1_En_12_Fig8_HTML.png

图 12-8

class task_arena公共接口的子集

仅仅为了确保正确性而使用class task_arena而不是isolate函数来创建隔离几乎没有任何意义。也就是说,class task_arena仍然有重要的用途。让我们看看class task_arena的基础知识,同时,揭示它的优势和劣势。

通过task_arena构造器,我们可以使用max_concurrency参数设置 arena 中线程的总槽数,并使用reserved_for_masters参数设置专门为主线程保留的槽数。在第十一章中提供了更多关于task_arena如何用于控制计算使用的线程数量的细节。

图 12-9 显示了一个小例子,其中用max_concurrency=2创建了一个单独的task_arena ta2,并且在那个竞技场中执行了一个执行parallel_for的任务。

../img/466505_1_En_12_Fig9_HTML.png

图 12-9

最大并发数为2task_arena

当一个线程调用一个task_arena的 execute 方法时,它试图作为主线程加入竞技场。如果没有可用的槽,它将任务排入任务区。否则,它会加入竞技场,并在该竞技场中执行任务。在图 12-9 中,线程将加入task_arena ta2,启动parallel_for,然后参与执行来自parallel_for的任务。由于 arena 的max_concurrency为 2,因此最多有一个额外的工作线程可以加入并参与执行该任务 arena 中的任务。如果我们执行 Github 上图 12-9 中的仪表化示例,我们会看到


There are 4 logical cores.
2 threads participated in ta2

我们已经可以开始看到isolateclass task_arena之间的差异。的确,只有ta2中的线程能够执行ta2中的任务,所以存在工作隔离,但是我们也能够设置能够参与执行嵌套的parallel_for的线程的最大数量。

图 12-10 更进一步,创建了两个任务竞技场,一个max_concurrency为 2,另一个max_concurrency为 6。然后用一个parallel_invoke创建两个任务,一个执行ta2中的parallel_for,另一个执行ta6中的parallel_for。两个parallel_for循环具有相同的迭代次数,并且每次迭代旋转相同的时间。

../img/466505_1_En_12_Fig10_HTML.png

图 12-10

使用两个task_arena对象,一个循环使用六个线程,另一个循环使用两个线程

我们已经有效地将八个线程分成两组,让其中两个线程在ta2中的parallel_for上工作,六个线程在ta6中的parallel_for上工作。我们为什么要这么做?也许我们认为ta6的工作更重要。

如果我们在一个有八个硬件线程的平台上执行图 12-10 中的代码,我们将看到类似如下的输出


ta2_time == 0.500409
ta6_time == 0.169082

There are 8 logical cores.
2 threads participated in ta2
6 threads participated in ta6

这是使用isolatetask_arena创建隔离的关键区别。当使用task_arena时,我们几乎总是更关心控制参与执行任务的线程,而不是隔离本身。隔离不是为了正确性,而是为了性能。显式的task_arena是一把双刃剑——它让我们控制参与工作的线程,但也在它们之间筑起了一道高墙。当一个线程离开由isolate创建的隔离区域时,它可以自由地参与执行它所在领域的任何其他任务。当一个线程在一个显式的task_arena中没有工作可做时,它必须返回到全局线程池,然后找到另一个有工作可做并且有空位的地方。

注意

我们只是提供了一个关键的经验法则:使用isolate主要是为了帮助正确性;使用task_arenas主要是为了性能。

让我们再次考虑图 12-10 中的例子。我们在task_arena ta6中创造了更多的插槽。结果,ta6parallel_forta2parallel_for完成得快多了。但是在ta6中的工作完成后,分配给那个 arena 的线程返回到全局线程池。他们现在很闲,但无法帮助完成ta2中的工作——竞技场只有两个线程槽,而且已经满了!

抽象非常强大,但是它在线程之间建立的高墙限制了它的实际应用。第十一章更详细地讨论了如何将class task_arenaclass task_scheduler_initclass global_control一起使用,以控制 TBB 应用中特定并行算法可用的线程数量。第二十章展示了我们如何使用task_arena对象在非统一内存访问(NUMA)平台中的特定内核上划分工作和调度工作,以针对数据局部性进行调优。在这两章中,我们会看到task_arena非常有用,但也有缺点。

不要试图使用task_arenas来创建正确的工作隔离

在第 11 和 20 章描述的特定用例中,线程的数量甚至它们在特定内核上的位置都受到严格控制,因此我们希望在不同的领域拥有不同的线程。然而在一般情况下,需要task_arena对象来管理和迁移线程只会产生开销。

作为一个例子,让我们再看一组嵌套的parallel_for循环,但是现在没有正确性问题。我们可以在图 12-11 中看到代码和可能的任务树。如果我们执行这组循环,那么所有的任务都将出现在同一个任务舞台上。当我们在上一节中使用isolate时,所有的任务仍然保存在同一个竞技场中,但是线程在窃取任务之前通过检查任务来隔离自己,以确保它们被允许根据隔离约束来获取任务。

../img/466505_1_En_12_Fig11_HTML.png

图 12-11

两个嵌套的parallel_for循环的例子:(a)源代码和(b)任务树

现在,让我们修改这个简单的嵌套循环示例,使用显式 task arena 对象创建隔离。如果我们想让每个在外循环中执行迭代的线程只执行自己内循环中的任务,这可以通过使用图 12-4 中的isolate轻松实现,我们可以在每个外体中创建本地nested显式task_arena实例,如图 12-12(a) 和图 12-12(b) 所示。

../img/466505_1_En_12_Fig12_HTML.png

图 12-12

为每个外部循环体执行创建一个显式的task_arena。现在,在内部执行时,线程将与外部工作和不相关的内部循环隔离开来。

如果M == 4,总共会有五个竞技场,当每个线程调用nested.execute时,会与外循环任务以及不相关的内循环任务隔离。我们创造了一个非常优雅的解决方案,对吗?

当然不是!我们不仅要创建、初始化和销毁几个task_arena对象,这些领域还需要填充工作线程。如第十一章所述,工作线程按照它们拥有的槽数比例填充任务区域。如果我们有一个有四个硬件线程的系统,每个竞技场只能得到一个线程!那有什么意义?如果我们有更多的线程,它们将被平均分配到不同的任务领域。当每个内部循环完成时,它的线程将返回到全局线程池,然后迁移到另一个尚未完成的任务舞台。这不是一个廉价的操作!

拥有多个任务竞技场并在它们之间迁移线程根本不是实现负载平衡的有效方式。我们在图 12-12(b) 中的玩具例子只显示了四次外部迭代;如果有许多迭代,我们将在每个外部任务中创建和销毁 task_arenas。我们的四个工作线程会从一个任务区跑到另一个任务区寻找工作!对于这些情况,请坚持使用isolate功能!

摘要

我们现在已经学会了当 TBB 任务和算法不能一起工作时,如何将它们分开。我们看到,如果我们不小心的话,嵌套并行与 TBB 的窃取方式相结合会导致危险的情况。然后我们看到this_task_arena::isolate函数可以用来处理这些情况,但是也必须小心使用,否则我们会产生新的问题。

然后,我们讨论了当我们出于性能原因想要创建隔离时,如何使用class task_arena。虽然class task_arena可以用来创建隔离以解决正确性问题,但是它较高的开销使得它不太适合这个目的。然而,正如我们在第 11 和 20 章节中看到的,当我们想要控制算法使用的线程数量或者控制线程在内核上的位置时,class task_arena是我们工具箱中必不可少的一部分。

更多信息

阿列克谢·卡特拉诺夫,“英特尔线程构建模块(TBB)中的工作隔离功能”, https://software.intel.com/en-us/blogs/2018/08/16/the-work-isolation-functionality-in-intel-threading-building-blocks-intel-tbb

Creative Commons

开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。

本章中的图像或其他第三方材料包含在该章的知识共享许可中,除非该材料的信用额度中另有说明。如果材料未包含在本章的知识共享许可中,并且您的预期用途不被法定法规允许或超出了允许的用途,您将需要直接从版权所有者处获得许可。

Footnotes 1

来自柯林斯字典:从事第二职业,尤指。在晚上,而且经常是非法的。

 

十三、创建线程到内核和任务到线程的亲和性

当使用线程构建模块库开发并行应用时,我们通过使用高级执行接口或低级 API 来创建任务。这些任务由 TBB 库使用工作窃取的方式调度到软件线程上。这些软件线程由操作系统(OS)调度到平台的核心(硬件线程)上。在这一章中,我们将讨论 TBB 的一些特性,这些特性让我们能够影响操作系统和 TBB 做出的调度选择。当我们想要影响操作系统,以便它将软件线程调度到特定的内核上时,就会使用线程到内核的亲和性当我们想要影响 TBB 调度程序,以便它将任务调度到特定的软件线程上时,就使用任务到线程的亲和性。根据我们想要达到的目标,我们可能对一种或另一种亲和力感兴趣,或者对两者的结合感兴趣。

创造亲和力有不同的动机。最常见的动机之一是利用数据局部性。正如我们在本书中反复提到的,数据局部性会对并行应用程序的性能产生巨大的影响。TBB 库、它的高级执行接口、它的工作窃取调度器和它的并发容器都是在考虑了本地性的情况下设计的。对于许多应用程序来说,使用这些特性可以在不进行任何手动调整的情况下获得良好的性能。但是,有时我们需要提供提示或完全掌握自己的事情,以便 TBB 和操作系统中的调度程序更好地调度数据附近的工作。除了数据局部性之外,当使用异构系统时,当内核的能力不同时,或者当软件线程具有不同的属性时,例如更高或更低的优先级时,我们也可能对亲和性感兴趣。

在第十六章中,介绍了 TBB 并行算法揭示的数据局部性的高级特性。在第十七章中,我们将讨论 TBB 流程图中调整缓存和内存使用的特性。在第二十章中,我们展示了如何使用 TBB 库的特性来调优非一致内存访问(NUMA)架构。对于许多读者来说,这些章节中的信息将足以完成他们需要执行的特定任务,以调整他们的应用程序。在这一章中,我们将重点关注由 TBB 调度程序和任务提供的底层基础支持,这些任务有时由这些章节中描述的高级功能进行抽象,有时直接在这些章节中使用以创建关联性。

创建线程到内核的关联性

所有主流操作系统都提供了允许用户设置软件线程亲缘关系的接口,包括 Linux 上的pthread_setaffinity_npsched_setaffinity以及 Windows 上的SetThreadAffinityMask。在第二十章中,我们使用可移植硬件本地性(hwloc)包作为一种可移植的方式来设置跨平台的关联性。在这一章中,我们不关注设置亲缘关系的机制——因为这些机制会因系统而异——相反,我们关注 TBB 库提供的挂钩,这些挂钩允许我们使用这些接口来设置 TBB 主线程和辅助线程的亲缘关系。

默认情况下,TBB 库会创建足够的工作线程来匹配可用内核的数量。在第十一章中,我们讨论了如何改变这些默认值。无论我们是否使用默认值,TBB 库都不会自动将这些线程关联到特定的内核。TBB 允许操作系统在其认为合适的时候调度和迁移线程。在放置 TBB 线程的地方给予操作系统灵活性是库中有意的设计选择。在多程序环境中,TBB 在这个环境中表现出色,操作系统可以看到所有的应用程序和线程。如果我们在单个应用程序的有限视图中决定线程应该在哪里执行,我们可能会做出导致整体系统资源利用率低下的选择。因此,通常最好不要将线程关联到内核,而是允许操作系统选择 TBB 主线程和工作线程的执行位置,包括允许操作系统在程序执行期间动态迁移线程。

然而,就像我们将在本书的许多章节中看到的那样,TBB 图书馆提供了一些功能,如果我们愿意,可以让我们改变这种行为。如果我们想让 TBB 线程对内核具有亲和力,我们可以使用task_scheduler_observer类来实现(参见 task_scheduler_observer 观察调度程序)。该类允许应用程序定义回调,每当线程进入和离开 TBB 调度程序或特定的任务区域时调用这些回调,并使用这些回调来分配亲缘关系。TBB 库没有提供抽象来帮助进行设置线程关联性所需的特定于操作系统的调用,所以我们必须使用我们前面提到的特定于操作系统的或可移植的接口来自己处理这些底层细节。

Task_Scheduler_Observer类观察调度程序

task_scheduler_observer类提供了一种观察线程何时开始或停止参与任务调度的方法。该类的接口如下所示:

../img/466505_1_En_13_Figa_HTML.png

为了使用这个类,我们创建了自己的类,它继承了task_scheduler_observer并实现了on_scheduler_entryon_scheduler_exit回调。当这个类的一个实例被构造并且它的observe状态被设置为 true 时,每当一个主线程或工作线程进入或退出全局 TBB 任务调度器时,入口和出口函数将被调用。

最近对该类的扩展现在允许我们向构造器传递一个task_arena。该扩展是 TBB 2019 Update 4 之前的预览功能,但现在完全支持。当传递一个task_arena引用时,观察器将只接收进入和退出该特定领域的线程的回调:

../img/466505_1_En_13_Figb_HTML.png

图 13-1 展示了一个简单的例子,展示了如何在 Linux 上使用task_scheduler_observer对象将线程固定到内核。在这个例子中,我们使用sched_setaffinity函数来设置每个线程加入默认竞技场时的 CPU 掩码。在第二十章中,我们展示了一个使用 hwloc 软件包分配亲缘关系的例子。在图 13-1 的例子中,我们使用tbb::this_task_arena::max_concurrency()来查找竞技场中的槽数,使用tbb::this_task_arena::current_thread_index()来查找调用线程被分配到的槽。因为我们知道默认领域中的插槽数量与逻辑核心的数量相同,所以我们将每个线程固定到与其插槽号相匹配的逻辑核心。

../img/466505_1_En_13_Fig1_HTML.png

图 13-1

在 Linux 平台上使用task_scheduler_observer将线程固定到内核

我们当然可以创建更复杂的方案来为线程分配逻辑内核。而且,虽然我们在图 13-1 中没有这么做,但是我们也可以为每个线程存储原始的 CPU 掩码,这样我们就可以在线程离开竞技场时恢复它。

正如我们在第二十章中所讨论的,我们可以使用task_scheduler_observer类,结合显式task_arena实例,来创建独立的线程组,这些线程组被限制在共享非统一内存访问(NUMA)系统(一个 NUMA 节点)中相同本地内存库的内核上。如果我们还控制数据放置,我们可以通过将工作放到数据所在的 NUMA 节点的舞台上来大大提高性能。详见第二十章。

我们应该始终记住,如果我们使用线程到内核的亲和性,我们会阻止操作系统将线程从超额预订的内核迁移到使用率较低的内核,因为它试图优化系统利用率。如果我们在生产应用中这样做,我们需要确保不会降低多道程序的性能!正如我们将多次提到的,只有专门运行单个应用程序的系统才有可能拥有限制动态迁移的环境。

创建任务到线程的关联性

因为我们在 TBB 使用任务来表达我们的并行工作,所以创建线程到内核的亲和性,正如我们在上一节中所描述的,只是难题的一部分。如果我们将线程固定到内核上,我们可能不会得到太多好处,但是会让我们的任务被工作窃取随机移动!

当使用第十章中介绍的低级 TBB 任务接口时,我们可以提供一些提示,告诉 TBB 调度器应该在特定的 arena 槽中的线程上执行一个任务。因为我们可能会尽可能使用更高级的算法和任务接口,例如parallel_fortask_group和流程图,但是我们很少直接使用这些低级接口。第十六章展示了affinity_partitionerstatic_partitioner类如何与 TBB 循环算法一起使用,从而在不求助于这些低级接口的情况下创建亲缘关系。类似地,第十七章讨论了影响亲和力的 TBB 流图的特征。

因此,虽然任务到线程的亲和性是在低级任务类中公开的,但我们将通过高级抽象几乎专门使用这一特性。因此,使用我们在本节中描述的接口是留给 TBB 专家的,他们使用最底层的任务接口编写自己的算法。如果您是这样的专家,或者想更深入地了解高级接口是如何实现亲和力的,请继续阅读这一部分。

图 13-2 显示了 TBB task类提供的函数和类型,我们用它们来提供相似性提示。

../img/466505_1_En_13_Fig2_HTML.png

图 13-2

tbb::task中用于任务到线程关联的函数

类型affinity_id被用来表示一个任务在竞技场中的位置。零值意味着任务没有关联性。非零值具有映射到 arena 槽的实现定义的值。在生成任务之前,我们可以通过向其set_affinity函数传递一个affinity_id来设置任务与竞技场插槽的亲缘关系。但是因为affinity_id的含义是由实现定义的,所以我们不传递特定的值,例如 2 表示插槽 2。相反,我们通过覆盖note_affinity回调函数从之前的任务执行中捕获一个affinity_id

当(1)任务没有亲缘关系,但是将在除了产生它的线程之外的线程上执行,或者(2)任务有亲缘关系,但是将在除了它的亲缘关系所指定的线程之外的线程上执行时,函数note_affinity由 TBB 库在调用任务的execute函数之前调用。通过覆盖这个回调,我们可以跟踪 TBB 窃取行为,这样我们就可以向库提供提示,以便在算法的后续执行中重新创建相同的窃取行为,正如我们将在下一个示例中看到的那样。

最后,affinity函数让我们查询任务的当前亲缘性设置。

图 13-3 显示了一个继承自tbb::task的类,它使用任务关联函数将affinity_id的值记录到一个全局数组a中。它只记录其doMakeNotes变量设置为真时的值。execute函数打印任务 id、它正在执行的线程的槽,以及记录在这个任务 id 数组中的值。如果任务的doMakeNotes为真(它将记录该值),它将在报告前加上“嗯”前缀,“耶!”如果任务正在 array a中记录的 arena 槽中执行(它被再次调度到同一个线程上),并且“boo!”如果它在不同的竞技场插槽中执行。打印的细节包含在函数printExclaim中。

../img/466505_1_En_13_Fig3_HTML.png

图 13-3

使用任务关联性函数

虽然affinity_id的含义是实现定义的,但 TBB 是开源的,所以我们在实现方面达到了顶峰。因此我们知道,如果没有亲缘关系,affinity_id是 0,否则它是槽索引加 1。在 TBB 的生产应用中,我们不应该依赖这些知识,但是在我们的例子的execute函数中,我们依赖这些知识,所以我们可以分配正确的感叹词“耶!”或者“嘘!”。

图 13-3 中的函数fig_13_3构建并执行三个任务树,每个任务树有八个任务,并给它们分配从 0 到 7 的 id。这个例子使用了我们在第十章中介绍的低级任务接口。第一个任务树使用note_affinity来跟踪任务何时被窃取,以便在主线程之外的其他线程上执行。第二个任务树执行时没有注意或设置关联性。最后,最后一个任务树使用set_affinity来重新创建第一次运行时记录的调度。

当我们在具有八个线程的平台上执行这个示例时,我们记录了以下输出:


note_affinity
id:slot:a[i]
hmm. 7:0:-1
hmm. 0:1:1
hmm. 1:6:6
hmm. 2:3:3
hmm. 3:2:2
hmm. 4:4:4
hmm. 5:7:7
hmm. 6:5:5

without set_affinity
id:slot:a[i]
yay! 7:0:-1
boo! 0:4:1
boo! 1:3:6
boo! 4:5:4
boo! 3:7:2
boo! 2:2:3
boo! 5:6:7
boo! 6:1:5

with set_affinity
id:slot:a[i]
yay! 7:0:-1
yay! 0:1:1
yay! 4:4:4
yay! 5:7:7
yay! 2:3:3
yay! 3:2:2
yay! 6:5:5
yay! 1:6:6

从这个输出中,我们看到第一棵树中的任务分布在八个可用的线程上,每个任务的affinity_id记录在数组a中。执行下一组任务时,每个任务记录的affinity_id不用于设置亲和度,任务被不同的线程随机窃取。这就是随机偷窃所做的!但是,当我们执行最后一个任务树并使用set_affinity时,第一次运行的线程分配被重复。太好了,这正是我们想要的!

然而,set_affinity只提供了一个亲和提示,TBB 图书馆实际上可以随意忽略我们的请求。当我们使用这些接口设置亲缘关系时,对具有亲缘关系的任务的引用被放置在目标线程的亲缘关系邮箱中(参见图 13-4 )。但是实际的任务保留在产生它的线程的本地队列中。任务分派器仅在其本地队列中的工作耗尽时检查亲和邮箱,如第九章中的任务分派循环所示。因此,如果一个线程没有足够快地检查它的相似性邮箱,另一个线程可能会先窃取或执行它的任务。

../img/466505_1_En_13_Fig4_HTML.png

图 13-4

相似性邮箱保存对任务的引用,该任务保留在产生该任务的线程的本地队列中

为了证明这一点,我们可以在我们的小例子中改变任务关联性的分配方式,如图 13-5 所示。现在,愚蠢的是,我们将所有的亲缘关系都设置到了同一个槽位,即a[2]中记录的那个槽位。

../img/466505_1_En_13_Fig5_HTML.png

图 13-5

首先运行不同任务组的功能,有时记录相似性,有时设置相似性。还显示了一个输出示例。

如果 TBB 调度器接受我们的相似性请求,将会有很大的负载不平衡,因为我们已经要求它将所有的工作发送到同一个工作线程。但是如果我们执行这个新版本的示例,我们会看到:

../img/466505_1_En_13_Figc_HTML.png

因为 affinity 只是一个提示,所以其他空闲线程仍然会找到任务,在槽a[2]中的线程能够清空其 affinity 邮箱之前,从主线程的本地队列中窃取它们。事实上,只有第一个产生的任务id==0被线程在先前记录在a[2]中的槽中执行。因此,我们仍然看到我们的任务分布在所有八个线程上。

TBB 库忽略了我们的请求,而是避免了将所有这些任务发送到同一个线程所造成的负载不平衡。这种弱亲缘关系在实践中是有用的,因为它让我们交流亲缘关系,即应该提高性能,但它仍然允许库进行调整,以便我们不会无意中造成很大的负载不平衡。

虽然我们可以直接使用这些任务接口,但我们在第十六章中看到,循环算法提供了一个简化的抽象,affinity_partitioner幸运的是,它对我们隐藏了这些底层细节。

我们应该何时以及如何使用 TBB 亲和力特征?

只有当我们在专用系统上调优以获得绝对最佳的性能时,我们才应该使用task_scheduler_observer对象来创建线程到内核的亲和性。否则,我们应该让操作系统去做它的工作,并从全局的角度来调度它认为合适的线程。如果我们选择将线程绑定到内核,我们应该仔细权衡将这种灵活性从操作系统中移除的潜在影响,尤其是如果我们的应用程序运行在多程序环境中。

对于任务到线程的亲和性,我们通常希望使用高级接口,比如第十六章中描述的affinity_partitioneraffinity_partitioner使用本章描述的特性来跟踪任务的执行位置,并向 TBB 调度程序提供提示,以便在循环的后续执行中重放分区。它还跟踪更改以保持提示是最新的。

因为 TBB 任务关联性只是调度器提示,误用这些接口的潜在影响要小得多——所以我们在使用任务关联性时不需要那么小心。事实上,我们应该被鼓励去尝试任务相似性,特别是通过更高层次的接口,作为调整我们的应用程序的正常部分。

摘要

在本章中,我们讨论了如何在我们的 TBB 应用中创建线程到内核和任务到线程的亲和性。虽然 TBB 没有提供一个接口来处理设置线程到内核亲缘关系的机制,但它的class task_scheduler_observer提供了一个回调机制,允许我们插入必要的调用到我们自己的特定于操作系统的或可移植的库,这些库分配亲缘关系。因为 TBB 偷工减料调度程序随机地将任务分配给软件线程,线程与内核的亲和性本身并不总是足够的。因此,我们也讨论了 TBB 的class task中的接口,它让我们向 TBB 调度器提供关于我们希望任务被调度到哪个软件线程上的相似性提示。我们注意到我们很可能不会直接使用这些接口,而是使用第 16 和 17 章中描述的更高级接口。对于有兴趣了解这些低级接口的读者,我们提供了一些例子,展示了如何使用note_affinityset_affinity函数为使用低级 TBB 任务接口的代码实现任务到线程的相似性。

就像 TBB 库的许多优化特性一样,需要小心使用相似性。不正确地使用线程到内核的关联性会限制操作系统平衡负载的能力,从而显著降低性能。使用任务到线程的相似性提示,仅仅是 TBB 调度器可以忽略的提示,如果不明智地使用,可能会对性能产生负面影响,但影响要小得多。

更多信息

Creative Commons

开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。

本章中的图像或其他第三方材料包含在该章的知识共享许可中,除非该材料的信用额度中另有说明。如果材料未包含在本章的知识共享许可中,并且您的预期用途不被法定法规允许或超出了允许的用途,您将需要直接从版权所有者处获得许可。

十四、使用任务优先级

线程构建模块调度程序不是实时调度程序,因此不适合在实时系统中使用。在实时系统中,可以给任务一个必须完成的截止日期,如果错过了截止日期,任务的有用性就会降低。在硬实时系统中,错过最后期限会导致整个系统失败。在软实时系统中,错过截止时间并不是灾难性的,但会导致服务质量下降。TBB 图书馆不支持给任务分配期限,但是它支持任务优先级。这些优先级可能在具有软实时需求的应用中有用。无论它们是否足够,都需要了解应用程序的软实时需求以及 TBB 任务和任务优先级的属性。

除了软实时使用,任务优先级还可以有其他用途。例如,我们可能希望优先处理一些任务,因为这样做会提高性能或响应能力。也许我们想让释放内存的任务优先于分配内存的任务,以便减少应用程序的内存占用。或者,我们希望将接触缓存中已有数据的任务优先于将新数据加载到缓存中的任务。

在本章中,我们将描述 TBB 任务和 TBB 任务调度程序所支持的任务优先级。考虑将 TBB 用于软实时应用程序的读者可以使用这些信息来确定 TBB 是否足以满足他们的要求。如果需要实现受益于任务优先级的性能优化,其他读者可能会发现这些信息很有用。

支持 TBB 任务类中的非抢占式优先级

就像在第十三章中描述的对任务关联性的支持一样,TBB 对优先级的支持是通过低级任务类中的函数来实现的。TBB 图书馆定义了三个优先级:priority_normalpriority_lowpriority_high,如图 14-1 所示。

../img/466505_1_En_14_Fig1_HTML.png

图 14-1

支持优先级的类任务的类型和功能

一般来说,TBB 会在优先级较低的任务之前执行优先级较高的任务。但是有一些警告。

最重要的警告是,TBB 任务是由 TBB 线程非抢占式执行的。一旦任务开始执行,它将执行到完成——即使更高优先级的任务已经产生或排队。虽然这种行为看起来是一个缺点,因为它可能会延迟应用程序切换到更高优先级的任务,但它也是一个优点,因为它可以帮助我们避免一些危险的情况。想象一下,如果一个任务 t 0 持有一个共享资源的锁,然后产生了更高优先级的任务。如果 TBB 不允许 t 0 完成并释放它的锁,那么如果更高优先级的任务阻塞对同一资源的锁的获取,它们就会死锁。一个更复杂但类似的问题,优先级反转,是 20 世纪 90 年代末火星探路者号出现问题的著名原因。在“火星上发生了什么?”,迈克·琼斯建议优先继承作为解决这些情况的一种方法。使用优先级继承,阻塞较高优先级任务的任务继承它阻塞的最高任务的优先级。TBB 库没有实现优先级继承或其他复杂的方法,因为它使用了非抢占式优先级,避免了许多这样的问题。

TBB 库没有为设置 线程优先级 提供任何高级抽象。因为在 TBB 中没有对线程优先级的高级支持,如果我们想要设置线程优先级,我们需要使用特定于操作系统的代码来管理它们——就像我们在第十三章中对线程到内核关联性所做的那样。就像线程到内核的亲和性一样,当线程进入和退出 TBB 任务调度程序或特定的任务领域时,我们可以使用task_scheduler_observer对象并在回调中调用这些特定于操作系统的接口。但是,我们警告开发人员在使用 线程优先级 时要格外小心。如果我们引入线程优先级,这是抢占式的,我们也邀请回来所有已知的病理伴随抢占式优先级,如优先级反转。

**### 重要的经验法则

不要为在同一舞台上运行的线程设置不同的优先级。奇怪的事情会发生,因为 TBB 平等地对待竞技场中的线程。

除了 TBB 任务执行的不可抢占性之外,它对任务优先级的支持还有一些其他重要的限制。首先,更改可能不会立即在所有线程上生效。即使存在较高优先级的任务,一些较低优先级的任务也可能开始执行。第二,工作者线程可能需要迁移到另一个领域来获得对最高优先级任务的访问,正如我们之前在第十二章中提到的,这可能需要时间。一旦工作者已经迁移,这可能会留下一些没有工作者线程的领域(没有高优先级任务)。但是,因为主线程不能迁移,所以主线程将留在那些领域中,并且它们自己不会被停止——它们可以继续从它们自己的任务领域中执行任务,即使它们具有较低的优先级。

任务优先级并不像第十三章中描述的 TBB 对任务-线程相似性的支持。尽管如此,还是有足够多的警告让任务优先级在实践中比我们期望的要弱。此外,在复杂的应用程序中,只支持低、正常和高三个优先级,这是非常有限的。尽管如此,我们将在下一节继续描述使用 TBB 任务优先级的机制。

设置静态和动态优先级

静态优先级可以分配给排队到共享队列的单个任务(参见第十章中的排队任务)。通过set_group_priority函数或者通过task_group_context对象的set_priority函数,动态优先级可以被分配给任务组(参见task_group_context侧栏)。

Task_Group_Context:每个任务都属于一个组

一个task_group_context代表一组可以一起取消或设置优先级的任务。所有任务都属于某个组,一个任务一次只能是其中一个组的成员。

在第十章的中,我们使用特殊函数比如allocate_root()来分配 TBB 任务。这个函数有一个重载,让我们将一个task_group_context分配给一个新分配的根任务:

../img/466505_1_En_14_Figa_HTML.png

task_group_context也是 TBB 高级算法和 TBB 流图的可选参数,例如:

../img/466505_1_En_14_Figb_HTML.png

我们可以在分配期间在任务级别分配组,也可以通过更高级的接口,例如 TBB 算法和流程图。还有其他的抽象,比如task_group,让我们为了执行的目的对任务进行分组。task_group_context组的目的是支持取消、异常处理和优先级。

当我们使用task::enqueue函数来提供一个优先级时,这个优先级只影响单个任务,并且以后不能改变。当我们给一组任务分配一个优先级时,这个优先级会影响组中的所有任务,并且这个优先级可以在任何时候通过调用task::set_group_prioritytask_group_context::set_priority来改变。

TBB 调度器跟踪就绪任务的最高优先级,包括排队的和产生的任务,并推迟(除了前面的警告)较低优先级任务的执行,直到所有较高优先级任务都被执行。默认情况下,所有任务和任务组都是用priority_normal创建的。

两个小例子

图 14-2 显示了一个例子,它在一个有 P 个逻辑内核的平台上排列了 25 个任务。每个任务在给定的持续时间内积极地旋转。task_priority函数中的第一个任务以正常优先级排队,并被设置为旋转大约 500 毫秒。然后,函数中的 for 循环创建 P 个低优先级、P 个普通优先级和 P 个高优先级任务,每个任务都将活跃地旋转大约 10 毫秒。当每个任务执行时,它会将一条消息记录到线程本地缓冲区中。高优先级任务idH为前缀,普通任务idN为前缀,低优先级任务idL为前缀。在函数结束时,打印所有线程本地缓冲区,提供参与线程执行任务的顺序。这个例子的完整实现可以在 Github 库中找到。

../img/466505_1_En_14_Fig2_HTML.png

图 14-2

将具有不同优先级的任务排队

在具有八个逻辑核心的系统上执行此示例,我们会看到以下输出:


N:0              ← thread 1
H:7 H:5 N:3 L:7  ← thread 2
H:2 H:1 N:8 L:5  ← thread 3
H:6 N:1 L:3 L:2  ← thread 4
H:0 N:2 L:6 L:4  ← thread 5
H:3 N:4 N:5 L:0  ← thread 6
H:4 N:7 N:6 L:1  ← thread 8

在这个输出中,每一行代表一个不同的 TBB 工作线程。对于每个线程,它执行的任务从左到右排序。主线程从不参与这些任务的执行,因为它不调用wait_for_all,所以我们只能看到 7 行。第一个线程只执行第一个执行了 500 毫秒的正常优先级的长任务。因为 TBB 任务是不可抢占的,所以这个线程一旦开始就不能放弃这个任务,所以即使当更高优先级的任务变得可用时,它也继续执行这个任务。否则,我们会看到,即使for-循环将高优先级、普通优先级和低优先级任务混合在一起排队,高优先级任务也会首先由工作线程执行,然后是普通任务,最后是低优先级任务。

图 14-3 显示了使用两个本机线程t0t1并行执行两个parallel_for算法的代码。每个parallel_for有 16 次迭代,并使用一个simple_partitioner。如第十六章中更详细的描述,一个simple_partitioner划分迭代空间,直到达到一个固定的粒度,默认的粒度是 1。在我们的例子中,每个parallel_for将产生 16 个任务,每个任务将持续 10 毫秒。线程t0执行的循环首先创建一个task_group_context,并将其优先级设置为priority_high。由另一个线程t1执行的循环使用默认的task_group_context,它有一个priority_normal

../img/466505_1_En_14_Fig3_HTML.png

图 14-3

执行具有不同优先级的算法

在具有八个逻辑内核的平台上执行时,示例输出如下:


Normal
High
High
High
High
High
High
Normal
High
High
High
High
High
High
High
High
Normal
High
High
Normal
Normal
Normal
Normal
Normal
Normal
Normal
Normal
Normal
Normal
Normal
Normal
Normal

最初,执行了七个“High”任务

对于每一个“Normal”任务。这是因为以普通优先级启动了parallel_for的线程t1不能从它的隐式任务竞技场迁移出去。它只能执行“Normal”任务。然而,其他七个线程只执行“High”任务,直到它们全部完成。一旦高优先级任务完成,工作线程就可以迁移到线程t1的竞技场来帮忙。

不使用 TBB 任务支持来实施优先级

低,正常,高不够怎么办?一种解决方法是生成通用包装器任务,这些任务查看优先级队列或其他数据结构,以找到它们应该做的工作。通过这种方法,我们依靠 TBB 调度器将这些通用包装器任务分布在内核上,但任务本身通过共享数据结构强制实施优先级。

图 14-4 显示了一个使用task_groupconcurrent_priority_queue的例子。当一项工作需要完成时,采取两个动作:(1)将工作的描述推入共享队列,以及(2)在task_group中产生一个包装器任务,它将弹出并执行共享队列中的一个项目。结果是,每个工作项只产生一个任务——但是直到任务执行后才确定任务将处理的具体工作项。

../img/466505_1_En_14_Fig4_HTML.png

图 14-4

使用并发优先级队列将工作提供给包装任务

默认情况下,concurrent_priority_queue依赖于operator<来决定顺序,所以当我们定义如图 14-4 所示的work_item::operator<时,我们将看到一个输出,显示项目以降序执行,从 15 到 0:


WorkItem: 15
WorkItem: 14
WorkItem: 13
WorkItem: 12
WorkItem: 11
WorkItem: 10
WorkItem: 9
WorkItem: 8
WorkItem: 7
WorkItem: 6
WorkItem: 5
WorkItem: 4
WorkItem: 3
WorkItem: 2
WorkItem: 1
WorkItem: 0

如果我们将运算符改为返回 true if ( priority > b.priority ),那么我们将看到任务从 0 到 15 按升序执行。

使用通用包装器任务方法提供了更大的灵活性,因为我们可以完全控制如何定义优先级。但是,至少在图 14-4 中,它引入了一个潜在的瓶颈——线程并发访问的共享数据结构。即便如此,当 TBB 任务优先级不够时,我们可能会使用这种方法作为备用计划。

摘要

在本章中,我们概述了 TBB 的任务优先级支持。使用class task提供的机制,我们可以为任务分配低、正常和高优先级。我们展示了可以使用task_group_context对象将静态优先级分配给排队的任务,将动态优先级分配给任务组。因为 TBB 任务是由 TBB 工作线程非抢占式执行的,所以 TBB 的优先级也是非抢占式的。我们简要讨论了非抢占式优先级的优点和缺点,还强调了在使用这种支持时需要注意的一些其他注意事项。然后,我们提供了几个简单的例子,展示了如何将任务优先级应用于 TBB 任务和算法。

由于库中的任务优先级支持有许多限制,我们用一个使用包装器任务和优先级队列的替代方案来结束我们的讨论。

TBB 调度程序不是一个硬实时调度程序。我们在这一章中看到,尽管对任务和算法的优先级排序有一些有限的支持。这些特性对于软实时应用程序或应用性能优化是否有用,需要由开发人员根据具体情况来考虑。

更多信息

迈克·琼斯,“火星上发生了什么?”1997 年 12 月 5 日发出的通知。 www.cs.cmu.edu/afs/cs/user/raj/www/mars.html

沙、拉杰库马尔和莱霍奇基。优先级继承协议:一种实时同步的方法。IEEE 计算机学报,第 39 卷,第 1175-1185 页,1990 年 9 月。

Creative Commons

开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。

本章中的图像或其他第三方材料包含在本章的知识共享许可中,除非在材料的信用额度中另有说明。如果材料不包括在本章的知识共享许可中,并且您的预期使用不被法律法规允许或超出了允许的使用范围,您将需要直接从版权所有者处获得许可。**

十五、取消和异常处理

或多或少,我们都会被运行时错误所困扰,无论是在顺序开发还是并行开发中。为了减轻痛苦,我们学会了使用错误代码或更高级的替代方法(如异常处理)来捕获它们。像大多数面向对象语言一样,C++ 支持异常处理,当方便地使用时,可以开发健壮的应用程序。现在,考虑到 TBB 在 C++ 的基础上增加了基于任务的并行性,开发人员期望异常处理得到很好的支持是完全可以理解的。正如我们将在本章中看到的,异常处理在 TBB 中确实得到了很好的自动支持。这意味着在出现错误的情况下,我们的代码可以求助于一个异常处理程序(如果有的话),否则就终止整个工作。考虑到这一点,在 TBB 实施支持当然不简单

  1. 异常可以在由多个线程执行的任务中抛出。

  2. 为了终止抛出异常的工作,必须实现任务的取消。

  3. 必须保持 TBB 的可组合性。

  4. 如果没有异常发生,异常管理不应该影响性能。

TBB 内部的异常实现满足所有这些要求,包括支持任务取消。正如我们所说的,任务取消支持是必要的,因为抛出异常会导致需要取消生成异常的并行算法的执行。例如,如果一个parallel_for算法引发越界或被零除异常,库可能需要取消整个parallel_for。这要求 TBB 取消所有涉及处理并行迭代空间块的任务,然后跳转到异常处理程序。TBB 的任务取消实现无缝地实现了对违反parallel_for的任务的必要取消,而不影响正在执行不相关的并行工作的任务。

任务取消不仅是异常处理的一个要求,它本身也有价值。因此,在本章中,我们首先展示如何利用抵消来加速一些并行算法。尽管 TBB 算法的取消只是开箱即用,高级 TBB 开发者可能想知道如何完全控制任务取消,以及它在 TBB 是如何实现的。我们在这一章也尽量满足高级开发者(记住这是本书的高级部分)。本章的第二部分继续讨论异常处理。同样,异常处理“工作正常”,没有任何额外的复杂性:依靠我们众所周知的 try-catch 构造(正如我们在顺序代码中所做的那样),我们只需要准备好捕获标准 C++ 预定义的异常以及一些额外的 TBB 异常。再说一次,在这方面我们也不会满足于基础。为了结束这一章,我们将描述如何构建我们自己的自定义 TBB 异常,并深入研究 TBB 异常处理和 TBB 取消是如何相互影响的。

即使您对异常处理持怀疑态度,因为您属于“错误代码”学派,请继续阅读并发现我们是否最终让您相信了 TBB 异常处理在开发可靠、容错的并行应用程序时的优势。

如何取消集体工作

有些情况下一项工作不得不被取消。例子从外部原因(用户通过按 GUI 按钮取消执行)到内部原因(已经找到一个项目,这减少了任何进一步搜索的需要)。我们在顺序代码中看到过这种情况,但在并行应用程序中也会出现。例如,一些昂贵的全局优化算法遵循分支定界并行模式,其中搜索空间被组织为树,并且如果解决方案可能在不同的分支中找到,我们可能希望取消遍历一些分支的任务。

让我们看看如何用一个有点做作的例子来实现取消:我们想找到整数向量data中的单个-2的位置。这个例子是人为设计的,因为我们设置了data[500]=-2,所以我们事先知道输出(即–2 存储在哪里)。这个实现使用了一个parallel_for算法,如图 15-1 所示。

../img/466505_1_En_15_Fig1_HTML.png

图 15-1

查找存储–2 的索引

这个想法是当其中一个任务发现data[500]==-2时,取消所有其他在parallel_for中协作的并发任务。那么,task::self().cancel_group_execution()到底是什么?嗯,task::self()返回调用线程正在运行的最内层任务的引用。任务已经在几个章节中介绍过,但是细节在第 10–14 章中提供。在那些章节中,我们看到了任务类中包含的一些成员函数,cancel_group_execution()只是多了一个。顾名思义,这个成员函数不只是取消调用任务,而是取消所有属于同一组的任务。

在这个例子中,任务组由在parallel_for算法中协作的所有任务组成。通过取消这个组,我们停止了它的所有任务,实质上中断了并行搜索。想象一下任务发现data[500]==-2向其他兄弟任务大喊“嘿,伙计们,我想到了!不要再搜了!”。一般来说,每个 TBB 算法都创建自己的任务组,在这个 TBB 算法中协作的每个任务都属于这个组。这样,组/算法的任何任务都可以取消整个 TBB 算法。

对于一个大小为n=1,000,000,000的向量,这个循环消耗0.01秒,输出可以是这样的


Index 500 found in 0.01368 seconds!

然而,如果task::self().cancel_group_execution()被注释掉,在我们写这些行的笔记本电脑上,执行时间会增加到1.56秒。

就是这样。我们都准备好了。这就是我们做(基本)TBB 算法抵消所需要知道的全部内容。但是,现在我们有了明确的取消任务的动机(上例中超过 100 倍加速!),我们还可以(可选地)深入了解任务取消是如何工作的,以及完全控制哪些任务实际上被取消的一些考虑因素。

高级任务取消

在第十四章中,介绍了task_group_context的概念。每个任务都属于一个且只有一个task_group_context,为了简单起见,我们从现在开始称之为TGC。一个TGC代表一组可以取消或设置优先级的任务。在第十四章中,一些例子说明了如何改变TGC的优先级。我们还说过一个TGC对象可以选择性地传递给高级算法,比如parallel_for或者流图。例如,编写图 15-1 代码的另一种方法如图 15-2 所示。

../img/466505_1_En_15_Fig2_HTML.png

图 15-2

图 15-1 中代码的替代实现

在这段代码中,我们看到一个TGCtg,被创建并作为parallel_for的最后一个参数传递,还被用来调用tg.cancel_group_execution()(现在使用了task_group_context class的一个成员函数)。

注意图 15-1 和 15-2 的编码完全相同。可选的TGC参数tg,作为parallel_for的最后一个参数通过,只是为更详细的开发打开了大门。例如,假设我们也将同一个TGC变量tg传递给我们在并行线程中启动的parallel_pipeline。现在,在parallel_forparallel_pipeline中协作的任何任务都可以调用tg.cancel_group_execution()来取消两个并行算法。

任务还可以通过调用返回指向TGC的指针的成员函数group()来查询它所属的TGC。这样,我们可以安全地将这条线添加到图 15-2 : assert(task::self().group()==&tg);parallel_for的λ内。这意味着以下三行在图 15-2 的代码中是完全等价的,可以互换:


  tg.cancel_group_execution();
  tbb::task::self().group()->cancel_group_execution();
  tbb::task::self().cancel_group_execution();

当一个任务触发整个TGC的取消时,在队列中等待的衍生任务在没有运行的情况下被终结,但是已经运行的任务不会被 TBB 调度器取消,因为,正如您肯定记得的,调度器是不可抢占的。也就是说,在将控制传递给task::execute()函数之前,调度程序检查任务的TGC的取消标志,然后决定是应该执行该任务还是取消整个TGC。但是如果任务已经拥有了控制权,那么它就拥有了控制权,直到它屈尊将控制权归还给调度程序。但是,如果我们还想取消正在运行的任务,每个任务可以使用以下两种方法之一来共享取消状态:

../img/466505_1_En_15_Figa_HTML.png

下一个问题:新任务分配给哪个TGC?当然,我们有设备来完全控制这种映射,但是也有一个默认的行为是值得了解的。首先,我们介绍如何手动将任务映射到一个TGC中。

TGC 的明确分配

正如我们所见,我们可以创建TGC对象,并将它们传递给高级并行算法(parallel_for,...)和低级任务 API ( allocate_root())。请记住,在第十章中,我们还介绍了作为中级 API 的task_group类,用于轻松创建共享TGC的任务,这些任务可以通过单个动作同时取消或分配优先级。使用同一个task_group::run()成员函数发起的所有任务将属于同一个TGC,因此组中的一个任务可以取消整个帮派。

作为一个例子,考虑图 15-3 的代码,其中我们重写了一个data向量中“隐藏”的给定值的并行搜索,并得到它存储的索引。这一次,我们使用手动实现的分而治之的方法,该方法使用了task_group特性(parallel_for方法实际上正在做一些类似的事情,即使我们没有看到)。

../img/466505_1_En_15_Fig3_HTML.png

图 15-3

使用task_group类手动实现并行搜索

为了方便起见,向量data、结果索引myindextask_groupg都是全局变量。这段代码递归地将搜索空间一分为二,直到某个grainsize(我们在第十章中看到的cutoff值)。函数ParallelSearch(begin,end)是用来完成这种并行划分的函数。当粒度变得足够小时(在我们的例子中是 100 次迭代),调用SequentialSearch(begin,end)。如果我们寻找的值–2 在SequentialSearch内遍历的一个范围中找到,那么在我们的四核笔记本电脑中使用g.cancel().取消所有产生的任务,对于 N 等于 1000 万,这是我们算法的输出:


  SerialSearch:   5000000 Time: 0.012667
  ParallelSearch: 5000000 Time: 0.000152 Speedup: 83.3355

5000000是我们已经找到的-2值的索引。看看加速,我们会被它比顺序代码快 83 倍的速度所迷惑。然而,这是我们见证并行实现比顺序实现需要做更少工作的情况之一:一旦任务找到了密钥,就不再需要遍历向量Data。在我们的运行中,键在向量的中间,N/2,顺序版本必须到达那个点,而并行版本在不同的位置开始并行搜索,例如,0,N/4, N/2, N·3/4,等等。

如果你对所实现的速度提升感到惊讶,那就等着瞧吧,因为我们可以做得更好。记住cancel()不能终止已经运行的任务。但是同样,我们可以从一个正在运行的任务中查询,以检查TGC中是否有不同的任务取消了执行。为了使用task_group类实现这一点,我们只需要插入:

../img/466505_1_En_15_Figb_HTML.png

ParallelSearch()功能开始时。这个明显较小的 mod 导致这些执行时间:


SerialSearch:   5000000 Time: 0.012634
ParallelSearch: 5000000 Time: 2e-06 Speedup: 6317

我们希望我们能在四核机器上一直获得这样的并行加速!!

注意

高级且很少需要:除了显式创建一个task_group,为 TBB 并行算法设置TGC,以及使用allocate_root为根任务设置 TCG,我们还可以使用其成员函数来更改任何任务的 TGC:

void task::change_group(task_group_context& ctx);

因为我们可以使用task::group()查询任何任务的TGC,所以我们可以完全控制将任何任务移动到任何其他任务的TGC。例如,如果两个任务可以访问一个TGC_X变量(假设您有一个全局task_group_context ∗TGC_X),并且第一个任务已经执行了这个变量:

TGC_X=task::self().group();

然后第二个可以这样执行:

task::self().change_group(∗TGC_X);

TGC 的默认分配

现在,如果我们不显式指定TGC,会发生什么?默认行为有一些规则:

  • 创建task_scheduler_init(通过使用 TBB 算法显式或隐式创建)的线程创建自己的TGC,标记为“隔离的该线程执行的第一个任务属于那个TGC,后续子任务继承同一个父任务的TGC

  • 当这些任务中的一个调用并行算法而没有显式地传递一个TGC作为可选参数(例如,parallel_forparallel_reduceparallel_dopipeline、流程图等)时。),现在标记为“ bound ”的新TGC,被隐式地创建,用于将在该嵌套算法中协作的新任务。因此,这个TGC是一个绑定到独立父TGC的子*。*

  • 如果并行算法的任务调用嵌套的并行算法,则为这个新算法创建新的绑定子代TGC,其中父代现在是调用任务的TGC

图 15-4 中描述了一个由假想的 TBB 码自动构建的TGC树林的例子。

../img/466505_1_En_15_Fig4_HTML.jpg

图 15-4

运行假想的 TBB 代码时自动创建的TGC树森林

在我们假设的 TBB 代码中,用户想要嵌套几个 TBB 算法,但是对TGC s 一无所知,所以他只是调用这些算法,而没有传递可选的显式的TGC对象。在一个主线程中,有一个对parallel_invoke的调用,它自动初始化调度程序,创建一个竞技场和第一个隔离的TGCA。然后,在parallel_invoke中,创建了两个 TBB 算法,一个流图和一个pipeline。对于这些算法中的每一个,自动创建一个新的TGCBC,并绑定到A。在其中一个流程图节点中,创建了一个task_group,并且在不同的流程图节点中实例化了一个parallel_for。这导致两个新创建的TGCDE,它们被绑定到B。这就形成了我们的TGC森林的第一棵树,它有一个孤立的根,所有其他的TGCs都绑定在这里,也就是说,它们有一个父树。第二棵树构建在一个不同的主线程中,它创建了一个只有两个并行范围的parallel_for,并且为每个范围调用一个嵌套的parallel_for。还是那句话,树根是一个孤立的TGCF,其他的TGCsGH,都是绑定的。请注意,用户只是编写了 TBB 代码,将一些 TBB 算法嵌套到其他 TBB 算法中。是 TBB 机器为我们创造了TGC s 的森林。不要忘记任务:有几个任务共享每个TGC

现在,如果任务被取消会发生什么?别紧张。规则是包含这个任务的整个TGC被取消,但是取消也向下传播。例如,如果我们取消了流程图的一个任务(TGC B),我们也会取消task_group ( TGC D)和parallel_for ( TGC E),如图 15-5 所示。这是有意义的:我们正在取消流程图,以及从那里创建的一切。这个例子有些做作,因为可能很难找到这种算法嵌套的实际应用。然而,它说明了不同的TGC是如何自动链接在一起,以处理被大肆吹嘘的 TBB 的可组合性。

../img/466505_1_En_15_Fig5_HTML.jpg

图 15-5

从属于TGC B的任务中调用取消

但是等等,我们可能想要取消流图和task_group,但是保持parallel_for ( TGC E)的活力。好吧,这也可以通过手动创建一个隔离的TGC对象并将其作为 parallel for 的最后一个参数来传递。为此,我们可以编写类似于图 15-6 的代码,其中流程图gfunction_node利用了这种可能性。

../img/466505_1_En_15_Fig6_HTML.png

图 15-6

TGC的树中分离嵌套算法的替代方法

隔离的TGC对象TGC_E在堆栈上创建,并作为最后一个参数传递给parallel_for。现在,如图 15-7 所示,即使流程图的一个任务取消了它的TGC B,取消向下传播到TGC D,但是不能到达TGC E,因为它已经从树中分离出来创建了。

../img/466505_1_En_15_Fig7_HTML.jpg

图 15-7

TGC E现在被隔离,不会被取消

更准确地说,孤立的TGC E现在可以是我们的TGC s 森林中另一棵树的根,因为它是一个孤立的TGC,并且它可以是为更深层次的嵌套算法创建的新TGC的父代。我们将在下一节看到一个这样的例子。

总的来说,如果我们嵌套 TBB 算法而没有显式地传递一个TGC对象给它们,那么默认的TGC s 的森林将会在取消的情况下产生预期的行为。然而,通过创建必要数量的TGC对象并将它们传递给期望的算法,这种行为可以由我们随意控制。例如,我们可以创建一个单独的TGCA,并将其传递给我们假设的 TBB 示例的第一个线程中调用的所有并行算法。在这种情况下,所有算法中协作的所有任务都将属于那个TGC A,如图 15-8 所示。如果现在流程图的一个任务被取消,不仅嵌套的task_groupparallel_for算法也被取消,所有共享TGC A的算法也被取消。

../img/466505_1_En_15_Fig8_HTML.jpg

图 15-8

在修改了我们假设的 TBB 代码之后,我们将单个 TGC A 传递给所有的并行算法

关于取消的最后一点,我们想强调的是,有效地跟踪TGC的森林以及它们是如何被链接起来的,是一件非常具有挑战性的事情。感兴趣的读者可以看看 Andrey Marochko 和 Alexey Kukanov 的论文(参见“更多信息”部分),其中他们详细阐述了实现决策和内部细节。主要的收获是,如果不需要取消,要非常小心地确保TGC记账不会影响性能。

TBB 的异常处理

注意

如果对 C++ 异常不太熟悉,这里有一个例子可以帮助说明基本原理:

../img/466505_1_En_15_Figc_HTML.png

运行这段代码后的输出是

Re-throwing value: 5Value caught: 5

正如我们所看到的,第一个 try 块包含一个嵌套的 try catch。这个异常抛出一个值为 5 的整数。因为 catch 块匹配类型,所以这段代码成为异常处理程序。这里,我们只打印接收到的值,并向上重新抛出异常。在外层有两个 catch 块,但是第一个被执行,因为参数类型与抛出值的类型相匹配。外部级别中的第二个 catch 会收到一个省略号(…),因此如果异常具有前面 catch 函数链中未考虑的类型,它将成为实际的处理程序。例如,如果我们抛出 5.0 而不是 5,输出消息将是“发生了异常”

既然我们已经了解了取消是支持 TBB 异常管理的关键机制,那么让我们来看看问题的实质。我们的目标是掌握执行异常的防弹代码的开发,如图 15-9 所示。

../img/466505_1_En_15_Fig9_HTML.png

图 15-9

TBB 异常处理的基本示例

好吧,也许它还没有完全防弹,但作为第一个例子,它已经足够好了。事情是这样的,向量data只有 1000 个元素,但是parallel_for算法坚持走到位置 2000-1。雪上加霜的是,data不是用data[i],访问的,而是用Data.at(i),与前者相反,它增加了边界检查,如果我们不遵守规则,就会抛出std::out_of_range对象。因此,当我们编译并运行图 15-9 的代码时,我们会得到


Out_of_range: vector

正如我们所知,将产生几个任务来并行增加data元素。他们中的一些人会试图在超过 999 的位置增加。首先触及越界元素的任务,例如data.at(1003)++,显然必须取消。然后,std::vector::at()成员函数抛出std::out_of_range,而不是递增不存在的 1003 位置。因为异常对象没有被任务捕获,所以它被向上重新抛出,到达 TBB 调度程序。然后,调度器捕捉异常并继续取消相应TGC的所有并发任务(我们已经知道整个TGC是如何被取消的)。此外,异常对象的副本存储在TGC数据结构中。当所有的TGC任务被取消时,TGC被终结,这在开始执行TGC的线程中再次抛出异常。在我们的例子中,这是调用parallel_for的线程。但是parallel_for在一个try块中,该块带有一个接收out_of_range对象的catch函数。这意味着catch函数成为最终打印异常消息的异常处理程序。ex.what()成员函数负责返回一个字符串,其中包含一些关于异常的详细信息。

注意

实施细节。编译器不知道 TBB 并行算法的线程本质。这意味着将这样的算法包含在 try 块中只会导致调用线程(主线程)受到保护,但是工作线程将执行也会抛出异常的任务。为了解决这个问题,调度程序已经包含了 try-catch 块,这样每个工作线程都能够拦截从其任务中逸出的异常。

catch()函数的参数应该通过引用传递。这样,捕获基类的单个 catch 函数就能够捕获所有派生类型的对象。例如,在图 15-9 中,我们可以将catch (std::exception& ex)写成catch (std::out_of_range& ex),因为std::out_of_range是从std::logic_failure派生而来,而std::logic_failure又是从基类std::exception派生而来,通过引用捕获可以捕获所有相关的类。

并非所有的 C++ 编译器都支持 C++11 的异常传播特性。更准确地说,如果编译器不支持std::exception_ptr(在 C++11 之前的编译器中会发生这种情况),TBB 就不能重新抛出异常对象的精确副本。为了弥补这一点,在这种情况下,TBB 将异常信息汇总到一个tbb::captured_exception对象中,这个对象可以被重新抛出。还有一些关于如何总结不同种类的异常(std::exceptiontbb::tbb_exception或其他)的附加细节。然而,由于现在很难找到一个不支持 C++11 的编译器,我们不会额外关注这个 TBB 向后兼容特性。

定制我们自己的 TBB 例外

TBB 库已经提供了一些预定义的异常类,它们在图 B-77 的表格中列出。

但是,在某些情况下,衍生出我们自己特定的 TBB 例外是一种很好的做法。为此,我们可以使用抽象类tbb::tbb_exception,如图 15-10 所示。这个抽象类实际上是一个接口,因为它声明了我们被迫在派生类中定义的五个纯虚函数。

../img/466505_1_En_15_Fig10_HTML.png

图 15-10

tbb::tbb_exception派生出我们自己的异常类

tbb_exception界面的纯虚函数的细节如下

  • move()应该创建一个指向异常对象的副本的指针,该副本可以比原始对象存在的时间更长。移动原件的内容是明智的,尤其是如果它将被销毁。紧接在move()之后的throw()(以及destroy()what()name()中的函数说明)只是通知编译器这个函数不会抛出任何东西。

  • destroy()应该销毁由move()创建的副本。

  • throw_self()应投∗this

  • name()通常返回最初拦截的异常的 RTTI(运行时类型信息)名称。它可以通过使用typeid操作符和std::type_info类来获得。例如,我们可以返回typeid(∗this).name()

  • what()返回描述异常的空终止字符串。

然而,与其实现从tbb_exception派生所需的所有虚函数,还不如使用 TBB 类模板tbb::movable_exception来构建我们自己的异常,这样更容易,也更好。在内部,这个类模板为我们实现了所需的虚函数。之前描述的五个虚函数现在是常规的成员函数,我们可以选择是否覆盖它们。然而,正如我们在签名摘录中看到的,还有其他可用的功能:

../img/466505_1_En_15_Figd_HTML.png

将举例说明movable_exception构造器和data()成员函数。假设除以 0 是一个我们想要明确捕捉的异常事件。在图 15-11 中,我们展示了如何在类模板tbb::movable_exception的帮助下创建自己的异常。

../img/466505_1_En_15_Fig11_HTML.png

图 15-11

配置我们自己的可移动异常的方便选择

我们用我们希望与异常一起移动的数据创建我们的自定义类div_ex。在这种情况下,有效载荷是整数it,它将存储被 0 除的位置。现在我们能够创建一个对象,movable_exception类的de,用模板参数div_ex实例化,如我们在下面的代码行中所做的:


tbb::movable_exception<div_ex> de{div_ex{i}};

我们可以看到,我们传递了一个构造器div_exdiv_ex{i},作为参数给构造器movable_exception<div_ex>.

稍后,在 catch 块中,我们捕获异常对象为ex,并使用ex.data()成员函数获取对div_ex对象的引用。这样,我们就可以访问在div_ex中定义的成员变量和成员函数,如name()what()it。当输入参数n=1000000


Exception name: div_ex
Exception: Division by 0! at position: 500000

虽然我们添加了what()name()作为自定义div_ex类的成员函数,但是现在它们是可选的,所以如果我们不需要它们,我们可以去掉它们。在这种情况下,我们可以按如下方式更改 catch 块:

../img/466505_1_En_15_Fige_HTML.png

因为这个异常处理程序只有在接收到movable_exception<div_ex>时才会被执行,而这只有在被0除的情况下才会发生。

放在一起:可组合性、取消和异常处理

为了结束这一章,让我们用最后一个例子回到 TBB 的可组合性方面。在图 15-12 中,我们有一个代码片段显示了一个parallel_for,它将遍历矩阵Data的行,如果不是因为它在第一次迭代中抛出了一个异常(实际上是字符串“oops”)。!对于每一行,嵌套的parallel_for也应该并行遍历Data的列。

../img/466505_1_En_15_Fig12_HTML.png

图 15-12

嵌套在引发异常的外部parallel_for中的parallel_for

假设四个不同的任务正在运行外层循环的四个不同迭代i,并调用内层循环parallel_for。在那种情况下,我们可能会得到一个类似于图 15-13 的TGC树。

../img/466505_1_En_15_Fig13_HTML.jpg

图 15-13

图 15-12 中代码的可能 TGCs 树

这意味着当我们在外部循环的第一次迭代中到达关键字throw时,有几个内部循环正在运行。然而,外层中的异常向下传播,也取消了内部并行循环,不管它们在做什么。这种全局取消的可见结果是,一些正在将值从 false 更改为 true 的行被中断,因此这些行将具有一些 true 值和一些 false 值。

但是看,每一行都有一个名为root的孤立的task_group_context,这要感谢这一行:


tbb::task_group_context root(task_group_context::isolated);

现在,如果我们将这个TGC根作为内部parallel_for的最后一个参数传递,取消对该行的注释:

../img/466505_1_En_15_Figf_HTML.png

我们得到了TGC的不同配置,如图 15-14 所示。

../img/466505_1_En_15_Fig14_HTML.jpg

图 15-14

TGC 的不同配置

在这种新的情况下,异常引发了抛出它的TGCTGC A的取消,但是没有TGC A的子节点可以取消。现在,如果我们检查数组data的值,我们将看到行要么全部是真元素,要么全部是假元素,而不是像前一种情况那样是混合元素。这是因为一旦内部循环开始用真值设置一行,就不会中途取消。

在更一般的情况下,如果我们可以这样说图 15-4 中的 TGC 树的森林,如果一个嵌套算法抛出一个在任何级别都没有被捕获的异常,会发生什么呢?例如,让我们假设在图 15-15 的TGC s 的树中,在流图(TGC B)内部抛出了一个异常。

../img/466505_1_En_15_Fig15_HTML.jpg

图 15-15

嵌套 TBB 算法中引发的异常的影响

当然,TGC B和后代TGCs DE也被取消了。我们知道。但是异常向上传播,并且如果在那个级别它也没有被捕获,它也将引发TGC A中任务的取消,并且因为取消向下传播,TGC C也死亡。太好了。这是预期的行为:一个异常,不管它被抛出到什么级别,都可以优雅地抛弃整个并行算法(就像它抛弃串行算法一样)。我们可以通过在期望的级别捕获异常或者通过在隔离的TGC中配置所需的嵌套算法来防止取消链。是不是很整洁?

摘要

在本章中,我们看到取消 TBB 并行算法和使用异常处理来管理运行时错误是很简单的。如果我们采用默认行为,这两个特性都可以按预期的那样开箱即用。我们还讨论了 TBB 的一个重要特征,任务组上下文,TGC。这个元素是 TBB 中取消和异常处理实现的关键,可以手动利用它来更好地控制这两个特性。我们开始讲述取消操作,解释一个任务如何取消它所属的整个TGC。然后我们回顾了如何手动设置任务映射到的TGC,以及当开发人员没有指定映射时应用的规则。默认规则导致预期的行为:如果一个并行算法被取消,那么所有嵌套的并行算法也被取消。然后我们继续讨论异常处理。同样,TBB 异常的行为类似于顺序代码中的异常,尽管 TBB 的内部实现要复杂得多,因为由一个线程执行的一个任务中抛出的异常可能最终被另一个线程捕获。当编译器支持 C++11 特性时,可以在线程之间移动异常的精确副本,否则,在tbb::captured_exception中捕获异常的摘要,以便可以在并行上下文中重新抛出。我们还描述了如何使用类模板tbb::movable_exception配置我们自己的异常类。最后,我们通过阐述可组合性、取消和异常处理是如何相互作用的来结束这一章。

更多信息

以下是我们推荐的一些与本章相关的额外阅读材料:

Creative Commons

开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。

本章中的图像或其他第三方材料包含在该章的知识共享许可中,除非该材料的信用额度中另有说明。如果材料未包含在本章的知识共享许可中,并且您的预期用途不被法定法规允许或超出了允许的用途,您将需要直接从版权所有者处获得许可。

十六、调优 TBB 算法:粒度、局部性、并行性和确定性

在第二章中,我们描述了 TBB 图书馆提供的通用并行算法,并给出了几个例子来展示如何使用它们。在这样做的时候,我们注意到算法的默认行为通常是足够好的,但是我们声称如果需要的话,有办法调整性能。在这一章中,我们通过回顾一些 TBB 算法来支持这一观点,并讨论可以用来改变它们默认行为的重要特性。

有三个问题将主导我们的讨论。第一个是粒度——任务完成的工作量。TBB 库在调度任务方面很有效,但是我们需要考虑我们的算法将创建的任务的大小,因为任务大小会对性能产生重大影响,特别是如果任务非常小或非常大。第二个问题是数据局部性。正如在前言中详细讨论的,应用程序如何使用缓存和内存可以决定应用程序的性能。最后一个问题是可用的并行性。使用 TBB 时,我们的目标当然是引入并行性,但我们不能在不考虑粒度和位置的情况下盲目地这么做。调优应用程序的性能通常是在这三个问题之间进行权衡的一项工作。

TBB 算法和其他接口(如并行 STL)的一个关键区别是,TBB 算法提供了钩子和特性,让我们围绕这三个问题来影响它们的行为。TBB 算法不仅仅是我们无法控制的黑匣子!

在这一章中,我们将首先讨论任务粒度,并得出一个关于任务大小的经验法则。然后,我们将关注简单的循环算法,以及如何使用范围和分割器来控制任务粒度和数据局部性。我们还简要讨论了确定性及其在性能调优时对灵活性的影响。在本章的最后,我们将注意力转向 TBB 流水线算法,并讨论其特性如何影响粒度、数据局部性和最大并行度。

任务粒度:多大才算够大?

为了让 TBB 库在跨线程平衡负载方面拥有最大的灵活性,我们希望将一个算法完成的工作分成尽可能多的部分。同时,为了最小化工作窃取和任务调度的开销,我们希望创建尽可能大的任务。因为这些力是相互对立的,所以一个算法的最佳性能是在中间的某个地方找到的。

更复杂的是,确切的最佳任务大小因平台和应用程序而异,因此没有放之四海而皆准的确切准则。尽管如此,有一个大概的数字作为粗略的指导还是很有用的。考虑到这些警告,我们因此提供以下经验法则:

经验法则

TBB 任务应该平均大于 1 微秒,以有效地隐藏偷工减料的开销。这相当于几千个 CPU 周期——如果您喜欢使用周期,我们建议使用 10,000 个周期的经验法则。

重要的是要记住,不是每个任务都需要大于 1 微秒,事实上,这通常是不可能的。例如,在分而治之的算法中,我们可能使用小任务来划分工作,然后在叶子上使用更大的任务。这就是 TBB parallel_for算法的工作原理。TBB 任务既用于分割范围,又用于将主体应用于最终的子范围。分割任务通常做很少的工作,而循环体任务要大得多。在这种情况下,我们不能使所有的任务都大于 1 微秒,但是我们可以致力于使任务大小的平均值大于 1 微秒。

当我们使用像parallel_invoke这样的算法或者直接使用 TBB 任务时,我们可以完全控制任务的大小。例如,在第二章中,我们使用parallel_invoke实现了并行版本的快速排序,并在数组大小(以及任务执行时间)低于截止阈值时将递归并行实现定向到串行实现:

../img/466505_1_En_16_Figa_HTML.png

当我们使用简单的循环算法时,比如parallel_forparallel_reduceparallel_scan,它们的范围和划分器参数为我们提供了我们需要的控制。我们将在下一节更详细地讨论这些。

为循环选取范围和分割器

正如第二章所介绍的,一个范围代表一组递归可分的值——通常是一个循环的迭代空间。我们使用带有简单循环算法的范围:parallel_forparallel_reduceparallel_deterministic_reduceparallel_scan。TBB 算法划分其范围,并使用 TBB 任务将算法的主体对象应用于这些子范围。与分割器相结合,范围提供了一种简单但强大的方法来表示迭代空间,并控制如何将它们划分为任务和分配给工作线程。这种划分可用于调整任务粒度和数据局部性。

要成为一个范围,一个类必须模拟如图 16-1 所示的范围概念。范围可以被复制,可以使用拆分构造器进行拆分,并且可以可选地提供比例拆分构造器。它还必须提供检查它是否为空或可分的方法,并提供一个布尔常量,如果它定义了比例分割构造器,则该常量为真。

../img/466505_1_En_16_Fig1_HTML.png

图 16-1

范围概念

虽然我们可以定义自己的范围类型,但 TBB 库提供了如图 16-2 所示的阻塞范围,这将涵盖大多数情况。例如,我们可以用blocked_range2d<int, int> r(i_begin, i_end, j_begin, j_end )来表示以下嵌套循环的迭代空间:

../img/466505_1_En_16_Figb_HTML.png

../img/466505_1_En_16_Fig2_HTML.png

图 16-2

TBB 图书馆提供的封锁范围

对于感兴趣的读者,我们在本章末尾的“深入讨论”部分描述了如何定义一个自定义范围类型。

划分器概述

除了范围,TBB 算法还支持指定算法如何划分其范围的划分器。不同的分隔器类型如图 16-3 所示。

../img/466505_1_En_16_Fig3_HTML.png

图 16-3

TBB 图书馆提供的隔板

一个simple_partitioner用于递归划分一个范围,直到它的is_divisible方法返回 false。对于被阻止的范围类型,这意味着该范围将被分割,直到其大小小于或等于其粒度。如果我们已经高度调整了我们的粒度(我们将在下一节讨论这个),我们希望使用一个simple_partitioner,因为它确保最终的子范围符合提供的粒度。

一个auto_partitioner使用一个动态算法来充分分割一个范围以平衡负载,但是它不一定像is_divisible所允许的那样细分一个范围。当与 blocked range 类一起使用时,粒度仍然为最终块的大小提供了一个下限,但是由于auto_partitioner可以决定使用更大的粒度,所以它就不那么重要了。因此,使用粒度为 1 并让auto_partitioner决定最佳粒度通常是可以接受的。在 TBB 2019 中,parallel_forparallel_reduceparallel_scan使用的默认划分器类型是粒度为 1 的auto_partitioner

A static_partitioner尽可能均匀地在工作线程上分配范围,没有进一步负载平衡的可能性。工作分配和线程映射是确定性的,只取决于迭代次数、粒度和线程数量。在所有分区中,static_partitioner的开销最低,因为它不做动态决策。使用static_partitioner还可以改善缓存行为,因为调度模式将在同一个循环的执行中重复。然而,A static_partitioner严重限制了负载平衡,因此需要谨慎使用。在“使用static_partitioner”一节中,我们将重点介绍static_partitioner的优点和缺点。

affinity_partitioner结合了auto_partitionerstatic_partitioner的优点,如果在相同的数据集上重新执行循环时重用相同的分割器对象,则可以提高缓存亲和力。与static_partitioner一样,affinity_partitioner最初创建一个统一的分布,但允许额外的负载平衡。它还记录了哪个线程执行了该范围的哪个块,并试图在后续执行中重新创建这种执行模式。如果一个数据集完全适合处理器的缓存,重复调度模式可以显著提高性能。

选择粒度(或不选择粒度)来管理任务粒度

在本章的开始,我们谈到了任务粒度的重要性。当我们使用阻塞范围类型时,我们应该总是高度调整我们的粒度,对吗?不一定。在使用阻塞范围时,选择正确的粒度可能极其重要——或者几乎无关紧要——这完全取决于所使用的划分器。

如果我们使用一个simple_partitioner,粒度是传递给主体的范围大小的唯一决定因素。当使用simple_partitioner时,范围被递归细分,直到is_divisible返回 false。相比之下,所有其他划分器都有自己的内部算法来决定何时停止划分范围。选择 1 的粒度对于那些只使用is_divisible作为下限的划分器来说已经足够了。

为了演示粒度对不同划分器的影响,我们可以使用一个简单的parallel_for微基准测试,并改变循环中的迭代次数(N)、粒度、每次循环迭代的执行时间以及划分器。

../img/466505_1_En_16_Fig4_HTML.png

图 16-4

使用划分器(p)、粒度(gs)和每次迭代时间(tpi)来测量用N次迭代执行parallel_for的时间的函数

本章介绍的所有性能结果都是在单插槽服务器上收集的,该服务器采用英特尔至强处理器 E3-1230,具有四个内核,每个内核支持两个硬件线程;该处理器的基本频率为 3.4 GHz,共享 8 MB 三级高速缓存,每核 256 KB L2 高速缓存。该系统运行的是 SUSE Linux Enterprise Server 12。所有样本均使用英特尔 C++ 编译器 19.0 线程构建模块 2019 进行编译,使用编译器标志“–STD = c++ 11–O2–TBB”。

图 16-5 显示了图 16-4 中的程序在 N=2 18 时的结果,使用了 TBB 可用的每种划分器类型,粒度范围也有所不同。我们可以看到,对于非常小的10 n s 的time_per_iteration,当粒度为> = 128 时,simple_partitioner接近另一个partitioner的最大性能。随着每次迭代时间的增加,simple_partitioner更快地接近最大性能,因为需要更少的迭代来克服调度开销。

../img/466505_1_En_16_Fig5_HTML.png

图 16-5

加速不同的分区类型和增加粒度。正在测试的循环中的总迭代次数是 2 18 == 262144

对于图 16-5 中显示的除simple_partitioner之外的所有划分器类型,我们看到从粒度 1 到 4096 的最大性能。我们的平台有 8 个逻辑内核,因此我们需要一个小于或等于 2 18 /8 == 32,768 的粒度来为每个线程提供至少一个块;因此,在粒度达到 32768 之后,所有的划分器都开始变小。我们可能还会注意到,在粒度为 4096 的情况下,auto_partitioneraffinity_partitioner在所有图中都表现出性能下降。这是因为选择大粒度限制了这些算法的选择,干扰了它们完成自动划分的能力。

这个小实验证实了粒度对simple_partitioner至关重要。我们可以使用一个simple_partitioner来手动选择任务的大小,但是当我们这样做的时候,我们需要更准确地选择。

第二点是,当主体大小接近 1 us (10ns x 128 = 1.28 us)时,可以看到高效的执行,加速接近线性上限。这个结果加强了我们在本章前面介绍的经验法则!这并不奇怪,因为像这样的经验和实验首先是我们的经验法则的原因。

范围、分区器和数据缓存性能

范围和分区器可以通过启用缓存无关算法或启用缓存关联来提高数据缓存性能。当数据集太大而无法放入数据缓存时,缓存无关算法非常有用,但是如果使用分而治之的方法解决这个问题,就可以在算法中重用数据。相比之下,当数据集完全适合缓存时,缓存相似性非常有用。高速缓存关联用于将一个范围的相同部分重复调度到相同的处理器上,以便可以从相同的高速缓存中再次访问适合高速缓存的数据。

缓存无关算法

高速缓存不经意算法是一种不依赖于硬件高速缓存参数知识就能实现良好(甚至最佳)使用数据高速缓存的算法。该概念类似于循环平铺或循环分块,但不需要精确的平铺或分块大小。缓存无关算法通常递归地将问题分成越来越小的子问题。在某种程度上,这些小的子问题开始适合机器的缓存。递归细分可能会一直持续到尽可能小的大小,或者可能会有一个效率分界点,但这个分界点是与缓存大小相关的 而不是 ,并且通常会创建访问大小远低于任何合理缓存大小的数据的模式。

因为高速缓存无关算法对高速缓存性能一点也不感兴趣,我们已经听到了许多其他建议的名称,例如高速缓存不可知,因为这些算法针对它们遇到的任何高速缓存进行优化;和缓存偏执,因为他们假设可以有无限级缓存。但是缓存遗忘是文献中使用的名称,并且它已经被记住了。

这里,我们将使用矩阵转置作为一个算法示例,它可以从缓存无关的实现中获益。矩阵转置的非缓存无关串行实现如图 16-6 所示。

../img/466505_1_En_16_Fig6_HTML.png

图 16-6

矩阵转置的串行实现

为了简单起见,让我们假设四个元素适合我们机器中的一个缓存行。图 16-7 显示了在 N×N 矩阵a的前两行转置期间将被访问的缓存行。如果高速缓存足够大,它可以在第一行a的转置期间保留在b中访问的所有高速缓存行,而不需要在第二行a的转置期间重新加载这些高速缓存行。但是如果它不够大,这些缓存线将需要重新加载——导致每次访问矩阵b时缓存未命中。在图中,我们展示了一个 16×16 的阵列,但是想象一下如果它非常大。

../img/466505_1_En_16_Fig7_HTML.png

图 16-7

转置矩阵a的前两行时访问的缓存行。为简单起见,我们在每个高速缓存行中显示四个项目。

该算法的高速缓存无关实现减少了在相同高速缓存行或数据项的重复使用之间访问的数据量。如图 16-8 所示,如果我们在移动到矩阵a的其他块之前,只专注于转置矩阵a的一个小块,我们可以减少缓存行的数量,这些缓存行保存需要保留在缓存中的 b 的元素,以获得缓存行重用带来的性能提升。

../img/466505_1_En_16_Fig8_HTML.png

图 16-8

一次转置一个块可以减少需要保留的缓存行的数量,从而有利于重用

图 16-9 显示了矩阵转置的缓存无关实现的串行实现。它沿ij维度递归细分问题,并在范围低于阈值时使用串行 for 循环。

../img/466505_1_En_16_Fig9_HTML.png

图 16-9

矩阵转置的串行高速缓存无关实现

因为实现在ij方向的划分之间交替,矩阵a使用图 16-10 所示的遍历模式转置,首先完成块 1,然后 2,然后 3,等等。如果gs是 4,我们的缓存行大小是 4,我们在每个块内得到重用,如图 16-8 所示。但是,如果我们的缓存行是 8 项而不是 4 项(这对于实际系统来说更有可能),我们不仅可以在最小的块内重用,还可以跨块重用。例如,如果数据高速缓存可以保留在块 1 和块 2 期间加载的所有高速缓存行,则当转置块 3 和块 4 时,这些高速缓存行将被重用。

../img/466505_1_En_16_Fig10_HTML.png

图 16-10

一种遍历模式,在移动到其他块之前计算a的子块的转置

这就是缓存无关算法的真正威力——我们不需要确切知道内存层次结构的级别大小。随着子问题变得越来越小,它们在内存层次结构中的位置也越来越小,从而提高了每一级的重用性。

TBB 循环算法和 TBB 调度程序是专门为支持高速缓存无关算法而设计的。因此,我们可以使用图 16-11 所示的parallel_forblocked_range2dsimple_partitioner快速实现矩阵转置的缓存无关并行实现。我们使用一个blocked_range2d,因为我们希望迭代空间被细分成二维块。我们使用simple_partitioner,因为只有当块被细分为小于缓存大小时,我们才能从重用中获益;其他类型的分区器优化负载平衡,因此如果范围大小足以平衡负载,可以选择更大的范围大小。

../img/466505_1_En_16_Fig11_HTML.png

图 16-11

矩阵转置的高速缓存无关并行实现,使用一个simple_partitioner、一个blocked_range2d和一个粒度(gs)

图 16-12 显示了 TBB parallel_for递归细分范围的方式创建了我们想要的缓存无关实现的相同块。TBB 调度器的深度优先工作和宽度优先窃取行为也意味着块将以类似于图 16-10 所示的顺序执行。

../img/466505_1_En_16_Fig12_HTML.png

图 16-12

blocked2d_range 的递归细分提供了一个与我们的高速缓存无关并行实现所需的块相匹配的划分

图 16-13 显示了图 16-9 中串行缓存无关实现的性能,使用 1D blocked_range的实现的性能,以及类似于图 16-11 中的blocked_range2d实现的性能。我们实现了我们的并行版本,这样我们可以很容易地改变粒度和划分器。所有版本的代码都可以在fig_16_11.cpp找到。

在图 16-13 中,我们展示了与图 16-6 中的简单串行实现相比,我们在 8192×8192 矩阵上实现的加速。

../img/466505_1_En_16_Fig13_HTML.jpg

图 16-13

在我们的测试机上,对于 N=8192,使用不同粒度和划分器的加速比

矩阵转置受限于我们读写数据的速度——没有任何计算。从图 16-13 中我们可以看到,不管我们使用的粒度大小如何,我们的 1D blocked_range并行实现比我们的简单串行实现性能更差。串行实现已经受到内存带宽的限制——添加额外的线程只会给已经不堪重负的内存子系统增加更多压力,而且于事无补。

我们的串行缓存忽略算法对内存访问进行了重新排序,减少了缓存未命中的数量。它明显优于简单版本。当我们在我们的并行实现中使用一个blocked_range2d时,我们同样得到 2D 细分。但是正如我们在图 16-13 中看到的,只有当我们使用一个simple_partitioner时,它才完全表现得像一个缓存无关的算法。事实上,我们的高速缓存无关并行算法通过一个blocked_range2d和一个simple_partitioner降低了内存层次的压力,现在使用多线程可以提高串行高速缓存无关实现的性能!

不是所有的问题都有缓存无关的解决方案,但是很多常见的问题都有。值得花时间研究问题,看看缓存无关的解决方案是否可行,是否值得。如果是这样,阻塞范围类型和simple_partitioner将使得用 TBB 算法实现一个变得非常容易。

缓存相似性

缓存无关算法通过将具有数据局部性但不适合缓存的问题分解成适合缓存的较小问题来提高缓存性能。相比之下,高速缓存相似性解决了跨已经适合高速缓存的数据重复执行范围的问题。由于数据适合缓存,如果在后续执行中将相同的子范围分配给相同的处理器,则可以更快地访问缓存的数据。我们可以使用affinity_partitionerstatic_partitioner来为 TBB 循环算法启用缓存关联。图 16-14 显示了一个简单的微基准,它为 1D 数组中的每个元素增加一个值。该函数接收对分割器的引用——我们需要接收分割器作为在affinity_partitioner对象中记录历史的引用。

../img/466505_1_En_16_Fig14_HTML.png

图 16-14

使用 TBB parallel_for向 1D 数组的所有元素添加值的函数

为了查看缓存关联性的影响,我们可以重复执行这个函数,为N发送相同的值,并发送相同的数组a。当使用auto_partitioner时,线程子范围的调度将随着调用的不同而不同。即使数组a完全适合处理器的缓存,在随后的执行中,a的相同区域可能不会落在同一个处理器上:

../img/466505_1_En_16_Figc_HTML.png

然而,如果我们使用一个affinity_partitioner,TBB 库将记录任务调度,并使用相似性提示在每次执行时重新创建它(参见第十三章了解更多关于相似性提示的信息)。因为历史记录在分区器中,所以我们必须在后续执行中传递相同的分区器对象,而不能像使用auto_partitioner那样简单地创建一个临时对象:

../img/466505_1_En_16_Figd_HTML.png

最后,我们还可以使用一个static_partitioner来创建缓存亲缘关系。因为当我们使用static_partitioner时调度是确定的,所以我们不需要为每次执行传递相同的 partitioner 对象:

../img/466505_1_En_16_Fige_HTML.png

我们在测试机上使用 N=100,000 和 M=10,000 执行了这个微基准测试。我们的 doubles 数组的大小将是 100,000 × 8 = 800 K。我们的测试机器有四个 256 K L2 数据缓存,每个内核一个。使用affinity_partitioner时,测试完成速度比使用auto_partitioner时快 1.4 倍。当使用static_partitioner时,测试完成速度比使用auto_partitioner!时快 2.4 倍,因为数据能够适合 L2 缓存的总大小(4 × 256 K = 1 MB),重放相同的调度对执行时间有显著影响。在下一节中,我们将讨论为什么在这种情况下static_partitioner的表现优于auto_partitioner,以及为什么我们不应该对此过于惊讶或兴奋。如果我们将 N 增加到 1,000,000 个元素,我们将不再看到执行时间的巨大差异,因为数组a现在太大了,不适合我们测试系统的缓存——在这种情况下,有必要重新思考实现平铺/分块以利用缓存局部性的算法。

使用static_partitioner

static_partitioner是开销最低的分区器,它可以在一个竞技场中的线程间快速提供阻塞范围的均匀分布。由于分区是确定性的,所以当一个循环或一系列循环在同一范围内重复执行时,它还可以改善缓存行为。在上一节中,我们看到它在微基准测试中明显优于affinity_partitioner。但是,因为它创建的块刚好够给竞技场中的每个线程提供一个块,所以没有机会通过工作窃取来动态平衡负载。实际上,static_partitioner禁用了 TBB 图书馆的工作窃取调度方法。

尽管 TBB 有一个很好的理由将static_partitioner包括在内。随着内核数量的增加,随机窃取工作变得更加昂贵;尤其是当从应用程序的串行部分过渡到并行部分时。当主线程第一次产生新的工作时,所有的工作线程都会醒来,像一群雷鸣般的试图找到工作去做。更糟糕的是,他们不知道去哪里查找,开始随机地不仅查看主线程的 dequee,还查看彼此的本地 dequee。一些工作线程最终会在主线程中找到该工作并对其进行细分,另一个工作线程最终会找到这个细分的片段,对其进行细分,以此类推。过了一段时间,事情就会稳定下来,所有的工人都会找到事情做,并愉快地在他们自己的地方工作。

但是,如果我们已经知道工作负载得到了很好的平衡,系统没有超额预订,并且我们所有的内核都同样强大,那么我们真的需要所有这些窃取工作的开销来在工作人员之间实现均匀分布吗?如果我们用一个static_partitioner就不会!它就是为这种情况而设计的。它将任务均匀地分配给工作线程,这样它们就不必窃取任务了。当应用时,static_partitioner是划分循环最有效的方式。

但是不要对static_partitioner!过于兴奋,如果工作负载不均匀或者任何内核都超额订阅了额外的线程,那么使用static_partitioner会破坏性能。例如,图 16-15 显示了我们在图 16-5(c) 中用来检验粒度对性能影响的相同微基准配置。但是图 16-15 显示了如果我们添加一个在其中一个内核上运行的额外线程会发生什么。对于除了static_partitioner之外的所有线程,由于额外的线程,影响很小。然而,static_partitioner假设所有的内核能力相同,并在它们之间均匀地分配工作。结果,过载的内核成为瓶颈,加速性能受到严重影响。

../img/466505_1_En_16_Fig15_HTML.jpg

图 16-15

当一个额外的线程在后台执行自旋循环时,不同分区类型的加速和粒度的增加。每次迭代的时间被设置为 1 us。

图 16-16 显示了一个工作随着每次迭代而增加的循环。如果使用了一个static_partitioner,得到最低迭代集的线程将比得到最高迭代集的不幸线程有更少的工作要做。

../img/466505_1_En_16_Fig16_HTML.png

图 16-16

在每次迭代中工作量增加的循环

如果我们使用 N=1000 的每种划分器类型运行图 16-16 中的循环十次,我们会看到以下结果:


auto_partitioner = 0.629974 seconds
affinity_partitioner = 0.630518 seconds
static_partitioner = 1.18314 seconds

auto_partitioneraffinity_partitioner能够在线程间重新平衡负载,而static_partitioner仍坚持其最初的统一但不公平的分配。

因此,static_partitioner几乎只在高性能计算(HPC)应用中有用。这些应用程序运行在具有多个内核的系统上,并且通常以批处理模式运行,即一次运行一个应用程序。如果工作负载不需要 任何 的动态负载平衡,那么static_partitioner将几乎总是优于其他划分器。不幸的是,平衡良好的工作负载和单用户、批处理模式的系统是例外,而不是规则。

限制调度程序以实现确定性

在第二章中,我们讨论了结合律和浮点类型。我们注意到浮点数的任何实现都是近似的,所以当我们依赖于结合性或交换性等属性时,并行性会导致不同的结果——这些结果不一定是错误的;他们只是不同而已。尽管如此,在归约的情况下,如果我们想确保在同一台机器上对相同的输入数据执行时得到相同的结果,TBB 提供了一个parallel_deterministic_reduce算法。

正如我们可能猜测的那样,parallel_deterministic_reduce只接受simple_partitionerstatic_partitioner,因为子范围的数量对于这两种划分器类型都是确定的。无论有多少线程动态参与执行,任务如何映射到线程,在给定的机器上,parallel_deterministic_reduce也总是执行相同的一组拆分和连接操作——而parallel_reduce算法可能不会。结果是parallel_deterministic_reduce在同一台机器上运行时总是返回相同的结果——但是牺牲了一些灵活性。

图 16-17 显示了使用parallel_reduce ( r-autor-simpler-static)和parallel_deterministic_reduce ( d-simpled-static)实现时第二章中 pi 计算示例的加速。两者的最大加速是相似的;然而,auto_partitioner对于parallel_reduce来说表现很好,而这根本不是parallel_deterministic_reduce.的选项。如果需要,我们可以实现我们的基准的确定性版本,但必须处理选择良好粒度的复杂性。

虽然parallel_deterministic_reduce会有一些额外的开销,因为它必须执行所有的拆分和连接,但这种开销通常很小。更大的限制是我们不能使用任何自动为我们找到块大小的分割器。

../img/466505_1_En_16_Fig17_HTML.jpg

图 16-17

使用带有一个auto_partitioner ( r-auto)、一个simple_partitioner ( r-simple)和一个static_partitioner ( r-static)的parallel_reduce,加速第二章中的 pi 示例;还有parallel_deterministic_reduce带一个simple_partitioner ( d-simple)和一个static_partitioner ( d-static)。我们显示了粒度范围从 1 到N的结果。

优化 TBB 管道:过滤器、模式和令牌的数量

正如循环算法一样,TBB 流水线的性能受到粒度、位置和可用并行度的影响。与循环算法不同,TBB 管道不支持范围和分割器。相反,用于优化管道的控制包括过滤器数量、过滤器执行模式以及运行时传递给管道的令牌数量。

TBB 管道过滤器是作为任务产生的,并由 TBB 库调度,因此,正如循环算法创建的子范围一样,我们希望过滤器体执行足够长的时间以减少开销,但我们也希望有足够的并行性。我们通过将工作分解成过滤器来平衡这些关注。由于最慢的串行级将成为瓶颈,因此过滤器还应该在执行时间上很好地平衡。

如第二章所述,管道过滤器也是用执行模式创建的:serial_in_orderserial_out_of_orderparallel。使用serial_in_order模式时,一个过滤器一次最多只能处理一个项目,并且必须按照第一个过滤器生成它们的顺序进行处理。一个serial_out_of_order过滤器被允许以任何顺序执行项目。允许对不同的项目并行执行一个parallel过滤器。我们将在本节的后面讨论这些不同的模式是如何限制性能的。

运行时,我们需要为 TBB 管道提供一个max_number_of_live_tokens参数,该参数约束在任何给定时间允许流经管道的项目数量。

图 16-18 显示了我们将用来探索这些不同控件的微基准的结构。在图中,两个管道都显示有八个过滤器,但我们将在实验中改变这个数字。顶部管道的过滤器使用相同的执行mode,,并且都有相同的spin_time——所以这代表了一个非常平衡的管道。底部管道有一个比imbalance * spin_time旋转的过滤器——我们将改变这个不平衡因子,看看不平衡对加速的影响。

../img/466505_1_En_16_Fig18_HTML.png

图 16-18

平衡的管道微基准和不平衡的管道微基准

了解平衡的管道

让我们首先考虑一下我们对于任务大小的经验法则在管道中的应用情况。1 微秒的滤波器体足以减少开销吗?图 16-19 显示了在仅使用单个令牌的情况下,当输入 8000 个项目时,我们的平衡管道微基准测试的加速。显示了不同过滤器执行时间的结果。因为只有一个令牌,所以一次只允许一个项目流过管道。结果是管道的序列化执行(即使过滤器执行模式设置为并行)。

../img/466505_1_En_16_Fig19_HTML.jpg

图 16-19

当在我们的测试机器上执行具有八个过滤器、一个令牌和 8000 个项目的平衡管道时,不同过滤器执行模式所看到的开销

与真正的串行执行相比,在真正的串行执行中,我们在 for 循环中执行适当数量的旋转,我们看到了将工作管理为 TBB 流水线的影响。在图 16-19 中,我们看到当spin_time接近 1 微秒时,开销相当低,我们非常接近真正串行执行的执行时间。似乎我们的经验法则也适用于 TBB 管道!

现在,让我们看看过滤器的数量如何影响性能。在串行流水线中,并行性仅来自不同滤波器的重叠。在具有并行过滤器的流水线中,并行性也通过对不同的项目同时执行并行过滤器来获得。我们的目标平台支持八个线程,因此我们预计并行执行的加速比最多为 8。

图 16-20 显示了当令牌数设置为 8 时,我们的平衡管道微基准测试的加速。对于这两种串行模式,加速会随着滤波器数量的增加而增加。记住这一点很重要,因为串行流水线的加速不像 TBB 循环算法那样随数据集大小而变化。然而,包含所有并行过滤器的平衡流水线即使只有一个过滤器也具有 8 倍的加速比。这是因为 8000 个输入项可以在单个过滤器中并行处理——没有串行过滤器会成为瓶颈。

../img/466505_1_En_16_Fig20_HTML.jpg

图 16-20

当执行具有 8 个令牌、8000 个项目和不断增加的过滤器数量的平衡管道时,不同的过滤器执行模式实现的加速。过滤器旋转 100 微秒。

在图 16-21 中,我们看到了使用八个过滤器但令牌数量不同时,我们的平衡流水线的加速。因为我们的平台有八个线程,如果我们的令牌少于八个,那么就没有足够的项目来保持所有线程的忙碌。一旦管道中至少有八个项目,所有线程都可以参与。将令牌数量增加到八个以上对性能几乎没有影响。

../img/466505_1_En_16_Fig21_HTML.jpg

图 16-21

当执行具有八个过滤器、8000 个项目和不断增加的令牌数量的平衡管道时,不同过滤器执行模式实现的加速。过滤器旋转 100 微秒。

了解不平衡的管道

现在,让我们看看图 16-18 中不平衡管道的性能。在这个微基准测试中,除了一个过滤器旋转了spin_time * imbalance秒之外,所有的过滤器都旋转了spin_time秒。因此,当N物品通过我们带有八个过滤器的不平衡管道时,处理这些物品所需的工作量为

{T}_1=N\ast \left(7\ast spin\_ time+ spin\_ time\ast imbalance\right)

在稳定状态下,串行流水线受到最慢串行级的限制。当不平衡滤波器以串行模式执行时,同一流水线的临界路径长度等于

{T}_{\infty }=N\ast \max \left( spin\_ time, spin\_ time\ast imbalance\right)

图 16-22 显示了在我们的测试平台上使用不同的不平衡系数执行不平衡流水线的结果。我们还包括理论上的最大加速,标记为“工作/关键路径”,计算为

Speedu{p}_{\mathrm{max}}=\frac{7\ast \mathrm{spin}\_\mathrm{time}+\mathrm{spin}\_\mathrm{time}\ast \mathrm{imbalance}}{\max \left(\mathrm{spin}\_\mathrm{time},\kern0.5em \mathrm{spin}\_\mathrm{time}\ast \mathrm{imbalance}\right)}

不出所料,图 16-22 显示串行流水线受到最慢滤波器的限制——测量结果接近我们的工作/关键路径长度计算预测。

../img/466505_1_En_16_Fig22_HTML.jpg

图 16-22

当执行具有八个过滤器、8000 个项目和不同不平衡因子的不平衡管道时,不同过滤器执行模式实现的加速。七个过滤器旋转 100 微秒,其他的旋转imbalance * 100微秒。

相比之下,图 16-22 中的并行管道不受最慢阶段的限制,因为 TBB 调度程序可以将最慢过滤器的执行与同一过滤器的其他调用重叠。您可能想知道将令牌数量增加到八个以上是否会有帮助,但在这种情况下,没有帮助。我们的测试系统只有八个线程,因此我们最多可以重叠最慢过滤器的八个实例。虽然在某些情况下,临时负载不平衡可以通过拥有比线程数量更多的令牌来消除,但在我们的微基准测试中,不平衡是一个常量,我们实际上受到关键路径长度和线程数量的限制,任何数量的额外令牌都不会改变这一点。

但是,在一些算法中,令牌数量不足会妨碍窃取工作的 TBB 调度程序的自动负载平衡功能。这种情况发生在各级不平衡,并且有一系列级使管道停止工作的时候。A. Navarro 等人证明了(参见本章末尾的“更多信息”部分),如果使用正确的令牌数进行适当配置,在 TBB 中实现的流水线算法可以产生最佳性能。她设计了一个基于排队论的分析模型,有助于找到这个关键参数。这篇论文的一个主要观点是,当令牌的数量足够大时,TBB 中的工作窃取模拟了一个能够为所有线程提供服务的全局队列(在排队论中,一个具有由所有资源服务的单个全局队列的理论集中式系统是已知的理想情况)。然而,在现实中,当一个全局单队列由大量线程服务时,它会出现争用。TBB 实现的根本优势在于,它采用了分布式解决方案,每个线程一个队列,由于工作窃取调度器的作用,该队列表现为一个全局队列。也就是说,分散式 TBB 实现像理想的集中式系统一样运行,但是没有集中式系统的瓶颈。

管道和数据局部性以及线程关联性

对于 TBB 循环算法,我们使用阻塞范围类型affinity_partitionerstatic_partitioner来调整缓存性能。TBB parallel_pipeline功能和pipeline类没有类似的选项。但是并没有失去一切!TBB 管道中内置的执行顺序旨在增强时态数据局部性,而无需做任何特殊处理。

当 TBB 主线程或工作线程完成 TBB 过滤器的执行时,它执行流水线中的下一个过滤器,除非该过滤器由于执行模式的限制而不能被执行。例如,如果过滤器 f 0 生成一个项目i,并且其输出被传递到下一个过滤器 f 1 ,运行 f 0 的同一线程将继续执行 f1——除非下一个过滤器是一个serial_out_of_order过滤器,并且它当前正在处理其他东西,或者如果它是一个serial_in_order过滤器并且项目i不是队列中的下一个项目。在这种情况下,该项在下一个过滤器中被缓冲,线程将寻找其他工作去做。否则,为了最大化局部性,线程将跟踪它刚刚生成的数据,并通过执行下一个过滤器来处理该项。

在内部,过滤器f 0 中的一个项目的处理被实现为由线程/核执行的任务。过滤完成后,任务会自行回收(参见第十章中的任务回收)以执行下一个过滤f 1 。本质上,垂死的任务f 0 转世成新的f 1 任务,绕过调度器——执行f 0 的同一线程/内核也将执行f 1 。就数据局部性和性能而言,这比常规/简单的管道实现要好得多:filter f 0 (由一个或多个线程服务)将项目排入 filter f 1 的队列中(其中 f 1 也由一个或多个线程服务)。这种幼稚的实现破坏了局部性,因为由过滤器f 0 在一个核上处理的项目很可能由过滤器f 1 在不同的核上处理。在 TBB,如果f 0f 1 满足前面提到的条件,这种情况永远不会发生。因此,TBB 管道偏向于在管道开始注入更多物品之前完成已经在飞行中的物品;这种行为不仅利用了数据局部性,而且通过减少串行筛选器所需的队列大小,使用了更少的内存。

不幸的是,TBB 管道过滤器不支持相似性提示。没有办法暗示我们想要特定的过滤器在特定的工作线程上执行。但是,也许令人惊讶的是,有一个硬亲和机制。然而,使用thread_bound_filter需要使用更容易出错、类型不安全的tbb::pipeline接口,我们将在下一节“深入讨论”中对此进行描述

杂草深处

本节涵盖了一些 TBB 用户很少使用的功能,但在需要时,它们会非常有用。如果您需要创建自己的范围类型或在 TBB 管道中使用thread_bound_filter,您可以选择跳过这一节,按需阅读。或者,如果你真的想尽可能多地了解 TBB,请继续读下去!

打造您自己的产品系列

正如本章前面提到的,被阻止的范围类型包含了最常见的情况。在我们使用 TBB 的这些年里,我们个人只遇到过少数几个实施我们自己的 Range 类型有意义的情况。但是如果我们需要,我们可以通过实现模拟图 16-1 中描述的范围概念的类来创建我们自己的范围类型。

作为一个有用但非典型的范围类型的例子,我们可以再次回顾快速排序算法,如图 16-23 所示。

../img/466505_1_En_16_Fig23_HTML.png

图 16-23

串行快速排序的实现

在这里,我们将并行化快速排序,而不是作为一个递归算法,而是使用一个parallel_for和我们自己的自定义ShuffleRange.我们的pforQuicksort实现如图 16-24 所示。

../img/466505_1_En_16_Fig24_HTML.png

图 16-24

使用一个parallel_for和一个实现范围的自定义ShuffleRange实现并行快速排序

在图 16-24 中,我们可以看到parallel_for体λ表达式是基础情况,这里我们称之为serialQuicksort。我们还使用了一个simple_partitioner,这意味着我们的范围将被递归分割,直到它从它的is_divisible方法返回 false。因此,快速排序的所有洗牌魔力都需要发生在ShuffleRange类中,因为它将自己分成了子范围。ShuffleRange的等级定义如图 16-24 所示。

ShuffleRange对范围概念建模,定义复制构造器、拆分构造器、empty方法、is_divisible方法和设置为falseis_splittable_in_proportion成员变量。这个类还包含描述数组元素的beginend迭代器以及一个cutoff值。

先说empty。如果其begin迭代器位于或超过其end迭代器,则范围为空。

我们使用临界值来确定是否应该进一步划分范围。记住,我们使用的是simple_partitioner,所以parallel_for将继续划分范围,直到is_divisible返回 false。因此,ShuffleRange is_divisible实现只是对这个截止值的检查。

好了,现在我们可以看看我们实现的核心,图 16-24 所示的ShuffleRange分裂构造器。它接收一个对需要分割的原始ShuffleRange r的引用和一个用于区分这个构造器和复制构造器的tbb::split对象。构造器的主体是基本的旋转和洗牌算法。它将原始范围r更新为左分区,并将新构建的ShuffleRange更新为右分区。

在我们的测试平台上执行我们的pforQuicksort产生的性能结果与第二章中的parallel_invoke实现非常相似。但是这个例子显示了范围概念的灵活性。我们可能认为范围的递归划分在parallel_for中可以忽略不计,但在我们的pforQuicksort实现中却不是这样。我们依靠ShuffleRange的分裂来完成大部分的工作。

管道类和线程绑定过滤器

正如我们在本章前面的讨论中提到的,tbb::parallel_pipeline不支持相似性提示。我们不能表达我们更喜欢特定的过滤器在特定的线程上执行。然而,如果我们使用旧的、线程不安全的tbb::pipeline类,那么它支持线程绑定过滤器!TBB 工作线程根本不处理这些线程绑定的筛选器;相反,我们需要通过直接调用它们的process_itemtry_process_item函数来显式处理这些过滤器中的项目。

通常情况下,thread_bound_filter并不用于改善数据局部性,而是在过滤器必须在特定线程上执行时使用——可能是因为只有该线程有权访问完成过滤器执行的操作所需的资源。这种情况在实际应用中可能会出现,例如,当一个通信或卸载库要求所有通信都来自一个特定线程时。

让我们考虑一个模拟这种情况的人为例子,其中只有主线程可以访问一个打开的文件。要使用thread_bound_filter,,我们需要使用tbb::pipeline的类型不安全类接口。使用tbb::parallel_pipeline功能时,我们无法创建thread_bound_filter。我们很快就会明白为什么使用带有parallel_pipeline接口的thread_bound_filter是没有意义的。

在我们的例子中,我们创建了三个过滤器。我们的大多数过滤器将继承自tbb::filter,覆盖operator()函数:

../img/466505_1_En_16_Figf_HTML.png

我们的SourceFilter,如图 16-25 所示,是从tbb::filter继承而来的serial_in_order过滤器,产生一系列数字。由tbb::pipeline实现的类型不安全接口要求我们将每个过滤器的输出作为void *返回。NULL用于指示输入流的结束。我们可以很容易地理解为什么新的parallel_pipeline接口在应用时更受青睐。

我们创建的第二个过滤器类型MultiplyFilter,将传入的值乘以 2 并返回它。它也将是一个serial_in_order过滤器,并从tbb::filter继承而来。

最后,BadWriteFilter实现了一个过滤器,将输出写到一个文件中。该类也继承自tbb::filter,如图 16-25 所示。

函数fig_16_25将所有这些类放在一起——同时故意引入一个错误。它使用我们的过滤器类和tbb::pipeline接口创建了一个三级管道。它创建一个管道对象,然后一个接一个地添加每个过滤器。为了运行管道,它调用void pipeline::run(size_t max_number_of_live_tokens)传入八个令牌。

正如我们在运行这个例子时应该预料到的那样,BadWriteFilter wf有时在主线程之外的线程上执行,所以我们看到了输出


Error!
Done.

虽然这个例子看起来有些做作,但是请记住,当需要在特定线程上执行时,我们试图模拟真实的情况。本着这种精神,让我们假设我们不能简单地让所有线程都可以访问ofstream,而是必须在主线程上进行写操作。

../img/466505_1_En_16_Fig25_HTML.png

图 16-25

一个错误的例子,如果BadWriteFilter试图从一个工作线程写入output就会失败

图 16-26 显示了我们如何使用thread_bound_filter来解决这个限制。为此,我们创建了一个从thread_bound_filter.继承而来的过滤器类ThreadBoundWriteFilter。事实上,除了改变该类继承的内容之外,过滤器类的实现与BadWriteFilter相同。

虽然类的实现是相似的,但是我们对过滤器的使用必须有很大的变化,如函数fig_16_26所示。我们现在从一个单独的线程运行管道——我们需要这样做,因为我们必须保持主线程可用,以服务线程绑定过滤器。我们还添加了一个 while 循环,重复调用我们的ThreadBoundWriteFilter对象上的process_item函数。过滤器就是在这里执行的。while 循环一直继续,直到对process_item的调用返回tbb::thread_bound_filter::end_of_stream,表明不再有要处理的项目。

运行图 16-26 中的示例,我们看到我们已经解决了问题:

../img/466505_1_En_16_Fig26_HTML.png

图 16-26

仅从主线程写入output的示例


Done.

摘要

在这一章中,我们深入研究了可以用来调整 TBB 算法的特性。我们围绕调优 TBB 应用程序时的三个常见问题展开讨论:任务粒度、可用并行性和数据局部性。

对于循环算法,我们主要关注阻塞范围类型和不同的划分器类型。我们发现,我们可以使用 1 微秒作为任务应该执行多长时间的一般指导,以减轻任务调度的开销。这一粗略的准则适用于两种循环算法,如parallel_for,也适用于parallel_pipeline中的滤波器尺寸。

我们讨论了如何使用阻塞范围类型来控制粒度以及优化内存层次结构。我们使用了blocked_range2dsimple_partitioner来实现矩阵转置的缓存无关实现。然后,我们展示了如何使用affinity_partitionerstatic_partitioner来重放范围调度,以便相同的线程重复访问相同的数据。我们发现,虽然static_partitioner在以批处理模式执行时对于平衡的工作负载是性能最好的分区器,但是一旦负载不平衡或者系统被过量订阅,它就会因为工作窃取而无法动态平衡负载。然后,我们简要回顾了确定性,描述了deterministic_parallel_reduce如何提供确定性结果,但是只能通过强迫我们使用simple_partitioner并仔细选择粒度,或者使用static_partitioner并牺牲动态负载平衡。

接下来,我们将注意力转向parallel_pipeline以及过滤器数量、执行模式和令牌数量如何影响性能。我们讨论了平衡和不平衡管道的行为。最后,我们还注意到,虽然 TBB 管道没有为我们提供挂钩来调整缓存关联性,但它旨在通过让线程在项目流经管道时跟随项目来实现时间局部性。

我们以一些高级主题结束了这一章,包括如何创建我们自己的范围类型以及如何使用一个thread_bound_filter

更多信息

有关高速缓存无关算法的更多信息:

  • 马特奥·弗里戈、查尔斯·莱瑟森、哈拉尔德·普罗科普和斯里达尔·拉马钱德兰。2012.缓存无关算法。 ACM 运输。算法 8,1,第 4 篇(2012 年 1 月),22 页。

有关流水线并行性的更深入的讨论:

  • Angeles Navarro 等人,“流水线并行性的分析建模”,ACM-IEEE 并行架构和编译技术国际会议(PACT'09)。2009.

如需了解更多关于雷群问题的信息:

Creative Commons

开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。

本章中的图像或其他第三方材料包含在该章的知识共享许可中,除非该材料的信用额度中另有说明。如果材料未包含在本章的知识共享许可中,并且您的预期用途不被法定法规允许或超出了允许的用途,您将需要直接从版权所有者处获得许可。

十七、流程图:超越基础

这一章包含了从 TBB 的流图中获得最佳性能的一些关键提示。TBB 流图 API 的结构化程度较低,这提供了一种需要一些思考才能获得最佳可伸缩性能的表达能力——我们将在本章深入探讨让我们将流图调整到其最大潜力的细节。

在第三章中,我们介绍了tbb::flow名称空间中的类和函数,以及如何用它们来表达简单的数据流和依赖图。在这一章中,我们将讨论使用 TBB 流图时出现的一些更高级的问题。正如在第十六章中,我们的大部分讨论将围绕粒度、有效的内存使用和创建足够的并行性。但是因为流程图 API 让我们表达比第十六章中描述的并行算法更少结构化的并行性,所以我们也将讨论在构建流程图时需要注意的一些注意事项。

从 480 页开始的“关键 FG 建议:该做什么和不该做什么”一节给出了非常具体的经验法则,这些法则在 TBB 中使用流程图时非常有用。

在本章的最后,我们简要介绍了英特尔 Parallel Studio XE 中的一款工具——流程图分析器(FGA)。它为 TBB 流图的图形化设计和分析提供了强有力的支持。虽然在处理流程图时不需要使用 FGA,但在设计和分析过程中可视化图表会非常有帮助。该工具对每个人都是免费的,我们强烈推荐它给任何认真做 TBB 流图工作的人。

针对粒度、局部性和并行性进行优化

在本节中,我们将重点关注推动我们在第十六章中讨论的三个问题。我们首先看看节点粒度对性能的影响。因为流图用于结构化程度较低的算法,所以在讨论粒度时,我们需要考虑并行性是如何引入的——结构是否需要大量窃取,或者任务的生成是否在线程间分布良好?此外,我们可能希望在流程图中使用一些非常小的节点,只是因为它们使设计更加清晰——在这种情况下,我们描述如何使用具有lightweight执行策略的节点来限制开销。我们要解决的第二个问题是数据局部性。与 TBB 并行算法不同,流图 API 不提供像范围和划分器这样的抽象;相反,它旨在自然地增强局部性。我们将讨论线程如何跟踪数据来利用局部性。我们的第三个问题是创建足够的并行性。正如第十六章中所述,针对粒度和局部性的优化有时会以受限的并行性为代价——我们需要确保小心走钢丝。

节点粒度:多大才算够大?

在第十六章中,我们讨论了范围和划分器,以及如何使用它们来确保由 TBB 遗传算法创建的任务足够大,以分摊调度开销,同时又足够小,以提供足够的独立工作项来实现可伸缩性。TBB 流图不支持范围和划分器,但是我们仍然需要关注任务粒度。

为了查看我们在第十六章中介绍的 1 微秒任务的经验法则是否也适用于流图节点,就像它适用于并行算法体一样,我们将探索几个简单的微基准来捕捉流图中可能存在的极端情况。我们将比较四个函数的执行时间,并对每个节点的执行使用不同的工作量。我们将这些功能称为序列FG 循环主循环每个工人的 FG 循环

我们相信,研究这些例子(图 17-1 到 17-4 )对于直观地掌握一些关键问题是至关重要的,这些问题区分了高度可扩展的流程图的使用和令人失望的流程图的使用。附录 B 中完整记录的 API 本身并不提供这种教育——我们希望您能够充分研究这些示例以掌握概念,因为我们相信这将使您更好地充分利用 TBB 流图(查看图 17-5 以查看理解这些图对性能的好处的量化)!).

串行循环是我们的基线,它包含一个 for 循环,调用一个活动的自旋等待函数 N 次,如图 17-1 所示。

../img/466505_1_En_17_Fig1_HTML.png

图 17-1

串行:对基线串行循环计时的功能

FG 循环功能如图 17-2 所示。这个函数构建了一个流图,它有一个从输出到输入的边。单个消息开始循环,然后节点旋转等待并向其输入发送回一个消息。该循环重复 N-1 次。因为节点在将消息发送回输入端之前会旋转,所以这个图仍然是一个串行循环——主体任务中的大部分工作不会重叠。但是,因为消息是在主体返回之前发送的,所以仍然有一小段时间间隔,在此期间另一个线程可以窃取try_put生成的任务。我们可以使用这个图来查看流图基础设施的基本开销。

../img/466505_1_En_17_Fig2_HTML.png

图 17-2

FG 循环:一个为串行流图计时的函数

我们的下一个微基准函数,主循环,如图 17-3 所示,不创建循环。相反,它在串行循环中直接从主线程向multifunction_node发送所有 N 条消息。由于multifunction_node具有无限的并行性,并且串行 for-loop 会非常快地发送消息,因此创建了许多并行任务。然而,因为主线程是唯一调用节点n上的try_put方法的线程,所以所有主体任务都被生成到主线程的本地队列中。参与执行该图的工作线程将被迫窃取它们执行的每个任务——并且只有在它们随机选择主线程作为受害者之后。我们可以使用这个图来查看具有足够并行性的流图的行为,但是这需要大量的工作量。

../img/466505_1_En_17_Fig3_HTML.png

图 17-3

主循环:仅从主线程提交消息的函数;工人必须窃取他们执行的每一项任务

最后,图 17-4 显示了每个工人功能的 FG 循环。这个函数将任务分布在主线程和工作线程的本地队列中,因为一旦一个线程窃取了它的初始任务,它就会将任务生成到它自己的本地队列中。我们可以用这个图来看一个流量图的行为,有非常少量的窃取。

../img/466505_1_En_17_Fig4_HTML.png

图 17-4。

每个工作线程的 FG 循环:这个函数创建的并行度刚好满足工作线程的数量。一旦一个工作者窃取了它的初始任务,它将从它的本地队列中执行它的剩余任务。

除非另有说明,本章中介绍的所有性能结果都是在单插槽服务器上采集的,该服务器采用英特尔至强处理器 E3-1230,具有四个内核,每个内核支持两个硬件线程;该处理器的基本频率为 3.4 GHz,共享 8 MB 三级高速缓存,每核 256 KB L2 高速缓存。该系统运行的是 SUSE Linux Enterprise Server 12。所有样本均使用英特尔 C++ 编译器 19.0 线程构建模块 2019 进行编译,使用编译器标志“–std=c++11 –O2 –tbb”。

我们使用N =65,536 以及 100 纳秒、1 微秒、10 微秒和 100 微秒的自旋等待时间来运行这些微基准测试。我们收集了 10 次试验的平均执行时间,并在图 17-5 中展示了结果。从这些结果中,我们可以看到,当任务大小非常小时,例如 100 纳秒,流图基础设施的开销在所有情况下都会导致性能下降。随着任务大小至少达到 1 微秒,我们开始从并行执行中获益。当我们达到 100 微秒的任务规模时,我们能够达到接近完美的线性加速。

../img/466505_1_En_17_Fig5_HTML.png

图 17-5

不同旋转等待时间的加速比 T 序列 /T 基准

通过在流图分析器(FGA)中收集跟踪并查看结果,我们可以进一步了解我们的微基准测试的性能——本章末尾将更详细地介绍 FGA。图 17-6 显示了当使用 1 微秒的自旋等待时间时,不同函数的每个线程的时间线。这些时间线长度相同,显示了每个线程在一段时间内所做的工作。时间轴中的间隙(灰色)表示线程没有主动执行节点的主体。在图 17-6(a) 中,我们看到了 FG 循环 ,的行为,它就像一个串行循环。但是我们可以看到,主体中的try_put和任务出口之间的小间隙允许任务在线程之间来回切换,因为它们能够在任务产生时窃取每个任务。这部分解释了图 17-5 中所示的微基准测试相当大的开销。正如我们在本章后面所解释的,大多数功能节点在可能的情况下使用调度程序旁路来跟随它们的数据到下一个节点(参见第十六章中关于管道、数据局部性和线程关联性的讨论,以获得为什么调度程序旁路可以提高缓存性能的更详细的讨论)。由于multifunction_node将输出消息直接放入主体实现内部的输出端口,它不能使用调度器旁路立即跟随数据到下一个节点——它必须先完成自己的主体!因此,A multifunction_node不使用调度程序旁路来优化局部性。无论如何,这使得图 17-6(a) 中的性能成为最坏情况下的开销,因为没有使用调度程序旁路。

在图 17-6(b) 中,我们看到主线程正在生成所有的任务,而工作线程必须窃取每个任务,但是任务一旦被窃取就可以并行执行。因为工作线程必须窃取每个任务,所以它们在查找任务时比主线程慢得多。在图 17-6(b) 中,主线程持续忙碌——它可以从其本地队列中快速弹出下一个任务——而工作线程的时间线显示了一些间隙,在这些间隙中,它们相互争斗,以从主线程的本地队列中窃取下一个任务。

图 17-6(c) 显示了每个工作线程的 FG 循环的良好行为,其中每个线程都能够从其本地队列中快速弹出下一个任务。现在我们在时间线上看到很少的间隙。

../img/466505_1_En_17_Fig6_HTML.jpg

图 17-6

当使用 1 微秒的自旋等待时,每个微基准测试的两毫秒时间线区域

观察这些极端的行为并注意图 17-5 中的性能,我们很乐意为流图节点推荐一个类似的经验法则。虽然一个病理案例,如主循环,在 1 微秒的时间内显示了 2.8 的有限加速,但它仍然显示了加速。如果工作更加平衡,比如每个工人使用 **FG 循环,**1 微秒的身体提供了很好的加速。考虑到这些警告,我们再次推荐 1 微秒的执行时间作为一个粗略的准则:

经验法则

为了从并行执行中获益,流图节点的执行时间应该至少为 1 微秒。这相当于几千个 CPU 周期——如果你喜欢使用周期,我们建议一个10,000 cycle经验法则。

就像 TBB 算法一样,这个规则并不意味着我们必须不惜一切代价避免小于 1 微秒的节点。只有当我们的流图的执行时间由小节点支配时,我们才真正有问题。如果我们混合了具有不同执行时间的节点,那么与较大节点的执行时间相比,小节点引入的开销可以忽略不计。

如果节点太小该怎么办

如果流图中的一些节点小于推荐的 1 微秒阈值,则有三种选择:(1)如果该节点对应用程序的总执行时间没有显著影响,则什么都不做,(2)将该节点与周围的其他节点合并以增加粒度,或者(3)使用lightweight执行策略。

如果节点的粒度很小,但是它对总执行时间的贡献也很小,那么可以安全地忽略该节点;就让它保持原样。在这些情况下,清晰的设计可能会胜过任何无关紧要的效率。

如果必须解决节点的粒度问题,一种选择是将其与周围的节点合并。节点真的需要和它的前任和继任者分开封装吗?如果节点只有一个前任或一个继任者,并且具有相同的并发级别,那么它可能很容易与这些节点合并。如果它有多个前趋者或后继者,那么由该节点执行的操作可能会被复制到每个节点中。在任何情况下,如果合并不改变图的语义,将节点合并在一起是一种选择。

最后,在构造节点时,可以通过模板参数将节点更改为使用轻量级执行策略。例如:

../img/466505_1_En_17_Figa_HTML.png

该策略表示节点主体包含少量工作,如果可能的话,应该在没有任务调度开销的情况下执行。

有三种轻量级策略可供选择:queueing_lightweightrejecting_lightweightlightweight、??【这些策略在附录 b 中有详细描述,除了source_node之外,所有功能节点都支持轻量级策略。轻量级节点可能不会产生执行主体的任务,而是在调用线程的上下文中直接在try_put内执行主体。这意味着派生的开销被移除了——但是其他线程没有机会窃取任务,因此并行性受到了限制!

图 17-7 显示了两个简单的图形,我们可以用它们来展示轻量级策略的好处和风险:第一个是一个链multifunction_node对象,第二个是一个连接到两个链multifunction_node对象的multifunction_node对象。

../img/466505_1_En_17_Fig7_HTML.png

图 17-7

用于检查lightweight政策影响的流程图

图 17-8 显示了使用lightweight策略对图 17-7 所示图表的影响,图中使用了 1000 个节点的链,都使用相同的执行策略(lightweight或不使用)。我们通过每个图形发送一条消息,并改变每个节点旋转的时间,从 0 到 1 毫秒不等。我们应该注意,当只发送一条消息时,单链不允许任何并行性,而使用两条链,我们可以实现 2 倍的最大加速。

../img/466505_1_En_17_Fig8_HTML.jpg

图 17-8

对单链和双链样本使用轻量级策略的影响。大于 1 的值意味着轻量级策略提高了性能。

lightweight策略不能限制单链情况下的并行性,因为在这个图中没有并行性。因此,我们在图 17-8 中看到,它改善了所有情况下的性能,尽管随着节点粒度的增加,它的影响变得不那么显著。对于单链情况,该比率接近 1.0,因为产卵任务的开销与身体的旋转时间相比变得可以忽略不计。双链案例确实有潜在的相似性。然而,如果所有节点都使用一个lightweight策略,那么两个链都将由执行第一个multifunction_node的线程来执行,潜在的并行性将被消除。正如我们所料,当我们接近 1 微秒的经验法则执行时间时,lightweight策略的优势被受限并行性所掩盖。即使节点旋转了 0.1 微秒,该比率也会下降到 1 以下。当使用两个链时,该比率接近 0.5,因为图的串行化导致我们预期的 2 倍加速的完全损失。

通过合并节点或使用lightweight策略来解决粒度问题可以减少开销,但正如我们所看到的,它们也会限制可伸缩性。这些“优化”可以带来显著的改进,但必须明智地应用,否则可能弊大于利。

内存使用和数据局部性

与迭代数据结构的 TBB 并行算法不同,流图将数据结构从一个节点传递到另一个节点。消息可以是基本类型、对象、指针,或者在依赖图的情况下是tbb::flow::continue_msg对象。为了获得最佳性能,我们需要同时考虑数据局部性和内存消耗。我们将在本节中讨论这两个问题。

流图中的数据局部性

数据在节点之间传递,当一个节点接收到一条消息时,它会将消息正文作为 TBB 任务执行。该任务使用所有 TBB 任务使用的相同工作窃取调度程序进行调度。在图 17-6(a) 中,当一个串行循环作为流程图执行时,我们看到一个线程产生的任务可能被另一个线程执行。然而,我们注意到这部分是由于使用multifunction_node对象的微基准测试,它不使用调度程序旁路来优化性能。

通常,其他功能节点,包括source_nodefunction_nodecontinue_node,如果其中一个后继节点可以立即运行,则使用调度程序旁路。如果这些节点中的一个访问的数据适合数据缓存,那么它可以在执行后继节点时被同一个线程重用。

由于我们可以从流图中的局部性中受益,因此值得考虑数据大小,甚至将数据分成更小的部分,这样可以通过调度程序旁路从局部性中受益。例如,我们可以重温一下我们在第十六章中使用的矩阵转置内核,作为演示这种效果的例子。我们现在将使用图 17-9 所示的FGMsg结构传递三对ab矩阵。你可以在图 16-6 到图 16-13 中看到第十六章矩阵转置内核的串行、高速缓存不经意和并行实现。

图 17-9 中也显示了我们第一个没有将数组分成小块的实现。source_node, initialize发送三条消息,每条消息是三个矩阵对之一。这个节点连接到一个具有无限并发性的function_node, transposetranspose节点调用第十六章中的简单串行矩阵转置函数。最后一个节点check确认转置正确完成。

../img/466505_1_En_17_Fig9_HTML.png

图 17-9

发送一系列矩阵进行转置的图形,每个矩阵都使用第十六章中的简单串行矩阵转置进行转置

我们的简单实现发送完整的矩阵,这些矩阵由transpose以非缓存无关的方式进行处理。正如我们可能预料的那样,这并没有很好地执行。在我们的测试机器上,它只比连续三次执行第十六章中矩阵转置的非缓存无关串行实现快 8%,每对矩阵执行一次。这并不奇怪,因为基准测试是受内存限制的——当我们无法从内存中获取一个转置所需的数据时,尝试并行执行多个转置并没有多大帮助。如果我们将我们的简单流程图与第十六章中的串行缓存无关转置进行比较,它看起来甚至更糟,当在我们的测试机上执行时,需要 2.5 倍长的时间来处理三对矩阵。幸运的是,有许多方法可以提高这个流程图的性能。例如,我们可以在transpose节点中使用串行缓存无关实现。或者,我们可以使用第十六章中的parallel_for实现,它在transpose节点中使用了blocked_range2dsimple_partitioner。我们将很快看到,这些都将极大地提高我们的基础案例加速 1.08。

然而,我们也可以将矩阵块作为消息发送,而不是将每对ab矩阵作为一个大消息发送。为此,我们扩展了我们的消息结构,以包含一个blocked_range2d:

../img/466505_1_En_17_Figb_HTML.png

然后我们可以构建一个实现,其中initialize节点将ab矩阵的块作为消息发送;在移动到下一个矩阵之前发送来自一对矩阵的所有块。图 17-10 显示了一种可能的实现方式。在这种实现中,堆栈由source_node维护,以模拟深度优先细分和块的执行,这将通过由 TBB parallel_for执行的范围的递归细分来实现。我们将不深入描述图 17-10 中的实现。相反,我们将简单地注意到它发送的是块而不是全矩阵。

../img/466505_1_En_17_Fig10_HTML.png

图 17-10

利用第十六章【高级算法】中描述的 blocked_range2d,发送一系列矩阵块进行转置的图形

图 17-11 显示了在我们的测试机上执行矩阵转置的几个变体的加速。我们可以看到,我们的第一个实现,标记为“流图”,显示了 8%的小改进。pfor-br2d 实现是图 16-11 中基于parallel_for的实现,其中blocked_range2dsimple_partitioner,执行三次,每对矩阵执行一次。其余的条都对应于优化的流图版本:“流图+不经意”类似于图 17-9 ,但是从transpose节点体内部调用矩阵转置的串行缓存不经意实现;“流图+ pfor-br2d”在transpose体中使用了一个parallel_for;“平铺流图”是我们在图 17-10 中的实现;“平铺流图+ pfor2d”与图 17-10 相似,但使用了一个parallel_for来处理其平铺。图 17-10 中的平铺流程图表现最佳。

../img/466505_1_En_17_Fig11_HTML.jpg

图 17-11

矩阵转置的不同变体的加速。我们使用 32×32 的瓦片,因为这在我们的测试系统上表现最好。

令人惊讶的是,具有嵌套parallel_fors的平铺流图版本的性能不如没有嵌套并行的平铺流图。在第九章中,我们声称我们可以在 TBB 不受惩罚地使用嵌套并行——那么哪里出错了呢?残酷的现实是,一旦我们开始调优我们的 TBB 应用程序的性能——我们经常需要牺牲完全的可组合性来换取性能(参见可组合性方面侧栏)。在这种情况下,嵌套并行性干扰了我们小心翼翼地尝试实现的缓存优化。每个节点都被发送了一个适合其数据缓存的切片进行处理——通过嵌套并行,我们通过与其他线程共享切片来消除这种完美的匹配。

可组合性方面

我们可以将可组合性分解为三个愿望:

  1. 正确性(作为绝对)

  2. 使用能力(作为实际问题)

  3. 绩效(作为一种期望)

    首先,我们希望可以混合和匹配代码,而不用担心它会突然出现故障(得到错误的答案)。TBB 给了我们这种能力,这在很大程度上是一个已经解决的问题——一个问题是,当使用有限精度数学(如本机浮点运算)时,不确定的执行顺序会使答案不同。我们在第十六章中讨论了这一点,提供了维护可组合性的“正确性”方面的方法。

    第二,我们希望程序不会崩溃。在许多情况下,这是一个实际问题,因为最常见的问题(无限制的内存使用)理论上可以用无限大小的内存来解决。☺ TBB 在很大程度上解决了这方面的可组合性,使其具有编程模型所不具备的优势(如 OpenMP)。TBB 在结构化程度较低的流程图方面确实需要更多的帮助,所以我们讨论在流程图中使用limiter_nodes来控制内存使用——这在大型流程图中尤其重要。

    最后,对于最佳性能,我们不知道全面性能可组合性的通用解决方案。现实情况是,高度优化的代码与运行在同一硬件上的其他代码竞争,会干扰任一代码的最佳性能。这意味着我们可以从手动调整代码中获益。幸运的是,TBB 为我们提供了调优控制,像 Flow Graph Analyzer 这样的工具有助于我们洞察并指导我们的调优。一旦调优,我们的经验是代码可以很好地工作,感觉是可组合的——但是盲目使用代码并获得最高性能的技术并不存在。“足够好”的表现可能经常发生,但“伟大”需要努力。

我们不应该过于关注图 17-11 中结果的细节——毕竟,这是一个内存受限的微基准测试。但是它清楚地表明,我们可以从考虑节点的大小中获益,不仅从粒度角度,而且从数据局部性角度。当我们从一个发送整个数组并且没有在节点中实现经过调整的内核的简单实现转移到我们的更能感知缓存的平铺流图版本时,我们看到了显著的性能提升。

挑选最佳消息类型并限制传输中的消息数量

当我们允许消息进入一个图,或者当我们通过一个流图沿着多条路径分割它们时,我们消耗更多的内存。除了担心局部性,我们可能还需要限制内存增长。

当消息被传递到数据流图中的节点时,它可能被复制到该节点的内部缓冲区中。例如,如果一个串行节点需要推迟任务的生成,它会将传入的消息保存在一个队列中,直到可以合法地生成一个任务来处理它们。如果我们在流图中传递非常大的对象,这种复制会非常昂贵!因此,如果可能的话,最好是传递指向大型对象的指针,而不是对象本身。

C++11 标准引入了类(在namespace std) unique_ptrshared_ptr中),这对于简化流图中指针传递的对象的内存管理非常有用。例如,在图 17-12 中,让我们假设一个BigObject很大并且建造很慢。通过使用shared_ptr传递对象,只有shared_ptr被复制到串行节点n的输入缓冲区,而不是整个BigObject。此外,由于使用了shared_ptr,一旦每个BigObject到达图的末尾并且其引用计数达到 0,它就会被自动销毁。多方便啊!

../img/466505_1_En_17_Fig12_HTML.png

图 17-12

使用std::shared_ptr避免缓慢复制,同时简化内存管理

当然,当我们使用指向对象的指针时,我们需要小心。通过传递指针而不是对象,多个节点可以通过shared_ptr同时访问同一个对象。如果您的图依赖于功能并行性,即相同的消息被广播到多个节点,这一点尤其正确。shared_ptr将正确处理引用计数的递增和递减,但是我们需要确保在访问所指向的对象时,我们正确地使用了边来防止任何潜在的竞争情况。

正如我们在讨论节点如何映射到任务时所看到的,当消息到达功能节点时,可能会产生任务或者缓冲消息。在设计数据流图时,我们不应该忘记这些缓冲区和任务,以及它们的内存占用。

例如,让我们考虑图 17-13 。有两个节点,serial_nodeunlimited_node;两者都包含一个长自旋循环。for循环为两个节点快速分配大量输入。节点serial_node是串行的,因此它的内部缓冲区将快速增长,因为它接收消息的速度比它完成任务的速度快。相比之下,节点unlimited_node将在每条消息到达时立即产生任务——用大量任务迅速淹没系统——远远超过工作线程的数量。这些产生的任务将在内部工作线程队列中缓冲。在这两种情况下,我们的图可能会很快消耗大量内存,因为它们允许 BigObject 消息进入图中的速度比它们被处理的速度更快。

我们的例子使用了一个原子计数器bigObjectCount,来跟踪在任何给定时间当前分配了多少个ObjectCount对象。在执行结束时,该示例打印最大值。当我们用A_VERY_LARGE_NUMBER=4096运行图 17-13 中的代码时,我们看到了一个"maxCount == 8094"serial_nodeunlimited_node都可以快速积累 BigObject 对象!

../img/466505_1_En_17_Fig13_HTML.png

图 17-13

一个例子有连续的function_nodeserial_node,还有无限的function_nodeunlimited_node

有三种常见的方法来管理流程图中的资源消耗:(1)使用limiter_node , (2)使用并发限制,和/或(3)使用令牌传递模式。

我们使用一个limiter_node来设置可以通过图中给定点的消息数量的限制。limiter_node的接口子集如图 17-14 所示。

../img/466505_1_En_17_Fig14_HTML.png

图 17-14

示例使用的limiter_node接口的子集

一个limiter_node维护通过它的消息的内部计数。发送到limiter_node上的decrement端口的消息会减少计数,允许更多的消息通过。如果计数等于节点的threshold,任何到达其输入端口的新消息都将被拒绝。

在图 17-15 中,一个source_node source生成大量的BigObjects。一旦先前生成的消息被消耗掉,一个source_node只会产生一个新的任务来生成一条消息。我们在sourceunlimited_node之间插入一个limiter_node limiter,构造为限制 3,以限制发送到unlimited_node的消息数量。我们还添加了一个从unlimited_node回到limiter_node递减端口的边沿。通过limiter发送的消息数量现在最多比通过limiter的减量端口发回的消息数量多 3。

../img/466505_1_En_17_Fig15_HTML.jpg

图 17-15

使用一个limiter_node一次只允许三个BigObjects到达unlimited_node

我们还可以使用节点上的并发限制来限制资源消耗,如图 17-16 所示。在代码中,我们有一个可以无限并发地安全执行的节点,但是我们选择了一个较小的数字来限制并发产生的任务数量。

../img/466505_1_En_17_Fig16_HTML.png

图 17-16

使用一个tbb::flow::rejecting策略和一个concurrency_limit来一次只允许三个BigObjects到达limited_to_3_node

我们可以通过构造一个执行策略、flow::rejectingflow::rejecting_lightweight来关闭function_node的内部缓冲。图 17-16 中的source_node只有在被消耗时才会继续产生新的输出。

在数据流图中限制资源消耗的最后一种常用方法是使用基于令牌的系统。如第二章所述,tbb::parallel_pipeline算法使用令牌来限制管道中将要运行的项目的最大数量。我们可以使用令牌和预留join_node创建一个类似的系统,如图 17-17 所示。在这个例子中,我们创建了一个source_node sourcebuffer_node token_buffer。这两个节点连接到预留join_node join的输入端。预留join_node, join_node< tuple< BigObjectPtr, token_t >, flow::reserving >,仅在它可以首先在其每个端口预留输入时消耗物品。由于source_node在其前一条消息未被消费时停止生成新消息,因此token_buffer中令牌的可用性限制了source_node可以生成的项目数量。当令牌由节点unlimited_node返回到token_buffer时,它们可以与source生成的附加消息配对,从而允许产生新的source任务。

图 17-18 显示了节点体串行执行过程中每种方法的加速。在这个图中,自旋时间是 100 微秒,我们可以看到令牌传递方法的开销稍微高一些,尽管这三种方法的加速比都接近 3,正如我们所预期的那样。

../img/466505_1_En_17_Fig18_HTML.png

图 17-18

这三种方法都限制了加速,因为一次只有三个项目被允许进入节点 n

../img/466505_1_En_17_Fig17_HTML.png

图 17-17

令牌传递模式使用令牌和一个tbb::flow::reserving join_node来限制可以到达节点unlimited_node的项目

在图 17-18 中,我们使用int作为令牌类型。一般来说,我们可以使用任何类型作为令牌,甚至是大型对象或指针。例如,如果我们想回收BigObject对象而不是为每个新输入分配它们,我们可以使用BigObjectPtr对象作为令牌。

任务竞技场和流程图

隐式和显式任务竞技场都会影响 TBB 任务和 TBB 通用并行算法的行为。任务产生的场所控制哪些线程可以参与执行任务。在第十一章中,我们看到了如何使用隐式和显式竞技场来控制参与执行并行工作的线程数量。在第 12–14 章中,我们看到了显式任务竞技场可以与task_sheduler_observer对象一起使用,以在线程加入竞技场时设置线程的属性。由于任务领域对可用并行性和数据局部性的影响,在本节中,我们将更仔细地研究任务领域如何与流图相结合。

流程图使用的默认竞技场

当我们构造一个tbb::flow::graph对象时,graph 对象捕获一个对构造该对象的线程的竞技场的引用。每当产生一个任务来执行图中的工作时,该任务就在这个场所中产生,而不是在导致该任务产生的线程的场所中产生。

为什么?

嗯,TBB 流图没有 TBB 并行算法那么结构化。TBB 算法使用 fork-join 并行性,TBB 任务竞技场的行为很好地匹配了这种模式——每个主线程都有自己的默认竞技场,因此如果不同的主线程并发执行算法,它们的任务在不同的任务竞技场中彼此隔离。但是对于 TBB 流图,可能有一个或多个主线程明确地将消息放入同一个图中。如果与这些交互相关的任务在每个主线程的竞技场中产生,那么来自一个图的一些任务将与来自同一图的其他任务隔离。这很可能不是我们想要的行为。

因此,所有的任务都产生在一个单独的舞台上,这个舞台就是构建 graph 对象的线程的舞台。

更改流程图使用的任务领域

我们可以通过调用图的reset()函数来改变图所使用的任务竞技场。这将重新初始化图形,包括重新捕获任务竞技场。我们在图 17-19 中通过构建一个简单的图来演示这一点,图中有一个function_node打印了它的主体任务执行的竞技场中的槽的数量。因为主线程构建了图形对象,所以图形将使用默认的 arena,我们用八个槽来初始化它。

../img/466505_1_En_17_Fig19_HTML.png

图 17-19

使用graph::reset改变图形使用的任务竞技场

在图 17-19 中对n.try_put的前三次调用中,我们没有重置那个图g,我们可以看到任务在默认的有八个槽的竞技场中执行。


Without reset:
default : 8
a2 : 8
a4 : 8

但是在第二组调用中,我们调用 reset 来重新初始化图,节点首先在默认竞技场执行,然后在arena a2执行,最后在arena a4执行。


With reset:
default : 8
a2 : 2
a4 : 4

设置线程数量、线程与内核的相似性等。

既然我们知道了如何将任务竞技场与流程图关联起来,我们就可以使用第 11–14 章中描述的所有依赖于任务竞技场的性能调优。例如,我们可以使用任务竞技场将一个流程图从另一个流程图中分离出来。或者,我们可以使用task_scheduler_observer对象将线程固定到特定任务领域的内核,然后将该领域与流程图相关联。

FG 的关键建议:该做的和不该做的

流图 API 是灵活的——可能太灵活了。当第一次使用流图时,界面可能会令人望而生畏,因为有太多的选项。在本节中,我们提供了几个注意事项,这些事项记录了我们在使用这个高级界面时的一些经验。然而,就像我们对节点执行时间的经验法则一样,这些只是建议。有许多有效的使用模式在这里没有被捕获,我们确信一些我们说要避免的模式可能有有效的用例。我们介绍这些最著名的方法,但你的里程可能会有所不同。

Do:使用嵌套并行

就像管道一样,如果使用并行(flow::unlimited)节点,流图可以具有很大的可伸缩性,但是如果使用串行节点,则可伸缩性有限。增加缩放比例的一种方法是在 TBB 流图节点中使用嵌套并行算法。TBB 是关于可组合性的,所以我们应该尽可能使用嵌套并行。

不要:使用多功能节点代替嵌套并行

正如我们在本书中所看到的,TBB 并行算法,如parallel_forparallel_reduce,都经过了高度优化,包括范围和分割器等功能,让我们可以进一步优化性能。我们还看到流图接口非常有表现力——我们可以表达包含循环的图,并使用像multifunction_node这样的节点从每次调用中输出许多消息。因此,我们应该注意在图中创建模式的情况,这些模式可以使用嵌套并行更好地表达。一个简单的例子如图 17-20 所示。

../img/466505_1_En_17_Fig20_HTML.png

图 17-20

一个multifunction_node为它接收的每条消息发送许多消息。这种模式最好用嵌套的parallel_for循环来表达。

在图 17-20 中,对于multifunction_node接收到的每条消息,它都会生成许多输出消息,这些消息会无限并发地流入function_node中。这个图很像一个并行循环,其中multifunction_node作为控制循环,function_node作为主体。但是像图 17-3 和 17–5 中的主循环一样分发工作需要大量的窃取。虽然这种模式可能有一些有效的用法,但是使用高度优化的并行循环算法可能更有效。例如,整个图可以折叠成一个包含嵌套的parallel_for的节点。当然,这种替换是否可能或需要取决于应用。

Do:需要时,使用join_nodesequencer_nodemultifunction_node在流程图中重新建立顺序

因为流图不如简单的管道结构化,所以我们有时可能需要在图中的点上建立消息的顺序。在数据流图中建立顺序有三种常见的方法:使用键匹配join_node,使用sequencer_node,或者使用multifunction_node

例如,在第三章中,我们的立体 3D 流程图中的并行性允许左右图像在mergeImageBuffersNode点无序到达。在那个例子中,我们通过使用标签匹配join_node来确保正确的两幅图像被配对在一起作为mergeImageBuffersNode的输入。标签匹配join_node是一种密钥匹配join_node.类型,通过使用这种join_node类型,输入可以以不同的顺序到达两个输入端口,但仍然会根据它们的标签或密钥进行正确匹配。您可以在附录 b 中找到关于不同连接策略的更多信息。

另一种建立顺序的方法是使用sequencer_nodesequencer_node是一个缓冲区,它按照序列顺序输出消息,使用用户提供的 body 对象从传入的消息中获取序列号。

在图 17-21 中,我们可以看到一个三节点图,节点有first_nodesequencerlast_node。在最后一个串行输出节点last_node之前,我们使用一个sequencer_node来重新建立消息的输入顺序。因为function_node first_node是无限的,它的任务可以无序完成,并在完成时发送它们的输出。sequencer_node通过使用最初构造每个消息时分配的序列号来重新建立输入顺序。

如果我们执行一个没有序列器节点且N =10 的类似示例,当消息在去往last_node的途中相互传递时,输出被打乱:

../img/466505_1_En_17_Fig21_HTML.png

图 17-21

一个sequencer_node用于确保消息按照它们的my_seq_no成员变量指定的顺序打印


9 no sequencer
8 no sequencer
7 no sequencer
0 no sequencer
1 no sequencer
2 no sequencer
6 no sequencer
5 no sequencer
4 no sequencer
3 no sequencer

当我们执行图 17-21 中的代码时,我们会看到输出:


0 with sequencer
1 with sequencer
2 with sequencer
3 with sequencer
4 with sequencer
5 with sequencer
6 with sequencer
7 with sequencer
8 with sequencer
9 with sequencer

正如我们所看到的,sequencer_node可以重新建立消息的顺序,但是它需要我们分配序列号,还需要为sequencer_node提供一个主体,以便从传入的消息中获取序列号。

建立顺序的最后一种方法是使用序列号multifunction_node。对于给定的输入消息,multifunction_node可以在它的任何输出端口上输出零个或多个消息。由于不会强制为每个传入的消息输出一条消息,因此它可以缓冲传入的消息并保存它们,直到满足一些用户定义的排序约束。

例如,图 17-22 显示了我们如何使用multifunction_node来实现sequencer_node,方法是缓冲传入的消息,直到序列器中的下一条消息到达。这个例子假设最多N消息被发送到一个节点sequencer,并且序列号从 0 开始并且连续到N-1。向量v是用初始化为空shared_ptr对象的N元素创建的。当消息到达sequencer时,它被分配给v.的相应元素,然后从最后发送的序列号开始,发送具有有效消息的v的每个元素,序列号递增。对于某些传入消息,将不发送输出消息;对于其他人,可能会发送一条或多条消息。

../img/466505_1_En_17_Fig22_HTML.png

图 17-22

一个multifunction_node用于实现一个sequencer_node

虽然图 17-22 显示了如何使用multifunction_node按序列顺序对消息进行重新排序,但一般来说,可以使用任何用户定义的消息排序或捆绑。

Do:使用Isolate函数进行嵌套并行

在第十二章中,我们谈到了在使用 TBB 算法时,我们有时会因为性能或正确性的原因而需要创建隔离。对于流图来说也是如此,对于通用算法来说,对于嵌套并行来说尤其如此。图 17-23 中的图的实现显示了一个简单的图,图中有节点sourceunlimited_node,节点unlimited_node中有嵌套并行。在等待节点unlimited_node中嵌套的parallel_for循环完成时,线程可能会兼职(参见第十二章),并拾取节点unlimited_node的另一个实例。节点unlimited_node打印“X started by Y”,其中X是节点实例号,Y是线程 id。

../img/466505_1_En_17_Fig23_HTML.png

图 17-23

具有嵌套并行的图

在我们有八个逻辑内核的测试系统上,一个输出显示我们的线程 0 非常无聊,它在等待第一个parallel_for算法完成时,拾取了不止一个,而是三个不同的unlimited_node实例,如图 17-24 所示。

../img/466505_1_En_17_Fig24_HTML.jpg

图 17-24。

图 17-23 中示例的输出显示在左边,右边是显示重叠执行的图表。线程 0 同时参与三个不同节点调用的执行。

正如我们在第十二章中所讨论的,兼职通常是良性的,这里的情况就是如此,因为我们没有计算任何真实的东西。但是正如我们在之前关于隔离的讨论中所强调的,这种行为并不总是良性的,在某些情况下会导致正确性问题,或者降低性能。

我们可以在流程图中处理兼职,就像我们在第十二章中处理一般任务一样,使用this_task_arena::isolate函数或显式任务竞技场。例如,我们可以在隔离调用中调用它,而不是直接在节点体中调用parallel_for:


tbb::this_task_arena::isolate([P,spin_time]() {
  tbb::parallel_for(0, P-1, spin_time {
    spinWaitForAtLeast((i+1)∗spin_time);
  });
});

在修改我们的代码以使用这个函数后,我们看到线程不再兼职,每个线程都保持关注一个节点,直到该节点完成,如图 17-25 所示。

../img/466505_1_En_17_Fig25_HTML.jpg

图 17-25

没有一个节点同时执行不同的节点调用

Do:在流程图中使用取消和异常处理

在第十五章中,我们讨论了一般使用 TBB 任务时的任务取消和异常处理。因为我们已经熟悉了这个主题,所以在本节中我们将只强调与流程图相关的方面。

每个流程图使用一个单独的task_group_context

一个流图实例将其所有的任务生成到一个单一的任务竞技场中,并且它还为所有这些任务使用一个单一的task_group_context对象。当我们实例化一个图形对象时,我们可以向构造器传递一个显式的task_group_context:


tbb::task_group_context tgc;
tbb::flow::graph g{tgc};

如果我们不把一个传递给构造器,就会为我们创建一个默认对象。

取消流程图

如果我们想要取消一个流图,我们使用task_group_context来取消它,就像我们使用 TBB 通用算法一样。


tgc.cancel_group_excution();

就像 TBB 算法一样,已经开始的任务将会完成,但是与该图相关的新任务将不会开始。如附录 B 中所述,graph 类中还有一个帮助函数,它让我们可以直接检查图形的状态:


if (g.is_cancelled()) {
  std::cout << "My graph was cancelled!" << std::endl;
}

如果我们需要取消一个图,但是没有对它的task_group_context的引用,我们可以从任务中得到一个:


tbb::task::self().cancel_group_execution(); 

取消后重置流程图

如果图形被取消,无论是直接取消还是由于异常取消,我们都需要重新设置图形g.reset(),然后才能再次使用它。这将重置图形的状态——清除内部缓冲区,将边恢复到初始状态,等等。有关更多详细信息,请参见附录 B。

异常处理示例

为了了解异常如何与流程图一起工作,让我们看看图 17-26 中的图的实现。这个图提供了一个小的三节点图,它在第二个节点node2抛出一个异常。

../img/466505_1_En_17_Fig26_HTML.png

图 17-26

在其一个节点中抛出异常的流图

如果我们执行这个例子,我们会得到一个异常(希望这不是一个意外):


terminate called after throwing an instance of 'int'

由于我们没有处理异常,它传播到外部范围,我们的程序终止。当然,我们可以修改我们的节点node2的实现,这样它就可以在自己的主体中捕获异常,如图 17-27 所示。

../img/466505_1_En_17_Fig27_HTML.png

图 17-27。

在其一个节点中抛出异常的流图

如果我们做了这样的更改,我们的示例将运行完成,打印出“捕获”的消息,没有特定的顺序:


Caught 2
Caught 1

到目前为止,这些都不是非常特殊的(双关语);这就是异常应该如何工作。

流程图中异常处理的独特之处在于,我们可以在调用图的wait_for_all函数时捕捉异常,如图 17-28 所示。

../img/466505_1_En_17_Fig28_HTML.png

图 17-28

在其一个节点中抛出异常的流图

如果我们重新运行图 17-26 中的原始示例,但在调用wait_for_all时使用 try-catch 块,我们将只看到一条“catch”消息(针对 1 或 2):


Caught 2

节点node2中抛出的异常没有在节点主体中被捕获,因此它将传播到等待调用wait_for_all的线程。如果一个节点的主体抛出一个异常,那么它所属的图就会被取消。在这种情况下,我们看到没有第二个“被捕获”消息,因为node2只会执行一次。

当然,如果我们想在处理完在wait_for_all,捕获的异常后重新执行图表,我们需要调用g.reset(),因为图表已经被取消了。

Do:使用task_group_context设置图表的优先级

我们可以通过使用图表的task_group_context为图表产生的所有任务设置优先级,例如:


if (auto t = g.root_task()) {
  t->group()->set_priority(tbb::priority_high);
}

或者我们可以将一个具有预设优先级的task_group_context对象传递给图形的构造器。但是,无论在哪种情况下,这都为与该图相关的所有任务设置了优先级。我们可以创建一个高优先级的图和另一个低优先级的图。

在这本书出版前不久,对功能节点相对优先级的支持作为预览特性被添加到 TBB 中。使用这个特性,我们可以向节点的构造器传递一个参数,赋予它相对于其他功能节点的优先级。该接口首次在 TBB 2019 更新 3 中提供。感兴趣的读者可以在在线 TBB 发行说明和文档中了解有关这一新功能的更多详细信息。

不要:在不同图中的节点之间创建边

所有图形节点都需要一个对图形对象的引用,作为其构造器的参数之一。一般来说,只有在属于同一个图的节点之间构造边才是安全的。连接不同图中的两个节点会使推理图的行为变得困难,例如将使用什么任务竞技场,我们对wait_for_all的调用是否会正确地检测到图的终止,等等。为了优化性能,TBB 图书馆利用了其关于边缘的知识。如果我们通过一条边连接两个图,TBB 图书馆将自由地通过这条边达到优化的目的。我们可能认为我们已经创建了两个不同的图,但是如果有共享的边,TBB 可以开始以意想不到的方式将它们的执行混合在一起。

为了演示我们如何获得意想不到的行为,我们实现了如图 17-29 所示的类WhereAmIRunningBody。它打印出max_concurrency和优先级设置,我们将使用它们来推断这个主体的任务在执行时使用了什么任务竞技场和task_group_context

../img/466505_1_En_17_Fig29_HTML.png

图 17-29

一个 body 类,让我们推断出节点执行使用了什么任务 arena 和task_group_context

图 17-30 提供了一个使用WhereAmIRunningBody演示意外行为的例子。在这个例子中,我们创建了两个节点:g2_nodeg4_node。节点g2_node是参照g2构建的。图g2被传递对具有priority_normaltask_group_context的引用,并且g2是并发度为 2 的task_arena中的reset()。因此,我们应该期望g2_node在一个有两个线程的竞技场中以正常的优先级执行,对吗?节点g4_node是这样构造的,我们应该期望它在一个有四个线程的竞技场中以高优先级执行。

包含g2_node.try_put(0)g4_node.try_put(1)的第一组呼叫符合这些预期:

../img/466505_1_En_17_Fig30_HTML.png

图 17-30

由于跨图通信而出现意外行为的示例

但是,当我们从g2_nodeg4_node创建一条边时,我们在两个不同图中存在的节点之间创建了一个连接。我们包含g2_node.try_put(2)的第二组调用再次导致g2_node的主体在arena a2中以正常优先级执行。但是 TBB 试图减少调度开销,由于从g2_nodeg4_node的边沿,当它调用g4_node时,使用调度程序旁路(参见第十章中的调度程序旁路)。结果就是g4_nodeg2_node在同一个线程中执行,但是这个线程属于arena a2而不是a4。当任务被构造时,它仍然使用正确的task_group_context,但是它最终被安排在一个意想不到的地方。


2:g2_node executing in arena 2 with priority normal
2:g4_node executing in arena 2 with priority high

从这个简单的例子中,我们可以看到这条边打破了图形之间的分隔。如果我们使用 arenas a2a4来控制线程的数量,用于工作隔离或线程关联的目的,这个边缘将撤销我们的努力。我们 不应该 在图形之间制造边。

Do:使用try_put跨图形进行交流

在前面的“不要”中,我们决定不要在图形之间创建边。但是如果我们真的需要跨图交流呢?最不危险的选择是显式调用try_put将消息从一个图中的节点发送到另一个图中的节点。我们没有引入边缘,所以 TBB 库不会偷偷摸摸地优化两个节点之间的通信。即使在这种情况下,我们仍然需要小心,如图 17-31 中的例子所示。

这里,我们创建一个图g2,它向图g1发送一条消息,然后等待图g1和图g2。但是,等待的顺序是错误的!

由于节点g2_node2g1_node1发送消息,对g1.wait_for_all()的调用可能会立即返回,因为在调用时g1中没有发生任何事情。然后我们调用g2.wait_for_all(),它在g2_node2完成后返回。该调用返回后,g2结束,但g1刚刚收到来自g2_node2的消息,其节点g1_node1刚刚开始执行!

../img/466505_1_En_17_Fig31_HTML.png

图 17-31

向另一个流程图发送消息的流程图

幸运的是,如果我们以相反的顺序调用等待,事情会像预期的那样进行:


g2.wait_for_all();
g1.wait_for_all();

但是,我们仍然可以看到使用显式try_puts并非没有危险。当图形相互通信时,我们需要非常小心!

Do:使用composite_node封装节点组

在前两节中,我们警告过图之间的通信会导致错误。开发人员经常使用不止一个图,因为他们想在逻辑上将一些节点与其他节点分开。如果有一个需要多次创建的公共模式,或者如果在一个大的平面图中有太多的细节,那么封装一组节点是很方便的。

在这两种情况下,我们都可以使用一个tbb::flow::composite_node。一个composite_node用于封装其他节点的集合,这样它们就可以像一个一级图节点一样使用。其界面如下:

../img/466505_1_En_17_Figc_HTML.png

与我们在本章和第三章中讨论的其他节点类型不同,我们需要创建一个从tbb::flow::composite_node继承的新类来使用它的功能。例如,让我们考虑图 17-32(a) 中的流程图。该图结合了来自source1source2的两个输入,并使用令牌传递方案来限制内存消耗。

../img/466505_1_En_17_Fig32_HTML.jpg

图 17-32

一个受益于composite_node的例子

如果这种令牌传递模式在我们的应用程序中经常使用,或者被我们开发团队的成员使用,那么将它封装到它自己的节点类型中可能是有意义的,如图 17-32(b) 所示。它还通过隐藏细节来清理应用程序的高级视图。

图 17-33 显示了如果我们有一个节点实现了图 17-32(a) 的虚线部分,用一个单独的merge节点代替它,流程图实现看起来会是什么样子。在图 17-33 中,我们像任何其他流图节点一样使用merge节点对象,为其输入和输出端口创建边。图 17-34 显示了我们如何使用tbb::flow::composite_node来实现我们的MergeNode类。

../img/466505_1_En_17_Fig34_HTML.png

图 17-34

MergeNode的实施

../img/466505_1_En_17_Fig33_HTML.png

图 17-33

创建使用从tbb::flow::composite_node继承的类MergeNode的流图

在图 17-34 中,MergeNode继承了CompositeType,?? 是的别名

../img/466505_1_En_17_Figd_HTML.png

两个模板参数表明一个MergeNode将有两个输入端口,两个都接收BigObjectPtr消息,还有一个输出端口发送BigObjectPtr消息。类MergeNode封装的每个节点都有一个成员变量:一个tokenBuffer、一个join和一个combine节点。并且这些成员变量在MergeNode构造器的成员初始化列表中初始化。在构造器体中,对tbb::flow::make_edge的调用设置了所有的内部边。对set_external_ports的调用用于将成员节点的端口分配给MergeNode的外部端口。在这种情况下,join的前两个输入端口成为MergeNode的输入,combine的输出成为MergeNode的输出。最后,因为节点正在实现令牌传递方案,所以用令牌填充了tokenBuffer

虽然创建一个继承自tbb::flow::composite_node的新类型一开始可能会令人望而生畏,但是使用这个接口可以产生更可读和可重用的代码,特别是当你的流程图变得更大更复杂的时候。

英特尔顾问简介:流程图分析器

英特尔 Parallel Studio XE 2019 及更高版本中提供了流图分析器(FGA)工具。它是作为英特尔顾问工具的一项功能提供的。获取工具的说明可在 https://software.intel.com/en-us/articles/intel-advisor-xe-release-notes 找到。

开发 FGA 是为了支持使用 TBB 流图 API 构建的图形的设计、调试、可视化和分析。也就是说,FGA 的许多功能对于分析计算图都是有用的,不管它们来自哪里。目前,该工具对包括 OpenMP API 在内的其他并行编程模型的支持有限。

出于本书的目的,我们将只关注工具中的设计和分析工作流如何应用于 TBB。我们也用 FGA 来分析本章中的一些样本。然而,本章介绍的所有优化都可以在有或没有 FGA 的情况下完成。所以,如果你对使用 FGA 没有兴趣,你可以跳过这一节。但是,我们相信这个工具有很大的价值,所以跳过它将是一个错误。

FGA 设计工作流程

FGA 的设计工作流让我们可以图形化地设计 TBB 流图,验证它们的正确性,评估它们的可伸缩性,并且在我们对设计满意之后,生成一个使用 TBB 流图类和函数的 C++ 实现。FGA 不像微软的 Visual Studio、Eclipse 或 Xcode 那样是一个完全集成的开发环境(IDE)。相反,它让我们开始我们的流程图设计,但是我们需要跳出工具来完成开发。然而,如果我们以一种受约束的方式使用设计工作流,正如我们将在后面描述的,在设计器中进行迭代开发是可能的。

图 17-35 显示了设计工作流程中使用的 FGA 图形用户界面。在这里,我们将仅简要描述该工具的组件,因为我们描述了典型的工作流;流程图分析器文档提供了更完整的描述。

../img/466505_1_En_17_Fig35_HTML.png

图 17-35

使用 FGA 设计工作流程

典型的设计工作流程从空白画布和项目开始。如图 17-35 中编号为 1 的黑色圆圈所示,我们在节点调色板中选择节点,并将它们放置在画布上,通过绘制端口之间的边将它们连接在一起。节点面板包含 TBB 流图界面中所有可用的节点类型,并提供工具提示,提醒我们每种类型的功能。对于画布上的每个节点,我们可以修改其特定于类型的属性;例如,对于一个function_node,我们可以为主体提供 C++ 代码,设置并发限制,等等。我们还可以提供一个估计的“权重”,表示节点的计算复杂度,以便稍后我们可以运行一个可伸缩性分析,看看我们的图是否会表现良好。

一旦我们在画布上绘制了图形,我们就运行一个规则检查来分析图形,寻找常见的错误和反模式。在图 17-35 中用黑色圆圈 2 突出显示的规则检查结果显示了诸如不必要的缓冲、类型不匹配、图中可疑循环等问题。在图 17-35 中,规则检查发现在我们的limiter_node的输入和我们的multifunction_node的输出之间存在类型不匹配。作为响应,我们可以修改multifunction_node的端口输出类型来解决这个问题。

当我们修复了规则检查发现的所有正确性问题后,我们就可以运行可伸缩性分析了。可伸缩性分析在内存中构建了一个 TBB 流图,用虚拟体替换了计算节点体,虚拟体在与它们的“重量”属性成比例的时间内活跃地旋转。FGA 在不同数量的线程上运行我们的图表模型,并提供了一个加速表,例如:

../img/466505_1_En_17_Fige_HTML.jpg

使用这些特性,我们可以迭代地改进我们的图形设计。在这个过程中,我们可以将图形设计保存为 GraphML 格式(一种表示图形的通用标准)。当我们对我们的设计满意时,我们可以生成 C++ 代码,该代码使用 TBB 流图接口来表达我们的设计。这个代码生成器更准确地说是一个代码向导,而不是 IDE,因为它不直接支持迭代代码开发模型。如果我们更改了生成的代码,就没有办法将我们的更改重新导入到工具中。

FGA 迭代开发技巧

如果我们想创建一个可以在 FGA 内部继续调优的设计,我们可以使用一种受约束的方法,在这种方法中,我们指定重定向到在 FGA 之外维护的实现的节点体。这是必要的,因为没有办法将修改后的 C++ 代码重新导入 FGA。

例如,如果我们想使迭代开发更容易,我们不应该指定一个直接在主体代码中公开其实现的function_node:

../img/466505_1_En_17_Figf_HTML.png

相反,我们应该只指定接口,并重定向到可以单独维护的实现:

../img/466505_1_En_17_Figg_HTML.png

如果我们采用这种受约束的方法,我们通常可以维护 FGA 中的图设计及其GraphML表示,迭代地调整拓扑和节点属性,而不会丢失我们在工具之外所做的任何节点体实现更改。每当我们从 FGA 生成新的 C++ 代码时,我们简单地包括最新的实现头,节点体使用这些在工具外部维护的实现。

当然,流图分析器并不要求我们使用这种方法,但是如果我们想将 FGA 的代码生成功能用作一个简单的代码向导,这是一个很好的实践。

FGA 分析工作流程

FGA 的分析工作流独立于设计工作流。虽然我们肯定可以分析在 FGA 设计的流程图,但是我们也可以轻松地分析在工具之外设计和实现的 TBB 流程图。这是可能的,因为 TBB 库被设计为向 FGA 跟踪收集器提供运行时事件。从 TBB 应用程序中收集的跟踪信息让 FGA 重建了图结构和节点体执行的时间线——它确实 而不是 依赖于在设计工作流程中开发的 GraphML 文件。

如果我们想用FGA来分析一个使用流图的 TBB 应用程序,第一步是收集一个 FGA 轨迹。默认情况下,TBB 不会生成跟踪,所以我们需要激活跟踪收集。TBB 的 FGA 仪器是 TBB 2019 年之前的预览功能。如果我们使用的是旧版本的 TBB,我们需要采取额外的步骤。我们建议读者参考 FGA 文档,了解如何为他们正在使用的 TBB 和 FGA 版本收集跟踪信息。

一旦我们跟踪了我们的应用程序,FGA 的分析工作流将使用图 17-36 中用数字圆圈突出显示的活动:(1)检查树形图视图以了解图形性能的概况,并将其用作图形拓扑显示的索引,(2)运行关键路径算法以确定计算过程中的关键路径,以及(3)检查时间轴和并发数据以了解性能随时间的变化。分析通常是一个交互过程,随着应用程序性能的探索,它在这些不同的活动之间移动。

../img/466505_1_En_17_Fig36_HTML.png

图 17-36

使用 FGA 分析工作流程。这些结果是在具有 16 个内核的系统上收集的。

图 17-36 中标为(1)的树形图视图提供了一个图表的整体健康状况的概览。在树形图中,每个矩形的面积表示节点的总 CPU 时间,每个正方形的颜色表示在节点执行期间观察到的并发性。并发信息分为差(红色)、好(橙色)、好(绿色)和超额预订(蓝色)。

标记为“差”的大面积节点是热点,其平均并发性在硬件并发性的 0%到 25%之间。因此,这些是优化的良好候选。树形图视图也可以作为大图的索引;单击正方形将突出显示图表中的节点,选择该突出显示的节点将依次在时间轴跟踪视图中标记该节点的所有实例的任务。

图形拓扑画布与工具中的其他视图同步。在树状图视图、时间线或数据分析报告中选择一个节点将在画布中突出显示该节点。这使得用户可以快速地将性能数据与图形结构联系起来。

FGA 提供的最重要的分析报告之一是图表中的关键路径列表。当必须分析一个大而复杂的图形时,这个特性特别有用。计算关键路径的结果是形成关键路径的节点列表,如图 17-36 中标有(2)的区域所示。正如我们在第三章中讨论的,依赖图加速的上限可以通过将图中所有节点花费的总时间除以最长关键路径上花费的时间 T 1 /T 来快速计算。此上限可用于设置应用程序潜在加速的预期,以图形表示。

图 17-36 中标为(3)的时间线和并发视图显示了映射到软件线程的泳道中的原始轨迹。使用这些跟踪信息,FGA 可以计算额外的派生数据,如每个节点的平均并发性和图执行过程中的并发性直方图。在每个线程泳道的上方,一个直方图显示了在那个时间点有多少节点是活动的。这个视图允许用户快速识别低并发的时间区域。在这些低并发区域点击时间轴上的节点,可以让开发人员在他们的图中找到导致这些瓶颈的结构。

诊断 FGA 的性能问题

在本章中,我们讨论了使用流程图时可能出现的一些潜在的性能问题。在本节中,我们将简要讨论如何在基于 TBB 的应用程序中使用 FGA 来研究这些问题。

诊断 FGA 的粒度问题

就像我们的 TBB 通用循环算法一样,我们需要关注那些太小而无法从并行化中获益的任务。但是我们需要在这种关注与创建足够多的任务以允许我们的工作量扩展的需求之间取得平衡。特别是,正如我们在第三章中所讨论的,如果串行节点成为计算中的瓶颈,那么它们的可伸缩性就会受到限制。

在图 17-37 所示的 FGA 时间线示例中,我们可以看到有一个名为m的黑暗串行任务,它会导致低并发区域。颜色表明该任务的长度约为 1 毫秒——这超过了有效调度的阈值,但从时间表来看,这似乎是一个序列化瓶颈。如果可能的话,我们应该将这个任务分解成可以并行调度的任务——或者通过分解成多个独立的节点,或者通过嵌套并行。

../img/466505_1_En_17_Fig37_HTML.jpg

图 17-37

FGA 时间线根据任务的执行时间给任务着色。较轻的任务较小。

相比之下,在图 17-37 中,一些名为n的较小任务被并行执行。通过它们的颜色,看起来它们接近 1 微秒的阈值,因此我们可以在该区域的时间线中看到间隙,这表明可能存在一些不可忽略的调度开销。在这种情况下,如果可能的话,合并节点或使用一个lightweight策略来减少开销可能对我们有好处。

在 FGA 识别慢拷贝

图 17-38 展示了我们如何在 FGA 识别慢拷贝。在该图中,我们从类似于图 17-12 的图的运行时间线中看到 100 毫秒的片段,但是这些图直接传递BigObject消息(图 17-38(a) 和shared_ptr<BigObject>消息(图 17-38(b) )。为了使构造看起来很昂贵,我们在BigObject构造器中插入了一个自旋等待,这样构造每个对象需要 10 毫秒——使得BigObject的构造时间和我们的function_node主体的执行时间相等。在图 17-38(a) 中,我们可以看到在节点间复制消息所花费的时间在时间线上表现为间隙。在图 17-38(b) 中,我们通过指针传递,消息传递时间可以忽略不计,因此看不到间隙。

../img/466505_1_En_17_Fig38_HTML.jpg

图 17-38

在 FGA 中,长副本显示为节点体执行之间的间隙。所示的每个时间线段大约 100 毫秒长。

当使用 FGA 分析我们的流程图应用程序时,时间线上的缺口表明效率低下,需要进一步调查。在本节中,他们指出了节点之间的高成本复制,在上一节中,他们指出了与任务大小相比,调度的开销很大。在这两种情况下,这些差距应该促使我们寻找提高性能的方法。

使用 FGA 诊断兼职

在本章的前面,我们讨论了图 17-23 中的兼职图的执行,它产生了图 17-24 中的输出。FGA 在其执行时间表中提供了一个堆叠视图,让我们可以轻松发现兼职,如图 17-39 所示。

../img/466505_1_En_17_Fig39_HTML.png

图 17-39

按节点/区域分组的 FGA 时间表。我们可以看到线程 0 正在兼职,因为它显示为并发执行多个并行区域。

在堆栈视图中,我们可以看到一个线程正在执行的所有嵌套任务,包括来自流图节点的任务和来自 TBB 通用并行算法的任务。如果我们看到一个线程同时执行两个节点,这就是兼职。例如,在图 17-39 中,我们看到线程 0 开始在现有的n0实例中执行节点n0的另一个实例。在我们之前关于兼职的讨论中,我们知道如果一个线程在等待嵌套并行算法完成时窃取工作,就会发生这种情况。图 17-39 中的堆叠视图,让我们很容易看到一个嵌套的parallel_for,标记为p8,是这种情况下的罪魁祸首。

使用来自 FGA 的时间轴视图,我们可以通过注意一个线程在多个区域或节点中的重叠参与来识别线程何时兼职。作为开发人员,可能通过与 FGA 的其他互动,我们需要确定兼职是良性的,还是需要通过 TBB 的隔离功能来解决。

摘要

流图 API 是一个灵活而强大的接口,用于创建依赖关系和数据流图。在本章中,我们讨论了使用 TBB 流程图高级执行接口时的一些更高级的注意事项。因为它是在 TBB 任务之上实现的,所以它共享 TBB 任务支持的可组合性和优化特性。我们讨论了如何利用这些来优化粒度、有效缓存和内存使用,并创建足够的并行性。然后,我们列出了一些在首次探索流程图界面时会有帮助的注意事项。最后,我们简要介绍了流图分析器(FGA),这是英特尔 Parallel Studio XE 中的一款工具,支持 TBB 流图的图形设计和分析。

更多信息

迈克尔·沃斯,“英特尔线程构建模块流程图”,多布博士,2011 年 10 月 5 日。 www.drdobbs.com/tools/the-intel-threading-building-blocks-flow/231900177

Vasanth Tovinkere、Pablo Reble、Farshad Akhbari 和 Palanivel Guruvareddiar,“利用英特尔顾问的流图分析器提高代码性能”,《并行宇宙杂志》, https://software.seek.intel.com/driving-code-performance

Richard Friedman,“英特尔顾问的 TBB 流图分析器:让复杂的并行层更易于管理”,Inside HPC,2017 年 12 月 14 日, https://insidehpc.com/2017/12/intel-flow-graph-analyzer/

Creative Commons

开放存取本章根据知识共享署名-非商业-非专用 4.0 国际许可协议(http://Creative Commons . org/licenses/by-NC-nd/4.0/)的条款进行许可,该协议允许以任何媒体或格式进行任何非商业使用、共享、分发和复制,只要您适当注明原作者和来源,提供知识共享许可协议的链接,并指出您是否修改了许可材料。根据本许可证,您无权共享从本章或其部分内容派生的改编材料。

本章中的图像或其他第三方材料包含在该章的知识共享许可中,除非该材料的信用额度中另有说明。如果材料未包含在本章的知识共享许可中,并且您的预期用途不被法定法规允许或超出了允许的用途,您将需要直接从版权所有者处获得许可。